diff --git a/CHANGELOG.md b/CHANGELOG.md index e1cd372..2f994d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [Unreleased] +## [0.11.0] - 2026-06-02 + +### Added + +#### GQL Beta Language Expansion +- **Composable GQL read pipelines.** Added `WITH` pipelines, scalar aliases, projection-local row operations, and seeded later `MATCH` / `OPTIONAL MATCH` execution over the native graph pipeline substrate. +- **Richer scalar expressions and functions.** Added arithmetic, string predicates, `CASE`, scalar functions, and shared expression evaluation for GQL reads, mutations, and graph-row-backed execution. +- **`DISTINCT` and aggregation.** Added `RETURN DISTINCT`, `WITH DISTINCT`, `count`, `sum`, `avg`, `min`, `max`, `collect`, aggregate `DISTINCT`, grouping, aggregate row operations, and compact-row output support. +- **Read-only set and subquery constructs.** Added read-only `UNION`, `UNION ALL`, `EXISTS {}` predicates, and `CALL {}` subqueries with deterministic ordering, cursors, cap enforcement, correlation caching, and explain output. +- **Shortest-path GQL syntax.** Added constrained `shortestPath` and `allShortestPaths` path assignments backed by the native shortest-path algorithms, including path helper return values. +- **Keyed `MERGE`.** Added keyed node and relationship `MERGE` with `ON CREATE SET` / `ON MATCH SET`, mutation stats, mutation `RETURN DISTINCT`, and transaction-backed atomic execution. +- **Native graph pipeline connector APIs.** Added Rust, Node.js, and Python access to structured graph pipeline execution and explain surfaces, including async connector parity. + +### Changed + +#### GQL Execution +- **GQL lowering now targets a reusable native graph pipeline substrate.** Multi-stage reads, aggregation, unions, subqueries, shortest paths, and keyed merge reuse existing graph-row, graph algorithm, and transaction machinery instead of adding parser-owned execution paths. +- **Connector GQL coverage now includes the Phase 34 feature set.** Node.js TypeScript declarations, Python stubs, async wrappers, compact rows, cap forwarding, nested graph/path value conversion, and explain fields now cover the expanded GQL Beta subset. +- **Docs now present GQL Beta as a composable query surface.** README, getting-started, API reference, and GQL subset docs now cover pipelines, aggregation, unions, subqueries, shortest paths, and keyed merge while preserving the native API-first positioning. + +### Fixed + +- **Pipeline correctness hardening.** Fixed edge cases around scalar alias scope, mixed graph/scalar rows, cursor shape validation, selected-field projection preservation, final-page hydration, null semantics, aggregation caps, and deterministic `UNION` de-duplication. +- **Subquery and shortest-path guardrails.** Fixed correlated subquery cache behavior, nested invocation budgeting, `CALL` row cap handling, optional `EXISTS` semantics, shortest-path endpoint resolution, row-cap enforcement, and truthful explain output. +- **MERGE and mutation parity.** Fixed keyed `MERGE` row counting, mutation profiling, late `SET` evaluation over created or matched aliases, deterministic mutation `RETURN DISTINCT`, and transaction-local overlay/coalescing behavior. + ## [0.10.0] - 2026-05-27 ### Added @@ -376,6 +402,7 @@ Initial release. - Cross-platform CI: macOS, Linux, Windows - Benchmark CI with regression detection and cross-language parity validation +[0.11.0]: https://github.com/bhensley5/overgraph/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/bhensley5/overgraph/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/bhensley5/overgraph/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/bhensley5/overgraph/compare/v0.7.0...v0.8.0 diff --git a/Cargo.lock b/Cargo.lock index a82544a..28f532a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -666,7 +666,7 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "overgraph" -version = "0.10.0" +version = "0.11.0" dependencies = [ "arc-swap", "crc32fast", @@ -682,7 +682,7 @@ dependencies = [ [[package]] name = "overgraph-node" -version = "0.10.0" +version = "0.11.0" dependencies = [ "napi", "napi-build", @@ -693,7 +693,7 @@ dependencies = [ [[package]] name = "overgraph-python" -version = "0.10.0" +version = "0.11.0" dependencies = [ "overgraph", "pyo3", diff --git a/Cargo.toml b/Cargo.toml index 00ccd05..29d8960 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [".", "overgraph-node", "overgraph-python"] [package] name = "overgraph" -version = "0.10.0" +version = "0.11.0" edition = "2021" description = "An absurdly fast embedded graph database. Pure Rust, sub-microsecond reads." license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index ebd589c..cbc799b 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Graph structure and vector similarity can live in the same engine, so you can as - **Explicit write transactions.** Stage ordered node and edge mutations locally, read your own staged writes, then commit atomically with optimistic conflict detection. Available in Rust, Node.js, and Python. - **Three languages, one engine.** Rust core with native bindings for Node.js (napi-rs) and Python (PyO3). Not a wrapper around a REST API. Actual FFI into the same Rust engine with minimal overhead. - **Full queries as functions.** Use regular APIs for everything: `find_nodes` for direct property lookups, `query_node_ids` / `query_nodes` for full boolean node queries, and `query_graph_rows` for row-shaped graph patterns, optional matches, and bounded paths. -- **GQL Beta.** Write graph reads and writes as GQL/Cypher-style strings when that is easier to read than building request objects. `MATCH` reads and keyed `CREATE`, `SET`, `REMOVE`, `DELETE r`, and `DETACH DELETE n` mutations run on the same native substrates. +- **GQL Beta.** Write graph reads and writes as GQL/Cypher-style strings when that is easier than building request objects. Use `MATCH`, `WITH`, `DISTINCT`, aggregation, `UNION`, read-only subqueries, constrained shortest paths, `CREATE`, `MERGE`, `SET`, `REMOVE`, `DELETE r`, `DETACH DELETE n`, and mutation returns. ## Performance @@ -81,7 +81,7 @@ cargo add overgraph ## Quick start -The vector variables in these snippets are placeholders from your embedding model. Replace them with dense arrays that match the configured dimension and sparse `(dimension, weight)` entries from your sparse encoder. +These snippets intentionally show different parts of the same engine. Each workflow is available across Rust, Node.js, and Python; each block uses the language where that workflow reads cleanest. ### Python @@ -114,105 +114,78 @@ with OverGraph.open("./my-graph", dense_vector_dimension=384) as db: print(f"node {hit.node_id} score {hit.score:.4f}") ``` +Vector search is available across Rust, Node.js, and Python; this snippet shows the Python surface. The vector variables are placeholders from your embedding model. + ### Node.js ```javascript import { OverGraph } from 'overgraph'; -const db = OverGraph.open('./my-graph', { - denseVector: { dimension: 384 }, -}); +const db = OverGraph.open('./my-graph'); -// Embeddings come from your model. Dense vectors must match the configured dimension. -// Sparse vectors use { dimension, value } entries from your sparse encoder. -// Also accepts multiple labels: ['User', 'Engineer'] -const alice = db.upsertNode('User', 'alice', { - props: { name: 'Alice' }, - denseVector: aliceEmbedding, - sparseVector: aliceSparse, -}); +const [alice, bob, carol, project] = db.batchUpsertNodes([ + { labels: 'User', key: 'alice', props: { name: 'Alice' } }, + { labels: 'User', key: 'bob', props: { name: 'Bob' } }, + { labels: 'User', key: 'carol', props: { name: 'Carol' } }, + { labels: 'Project', key: 'atlas', props: { name: 'Atlas' } }, +]); + +db.batchUpsertEdges([ + { from: alice, to: bob, label: 'KNOWS' }, + { from: bob, to: carol, label: 'KNOWS' }, + { from: carol, to: project, label: 'WORKS_ON' }, +]); -const project = db.upsertNode('Project', 'overgraph', { - denseVector: projectEmbedding, - sparseVector: projectSparse, +const neighbors = db.neighbors(alice, { + direction: 'outgoing', + edgeLabelFilter: ['KNOWS'], }); -db.upsertEdge(alice, project, 'CREATED'); +const twoHop = db.traverse(alice, 2, { + direction: 'outgoing', + minDepth: 1, +}); -// Hybrid vector search scoped to a graph neighborhood -const hits = db.vectorSearch('hybrid', { - k: 10, - denseQuery: queryEmbedding, - sparseQuery: querySparse, - scope: { startNodeId: alice, maxDepth: 3 }, +const path = db.shortestPath(alice, project, { + direction: 'outgoing', + maxDepth: 3, }); -hits.forEach(h => console.log(`node ${h.nodeId} score ${h.score.toFixed(4)}`)); +console.log(neighbors.map(n => n.nodeId)); +console.log(twoHop.items.map(hit => [hit.nodeId, hit.depth])); +console.log(path?.nodes ?? []); db.close(); ``` +Neighbor expansion, bounded traversal, and shortest paths are available across Rust, Node.js, and Python; this snippet shows the Node.js surface. + ### Rust ```rust use overgraph::*; -use std::collections::BTreeMap; use std::path::Path; fn main() -> Result<(), Box> { - let opts = DbOptions { - dense_vector: Some(DenseVectorConfig { - dimension: 384, - metric: DenseMetric::Cosine, - hnsw: HnswConfig::default(), - }), - ..Default::default() - }; - let mut db = DatabaseEngine::open(Path::new("./my-graph"), &opts)?; - - // Embeddings come from your model. Dense vectors must match the configured dimension. - // Sparse vectors use (dimension, weight) pairs from your sparse encoder. - let mut props = BTreeMap::new(); - props.insert("name".into(), PropValue::String("Alice".into())); - // Also accepts multiple labels: &["User", "Engineer"] - let alice = db.upsert_node("User", "alice", UpsertNodeOptions { - props, - dense_vector: Some(alice_embedding), - sparse_vector: Some(alice_sparse), - ..Default::default() - })?; + let mut db = DatabaseEngine::open(Path::new("./my-graph"), &DbOptions::default())?; + + let alice = db.upsert_node("User", "alice", UpsertNodeOptions::default())?; + let bob = db.upsert_node("User", "bob", UpsertNodeOptions::default())?; + let carol = db.upsert_node("User", "carol", UpsertNodeOptions::default())?; + let project = db.upsert_node("Project", "atlas", UpsertNodeOptions::default())?; - let project = db.upsert_node("Project", "overgraph", UpsertNodeOptions { - dense_vector: Some(project_embedding), - sparse_vector: Some(project_sparse), + db.upsert_edge(alice, bob, "FOLLOWS", UpsertEdgeOptions::default())?; + db.upsert_edge(bob, carol, "FOLLOWS", UpsertEdgeOptions::default())?; + db.upsert_edge(carol, project, "WORKS_ON", UpsertEdgeOptions::default())?; + + let ranks = db.personalized_pagerank(&[alice], &PprOptions { + algorithm: PprAlgorithm::ApproxForwardPush, + edge_label_filter: Some(vec!["FOLLOWS".into(), "WORKS_ON".into()]), + max_results: Some(5), ..Default::default() })?; - db.upsert_edge(alice, project, "CREATED", UpsertEdgeOptions::default())?; - - // Hybrid vector search: dense + sparse with graph scoping - let hits = db.vector_search(&VectorSearchRequest { - mode: VectorSearchMode::Hybrid, - dense_query: Some(query_embedding), - sparse_query: Some(query_sparse), - k: 10, // required: 0 returns empty - label_filter: Some(NodeLabelFilter { - labels: vec!["User".into(), "Project".into()], - mode: LabelMatchMode::Any, - }), // default: None - ef_search: Some(200), // default: 128 - scope: Some(VectorSearchScope { // default: None (search all nodes) - start_node_id: alice, - max_depth: 3, - direction: Direction::Outgoing, // default: Outgoing - edge_label_filter: Some(vec!["CREATED".into()]), // default: None (all edge labels) - at_epoch: None, // default: None (current time) - }), - dense_weight: Some(0.7), // default: 1.0 - sparse_weight: Some(0.3), // default: 1.0 - fusion_mode: Some(FusionMode::ReciprocalRankFusion), // default: WeightedRankFusion - })?; - for hit in &hits { - println!("node {} score {:.4}", hit.node_id, hit.score); + for (node_id, score) in ranks.scores { + println!("node {node_id} rank {score:.4}"); } db.close()?; @@ -220,34 +193,40 @@ fn main() -> Result<(), Box> { } ``` +Personalized PageRank is available across Rust, Node.js, and Python; this snippet shows the Rust surface. + ## GQL Beta -OverGraph includes **GQL Beta**: a GQL/Cypher-style query language for graph reads and writes, running in the embedded Rust engine. Reads use the same graph-row executor as the native APIs, and mutations use the same write-transaction machinery. Use it when a query is easier to read as text; keep the native APIs when you want structured request objects or the full public API surface. +OverGraph includes **GQL Beta**: a GQL/Cypher-style query language for graph reads and writes. Use it when a graph operation is easier to read as text: create records, match patterns, shape rows with `WITH`, aggregate, combine branches with `UNION`, run read-only subqueries, use constrained shortest paths, and return mutation results. -```javascript -const created = db.executeGql( - `CREATE (p:Person {key: $key, name: $name, status: 'active'}) - RETURN p.name AS name`, - { key: 'ada', name: 'Ada' } -); - -const result = db.executeGql( - `MATCH (p:Person)-[r:WORKS_AT]->(c:Company) - WHERE p.status = $status AND r.since >= $minSince - RETURN p.name AS person, r.role AS role, c.name AS company - ORDER BY r.since DESC - LIMIT 10`, - { status: 'active', minSince: 2020 }, - { includePlan: true, profile: true } -); - -console.log(created.mutationStats); -console.log(result.rows); -console.log(result.stats); -console.log(result.plan?.read?.rowOps); +```python +db.execute_gql( + """ + CREATE (p:Person {key: 'gql-ada', name: 'Ada', status: 'active'}) + -[r:WORKS_AT {role: 'engineer', since: 2026}]-> + (c:Company {key: 'gql-overgraph', name: 'OverGraph'}) + RETURN p.name AS person, c.name AS company, r.role AS role + """ +) + +result = db.execute_gql( + """ + MATCH (p:Person)-[r:WORKS_AT]->(c:Company) + WHERE p.status = 'active' AND r.since >= 2020 + RETURN p.name AS person, r.role AS role, c.name AS company + ORDER BY r.since DESC + LIMIT 10 + """, + include_plan=True, + profile=True, +) + +print(result["rows"]) +print(result["stats"]) +print(result["plan"]["read"]["row_ops"]) ``` -GQL Beta is available in Rust (`execute_gql`), Node.js (`executeGql` / `executeGqlAsync`), and Python (`execute_gql`, including `AsyncOverGraph`). It supports `MATCH`, `OPTIONAL MATCH`, bounded paths, path functions, `WHERE`, `RETURN`, `ORDER BY`, `SKIP` / `OFFSET`, `LIMIT`, params, read cursors, compact rows, vector opt-in for returned node values, explain/profile, ReadOnly mode, `CREATE`, `SET`, `REMOVE`, `DELETE r`, `DETACH DELETE n`, mutation stats, and mutation `RETURN` for `CREATE` / `SET` / `REMOVE`. Unsupported features include `MERGE`, schema DDL, aggregation, `DISTINCT`, `WITH` / `UNION` / `CALL` / subqueries, vector mutation syntax, and full ISO GQL/Cypher compatibility. See the full [GQL Beta API reference](docs/api-reference.md#gql-beta) for syntax, result shapes, options, examples, and current limitations. +GQL Beta is available across Rust, Node.js, and Python. It supports params, read cursors, compact rows, vector opt-in for returned node values, explain/profile, read-only execution, mutation stats, async connector calls, and consistent result shapes across languages. See the full [GQL Beta API reference](docs/api-reference.md#gql-beta) for syntax, result shapes, options, and examples. ### Async support @@ -284,7 +263,7 @@ Both Python and Node.js connectors include full async variants of every API. Pyt - **Degree counts.** Count edges, sum weights, and compute averages without materializing neighbor lists. Batch `degrees` for bulk analysis. - **Direct property queries.** `find_nodes` and `find_nodes_paged` do focused equality lookups with semantic numeric equality for finite scalars. `find_nodes_range` and `find_nodes_range_paged` do domainless numeric range scans with exact bound and cursor semantics. - **Optional property indexes.** Declare node or edge equality/range indexes only where they pay off. Range indexes cover finite scalar numeric values across signed integers, unsigned integers, and finite floats; non-finite floats and non-numeric values are excluded. Use `ensure_node_property_index` / `ensure_edge_property_index`, list APIs, and drop APIs to manage them. Public query APIs stay index-transparent: when a matching declaration is `Ready`, OverGraph uses the declaration-backed path; otherwise it falls back to the same public API. -- **Full query APIs.** `query_node_ids`, `query_nodes`, `query_edge_ids`, `query_edges`, `query_graph_rows`, and explain APIs combine IDs, keys, labels, edge labels, endpoint constraints, property equality/IN/range/exists/missing filters, edge metadata filters, updated-at ranges, row-shaped graph patterns, optional groups, and bounded paths without a query string. `execute_gql` / `executeGql` adds GQL Beta for query-string reads and mutations over the same native substrates. OverGraph chooses the cheapest legal path with available indexes and planner stats, then verifies results against visible records. +- **Full query APIs.** `query_node_ids`, `query_nodes`, `query_edge_ids`, `query_edges`, `query_graph_rows`, and explain APIs combine IDs, keys, labels, edge labels, endpoint constraints, property equality/IN/range/exists/missing filters, edge metadata filters, updated-at ranges, row-shaped graph patterns, optional groups, and bounded paths without a query string. `execute_gql` / `executeGql` adds GQL Beta for query-string reads and mutations. OverGraph chooses the cheapest legal path with available indexes and planner stats, then verifies results against visible records. - **Time-range queries.** Find nodes created or updated within a time window. Sorted timestamp index for efficient range scans. ### Pagination diff --git a/benches/query_ops.rs b/benches/query_ops.rs index 211bc0f..0b2e1cd 100644 --- a/benches/query_ops.rs +++ b/benches/query_ops.rs @@ -1293,205 +1293,6 @@ fn build_pattern_engine() -> (tempfile::TempDir, DatabaseEngine, u64) { (dir, engine, company_ids[0]) } -fn build_high_fanout_pattern_engine() -> (tempfile::TempDir, DatabaseEngine, u64) { - let (dir, engine) = temp_db(); - let source = engine - .batch_upsert_nodes(vec![NodeInput { - labels: vec![bench_node_label(1)], - key: "fanout-source".to_string(), - props: BTreeMap::new(), - weight: 1.0, - dense_vector: None, - sparse_vector: None, - }]) - .unwrap()[0]; - let targets: Vec = (0..5_000) - .map(|i| NodeInput { - labels: vec![bench_node_label(2)], - key: format!("fanout-target-{i}"), - props: BTreeMap::new(), - weight: 1.0, - dense_vector: None, - sparse_vector: None, - }) - .collect(); - let target_ids = engine.batch_upsert_nodes(targets.clone()).unwrap(); - let edges: Vec = target_ids - .iter() - .map(|&target| EdgeInput { - from: source, - to: target, - label: "BenchEdge10".to_string(), - props: BTreeMap::new(), - weight: 1.0, - valid_from: None, - valid_to: None, - }) - .collect(); - engine.batch_upsert_edges(edges.clone()).unwrap(); - engine.flush().unwrap(); - (dir, engine, source) -} - -fn build_fanout_anchor_choice_engine() -> (tempfile::TempDir, DatabaseEngine) { - let (dir, engine) = temp_db(); - let hub = engine - .batch_upsert_nodes(vec![NodeInput { - labels: vec![bench_node_label(1)], - key: "small-hub".to_string(), - props: BTreeMap::new(), - weight: 1.0, - dense_vector: None, - sparse_vector: None, - }]) - .unwrap()[0]; - let mid_inputs: Vec<_> = (0..500) - .map(|index| NodeInput { - labels: vec![bench_node_label(3)], - key: format!("mid-{index:03}"), - props: BTreeMap::new(), - weight: 1.0, - dense_vector: None, - sparse_vector: None, - }) - .collect(); - let mids = engine.batch_upsert_nodes(mid_inputs.clone()).unwrap(); - const OPTIONAL_ANCHOR_COUNT: usize = 34; - let anchor_inputs: Vec<_> = (0..OPTIONAL_ANCHOR_COUNT) - .map(|index| NodeInput { - labels: vec![bench_node_label(2)], - key: format!("anchor-{index:02}"), - props: BTreeMap::new(), - weight: 1.0, - dense_vector: None, - sparse_vector: None, - }) - .collect(); - let anchors = engine.batch_upsert_nodes(anchor_inputs.clone()).unwrap(); - let mut edges = Vec::new(); - for &mid in &mids { - edges.push(EdgeInput { - from: hub, - to: mid, - label: "BenchEdge10".to_string(), - props: BTreeMap::new(), - weight: 1.0, - valid_from: None, - valid_to: None, - }); - } - for (mid, anchor) in mids.iter().take(anchors.len()).zip(anchors.iter()) { - edges.push(EdgeInput { - from: *mid, - to: *anchor, - label: "BenchEdge20".to_string(), - props: BTreeMap::new(), - weight: 1.0, - valid_from: None, - valid_to: None, - }); - } - engine.batch_upsert_edges(edges.clone()).unwrap(); - engine.flush().unwrap(); - (dir, engine) -} - -fn build_high_hub_delay_engine() -> (tempfile::TempDir, DatabaseEngine, u64) { - let (dir, engine) = temp_db(); - let root = engine - .batch_upsert_nodes(vec![NodeInput { - labels: vec![bench_node_label(1)], - key: "root".to_string(), - props: BTreeMap::new(), - weight: 1.0, - dense_vector: None, - sparse_vector: None, - }]) - .unwrap()[0]; - let low = engine - .batch_upsert_nodes(vec![NodeInput { - labels: vec![bench_node_label(3)], - key: "low".to_string(), - props: BTreeMap::new(), - weight: 1.0, - dense_vector: None, - sparse_vector: None, - }]) - .unwrap()[0]; - let target_inputs: Vec<_> = (0..512) - .map(|index| NodeInput { - labels: vec![bench_node_label(2)], - key: format!("hub-target-{index:03}"), - props: BTreeMap::new(), - weight: 1.0, - dense_vector: None, - sparse_vector: None, - }) - .collect(); - let targets = engine.batch_upsert_nodes(target_inputs.clone()).unwrap(); - let mut edges = vec![EdgeInput { - from: root, - to: low, - label: "BenchEdge20".to_string(), - props: BTreeMap::new(), - weight: 1.0, - valid_from: None, - valid_to: None, - }]; - for target in targets { - edges.push(EdgeInput { - from: root, - to: target, - label: "BenchEdge10".to_string(), - props: BTreeMap::new(), - weight: 1.0, - valid_from: None, - valid_to: None, - }); - } - engine.batch_upsert_edges(edges.clone()).unwrap(); - engine.flush().unwrap(); - (dir, engine, root) -} - -fn build_parallel_edge_pattern_engine() -> (tempfile::TempDir, DatabaseEngine, u64) { - let (dir, engine) = temp_db_with_edge_uniqueness(false); - let source = engine - .batch_upsert_nodes(vec![NodeInput { - labels: vec![bench_node_label(1)], - key: "parallel-source".to_string(), - props: BTreeMap::new(), - weight: 1.0, - dense_vector: None, - sparse_vector: None, - }]) - .unwrap()[0]; - let target = engine - .batch_upsert_nodes(vec![NodeInput { - labels: vec![bench_node_label(2)], - key: "parallel-target".to_string(), - props: BTreeMap::new(), - weight: 1.0, - dense_vector: None, - sparse_vector: None, - }]) - .unwrap()[0]; - let edges: Vec = (0..512) - .map(|_| EdgeInput { - from: source, - to: target, - label: "BenchEdge10".to_string(), - props: BTreeMap::new(), - weight: 1.0, - valid_from: None, - valid_to: None, - }) - .collect(); - engine.batch_upsert_edges(edges.clone()).unwrap(); - engine.flush().unwrap(); - (dir, engine, source) -} - const GRAPH_ROW_BENCH_EDGES: usize = 1_000; fn build_graph_row_optional_engine() -> (tempfile::TempDir, DatabaseEngine, u64) { @@ -1796,6 +1597,18 @@ fn setup_gql_mutation_return_smoke_db() -> (tempfile::TempDir, DatabaseEngine) { (dir, engine) } +fn setup_gql_merge_match_smoke_db() -> (tempfile::TempDir, DatabaseEngine) { + let (dir, engine) = temp_gql_mutation_bench_db(); + engine + .batch_upsert_nodes(vec![gql_bench_node( + "GqlBenchMerge", + "n", + gql_bench_props(&[("status", PropValue::String("old".to_string()))]), + )]) + .unwrap(); + (dir, engine) +} + fn assert_gql_mutation_result( result: overgraph::GqlExecutionResult, expected_rows: usize, @@ -1965,6 +1778,38 @@ fn bench_gql_queries(c: &mut Criterion) { }); }); + group.bench_function("gql_union_all_two_indexed_branches", |b| { + let (_dir, engine) = build_indexed_query_engine(); + let params = GqlParams::new(); + let options = GqlExecutionOptions::default(); + let query = "MATCH (n:BenchNode1) WHERE n.status = 'active' RETURN id(n) AS id \ + UNION ALL \ + MATCH (n:BenchNode1) WHERE n.tier = 'gold' RETURN id(n) AS id"; + b.iter(|| { + let result = engine + .execute_gql(black_box(query), black_box(¶ms), black_box(&options)) + .unwrap(); + assert_eq!(result.rows.len(), 1_500); + black_box(result) + }); + }); + + group.bench_function("gql_union_dedupe_overlapping_indexed_branches", |b| { + let (_dir, engine) = build_indexed_query_engine(); + let params = GqlParams::new(); + let options = GqlExecutionOptions::default(); + let query = "MATCH (n:BenchNode1) WHERE n.status = 'active' RETURN id(n) AS id \ + UNION \ + MATCH (n:BenchNode1) WHERE n.tier = 'gold' RETURN id(n) AS id"; + b.iter(|| { + let result = engine + .execute_gql(black_box(query), black_box(¶ms), black_box(&options)) + .unwrap(); + assert_eq!(result.rows.len(), 1_000); + black_box(result) + }); + }); + group.bench_function("gql_fixed_one_hop_pattern", |b| { let (_dir, engine, _company_id) = build_pattern_engine(); let params = GqlParams::new(); @@ -1980,6 +1825,25 @@ fn bench_gql_queries(c: &mut Criterion) { }); }); + group.bench_function("gql_shortest_path_bounded_endpoint_smoke", |b| { + let (_dir, engine, _company_id) = build_pattern_engine(); + let params = GqlParams::new(); + let options = GqlExecutionOptions::default(); + let query = "MATCH (a:BenchNode1) WHERE a.key = 'acct-0' \ + WITH a \ + MATCH (b:BenchNode2) WHERE b.key = 'company-0' \ + WITH a, b \ + MATCH p = shortestPath((a)-[:BenchEdge10*1..1]->(b)) \ + RETURN length(p)"; + b.iter(|| { + let result = engine + .execute_gql(black_box(query), black_box(¶ms), black_box(&options)) + .unwrap(); + assert_eq!(result.rows.len(), 1); + black_box(result) + }); + }); + group.bench_function("gql_fixed_branching_pattern", |b| { let (_dir, engine, _company_id) = build_pattern_engine(); let params = GqlParams::new(); @@ -2084,6 +1948,46 @@ fn bench_gql_queries(c: &mut Criterion) { ); }); + group.bench_function("gql_mutation_merge_create_smoke", |b| { + let params = GqlParams::new(); + let options = GqlExecutionOptions::default(); + let query = "MERGE (n:GqlBenchMerge {key: 'n'}) ON CREATE SET n.status = 'created'"; + b.iter_batched( + temp_gql_mutation_bench_db, + |(_dir, engine)| { + let result = engine + .execute_gql(black_box(query), black_box(¶ms), black_box(&options)) + .unwrap(); + let result = assert_gql_mutation_result(result, 0); + let stats = result.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.nodes_created, 1); + assert_eq!(stats.nodes_updated, 0); + black_box(result) + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("gql_mutation_merge_match_smoke", |b| { + let params = GqlParams::new(); + let options = GqlExecutionOptions::default(); + let query = "MERGE (n:GqlBenchMerge {key: 'n'}) ON MATCH SET n.status = 'matched'"; + b.iter_batched( + setup_gql_merge_match_smoke_db, + |(_dir, engine)| { + let result = engine + .execute_gql(black_box(query), black_box(¶ms), black_box(&options)) + .unwrap(); + let result = assert_gql_mutation_result(result, 0); + let stats = result.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.nodes_created, 0); + assert_eq!(stats.nodes_updated, 1); + black_box(result) + }, + BatchSize::SmallInput, + ); + }); + group.bench_function("gql_mutation_detach_delete_smoke", |b| { let params = GqlParams::new(); let options = GqlExecutionOptions::default(); diff --git a/docs/api-reference.md b/docs/api-reference.md index 4b938e4..3795503 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -92,6 +92,9 @@ Complete reference for OverGraph's public API across **Rust**, **Node.js**, and - [Graph Row Queries](#graph-row-queries) - [query_graph_rows](#query_graph_rows) - [explain_graph_rows](#explain_graph_rows) + - [Graph Pipeline Queries](#graph-pipeline-queries) + - [query_graph_pipeline](#query_graph_pipeline) + - [explain_graph_pipeline](#explain_graph_pipeline) - [GQL Beta](#gql-beta) - [Overview](#overview) - [Read Syntax](#read-syntax) @@ -103,7 +106,7 @@ Complete reference for OverGraph's public API across **Rust**, **Node.js**, and - [Params](#params) - [Explain, Profile, and Stats](#explain-profile-and-stats) - [Examples](#examples) - - [Not Yet Supported In GQL Beta](#not-yet-supported-in-gql-beta) + - [Current Limits](#current-limits) - [Query Request Types and Plans](#query-request-types-and-plans) - [NodeQuery](#nodequery) - [NodeFilter / QueryNodeFilter](#nodefilter--querynodefilter) @@ -2927,6 +2930,230 @@ Node.js and Python async APIs expose graph-row query and explain methods through --- +### Graph Pipeline Queries + +Graph pipeline queries are the structured public API for composable multi-stage graph reads. They +use the same native executor that GQL Beta lowers into for `WITH`, `DISTINCT`, aggregation, `UNION`, +read-only `CALL`, and shortest-path stages. Use graph pipelines when you want the Phase 34 row +pipeline substrate without parsing a GQL string. + +#### query_graph_pipeline + +Runs a graph pipeline request and returns explicit columns plus rows. The final page is governed by +`limit` and `options.max_rows`; intermediate pipeline materialization is governed by +`options.max_pipeline_rows`, `options.max_groups`, `options.max_collect_items`, +`options.max_union_branches`, `options.max_subquery_invocations`, and +`options.max_shortest_path_pairs`. + +**Rust** +```rust +let result = db.query_graph_pipeline(&GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![GraphNodePattern { + alias: "n".into(), + label_filter: Some(NodeLabelFilter { + labels: vec!["Person".into()], + mode: LabelMatchMode::All, + }), + ids: vec![], + keys: vec![], + filter: None, + }], + pieces: vec![], + where_: None, + optional_candidate_where: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::With, + items: GraphProjectionItems::Items(vec![ + GraphProjectItem { + expr: GraphExpr::Property { alias: "n".into(), key: "name".into() }, + alias: Some("name".into()), + projection: GraphReturnProjection::Auto, + }, + GraphProjectItem { + expr: GraphExpr::Property { alias: "n".into(), key: "rank".into() }, + alias: Some("rank".into()), + projection: GraphReturnProjection::Auto, + }, + ]), + distinct: false, + where_: None, + order_by: vec![GraphOrderItem { + expr: GraphExpr::Binding("rank".into()), + direction: GraphOrderDirection::Desc, + }], + skip: None, + limit: Some(GraphExpr::UInt(10)), + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("name".into()), + alias: Some("name".into()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: vec![], + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { skip: 0, limit: 100, cursor: None }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions::default(), +})?; +``` + +**Node.js** +```javascript +const result = db.queryGraphPipeline({ + stages: [ + { kind: 'match', nodes: [{ alias: 'n', labelFilter: { labels: ['Person'], mode: 'all' } }] }, + { + kind: 'project', + projectKind: 'with', + items: [ + { expr: { property: { alias: 'n', key: 'name' } }, as: 'name' }, + { expr: { property: { alias: 'n', key: 'rank' } }, as: 'rank' }, + ], + orderBy: [{ expr: { binding: 'rank' }, direction: 'desc' }], + limit: 10, + }, + { kind: 'project', projectKind: 'return', items: [{ expr: { binding: 'name' }, as: 'name' }] }, + ], + limit: 100, +}); +``` + +**Python** +```python +result = db.query_graph_pipeline({ + "stages": [ + {"kind": "match", "nodes": [{"alias": "n", "label_filter": {"labels": ["Person"], "mode": "all"}}]}, + { + "kind": "project", + "project_kind": "with", + "items": [ + {"expr": {"property": {"alias": "n", "key": "name"}}, "as": "name"}, + {"expr": {"property": {"alias": "n", "key": "rank"}}, "as": "rank"}, + ], + "order_by": [{"expr": {"binding": "rank"}, "direction": "desc"}], + "limit": 10, + }, + {"kind": "project", "project_kind": "return", "items": [{"expr": {"binding": "name"}, "as": "name"}]}, + ], + "limit": 100, +}) +``` + +Aggregation uses `GraphExpr::AggregateCall` in Rust and the `aggregate` expression tag in Node.js +and Python: + +```javascript +const counts = db.queryGraphPipeline({ + stages: [ + { kind: 'match', nodes: [{ alias: 'n', labelFilter: { labels: ['Person'], mode: 'all' } }] }, + { kind: 'return', items: [{ expr: { aggregate: { function: 'count' } }, as: 'people' }] }, + ], + limit: 10, +}); +``` + +```python +counts = db.query_graph_pipeline({ + "stages": [ + {"kind": "match", "nodes": [{"alias": "n", "label_filter": {"labels": ["Person"], "mode": "all"}}]}, + {"kind": "return", "items": [{"expr": {"aggregate": {"function": "count"}}, "as": "people"}]}, + ], + "limit": 10, +}) +``` + +##### Parameters + +| Parameter | Rust | Node.js | Python | Required | Description | +|-----------|------|---------|--------|----------|-------------| +| request | `&GraphPipelineQuery` | `GraphPipelineRequest` | `dict \| GraphPipelineRequest` | Yes | Ordered pipeline stages plus params, output, page, and safety options. | + +Pipeline request fields: + +| Field | Rust | Node.js | Python | Description | +|-------|------|---------|--------|-------------| +| stages | `stages` | `stages` | `stages` | Ordered stage list. Must end in `Project(Return)`. | +| params | `params` | `params` | `params` | Structured parameter values referenced by expressions. | +| at epoch | `at_epoch` | `atEpoch` | `at_epoch` | Optional snapshot epoch for temporal reads. | +| page | `page` | `skip`, `limit`, `cursor` | `skip`, `limit`, `cursor` | Final logical row pagination. Pipeline cursors are separate from graph-row cursors. | +| output | `output` | `output` | `output` | Same output modes as graph-row queries. | +| options | `options` | `options` | `options` | Pipeline safety caps, `include_plan`, and `profile`. | + +Supported stages: + +| Stage | Rust | Node.js kind | Python kind | Purpose | +|-------|------|--------------|-------------|---------| +| Match | `GraphPipelineStage::Match` | `match` | `match` | Graph-row-backed match stage. | +| Project | `GraphPipelineStage::Project` | `project`, `with`, `return` | `project`, `with`, `return` | `WITH` or terminal `RETURN` projection, `DISTINCT`, row ops, and post-projection filter. | +| Union | `GraphPipelineStage::Union` | `union` | `union` | `UNION` / `UNION ALL` over read pipeline branches. | +| Call | `GraphPipelineStage::Call` | `call` | `call` | Read-only subquery stage with imported aliases. | +| Shortest path | `GraphPipelineStage::ShortestPath` | `shortestPath` | `shortest_path` | Bounded native shortest-path stage. | + +##### Returns + +| Rust | Node.js | Python | +|------|---------|--------| +| `Result` | `GraphPipelineResult` | `GraphPipelineResult` | + +`GraphPipelineResult` has `columns`, `rows`, `next_cursor` / `nextCursor`, `stats`, and optional +`plan`, matching graph-row result shape. Rust and Python use snake_case result/stat fields; Node.js +uses camelCase. Pipeline stats add `rows_entered_pipeline` / `rowsEnteredPipeline`, +`intermediate_rows` / `intermediateRows`, `pipeline_rows_materialized` / +`pipelineRowsMaterialized`, `groups`, `collect_items` / `collectItems`, +union/subquery/shortest-path counters, `db_hits` / `dbHits`, `elapsed_us` / `elapsedUs`, +`effective_at_epoch` / `effectiveAtEpoch`, and `warnings`. + +#### explain_graph_pipeline + +Returns pipeline validation, normalized stage details, caps, stats, and nested graph-row explains +without returning rows. + +**Rust** +```rust +let explain = db.explain_graph_pipeline(&query)?; +``` + +**Node.js** +```javascript +const explain = db.explainGraphPipeline(request); +const asyncExplain = await db.explainGraphPipelineAsync(request); +``` + +**Python** +```python +explain = db.explain_graph_pipeline(request) +async_explain = await async_db.explain_graph_pipeline(request) +``` + +##### Returns + +| Rust | Node.js | Python | +|------|---------|--------| +| `Result` | `GraphPipelineExplain` | `dict` | + +`GraphPipelineExplain` includes `columns`, `effective_at_epoch` / `effectiveAtEpoch`, +`fingerprint`, `stages`, `row_ops` / `rowOps`, `order`, `cursor`, `projection`, `caps`, +`summaries`, `stats`, `warnings`, and `notes`. + +Node.js and Python async APIs expose pipeline query and explain methods through +`queryGraphPipelineAsync`, `explainGraphPipelineAsync`, and +`AsyncOverGraph.query_graph_pipeline` / `AsyncOverGraph.explain_graph_pipeline`. + +--- + ### GQL Beta #### Overview @@ -2934,29 +3161,25 @@ Node.js and Python async APIs expose graph-row query and explain methods through **GQL Beta** is OverGraph's GQL/Cypher-style query language for graph reads and writes, running in OverGraph's embedded Rust engine. -Read statements lower into the same graph-row substrate as -[`query_graph_rows`](#query_graph_rows). Mutation statements lower into native write transactions -and commit through `WriteTxn` / `TxnIntent` plus crate-private replacement adapters where by-ID -updates are required. Node.js and Python connectors call the Rust API directly; they do not -reimplement parsing, lowering, planning, execution, cursor handling, optional semantics, mutation -semantics, or path value conversion. +Use GQL Beta when a graph query or mutation is clearer as text than as request objects. It is not a +full ISO GQL or Cypher implementation; supported syntax is documented below, and unsupported +features are collected in [Current Limits](#current-limits). -What GQL Beta gives you: +Supported at a glance: - `MATCH`, `OPTIONAL MATCH`, `WHERE`, `RETURN`, `ORDER BY`, `SKIP` / `OFFSET`, and `LIMIT` query strings -- row-shaped graph reads over required patterns, optional groups, and bounded variable-length paths -- keyed mutations: `CREATE`, `SET`, `REMOVE`, `DELETE r`, and `DETACH DELETE n` -- mutation `RETURN` for `CREATE`, `SET`, and `REMOVE` +- `WITH`, `WITH *`, `WITH DISTINCT`, later `MATCH` stages, and terminal `RETURN DISTINCT` +- aggregation with `count`, `sum`, `avg`, `min`, `max`, and `collect` +- read-only `UNION`, `UNION ALL`, `EXISTS { ... }`, and `CALL { ... }` +- required patterns, optional patterns, bounded variable-length paths, and constrained shortest paths +- rich scalar expressions, arithmetic, string predicates, `CASE`, and scalar functions +- keyed mutations: `CREATE`, `MERGE`, `SET`, `REMOVE`, `DELETE r`, and `DETACH DELETE n` +- keyed node `MERGE`, unique relationship `MERGE`, `ON CREATE SET`, and `ON MATCH SET` +- mutation `RETURN` for `CREATE`, `MERGE`, `SET`, and `REMOVE`, including `RETURN DISTINCT` - mutation stats and unified query/mutation result shapes - scalar values, node values, edge values, path values, lists, maps, bytes, and nulls -- params, full-scan opt-in, caps, ReadOnly mode, explain/profile, warnings, and stats -- read continuation cursors -- vector omission by default, with explicit opt-in when returning node values -- Rust, Node.js, and Python parity over the same Rust parser, binder, lowerer, planner, and executor - -Features outside the current surface are listed in -[Not Yet Supported In GQL Beta](#not-yet-supported-in-gql-beta). A compact syntax companion is -available in [GQL Beta](gql-subset.md). +- params, read cursors, full-scan opt-in, caps, ReadOnly mode, explain/profile, warnings, and stats +- Rust, Node.js, Python, and async connector methods #### Read Syntax @@ -2965,17 +3188,52 @@ Read clause order: ```gql MATCH [, ...] [WHERE ] OPTIONAL MATCH [, ...] [WHERE ] -RETURN +WITH [DISTINCT] [ORDER BY ...] [SKIP ...] [LIMIT ...] [WHERE ] +CALL { } +RETURN [DISTINCT] ORDER BY [ASC|DESC], ... SKIP OFFSET LIMIT ``` -`WHERE`, `ORDER BY`, `SKIP` / `OFFSET`, and `LIMIT` are optional. Each required or optional match -clause can have its own `WHERE`. `OPTIONAL MATCH` clauses follow an initial required `MATCH`. -`SKIP` and `OFFSET` are synonyms; using both in one query is rejected. `LIMIT 0` validates the -query and returns an empty result without running graph-row execution. +`WHERE`, `ORDER BY`, `SKIP` / `OFFSET`, and `LIMIT` are optional. Each `MATCH` or `OPTIONAL MATCH` +can have its own `WHERE`. `OPTIONAL MATCH` follows an initial required `MATCH`. `SKIP` and `OFFSET` +are synonyms; using both in one query is rejected. `LIMIT 0` returns no result rows. + +`WITH` projects the names available to later clauses. `WITH *` preserves visible aliases, +`WITH DISTINCT` deduplicates rows, `WITH ORDER BY`, `SKIP` / `OFFSET`, and `LIMIT` apply +before `WITH ... WHERE` and before the next clause. + +Later `MATCH` and `OPTIONAL MATCH` stages can be seeded from aliases preserved by earlier stages: + +```gql +MATCH (p:Person) +WITH p, lower(trim(p.email)) AS email +WHERE email ENDS WITH '@example.com' +MATCH (p)-[:WORKS_AT]->(c:Company) +RETURN DISTINCT p.name AS person, email, c.name AS company +ORDER BY person +LIMIT 20 +``` + +Terminal `RETURN` supports `RETURN DISTINCT`, `RETURN DISTINCT *`, `ORDER BY`, `SKIP` / `OFFSET`, +and `LIMIT`. + +Read queries can be combined with `UNION` and `UNION ALL`: + +```gql +MATCH (p:Person) WHERE p.status = 'active' +RETURN p.name AS name +UNION +MATCH (p:Person) WHERE p.status = 'invited' +RETURN p.name AS name +``` + +Every union branch must be read-only, end in `RETURN`, and return the same output names. `UNION ALL` +keeps duplicates. `UNION` removes duplicate returned rows. Branch-local `ORDER BY`, +`SKIP` / `OFFSET`, and `LIMIT` apply before union results are combined. Union branch count is capped +by `max_union_branches`; `UNION` dedupe counts against `max_groups`. Pattern shapes: @@ -2990,12 +3248,29 @@ Pattern shapes: | Zero-to-N bounded path | `MATCH p = (a)-[:KNOWS*0..2]->(b)` or `MATCH p = (a)-[:KNOWS*..2]->(b)` | | Exact-length path | `MATCH p = (a)-[:KNOWS*2]->(b)` | | One-hop path plus edge alias | `MATCH p = (a)-[r:KNOWS*1..1]->(b)` | +| Shortest path | `MATCH p = shortestPath((a)-[:KNOWS*1..5]->(b))` | +| All equal shortest paths | `MATCH p = allShortestPaths((a)-[:KNOWS*1..5]-(b))` | | Property map predicates | `MATCH (n:Person {name: $name})` | -Relationship quantifiers must have a finite upper bound. The upper bound must fit the engine's path -hop cap. Variable-length paths are relationship-simple: one path cannot reuse the same edge ID. -Multi-hop relationship-list aliases are not supported; return the path alias and inspect -`edge_ids`. +Relationship quantifiers must have a finite upper bound no greater than `max_path_hops`. +Variable-length paths are relationship-simple: one path cannot reuse the same edge ID. + +Shortest-path reads use `shortestPath` or `allShortestPaths` with a required path alias. Bind the +start and end node aliases first, then match a bounded relationship pattern such as `*1..4`. +`shortestPath` returns at most one path for each input row. `allShortestPaths` returns all equal +shortest paths up to the path caps. `OPTIONAL MATCH` binds the path alias to null when no path is +found. GQL shortest paths are unweighted; use the native `shortest_path` APIs for weighted paths. + +Bind endpoints first: + +```gql +MATCH (a:Person {key: $from}) +WITH a +MATCH (b:Person {key: $to}) +WITH a, b +MATCH p = shortestPath((a)-[:KNOWS*1..4]->(b)) +RETURN p, node_ids(p) AS node_ids, edge_ids(p) AS edge_ids, length(p) AS hops +``` Expressions: @@ -3016,6 +3291,10 @@ Expressions: | Comparisons | `=`, `<>`, `<`, `<=`, `>`, `>=` | | Null checks | `IS NULL`, `IS NOT NULL` | | Membership | `IN` | +| Arithmetic | `n.rank + 1`, `n.score * 2`, `n.total / 4`, `-n.rank` | +| String predicates | `n.name STARTS WITH 'A'`, `n.email ENDS WITH '.org'`, `n.name CONTAINS 'da'` | +| Generic `CASE` | `CASE WHEN n.rank > 10 THEN 'high' ELSE 'low' END` | +| Simple `CASE` | `CASE n.status WHEN 'active' THEN 1 ELSE 0 END` | | Return all bound aliases | `RETURN *` | Functions: @@ -3032,13 +3311,120 @@ Functions: | `relationships(p)` | path alias | | `node_ids(p)` | path alias | | `edge_ids(p)` | path alias | +| `coalesce(value, ...)` | one or more scalar/list/map/null values | +| `to_string(value)` | scalar numeric, boolean, string, or null | +| `to_integer(value)` | numeric, base-10 integer string, or null | +| `to_float(value)` | numeric, finite-float string, or null | +| `abs(value)` | numeric or null | +| `floor(value)` | numeric or null | +| `ceil(value)` | numeric or null | +| `round(value)` | numeric or null | +| `lower(value)` | string or null | +| `upper(value)` | string or null | +| `trim(value)` | string or null | +| `substring(value, start[, length])` | string plus non-negative integer offsets | +| `size(value)` | string, list, map, or null | +| `head(list)` | list or null | +| `last(list)` | list or null | + +`ORDER BY` can sort null, bool, finite numeric, string, bytes, node, edge, and path values. Nulls +sort last. Lists, maps, and non-finite floats are rejected. + +Numeric expression behavior is checked. Integer arithmetic overflows are errors, division by zero is +an error, `/` returns a finite float, and non-finite float input or output is rejected. + +Rich expression example: + +```gql +MATCH (n:Person) +WITH n.name AS name, + lower(trim(n.email)) AS email, + n.rank + 2 AS boosted, + CASE n.status WHEN 'active' THEN upper(n.status) ELSE 'OTHER' END AS bucket +WHERE email CONTAINS '@' +RETURN name, email, boosted, bucket +``` + +`DISTINCT` works in both `RETURN` and `WITH`, including `RETURN DISTINCT *` and `WITH DISTINCT *`. +Scalars compare by value, nodes by node ID, edges by edge ID, paths by ordered `node_ids` / +`edge_ids`, lists by element value, and maps by sorted string keys. Distinct keys count against +`max_groups`. -`ORDER BY` uses graph-row order atoms. Null, bool, finite numeric, string, bytes, node, edge, and -path values are orderable. Nulls sort last. Lists, maps, and non-finite floats are rejected. +```gql +MATCH (p:Person)-[:WORKS_AT]->(c:Company) +WITH DISTINCT p +RETURN DISTINCT p.status AS status +ORDER BY status +``` + +Aggregation is available in `WITH` and terminal `RETURN` projections: + +| Aggregate | Behavior | +|-----------|----------| +| `count(*)` | Counts every input row. | +| `count(expr)` | Counts non-null values. | +| `sum(expr)` | Sums numeric non-null values with checked numeric behavior. | +| `avg(expr)` | Returns a finite float average over numeric non-null values. | +| `min(expr)` / `max(expr)` | Accept numeric, string, and boolean comparable domains. | +| `collect(expr)` | Collects non-null values in input order. | + +Aggregate `DISTINCT` is supported, for example `count(DISTINCT n.email)` and +`collect(DISTINCT n.status)`. Aggregate calls can appear inside projection expressions and +projection-local `ORDER BY`, such as `coalesce(avg(n.rank), 0.0)` or `ORDER BY count(*) DESC`. +Non-aggregate projected expressions become group keys. Empty `count` returns `0`, empty `collect` +returns `[]`, and empty `sum`, `avg`, `min`, and `max` return `null`. With no group keys, a zero-row +aggregate returns one row; with group keys, it returns zero rows. Aggregation uses `max_groups`; +`collect` also uses `max_collect_items`. + +```gql +MATCH (n:Person) +WITH n.group AS group, + count(*) AS total, + avg(n.rank) AS avg_rank, + collect(DISTINCT n.status) AS statuses +WHERE total > 1 +RETURN group, total, coalesce(avg_rank, 0.0) AS avg_rank, statuses +ORDER BY total DESC +``` Edge ID and edge-label metadata use `id(r)` and `type(r)`. Dot access such as `r.id` and `r.label` reads ordinary edge properties with those names when present. +##### Read-Only Subqueries + +`EXISTS { }` is a predicate expression. It returns true when the +subquery emits at least one row and false otherwise. It can reference aliases from the outer query +and uses the same read snapshot. Subquery columns are not exposed. + +```gql +MATCH (p:Person) +WHERE EXISTS { + MATCH (p)-[:WORKS_AT]->(c:Company) + WHERE c.status = 'active' + RETURN c +} +RETURN p.name AS name +``` + +`CALL { }` is a read-only subquery stage. It can reference aliases +from the outer query. Returned subquery rows are joined back to each outer row; if a subquery returns +zero rows for an outer row, that outer row is dropped. Returned column names must not collide with +preserved outer names. + +```gql +MATCH (p:Person) +CALL { + MATCH (p)-[:WORKS_AT]->(c:Company) + RETURN c.name AS company +} +RETURN p.name AS person, company +ORDER BY person +``` + +Nested read-only subqueries are allowed up to `max_subquery_depth`; total invocations are capped by +`max_subquery_invocations`. Mutating subqueries and procedure calls such as `CALL db.labels()` are +unsupported. + #### Mutation Syntax Mutation clause order: @@ -3046,22 +3432,25 @@ Mutation clause order: ```gql MATCH [WHERE ] OPTIONAL MATCH [WHERE ] +WITH [DISTINCT] [ORDER BY ...] [SKIP ...] [LIMIT ...] [WHERE ] +CALL { } CREATE [, ...] +MERGE (n:Label {key: expr}) [ON CREATE SET ...] [ON MATCH SET ...] +MERGE (a)-[r:TYPE]->(b) [ON CREATE SET ...] [ON MATCH SET ...] SET REMOVE DELETE DETACH DELETE -RETURN +RETURN [DISTINCT] ORDER BY [ASC|DESC], ... SKIP OFFSET LIMIT ``` -Read prefixes are optional for create-only statements, but all `MATCH` / `OPTIONAL MATCH` clauses -must appear before the first mutation clause. For mutation read prefixes, use repeated `MATCH` -clauses instead of comma-separated pattern lists. GQL Beta does not support read-after-write -pipelines, `WITH`, `UNWIND`, subqueries, or interleaved `MATCH CREATE MATCH` forms. +Read prefixes are optional for create-only statements. When a mutation does read first, put every +`MATCH`, `OPTIONAL MATCH`, `WITH`, `EXISTS {}`, or read-only `CALL {}` before the first write clause. +For mutation read prefixes, use repeated `MATCH` clauses instead of comma-separated pattern lists. Mutation forms: @@ -3069,29 +3458,42 @@ Mutation forms: |------|---------|-------| | Create node | `CREATE (n:Person {key: 'ada', name: 'Ada'})` | A created node needs at least one label and a string `key`. | | Create edge | `MATCH (a:Person) WHERE a.key = 'a' MATCH (b:Person) WHERE b.key = 'b' CREATE (a)-[r:KNOWS {since: 2026}]->(b)` | The edge needs exactly one relationship label. | +| Merge keyed node | `MERGE (n:Person {key: $key}) ON CREATE SET n.created = true ON MATCH SET n.seen = true` | Exactly one static label and identity property named `key`. The key must evaluate to a non-null string. | +| Merge unique edge | `MATCH (a:Person {key: $a}) MATCH (b:Person {key: $b}) MERGE (a)-[r:KNOWS]->(b)` | Requires bound non-null endpoints and `edge_uniqueness = true`. Null endpoint rows are skipped. | | Set property | `MATCH (n:Person) WHERE n.key = 'ada' SET n.status = 'active'` | `null` removes the property. | | Merge property map | `MATCH (n:Person) WHERE n.key = 'ada' SET n += $props` | The right side must be a map. Null map values remove properties. | | Add node label | `MATCH (n:Person) WHERE n.key = 'ada' SET n:Engineer` | Label/key conflicts reject the whole statement. | | Remove property | `MATCH (n:Person) WHERE n.key = 'ada' REMOVE n.status` | Missing properties are no-ops. | | Remove node label | `MATCH (n:Person) WHERE n.key = 'ada' REMOVE n:Engineer` | Removing the last live label is rejected. | | Delete edge | `MATCH (a)-[r:KNOWS]->(b) DELETE r` | Node deletion requires `DETACH DELETE`. | -| Detach delete node | `MATCH (n:Person) WHERE n.key = 'ada' DETACH DELETE n` | Incident edges are deleted through transaction cascade planning. | +| Detach delete node | `MATCH (n:Person) WHERE n.key = 'ada' DETACH DELETE n` | Incident edges are deleted with the node. | -`CREATE` is strict. It fails if a node `(label, key)` membership already exists in the transaction -snapshot or earlier staged creates. When `edge_uniqueness = true`, edge `CREATE` also fails if the -same `(from, to, label)` triple already exists. With `edge_uniqueness = false`, parallel edge +`CREATE` is strict. It fails if a node `(label, key)` membership already exists or was already +created earlier in the same statement. When `edge_uniqueness = true`, edge `CREATE` also fails if +the same `(from, to, label)` triple already exists. With `edge_uniqueness = false`, parallel edge creates are allowed. +`MERGE` supports two shapes: + +- keyed node: `MERGE (n:Label {key: expr})` +- unique relationship: `MERGE (a)-[r:TYPE]->(b)` + +`ON CREATE SET` runs only when the entity is created. `ON MATCH SET` runs when the entity already +exists or was created by an earlier row in the same statement. If the same missing key or +relationship triple appears more than once in one statement, the first row creates it and later rows +match that same entity. Later property assignments win deterministically. The statement commits as +one transaction with no partial writes. + Optional-null mutation targets are no-ops. Duplicate updates to the same target are deterministic: later mutation input rows win for updates, and duplicate deletes are idempotent. -Mutation statements commit zero or one transaction. Parse, semantic, param, expression, cap, -strict-create, staging, transaction conflict, and commit failures do not publish partial writes. +Mutation statements commit zero or one transaction. If parsing, validation, caps, strict-create, +conflict checking, or commit fails, no partial writes are published. ##### Mutation RETURN -`CREATE`, `SET`, and `REMOVE` may include `RETURN`, `ORDER BY`, `SKIP` / `OFFSET`, and `LIMIT`. -`DELETE` and `DETACH DELETE` reject `RETURN` in Phase 33. +`CREATE`, `MERGE`, `SET`, and `REMOVE` may include `RETURN`, `RETURN DISTINCT`, `ORDER BY`, +`SKIP` / `OFFSET`, and `LIMIT`. `DELETE` and `DETACH DELETE` still reject `RETURN`. Mutation row operations affect returned rows only. The mutation clauses still apply to every input row produced by the read prefix. For example, `RETURN ... LIMIT 0` performs the mutation and returns @@ -3102,14 +3504,14 @@ Mutation `RETURN` supports: - created and mutated aliases - non-mutated read-prefix aliases - path aliases captured by the read prefix +- `RETURN DISTINCT` over prevalidated return rows - compact rows in connectors - vector inclusion for returned node values when `include_vectors` / `includeVectors` is true - `ORDER BY`, `SKIP` / `OFFSET`, and `LIMIT` over prevalidated return expressions -Known Phase 33 limitation from `QPX-019`: mutation `RETURN ORDER BY` rejects keys whose final value -cannot be proven before commit, including commit-assigned created IDs/timestamps, created-edge -endpoint metadata, and same-mutation volatile `updated_at`. This avoids speculative ID/timestamp -reservation and keeps failed writes leak-free. +Mutation `RETURN` aggregation remains unsupported. Mutation `RETURN ORDER BY`, `RETURN DISTINCT`, +and `MERGE` actions cannot depend on commit-assigned values such as newly created IDs, timestamps, +created-edge endpoint metadata, or same-mutation `updated_at`. #### Method Reference @@ -3129,6 +3531,14 @@ let result = db.execute_gql( &GqlExecutionOptions::default(), )?; +let grouped = db.execute_gql( + "MATCH (n:Person) \ + WITH n.group AS group, count(*) AS total, collect(DISTINCT n.status) AS statuses \ + RETURN group, total, statuses ORDER BY total DESC", + &GqlParams::new(), + &GqlExecutionOptions::default(), +)?; + let created = db.execute_gql( "CREATE (n:Person {key: 'ada', name: 'Ada'}) RETURN n.name AS name", &GqlParams::new(), @@ -3148,6 +3558,14 @@ const result = db.executeGql( 'MATCH (n:Person) RETURN n.name AS name ORDER BY n.name LIMIT 10' ); +const grouped = db.executeGql( + `MATCH (n:Person) + WITH n.group AS group, count(*) AS total + WHERE total > 1 + RETURN group, total + ORDER BY total DESC` +); + const asyncResult = await db.executeGqlAsync( 'MATCH (n:Person) RETURN n.name AS name ORDER BY n.name LIMIT 10' ); @@ -3156,6 +3574,13 @@ const created = db.executeGql( "CREATE (n:Person {key: 'ada', name: 'Ada'}) RETURN n.name AS name" ); +const merged = db.executeGql( + `MERGE (n:Person {key: 'ada'}) + ON CREATE SET n.status = 'created' + ON MATCH SET n.status = 'matched' + RETURN n.key AS key, n.status AS status` +); + const explain = db.explainGql( 'MATCH (n:Person) RETURN n.name AS name' ); @@ -3167,6 +3592,18 @@ result = db.execute_gql( "MATCH (n:Person) RETURN n.name AS name ORDER BY n.name LIMIT 10" ) +path_result = db.execute_gql( + """ + MATCH (a:Person {key: $from_key}) + WITH a + MATCH (b:Person {key: $to_key}) + WITH a, b + MATCH p = shortestPath((a)-[:KNOWS*1..4]->(b)) + RETURN node_ids(p) AS node_ids, edge_ids(p) AS edge_ids + """, + {"from_key": "ada", "to_key": "ben"}, +) + async_result = await async_db.execute_gql( "MATCH (n:Person) RETURN n.name AS name ORDER BY n.name LIMIT 10" ) @@ -3193,18 +3630,25 @@ Option fields: | Option | Rust | Node.js | Python | Default | Description | |--------|------|---------|--------|---------|-------------| | Mode | `mode` | `mode` | `mode` | `Auto` / `"auto"` | `"auto"` permits reads and mutations. `"readOnly"` / `"read_only"` rejects mutation statements before write staging. | -| Full-scan opt-in | `allow_full_scan` | `allowFullScan` | `allow_full_scan` | `false` | Allows legal broad node/edge scans when no bounded native anchor exists. | +| Full-scan opt-in | `allow_full_scan` | `allowFullScan` | `allow_full_scan` | `false` | Allows legal broad node/edge scans when no bounded anchor exists. | | Result row cap | `max_rows` | `maxRows` | `max_rows` | `10000` | Maximum returned rows after row operations. Mutations do not page with cursors, so mutation `RETURN` must fit this cap. | | Cursor | `cursor` | `cursor` | `cursor` | `None` / `null` | Read continuation token from `next_cursor` / `nextCursor`. Mutation statements reject cursors. | | Cursor byte cap | `max_cursor_bytes` | `maxCursorBytes` | `max_cursor_bytes` | `16384` | Maximum accepted or emitted read cursor token size. | | Mutation row cap | `max_mutation_rows` | `maxMutationRows` | `max_mutation_rows` | `10000` | Maximum input rows a mutation may write from. | | Mutation op cap | `max_mutation_ops` | `maxMutationOps` | `max_mutation_ops` | `50000` | Maximum staged logical mutation operations before commit, including cascaded deletes. | +| Pipeline row cap | `max_pipeline_rows` | `maxPipelineRows` | `max_pipeline_rows` | `65536` | Maximum intermediate rows retained by multi-stage GQL reads. | +| Group/dedupe cap | `max_groups` | `maxGroups` | `max_groups` | `65536` | Maximum aggregate groups or canonical dedupe keys for `DISTINCT` and `UNION`. | +| Collect item cap | `max_collect_items` | `maxCollectItems` | `max_collect_items` | `65536` | Maximum collected values retained by aggregate collection stages. | +| Union branch cap | `max_union_branches` | `maxUnionBranches` | `max_union_branches` | `16` | Maximum read branches allowed in one `UNION` / `UNION ALL` statement. | +| Subquery invocation cap | `max_subquery_invocations` | `maxSubqueryInvocations` | `max_subquery_invocations` | `4096` | Maximum subquery invocations for supported subquery execution. | +| Subquery depth cap | `max_subquery_depth` | `maxSubqueryDepth` | `max_subquery_depth` | `2` | Maximum nested subquery depth. | +| Shortest-path pair cap | `max_shortest_path_pairs` | `maxShortestPathPairs` | `max_shortest_path_pairs` | `4096` | Maximum source/target pairs for supported shortest-path planning. | | Query byte cap | `max_query_bytes` | `maxQueryBytes` | `max_query_bytes` | `1048576` | Maximum GQL source text bytes accepted by the parser. | | Param byte cap | `max_param_bytes` | `maxParamBytes` | `max_param_bytes` | `1048576` | Maximum referenced param string/bytes/map-key bytes, both per value/key and total across referenced params. | | AST/param depth cap | `max_ast_depth` | `maxAstDepth` | `max_ast_depth` | `256` | Maximum parser AST depth and referenced runtime list/map nesting depth. | | Literal/param item cap | `max_literal_items` | `maxLiteralItems` | `max_literal_items` | `10000` | Maximum list/map literal items, per referenced list/map container, and total referenced list/map items. | -| Intermediate cap | `max_intermediate_bindings` | `maxIntermediateBindings` | `max_intermediate_bindings` | `65536` | Maximum native/intermediate row bindings held while executing reads or mutation read prefixes. | -| Frontier cap | `max_frontier` | `maxFrontier` | `max_frontier` | `65536` | Maximum graph-row frontier expansion size. | +| Intermediate cap | `max_intermediate_bindings` | `maxIntermediateBindings` | `max_intermediate_bindings` | `65536` | Maximum intermediate row bindings held while executing reads or mutation read prefixes. | +| Frontier cap | `max_frontier` | `maxFrontier` | `max_frontier` | `65536` | Maximum relationship-expansion frontier size. | | Path hop cap | `max_path_hops` | `maxPathHops` | `max_path_hops` | `16` | Maximum finite upper bound for variable-length paths. | | Paths per start cap | `max_paths_per_start` | `maxPathsPerStart` | `max_paths_per_start` | `4096` | Maximum variable-length paths retained per start row. | | Order materialization cap | `max_order_materialization` | `maxOrderMaterialization` | `max_order_materialization` | `65536` | Maximum rows/materialized order keys for ordered reads and mutation returns. | @@ -3214,8 +3658,8 @@ Option fields: | Compact rows | `compact_rows` | `compactRows` | `compact_rows` | `false` | Rust rows are already positional. In connectors, returns row arrays instead of row objects. Does not change execution. | | Include vectors | `include_vectors` | `includeVectors` | `include_vectors` | `false` | Includes dense/sparse vectors when returning node element values. | -`compactRows` / `compact_rows` is connector serialization only. It does not change parsing, lowering, -planning, selected fields, vector policy, ordering, stats, caps, warnings, or plan truth. +`compactRows` / `compact_rows` changes only connector row serialization. It does not change selected +fields, vector policy, ordering, stats, caps, warnings, or explain output. #### Results and Row Formats @@ -3278,10 +3722,9 @@ unambiguous: if multiple `RETURN` items use the same alias, clauses such as `ORD `LIMIT x` reject `x` instead of choosing one of the duplicate columns. When a result has another page, it includes `next_cursor` / `nextCursor`. Pass that value as the -next call's `cursor` option with the same logical query and params. GQL cursors are continuation -tokens over final logical result rows and validate the normalized graph-row fingerprint. They are -not pinned storage snapshots across pages. Mutation statements reject `cursor` and always return -`next_cursor` / `nextCursor` as null. +next call's `cursor` option with the same logical query and params. GQL cursors continue final +logical result rows; they are not pinned storage snapshots across pages. Mutation statements reject +`cursor` and always return `next_cursor` / `nextCursor` as null. Mutation results use the same row shapes and include `mutation_stats` / `mutationStats`: @@ -3314,6 +3757,13 @@ GQL values can be: Node.js bytes are returned as `Buffer`. Python bytes are returned as `bytes`. Rust uses `GqlValue::Bytes(Vec)`. +`collect` returns a list whose items follow normal GQL expression value rules. It can collect nested +lists, maps, and path values; when a node or edge alias is collected as an expression, the collected +value is its ID. Return node, edge, or path aliases directly when the result should contain hydrated +graph element values. Path helper functions return scalar/list values: `length(p)` returns hop +count, `node_ids(p)` / `edge_ids(p)` return ID lists, and `nodes(p)` / `relationships(p)` return ID +lists for helper expressions while returning a path alias as a value hydrates path `nodes` / `edges`. + Node values expose only requested fields: | Field | Rust | Node.js | Python | @@ -3377,8 +3827,7 @@ Hydrated nodes inside path values follow the same vector policy as returned node #### Params -Params are named and referenced with `$name` syntax. They are converted into Rust `GqlParamValue` -before planning and execution. +Params are named and referenced with `$name` syntax. Only params referenced by the query are resource-validated. Referenced list/map params are bounded by `max_ast_depth` and `max_literal_items`; referenced string, bytes, and map-key payload bytes are @@ -3437,11 +3886,9 @@ let result = db.execute_gql( #### Explain, Profile, and Stats -`explain_gql` / `explainGql` validates, binds, lowers, and plans the statement without executing -read rows or mutating data. In `Auto` mode, mutation explain is side-effect safe: it does not open, -stage, or commit a write transaction, allocate IDs, create label tokens, append WAL records, publish -snapshots, enqueue index work, or mutate memtables. In `ReadOnly` mode, mutation statements are -rejected. +`explain_gql` / `explainGql` validates and plans the statement without executing read rows or +mutating data. In `Auto` mode, mutation explain is side-effect safe: it does not allocate IDs, +create labels, stage writes, or commit. In `ReadOnly` mode, mutation statements are rejected. Explain result fields: @@ -3452,7 +3899,7 @@ Explain result fields: | Read explain | `read` | `read` | `read` | Nested read-plan payload for read statements or mutation read prefixes. | | Mutation explain | `mutation` | `mutation` | `mutation` | Nested mutation plan payload for mutation statements. | | Caps | `caps` | `caps` | `caps` | Effective execution caps. | -| Warnings | `warnings` | `warnings` | `warnings` | GQL/native planning warnings. | +| Warnings | `warnings` | `warnings` | `warnings` | GQL planning warnings. | | Notes | `notes` | `notes` | `notes` | Human-readable execution/planning notes. | Nested read explain fields: @@ -3460,27 +3907,31 @@ Nested read explain fields: | Field | Node.js | Python | Description | |-------|---------|--------|-------------| | `columns` | `columns` | `columns` | Output columns for the read target. | -| `target` | `target` | `target` | Current graph-row lowering returns `graph_row_query`. | -| `nativePlan` / `native_plan` | `nativePlan` | `native_plan` | Null for graph-row target; details are summarized in projection/warnings. | -| `pushedDown` / `pushed_down` | `pushedDown` | `pushed_down` | Predicates represented in native target planning. | -| `residual` | `residual` | `residual` | Predicates evaluated after native execution. | -| `projection` | `projection` | `projection` | Projection, graph-row plan, row-op, order, cursor, cap, and note summaries. | +| `target` | `target` | `target` | One of `node_query`, `edge_query`, `graph_row_query`, or `graph_pipeline_query`. | +| `nativePlan` / `native_plan` | `nativePlan` | `native_plan` | Populated for direct node/edge plans; row and pipeline plans are summarized in projection/warnings. | +| `pushedDown` / `pushed_down` | `pushedDown` | `pushed_down` | Predicates represented in the selected read plan. | +| `residual` | `residual` | `residual` | Predicates evaluated after the selected read plan. | +| `projection` | `projection` | `projection` | Projection, row-op, order, cursor, cap, and note summaries. | | `rowOps` / `row_ops` | `rowOps` | `row_ops` | `residual_filter`, `sort`, `skip`, `limit`, `projection`. | | `caps` | `caps` | `caps` | Effective read cap summary. | | `warnings` | `warnings` | `warnings` | Read planning warnings. | +For `graph_pipeline_query`, `projection` summarizes the read stages, including match, projection, +`DISTINCT`, aggregation, union, shortest path, subquery, row operations, cursors, and caps. Execution +stats are reported on `stats`; `elapsedUs` / `elapsed_us` is populated only when `profile` is true. + Nested mutation explain fields: | Field | Node.js | Python | Description | |-------|---------|--------|-------------| -| `readPrefix` / `read_prefix` | `readPrefix` | `read_prefix` | Planned graph-row read prefix, if present. | +| `readPrefix` / `read_prefix` | `readPrefix` | `read_prefix` | Planned read-prefix payload, if present. Its nested `graphRowTarget` / `graph_row_target` can report `graph_row_query` or `graph_pipeline_query`. | | `operations` | `operations` | `operations` | Mutation operation summaries with op, target alias, row multiplicity, and details. | | `returnPlan` / `return_plan` | `returnPlan` | `return_plan` | Mutation `RETURN` columns, order item count, skip, limit, and post-commit hydration summary. | | `wouldCreateNodeLabels` / `would_create_node_labels` | `wouldCreateNodeLabels` | `would_create_node_labels` | Node labels that could be created on execution. | | `wouldCreateEdgeLabels` / `would_create_edge_labels` | `wouldCreateEdgeLabels` | `would_create_edge_labels` | Edge labels that could be created on execution. | | `usesTransactionSnapshot` / `uses_transaction_snapshot` | `usesTransactionSnapshot` | `uses_transaction_snapshot` | True for mutation planning over the write transaction snapshot. | | `usesWriteTxn` / `uses_write_txn` | `usesWriteTxn` | `uses_write_txn` | True for executable mutations. | -| `replacementAdapters` / `replacement_adapters` | `replacementAdapters` | `replacement_adapters` | True when SET/REMOVE may use crate-private by-ID replacement adapters. | +| `replacementAdapters` / `replacement_adapters` | `replacementAdapters` | `replacement_adapters` | True when SET/REMOVE may replace records by ID. | | `atomicCommit` / `atomic_commit` | `atomicCommit` | `atomic_commit` | True when the plan commits as one transaction. | `includePlan` / `include_plan` attaches that same explain payload to executed results: @@ -3488,15 +3939,16 @@ Nested mutation explain fields: ```javascript const result = db.executeGql( `MATCH (p:Person)-[r:WORKS_AT]->(c:Company) - RETURN p.name AS person, r.since AS since, c.name AS company - ORDER BY r.since DESC + WITH c.name AS company, count(*) AS people + RETURN company, people + ORDER BY people DESC LIMIT 10`, null, { includePlan: true, profile: true } ); console.log(result.plan.kind); // 'query' -console.log(result.plan.read.target); // 'graph_row_query' +console.log(result.plan.read.target); // 'graph_pipeline_query' console.log(result.plan.read.rowOps); // e.g. ['sort', 'limit', 'projection'] console.log(result.stats.elapsedUs); // populated when profile is true ``` @@ -3506,7 +3958,7 @@ Stats fields: | Field | Rust | Node.js | Python | Description | |-------|------|---------|--------|-------------| | Rows returned | `rows_returned` | `rowsReturned` | `rows_returned` | Final result row count. | -| Native rows matched | `rows_matched` | `rowsMatched` | `rows_matched` | Rows produced/observed by graph-row execution before final projection. | +| Rows matched | `rows_matched` | `rowsMatched` | `rows_matched` | Rows produced or observed by read execution before final projection. | | Rows after filter | `rows_after_filter` | `rowsAfterFilter` | `rows_after_filter` | Rows remaining after residual filtering before final row ops. | | Intermediate bindings | `intermediate_bindings` | `intermediateBindings` | `intermediate_bindings` | Maximum/representative intermediate row count held by execution. | | Work counter | `db_hits` | `dbHits` | `db_hits` | Best-effort profile work units, not a storage IO contract. | @@ -3563,8 +4015,7 @@ db.executeGql( // throws: ReadOnly violation ``` -Mutation cursors are rejected after statement classification and before ReadOnly policy checks, -transaction opening, staging, or mutation explain planning: +Mutation statements reject cursors: ```javascript db.executeGql( @@ -3605,6 +4056,32 @@ console.log(updated.rows); console.log(updated.mutationStats.nodesUpdated); ``` +Keyed node `MERGE` with `ON CREATE SET` / `ON MATCH SET`: + +```javascript +const merged = db.executeGql( + `MATCH (s:Source) + WITH s.target_key AS key + MERGE (a:Account {key: key}) + ON CREATE SET a.status = 'created', a.count = 1 + ON MATCH SET a.status = 'matched', a.count = coalesce(a.count, 0) + 1 + RETURN DISTINCT a.key AS key, a.status AS status, a.count AS count` +); +``` + +Unique relationship `MERGE`: + +```javascript +const rel = db.executeGql( + `MATCH (a:Person {key: $from_key}) + MATCH (b:Person {key: $to_key}) + MERGE (a)-[r:KNOWS]->(b) + ON CREATE SET r.since = 2026 + ON MATCH SET r.seen = true + RETURN r` +); +``` + GQL delete mutation: ```javascript @@ -3686,6 +4163,20 @@ const rows = db.executeGql( ); ``` +`WITH`, rich expressions, `WITH DISTINCT`, and aggregation: + +```javascript +const rows = db.executeGql( + `MATCH (p:Person) + WITH DISTINCT p.group AS group, + count(*) AS people, + collect(DISTINCT lower(trim(p.status))) AS statuses + WHERE people > 1 + RETURN group, people, statuses + ORDER BY people DESC` +); +``` + Bounded path value and path functions: ```javascript @@ -3702,6 +4193,22 @@ console.log(rows.rows[0].p.nodeIds); console.log(rows.rows[0].p.edgeIds); ``` +Constrained shortest path with pre-bound endpoints: + +```python +paths = db.execute_gql( + """ + MATCH (a:Person {key: $from_key}) + WITH a + MATCH (b:Person {key: $to_key}) + WITH a, b + MATCH p = shortestPath((a)-[:KNOWS*1..4]->(b)) + RETURN p, node_ids(p) AS node_ids, edge_ids(p) AS edge_ids, length(p) AS hops + """, + {"from_key": "ada", "to_key": "cy"}, +) +``` + Continuation cursor: ```javascript @@ -3716,6 +4223,30 @@ const second = db.executeGql( ); ``` +Read-only union: + +```javascript +const candidates = db.executeGql( + `MATCH (p:Person) WHERE p.status = 'active' + RETURN p.name AS name + UNION ALL + MATCH (p:Person) WHERE p.status = 'invited' + RETURN p.name AS name` +); +``` + +Read-only `EXISTS {}` and `CALL {}` subqueries: + +```javascript +const rows = db.executeGql( + `MATCH (p:Person) + WHERE EXISTS { MATCH (p)-[:WORKS_AT]->(c:Company) RETURN c } + WITH p + CALL { MATCH (p)-[:WORKS_AT]->(c:Company) RETURN c.name AS company } + RETURN p.name AS person, company` +); +``` + `RETURN *` expands user-visible bound aliases in deterministic binding order: ```javascript @@ -3785,41 +4316,36 @@ result = await async_db.execute_gql( ) ``` -#### Not Yet Supported In GQL Beta +#### Current Limits -GQL Beta rejects: +GQL Beta is intentionally narrower than ISO GQL and Cypher. It rejects: - Full ISO GQL - Full Cypher compatibility -- `MERGE`, `ON CREATE`, `ON MATCH`, and upsert-like GQL syntax -- `DELETE n` without `DETACH` -- `RETURN` after `DELETE` or `DETACH DELETE` -- Mutation cursors -- Read-after-write graph matching such as `MATCH CREATE MATCH` -- Vector writes or vector mutation syntax +- `DELETE n` without `DETACH`, and `RETURN` after `DELETE` or `DETACH DELETE` +- Mutation cursors and mutation `RETURN` aggregation +- Read-after-write graph matching, including `WITH`, `MATCH`, `CALL`, `UNION`, or subqueries after the first write clause - Schema operations: `CREATE INDEX`, constraints, `DROP`, `ALTER`, `SHOW` -- Aggregation -- `DISTINCT` -- `WITH` -- `UNION` -- `CALL` -- Subqueries -- Procedures +- Vector writes or vector mutation syntax +- Mutating subqueries and procedure calls such as `CALL db.labels()` +- Unsupported `MERGE` shapes: unkeyed nodes, multi-label nodes, non-key identity maps, + relationship properties in the `MERGE` pattern, unbound endpoints, relationship MERGE without + `edge_uniqueness = true`, undirected or variable-length relationship MERGE, path-assigned MERGE, + and general pattern MERGE +- Mixed `UNION` / `UNION ALL` chains and mutation branches in `UNION` +- `UNWIND`, `FOREACH`, and `LOAD CSV` - Dynamic labels and dynamic relationship types -- Unbounded variable-length paths -- Shortest path pattern/function syntax +- Unbounded variable-length paths, weighted shortest-path GQL syntax, all-pairs shortest path, and broad shortest-path endpoint scans - Advanced path functions beyond `length`, `start_node`, `end_node`, `nodes`, `relationships`, `node_ids`, and `edge_ids` - Multi-hop relationship-list aliases separate from path aliases - Path assignment over multiple relationship segments - Pattern-local predicates inside node or relationship patterns -- Plan cache -- Native multi-label edge OR in direct edge lowering - List/map and non-finite-float `ORDER BY` domains -- Mutation `RETURN ORDER BY` on commit-assigned or same-mutation-volatile metadata, including created IDs/timestamps, created-edge endpoint metadata, and same-mutation `updated_at` +- Mutation `RETURN ORDER BY` or `RETURN DISTINCT` on commit-assigned or same-mutation-volatile metadata -Use native query APIs when you need request-object construction, strongly bounded pagination by ID, -native upsert semantics, vector writes, schema/index management, or APIs outside GQL Beta. Use GQL -Beta when a graph query or mutation reads better as text. +Use structured query APIs when you need request-object construction, strongly bounded pagination by +ID, native upsert semantics, vector writes, schema/index management, or APIs outside GQL Beta. Use +GQL Beta when a graph query or mutation reads better as text. --- @@ -6141,7 +6667,7 @@ const node = await db.getNodeAsync(42); Async methods run on the libuv thread pool. Write operations acquire an exclusive lock; read operations acquire a shared lock (allowing concurrent reads). -**Available async methods:** `closeAsync`, `ensureNodeLabelAsync`, `ensureEdgeLabelAsync`, `getNodeLabelIdAsync`, `getEdgeLabelIdAsync`, `getNodeLabelAsync`, `getEdgeLabelAsync`, `listNodeLabelsAsync`, `listEdgeLabelsAsync`, `upsertNodeAsync`, `upsertEdgeAsync`, `addNodeLabelAsync`, `removeNodeLabelAsync`, `batchUpsertNodesAsync`, `batchUpsertEdgesAsync`, `batchUpsertNodesBinaryAsync`, `batchUpsertEdgesBinaryAsync`, `getNodeAsync`, `getEdgeAsync`, `getNodeByKeyAsync`, `getEdgeByTripleAsync`, `getNodesAsync`, `getNodesByKeysAsync`, `getEdgesAsync`, `deleteNodeAsync`, `deleteEdgeAsync`, `invalidateEdgeAsync`, `graphPatchAsync`, `beginWriteTxnAsync`, `neighborsAsync`, `neighborsPagedAsync`, `neighborsBatchAsync`, `traverseAsync`, `topKNeighborsAsync`, `extractSubgraphAsync`, `shortestPathAsync`, `allShortestPathsAsync`, `isConnectedAsync`, `degreeAsync`, `degreesAsync`, `sumEdgeWeightsAsync`, `avgEdgeWeightAsync`, `findNodesAsync`, `findNodesPagedAsync`, `ensureNodePropertyIndexAsync`, `dropNodePropertyIndexAsync`, `listNodePropertyIndexesAsync`, `ensureEdgePropertyIndexAsync`, `dropEdgePropertyIndexAsync`, `listEdgePropertyIndexesAsync`, `findNodesRangeAsync`, `findNodesRangePagedAsync`, `findNodesByTimeRangeAsync`, `findNodesByTimeRangePagedAsync`, `nodesByLabelsAsync`, `edgesByLabelAsync`, `getNodesByLabelsAsync`, `getEdgesByLabelAsync`, `countNodesByLabelsAsync`, `countEdgesByLabelAsync`, `nodesByLabelsPagedAsync`, `edgesByLabelPagedAsync`, `getNodesByLabelsPagedAsync`, `getEdgesByLabelPagedAsync`, `queryNodeIdsAsync`, `queryNodesAsync`, `queryEdgeIdsAsync`, `queryEdgesAsync`, `queryGraphRowsAsync`, `explainNodeQueryAsync`, `explainEdgeQueryAsync`, `explainGraphRowsAsync`, `executeGqlAsync`, `explainGqlAsync`, `personalizedPagerankAsync`, `connectedComponentsAsync`, `componentOfAsync`, `vectorSearchAsync`, `exportAdjacencyAsync`, `pruneAsync`, `setPrunePolicyAsync`, `removePrunePolicyAsync`, `listPrunePoliciesAsync`, `syncAsync`, `flushAsync`, `compactAsync`, `compactWithProgressAsync`, `ingestModeAsync`, `endIngestAsync`. +**Available async methods:** `closeAsync`, `ensureNodeLabelAsync`, `ensureEdgeLabelAsync`, `getNodeLabelIdAsync`, `getEdgeLabelIdAsync`, `getNodeLabelAsync`, `getEdgeLabelAsync`, `listNodeLabelsAsync`, `listEdgeLabelsAsync`, `upsertNodeAsync`, `upsertEdgeAsync`, `addNodeLabelAsync`, `removeNodeLabelAsync`, `batchUpsertNodesAsync`, `batchUpsertEdgesAsync`, `batchUpsertNodesBinaryAsync`, `batchUpsertEdgesBinaryAsync`, `getNodeAsync`, `getEdgeAsync`, `getNodeByKeyAsync`, `getEdgeByTripleAsync`, `getNodesAsync`, `getNodesByKeysAsync`, `getEdgesAsync`, `deleteNodeAsync`, `deleteEdgeAsync`, `invalidateEdgeAsync`, `graphPatchAsync`, `beginWriteTxnAsync`, `neighborsAsync`, `neighborsPagedAsync`, `neighborsBatchAsync`, `traverseAsync`, `topKNeighborsAsync`, `extractSubgraphAsync`, `shortestPathAsync`, `allShortestPathsAsync`, `isConnectedAsync`, `degreeAsync`, `degreesAsync`, `sumEdgeWeightsAsync`, `avgEdgeWeightAsync`, `findNodesAsync`, `findNodesPagedAsync`, `ensureNodePropertyIndexAsync`, `dropNodePropertyIndexAsync`, `listNodePropertyIndexesAsync`, `ensureEdgePropertyIndexAsync`, `dropEdgePropertyIndexAsync`, `listEdgePropertyIndexesAsync`, `findNodesRangeAsync`, `findNodesRangePagedAsync`, `findNodesByTimeRangeAsync`, `findNodesByTimeRangePagedAsync`, `nodesByLabelsAsync`, `edgesByLabelAsync`, `getNodesByLabelsAsync`, `getEdgesByLabelAsync`, `countNodesByLabelsAsync`, `countEdgesByLabelAsync`, `nodesByLabelsPagedAsync`, `edgesByLabelPagedAsync`, `getNodesByLabelsPagedAsync`, `getEdgesByLabelPagedAsync`, `queryNodeIdsAsync`, `queryNodesAsync`, `queryEdgeIdsAsync`, `queryEdgesAsync`, `queryGraphRowsAsync`, `queryGraphPipelineAsync`, `explainNodeQueryAsync`, `explainEdgeQueryAsync`, `explainGraphRowsAsync`, `explainGraphPipelineAsync`, `executeGqlAsync`, `explainGqlAsync`, `personalizedPagerankAsync`, `connectedComponentsAsync`, `componentOfAsync`, `vectorSearchAsync`, `exportAdjacencyAsync`, `pruneAsync`, `setPrunePolicyAsync`, `removePrunePolicyAsync`, `listPrunePoliciesAsync`, `syncAsync`, `flushAsync`, `compactAsync`, `compactWithProgressAsync`, `ingestModeAsync`, `endIngestAsync`. `WriteTxn` handles expose async counterparts for the full transaction surface: `upsertNodeAsync`, `upsertNodeAsAsync`, `upsertEdgeAsync`, `upsertEdgeAsAsync`, `deleteNodeAsync`, `deleteEdgeAsync`, `invalidateEdgeAsync`, `stageAsync`, `getNodeAsync`, `getEdgeAsync`, `getNodeByKeyAsync`, `getEdgeByTripleAsync`, `commitAsync`, and `rollbackAsync`. Async transaction operations on one handle execute in call order. diff --git a/docs/getting-started.md b/docs/getting-started.md index a15bbbc..9d95b53 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -147,41 +147,103 @@ let nodes = db.get_nodes(&[alice, bob])?; ## Optional: use GQL Beta for query strings -Native APIs remain the primary structured API. GQL Beta is useful when a supported read or mutation -is clearer as a GQL/Cypher-shaped string. It is a bounded subset, not full ISO GQL or full Cypher. +GQL Beta is useful when a graph read or mutation is clearer as a GQL/Cypher-shaped string. This +example creates nodes, creates edges, then queries the graph with aggregation. **Python** ```python -created = db.execute_gql( - "CREATE (u:User {key: $key, role: $role}) RETURN u.key AS key", - {"key": "carol", "role": "designer"}, +db.execute_gql( + """ + CREATE (alice:User {key: 'gql-alice', role: 'engineer'}), + (bob:User {key: 'gql-bob', role: 'designer'}), + (project:Project {key: 'gql-atlas', name: 'Atlas'}) + RETURN alice.key AS alice, bob.key AS bob, project.name AS project + """ +) + +db.execute_gql( + """ + MATCH (alice:User {key: 'gql-alice'}) + MATCH (bob:User {key: 'gql-bob'}) + MATCH (project:Project {key: 'gql-atlas'}) + CREATE (alice)-[:WORKS_ON {since: 2026}]->(project), + (bob)-[:WORKS_ON {since: 2026}]->(project) + RETURN project.name AS project + """ ) rows = db.execute_gql( - "MATCH (u:User) WHERE u.key = $key RETURN u.key AS key, u.role AS role", - {"key": "carol"}, + """ + MATCH (u:User)-[r:WORKS_ON]->(p:Project) + WHERE p.key = 'gql-atlas' + WITH p.name AS project, count(*) AS contributors, collect(u.key) AS users + RETURN project, contributors, users + """ ) -print(created["mutation_stats"]) print(rows["rows"]) ``` **Node.js** ```javascript -const created = db.executeGql( - 'CREATE (u:User {key: $key, role: $role}) RETURN u.key AS key', - { key: 'carol', role: 'designer' } +db.executeGql( + `CREATE (alice:User {key: 'gql-alice', role: 'engineer'}), + (bob:User {key: 'gql-bob', role: 'designer'}), + (project:Project {key: 'gql-atlas', name: 'Atlas'}) + RETURN alice.key AS alice, bob.key AS bob, project.name AS project` +); + +db.executeGql( + `MATCH (alice:User {key: 'gql-alice'}) + MATCH (bob:User {key: 'gql-bob'}) + MATCH (project:Project {key: 'gql-atlas'}) + CREATE (alice)-[:WORKS_ON {since: 2026}]->(project), + (bob)-[:WORKS_ON {since: 2026}]->(project) + RETURN project.name AS project` ); const rows = db.executeGql( - 'MATCH (u:User) WHERE u.key = $key RETURN u.key AS key, u.role AS role', - { key: 'carol' } + `MATCH (u:User)-[r:WORKS_ON]->(p:Project) + WHERE p.key = 'gql-atlas' + WITH p.name AS project, count(*) AS contributors, collect(u.key) AS users + RETURN project, contributors, users` ); -console.log(created.mutationStats); console.log(rows.rows); ``` +**Rust** +```rust +db.execute_gql( + "CREATE (alice:User {key: 'gql-alice', role: 'engineer'}), \ + (bob:User {key: 'gql-bob', role: 'designer'}), \ + (project:Project {key: 'gql-atlas', name: 'Atlas'}) \ + RETURN alice.key AS alice, bob.key AS bob, project.name AS project", + &GqlParams::new(), + &GqlExecutionOptions::default(), +)?; + +db.execute_gql( + "MATCH (alice:User {key: 'gql-alice'}) \ + MATCH (bob:User {key: 'gql-bob'}) \ + MATCH (project:Project {key: 'gql-atlas'}) \ + CREATE (alice)-[:WORKS_ON {since: 2026}]->(project), \ + (bob)-[:WORKS_ON {since: 2026}]->(project) \ + RETURN project.name AS project", + &GqlParams::new(), + &GqlExecutionOptions::default(), +)?; + +let rows = db.execute_gql( + "MATCH (u:User)-[r:WORKS_ON]->(p:Project) \ + WHERE p.key = 'gql-atlas' \ + WITH p.name AS project, count(*) AS contributors, collect(u.key) AS users \ + RETURN project, contributors, users", + &GqlParams::new(), + &GqlExecutionOptions::default(), +)?; +``` + ## Query neighbors **Python** diff --git a/docs/gql-subset.md b/docs/gql-subset.md index e05525c..c255701 100644 --- a/docs/gql-subset.md +++ b/docs/gql-subset.md @@ -1,20 +1,28 @@ # GQL Beta -OverGraph ships **GQL Beta**, a GQL/Cypher-style query language for reads and writes that lowers -into the same native substrates used by the structured APIs. Use it when a query or mutation reads -better as text than as a request object. +OverGraph ships **GQL Beta**, a GQL/Cypher-style query surface for graph reads and writes. Use it +when a query or mutation reads better as text than as a request object. The authoritative public reference is the [GQL Beta section in the API docs](api-reference.md#gql-beta). This page is the compact syntax companion. -Connector calls are thin bindings over the Rust implementation: +Connector calls: - Rust: `DatabaseEngine::execute_gql(...)` and `DatabaseEngine::explain_gql(...)` - Node.js: `executeGql(...)`, `executeGqlAsync(...)`, `explainGql(...)`, `explainGqlAsync(...)` - Python: `execute_gql(...)`, async `execute_gql(...)`, `explain_gql(...)`, async `explain_gql(...)` -Connectors do not reimplement parsing, lowering, planning, execution, cursor handling, optional -semantics, mutation semantics, or path conversion. +Supported at a glance: + +- `MATCH`, `OPTIONAL MATCH`, `WHERE`, `RETURN`, `ORDER BY`, `SKIP` / `OFFSET`, and `LIMIT` +- `WITH`, `WITH *`, `WITH DISTINCT`, later `MATCH` stages, and `RETURN DISTINCT` +- aggregation with `count`, `sum`, `avg`, `min`, `max`, and `collect` +- `UNION`, `UNION ALL`, `EXISTS { ... }`, and read-only `CALL { ... }` +- bounded paths, path values, path helper functions, and constrained shortest paths +- `CREATE`, keyed node `MERGE`, unique relationship `MERGE`, `ON CREATE SET`, `ON MATCH SET`, + `SET`, `REMOVE`, `DELETE r`, `DETACH DELETE n`, and mutation returns +- params, read cursors, compact rows, explain/profile, ReadOnly mode, mutation stats, and vector + opt-in for returned node values ## Read Syntax @@ -23,17 +31,37 @@ Read clause shape: ```gql MATCH [, ...] [WHERE ] OPTIONAL MATCH [, ...] [WHERE ] -RETURN +WITH [DISTINCT] [WHERE ] [ORDER BY ...] [SKIP ...] [LIMIT ...] +CALL { } +RETURN [DISTINCT] ORDER BY [ASC|DESC], ... SKIP OFFSET LIMIT ``` -`WHERE`, `OPTIONAL MATCH`, `ORDER BY`, `SKIP` / `OFFSET`, and `LIMIT` are optional. `OPTIONAL -MATCH` clauses follow an initial required `MATCH`. `SKIP` and `OFFSET` are synonyms; specifying -both is rejected. Read `LIMIT 0` validates the statement and returns no rows without running -graph-row execution. +`WITH` controls what names are visible to later clauses. `WITH *` keeps visible aliases, +`WITH DISTINCT` deduplicates rows, and row operations on `WITH` apply before the next clause. + +```gql +MATCH (p:Person) +WITH p, lower(trim(p.email)) AS email +WHERE email ENDS WITH '@example.com' +MATCH (p)-[:WORKS_AT]->(c:Company) +RETURN DISTINCT p.name AS person, email, c.name AS company +ORDER BY person +LIMIT 20 +``` + +Read branches can be combined with `UNION` or `UNION ALL`: + +```gql +MATCH (p:Person) WHERE p.status = 'active' +RETURN p.name AS name +UNION ALL +MATCH (p:Person) WHERE p.status = 'invited' +RETURN p.name AS name +``` Pattern shapes: @@ -46,44 +74,81 @@ Pattern shapes: - Zero-to-N bounded path: `MATCH p = (a)-[:KNOWS*..2]->(b)` - Exact-length path: `MATCH p = (a)-[:KNOWS*2]->(b)` - One-hop path with relationship alias: `MATCH p = (a)-[r:KNOWS*1..1]->(b)` +- Shortest path: `MATCH p = shortestPath((a)-[:KNOWS*1..5]->(b))` +- All equal shortest paths: `MATCH p = allShortestPaths((a)-[:KNOWS*1..5]-(b))` -Variable-length paths require a finite upper bound and are relationship-simple. Multi-hop -relationship-list aliases are unsupported; return the path alias and inspect `edge_ids` instead. +Shortest path uses pre-bound endpoint aliases: + +```gql +MATCH (a:Person {key: $from}) +WITH a +MATCH (b:Person {key: $to}) +WITH a, b +MATCH p = shortestPath((a)-[:KNOWS*1..4]->(b)) +RETURN p, node_ids(p) AS node_ids, edge_ids(p) AS edge_ids, length(p) AS hops +``` Expressions include variables, `id(n)`, `id(r)`, `labels(n)`, `type(r)`, path functions, path -fields, property access, literals, params, boolean predicates, comparisons, null checks, `IN`, and -`RETURN *`. +fields, property access, literals, params, boolean predicates, comparisons, null checks, `IN`, +arithmetic, string predicates, `CASE`, and `RETURN *`. + +Scalar functions include `coalesce`, `to_string`, `to_integer`, `to_float`, `abs`, `floor`, `ceil`, +`round`, `lower`, `upper`, `trim`, `substring`, `size`, `head`, and `last`. + +Aggregation example: -`ORDER BY` accepts graph-row order atoms: null, bool, finite numbers, strings, bytes, nodes, edges, -and paths. Lists, maps, and non-finite floats are rejected. +```gql +MATCH (n:Person) +WITH n.group AS group, + count(*) AS total, + avg(n.rank) AS avg_rank, + collect(DISTINCT n.status) AS statuses +WHERE total > 1 +RETURN group, total, coalesce(avg_rank, 0.0) AS avg_rank, statuses +ORDER BY total DESC +``` + +Read-only subqueries: + +```gql +MATCH (p:Person) +WHERE EXISTS { MATCH (p)-[:WORKS_AT]->(c:Company) RETURN c } +WITH p +CALL { MATCH (p)-[:WORKS_AT]->(c:Company) RETURN c.name AS company } +RETURN p.name AS person, company +``` ## Mutation Syntax -Mutation shape: +Mutation clause shape: ```gql MATCH [WHERE ] OPTIONAL MATCH [WHERE ] +WITH [DISTINCT] [WHERE ] [ORDER BY ...] [SKIP ...] [LIMIT ...] +CALL { } CREATE [, ...] +MERGE (n:Label {key: expr}) [ON CREATE SET ...] [ON MATCH SET ...] +MERGE (a)-[r:TYPE]->(b) [ON CREATE SET ...] [ON MATCH SET ...] SET REMOVE DELETE DETACH DELETE -RETURN +RETURN [DISTINCT] ORDER BY [ASC|DESC], ... SKIP OFFSET LIMIT ``` -All read clauses must come before the first mutation clause. Create-only statements do not need a -read prefix. For mutation read prefixes, use repeated `MATCH` clauses instead of comma-separated -pattern lists. +Read prefixes go before the first write clause. Create-only statements do not need a read prefix. Mutation forms: - `CREATE (n:Person {key: 'ada', name: 'Ada'})` -- `MATCH (a:Person) WHERE a.key = 'a' MATCH (b:Person) WHERE b.key = 'b' CREATE (a)-[r:KNOWS {since: 2026}]->(b)` +- `CREATE (a:Person {key: 'a'})-[r:KNOWS {since: 2026}]->(b:Person {key: 'b'})` +- `MERGE (n:Person {key: $key}) ON CREATE SET n.created = true ON MATCH SET n.seen = true` +- `MATCH (a:Person {key: $a}) MATCH (b:Person {key: $b}) MERGE (a)-[r:KNOWS]->(b)` - `MATCH (n:Person) WHERE n.key = 'ada' SET n.status = 'active'` - `MATCH (n:Person) WHERE n.key = 'ada' SET n += $props` - `MATCH (n:Person) WHERE n.key = 'ada' SET n:Engineer` @@ -92,30 +157,26 @@ Mutation forms: - `MATCH (a)-[r:KNOWS]->(b) DELETE r` - `MATCH (n:Person) WHERE n.key = 'ada' DETACH DELETE n` -`CREATE` is strict: existing node `(label, key)` memberships conflict, and unique-edge databases -reject duplicate `(from, to, label)` edge creates. `MERGE` and upsert-like GQL syntax are not in -GQL Beta. - -`CREATE`, `SET`, and `REMOVE` can return rows: +Mutation return example: ```gql MATCH (n:Person) WHERE n.key = 'ada' SET n.status = 'active' -RETURN n.key AS key, n.status AS status +RETURN DISTINCT n.key AS key, n.status AS status ORDER BY key LIMIT 1 ``` -Mutation `RETURN ORDER BY`, `SKIP` / `OFFSET`, and `LIMIT` affect returned rows only. The mutation -still applies to every input row from the read prefix. `RETURN ... LIMIT 0` still mutates and -returns zero rows. - -`DELETE` and `DETACH DELETE` do not support `RETURN` in Phase 33. Mutation statements do not accept -`cursor` and never return `next_cursor` / `nextCursor`. +MERGE example: -Known Phase 33 limitation: mutation `RETURN ORDER BY` rejects commit-assigned or same-mutation -volatile metadata such as created IDs/timestamps, created-edge endpoint metadata, and same-mutation -`updated_at`. The future prepared-transaction option is tracked as `QPX-019`. +```gql +MATCH (s:Source) +WITH s.target_key AS key +MERGE (a:Account {key: key}) +ON CREATE SET a.status = 'created', a.count = 1 +ON MATCH SET a.status = 'matched', a.count = coalesce(a.count, 0) + 1 +RETURN DISTINCT a.key AS key, a.status AS status, a.count AS count +``` ## Options @@ -130,6 +191,13 @@ Rust uses `GqlExecutionOptions`. Node and Python expose connector-native option | `max_cursor_bytes` | `maxCursorBytes` | `max_cursor_bytes` | `16384` | | `max_mutation_rows` | `maxMutationRows` | `max_mutation_rows` | `10000` | | `max_mutation_ops` | `maxMutationOps` | `max_mutation_ops` | `50000` | +| `max_pipeline_rows` | `maxPipelineRows` | `max_pipeline_rows` | `65536` | +| `max_groups` | `maxGroups` | `max_groups` | `65536` | +| `max_collect_items` | `maxCollectItems` | `max_collect_items` | `65536` | +| `max_union_branches` | `maxUnionBranches` | `max_union_branches` | `16` | +| `max_subquery_invocations` | `maxSubqueryInvocations` | `max_subquery_invocations` | `4096` | +| `max_subquery_depth` | `maxSubqueryDepth` | `max_subquery_depth` | `2` | +| `max_shortest_path_pairs` | `maxShortestPathPairs` | `max_shortest_path_pairs` | `4096` | | `max_query_bytes` | `maxQueryBytes` | `max_query_bytes` | `1048576` | | `max_param_bytes` | `maxParamBytes` | `max_param_bytes` | `1048576` | | `max_ast_depth` | `maxAstDepth` | `max_ast_depth` | `256` | @@ -145,32 +213,15 @@ Rust uses `GqlExecutionOptions`. Node and Python expose connector-native option | `compact_rows` | `compactRows` | `compact_rows` | `false` | | `include_vectors` | `includeVectors` | `include_vectors` | `false` | -`mode: "readOnly"` / `mode="read_only"` rejects mutation statements before write staging. -`allowFullScan` / `allow_full_scan` is required for legal broad reads and broad mutation read -prefixes. - -`compactRows` / `compact_rows` switches connector row serialization from objects to arrays. It does -not change execution. `includeVectors` / `include_vectors` defaults to false; returned node values, -including nodes inside returned path values, omit dense and sparse vectors unless vector inclusion is -requested. +`compactRows` / `compact_rows` switches connector row serialization from objects to arrays. +`includeVectors` / `include_vectors` includes dense and sparse vectors in returned node values. ## Results -Rust returns positional rows: +Rust returns positional rows. Node.js and Python return object rows by default and positional arrays +when compact rows are enabled. -```rust -GqlExecutionResult { - kind, - columns, - rows, - next_cursor, - stats, - mutation_stats, - plan, -} -``` - -Node.js uses camelCase result fields: +Node.js result shape: ```js { @@ -184,7 +235,7 @@ Node.js uses camelCase result fields: } ``` -Python uses snake_case result fields: +Python result shape: ```python { @@ -198,11 +249,6 @@ Python uses snake_case result fields: } ``` -Stats include `rows_returned`, `rows_matched`, `rows_after_filter`, `intermediate_bindings`, -`db_hits`, optional `elapsed_us`, and `warnings`. Mutation stats include matched rows, mutation -rows/ops, created/updated/deleted counters, label/property counters, skipped null targets, -duplicate targets, db hits, elapsed time, and warnings. - ## Path Values Returning a path alias yields a path value: @@ -214,9 +260,8 @@ Returning a path alias yields a path value: | Hydrated nodes | `nodes` | `nodes` | `nodes` | | Hydrated edges | `edges` | `edges` | `edges` | -`node_ids(p)` and `edge_ids(p)` return ID lists. `nodes(p)` and `relationships(p)` return lists of -node or edge values. `start_node(p)` and `end_node(p)` return node IDs. Returning `p` directly -returns the path value shape above. +`length(p)` returns hop count. `node_ids(p)` and `edge_ids(p)` return ID lists. Returning `p` +directly returns the path value shape above. ## Cursors @@ -235,29 +280,16 @@ const second = db.executeGql( ); ``` -Cursors are continuation tokens over final logical result rows. They validate the normalized -graph-row query fingerprint. They are not pinned storage snapshots across pages. - ## Params -Parameter values: - -- `null` / `None` -- booleans -- signed and unsigned integer values where the connector can represent them -- finite floats -- strings -- bytes: Node `Buffer` or `ArrayBuffer`, Python `bytes` -- lists / arrays -- maps / dictionaries with string keys +Parameter values can be nulls, booleans, signed and unsigned integers where the connector can +represent them, finite floats, strings, bytes, lists, and maps with string keys. -Only referenced params are resource-validated; extra unused params are ignored. Referenced list/map -depth and item counts use the configured depth/item caps, and referenced string, bytes, and map-key -payload bytes are capped by `max_param_bytes`. +Only referenced params are resource-validated; extra unused params are ignored. ## Explain And Profile -`explain_gql` / `explainGql` returns a unified `GqlExecutionExplain` with: +`explain_gql` / `explainGql` returns a unified explain payload with: - `kind` - `columns` @@ -267,11 +299,6 @@ payload bytes are capped by `max_param_bytes`. - `warnings` - `notes` -Mutation explain is side-effect safe. It can parse, bind, lower, and describe a mutation in -`Auto` mode, but it does not open/stage/commit a write transaction, allocate IDs, create label -tokens, append WAL records, publish snapshots, enqueue index work, or mutate memtables. In -`ReadOnly` mode, mutation statements are rejected. - When `includePlan` / `include_plan` is true on `execute_gql`, the result includes the same explain payload in `plan`. When `profile` is true, `stats.elapsedUs` / `stats.elapsed_us` is populated. @@ -280,87 +307,67 @@ payload in `plan`. When `profile` is true, `stats.elapsedUs` / `stats.elapsed_us Node.js: ```js -const created = db.executeGql( - `CREATE (p:Person {key: $key, name: $name, status: 'active'}) - RETURN p.name AS name`, - { key: 'ada', name: 'Ada' } +db.executeGql( + `CREATE (p:Person {key: $personKey, name: $personName, status: 'active'}) + -[r:WORKS_AT {role: $role, since: $since}]-> + (c:Company {key: $companyKey, name: $companyName}) + RETURN p.name AS person, c.name AS company, r.role AS role`, + { + personKey: 'ada', + personName: 'Ada', + companyKey: 'overgraph', + companyName: 'OverGraph', + role: 'engineer', + since: 2026, + } ); const rows = db.executeGql( `MATCH (p:Person)-[r:WORKS_AT]->(c:Company) - WHERE p.status = $status - OPTIONAL MATCH path = (p)-[:KNOWS*1..2]->(friend:Person) - RETURN p.name AS person, c.name AS company, path, length(path) AS hops - ORDER BY p.name, hops - LIMIT 10`, - { status: 'active' }, + WITH c.name AS company, count(*) AS people, collect(DISTINCT p.name) AS names + RETURN company, people, names + ORDER BY people DESC`, + null, { includePlan: true } ); - -console.log(created.mutationStats); -console.log(rows.rows); ``` Python: ```python created = db.execute_gql( - "CREATE (p:Person {key: $key, name: $name}) RETURN p.name AS name", + """ + MERGE (p:Person {key: $key}) + ON CREATE SET p.name = $name, p.status = 'active' + ON MATCH SET p.seen = true + RETURN p.key AS key, p.name AS name + """, {"key": "ada", "name": "Ada"}, ) rows = db.execute_gql( """ MATCH (p:Person)-[r:WORKS_AT]->(c:Company) - WHERE p.status = $status + WHERE EXISTS { MATCH (p)-[:WORKS_AT]->(c) RETURN c } RETURN p.name AS person, c.name AS company ORDER BY p.name LIMIT 10 """, - {"status": "active"}, include_plan=True, ) - -print(created["mutation_stats"]) -print(rows["rows"]) ``` Rust: ```rust let result = engine.execute_gql( - "MATCH p = (a:Person)-[:KNOWS*1..3]->(b:Person) RETURN p, node_ids(p) AS ids LIMIT 10", + "MATCH (a:Person {key: 'ada'}) \ + WITH a \ + MATCH (b:Person {key: 'ben'}) \ + WITH a, b \ + MATCH p = shortestPath((a)-[:KNOWS*1..4]->(b)) \ + RETURN p, node_ids(p) AS ids, length(p) AS hops", &GqlParams::new(), &GqlExecutionOptions::default(), )?; ``` - -## Unsupported - -GQL Beta rejects: - -- Full ISO GQL -- Full Cypher compatibility -- `MERGE`, `ON CREATE`, `ON MATCH`, and upsert-like GQL syntax -- `DELETE n` without `DETACH` -- `RETURN` after `DELETE` or `DETACH DELETE` -- Mutation cursors -- Read-after-write graph matching such as `MATCH CREATE MATCH` -- Vector writes or vector mutation syntax -- Schema operations -- Aggregation -- `DISTINCT` -- `WITH` -- `UNION` -- `CALL` -- Subqueries and procedures -- Dynamic labels and dynamic relationship types -- Unbounded paths -- Shortest path syntax -- Advanced path functions beyond the listed path functions -- Multi-hop relationship-list aliases separate from path aliases -- Path assignment over multiple relationship segments -- Pattern-local predicates inside node or relationship patterns -- Native multi-label edge OR in pure anonymous direct-edge lowering -- List/map and non-finite-float `ORDER BY` domains -- Mutation `RETURN ORDER BY` on commit-assigned or same-mutation-volatile metadata diff --git a/overgraph-node/Cargo.toml b/overgraph-node/Cargo.toml index e18b385..808e353 100644 --- a/overgraph-node/Cargo.toml +++ b/overgraph-node/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "overgraph-node" -version = "0.10.0" +version = "0.11.0" edition = "2021" description = "Node.js native addon for OverGraph" diff --git a/overgraph-node/README.md b/overgraph-node/README.md index 3dfc62f..e8a909f 100644 --- a/overgraph-node/README.md +++ b/overgraph-node/README.md @@ -43,7 +43,7 @@ Graph structure and vector similarity can live in the same engine, so you can as - **Explicit write transactions.** Stage ordered node and edge mutations locally, read your own staged writes, then commit atomically with optimistic conflict detection through the Node.js API. - **Native Node.js, one engine.** Rust core with napi-rs bindings. Not a wrapper around a REST API. Actual FFI into the same Rust engine with minimal overhead. - **Full queries as functions.** Use regular APIs for direct lookups, full boolean node/edge queries, and `queryGraphRows` for row-shaped graph patterns, optional matches, and bounded paths. -- **GQL Beta.** Use `executeGql` / `executeGqlAsync` for GQL/Cypher-style graph reads and writes. `MATCH` reads and keyed `CREATE`, `SET`, `REMOVE`, `DELETE r`, and `DETACH DELETE n` mutations run on the same native substrates. +- **GQL Beta.** Use `executeGql` / `executeGqlAsync` for GQL/Cypher-style graph reads and writes. Use `MATCH`, `WITH`, `DISTINCT`, aggregation, `UNION`, read-only subqueries, constrained shortest paths, `CREATE`, `MERGE`, `SET`, `REMOVE`, `DELETE r`, `DETACH DELETE n`, and mutation returns. ## Performance @@ -107,13 +107,14 @@ db.close(); ## GQL Beta -The Node.js connector includes **GQL Beta**: a GQL/Cypher-style query language for graph reads and writes, backed by the same graph-row read executor and write-transaction machinery as the native APIs. Use it when a query is easier to read as text; keep native APIs such as `queryGraphRows` and explicit write transactions when structured request objects give you better control. +The Node.js connector includes **GQL Beta**: a GQL/Cypher-style query language for graph reads and writes. Use it when a graph operation is easier to read as text: create records, match patterns, shape rows with `WITH`, aggregate, combine branches with `UNION`, run read-only subqueries, use constrained shortest paths, and return mutation results. ```javascript const created = db.executeGql( - `CREATE (p:Person {key: $key, name: $name, status: 'active'}) - RETURN p.name AS name`, - { key: 'ada', name: 'Ada' } + `CREATE (p:Person {key: 'gql-ada', name: 'Ada', status: 'active'}) + -[r:WORKS_AT {role: 'engineer', since: 2026}]-> + (c:Company {key: 'gql-overgraph', name: 'OverGraph'}) + RETURN p.name AS person, c.name AS company, r.role AS role` ); const result = db.executeGql( @@ -138,7 +139,7 @@ const asyncResult = await db.executeGqlAsync( ); ``` -`mode: 'readOnly'` rejects mutation statements, and mutation statements do not accept or return cursors. GQL Beta supports `MATCH`, `OPTIONAL MATCH`, bounded paths, path functions, `WHERE`, `RETURN`, `ORDER BY`, `SKIP` / `OFFSET`, `LIMIT`, params, read cursors, compact rows, vector opt-in, explain/profile, `CREATE`, `SET`, `REMOVE`, `DELETE r`, `DETACH DELETE n`, mutation stats, and mutation `RETURN` for `CREATE` / `SET` / `REMOVE`. See the full [GQL Beta API reference](../docs/api-reference.md#gql-beta) for syntax, result shapes, options, examples, and current limitations. +GQL Beta is available across Rust, Node.js, and Python. It supports params, read cursors, compact rows, vector opt-in for returned node values, explain/profile, read-only execution, mutation stats, async connector calls, and consistent result shapes across languages. See the full [GQL Beta API reference](../docs/api-reference.md#gql-beta) for syntax, result shapes, options, and examples. ### Async support @@ -175,7 +176,7 @@ The Node.js connector includes `Async` suffixed variants for every API, such as - **Degree counts.** Count edges, sum weights, and compute averages without materializing neighbor lists. Batch `degrees` for bulk analysis. - **Direct property queries.** `findNodes` and `findNodesPaged` do focused equality lookups with semantic numeric equality for finite scalars. `findNodesRange` and `findNodesRangePaged` do domainless numeric range scans with exact bound and cursor semantics. - **Optional property indexes.** Declare node or edge equality/range indexes only where they pay off. Range indexes cover finite scalar numeric values across signed integers, unsigned integers, and finite floats; non-finite floats and non-numeric values are excluded. Use `ensureNodePropertyIndex` / `ensureEdgePropertyIndex`, list APIs, and drop APIs to manage them. Public query APIs stay index-transparent: when a matching declaration is `Ready`, OverGraph uses the declaration-backed path; otherwise it falls back to the same public API. -- **Full query APIs.** `queryNodeIds`, `queryNodes`, `queryEdgeIds`, `queryEdges`, `queryGraphRows`, and explain APIs combine IDs, keys, node label filters (`{ labels, mode: 'any' | 'all' }`), edge labels, endpoint constraints, property equality/IN/range/exists/missing filters, edge metadata filters, updated-at ranges, row-shaped graph patterns, optional groups, and bounded paths. `executeGql` adds GQL Beta for query-string reads and mutations over the same native substrates. OverGraph chooses the cheapest legal path with available indexes and planner stats, then verifies results against visible records. +- **Full query APIs.** `queryNodeIds`, `queryNodes`, `queryEdgeIds`, `queryEdges`, `queryGraphRows`, and explain APIs combine IDs, keys, node label filters (`{ labels, mode: 'any' | 'all' }`), edge labels, endpoint constraints, property equality/IN/range/exists/missing filters, edge metadata filters, updated-at ranges, row-shaped graph patterns, optional groups, and bounded paths. `executeGql` adds GQL Beta for query-string reads and mutations. OverGraph chooses the cheapest legal path with available indexes and planner stats, then verifies results against visible records. - **Time-range queries.** Find nodes created or updated within a time window. Sorted timestamp index for efficient range scans. ### Pagination diff --git a/overgraph-node/__test__/gql.mjs b/overgraph-node/__test__/gql.mjs index f0bc5f7..ada0be0 100644 --- a/overgraph-node/__test__/gql.mjs +++ b/overgraph-node/__test__/gql.mjs @@ -38,6 +38,10 @@ function approxArray(actual, expected) { } } +function byName(row) { + return row.name; +} + describe('GQL connector API', () => { let tmpDir; let db; @@ -171,12 +175,26 @@ describe('GQL connector API', () => { maxParamBytes: 9, maxAstDepth: 4, maxLiteralItems: 3, + maxPipelineRows: 11, + maxGroups: 12, + maxCollectItems: 13, + maxUnionBranches: 2, + maxSubqueryInvocations: 14, + maxSubqueryDepth: 1, + maxShortestPathPairs: 15, } ); assert.equal(cappedExplain.caps.maxQueryBytes, 128); assert.equal(cappedExplain.caps.maxParamBytes, 9); assert.equal(cappedExplain.caps.maxAstDepth, 4); assert.equal(cappedExplain.caps.maxLiteralItems, 3); + assert.equal(cappedExplain.caps.maxPipelineRows, 11); + assert.equal(cappedExplain.caps.maxGroups, 12); + assert.equal(cappedExplain.caps.maxCollectItems, 13); + assert.equal(cappedExplain.caps.maxUnionBranches, 2); + assert.equal(cappedExplain.caps.maxSubqueryInvocations, 14); + assert.equal(cappedExplain.caps.maxSubqueryDepth, 1); + assert.equal(cappedExplain.caps.maxShortestPathPairs, 15); const unusedOversized = db.executeGql( 'MATCH (n:Person) RETURN id(n) LIMIT 1', @@ -248,6 +266,240 @@ describe('GQL connector API', () => { assert.equal(asyncExplain.read.target, 'graph_row_query'); }); + it('executes Phase 34 WITH, rich expressions, DISTINCT, aggregation, and compact rows', () => { + const rich = db.executeGql( + `MATCH (n:Person) + WITH n, + lower(trim(n.name)) AS slug, + n.rank + 2 AS boosted, + -n.rank AS negRank, + CASE WHEN n.rank > 1 THEN upper(n.status) ELSE 'LOW' END AS bucket, + {name: n.name, scores: [n.rank, n.rank + 1], active: n.status = 'active'} AS payload + WHERE slug STARTS WITH 'a' + RETURN n.name AS name, slug, boosted, negRank, bucket, payload` + ); + assert.deepEqual(rich.columns, ['name', 'slug', 'boosted', 'negRank', 'bucket', 'payload']); + assert.deepEqual(rich.rows, [{ + name: 'Ada', + slug: 'ada', + boosted: 4, + negRank: -2, + bucket: 'ACTIVE', + payload: { active: true, name: 'Ada', scores: [2, 3] }, + }]); + + const distinct = db.executeGql( + 'MATCH (n:Person) RETURN DISTINCT n.group AS group ORDER BY group' + ); + assert.deepEqual(distinct.rows, [{ group: 'core' }, { group: 'ops' }]); + + const compactAgg = db.executeGql( + `MATCH (n:Person) + WITH n.group AS group, count(*) AS total, sum(n.rank) AS sumRank, + avg(n.rank) AS avgRank, collect(n.name) AS names + RETURN group, total, sumRank, avgRank, names + ORDER BY group`, + null, + { compactRows: true } + ); + assert.deepEqual(compactAgg.columns, ['group', 'total', 'sumRank', 'avgRank', 'names']); + assert.equal(compactAgg.kind, 'query'); + assert.deepEqual(compactAgg.rows[0].slice(0, 4), ['core', 2, 3, 1.5]); + assert.deepEqual([...compactAgg.rows[0][4]].sort(), ['Ada', 'Ben']); + assert.deepEqual(compactAgg.rows[1], ['ops', 1, 3, 3, ['Cy']]); + + const collectedNodeIds = db.executeGql( + 'MATCH (n:Person) RETURN collect(n) AS people', + null, + { includeVectors: true } + ).rows[0].people; + assert.deepEqual( + [...collectedNodeIds].sort((a, b) => a - b), + [ids.ada, ids.ben, ids.cy].sort((a, b) => a - b) + ); + }); + + it('executes Phase 34 UNION variants and read-only subqueries', () => { + const unionAll = db.executeGql( + `MATCH (n:Person) WHERE n.group = 'core' RETURN n.name AS name ORDER BY name + UNION ALL + MATCH (m:Person) WHERE m.status = 'active' RETURN m.name AS name ORDER BY name` + ); + assert.deepEqual(unionAll.rows.map(byName), ['Ada', 'Ben', 'Ada', 'Ben']); + + const union = db.executeGql( + `MATCH (n:Person) WHERE n.group = 'core' RETURN n.name AS name ORDER BY name + UNION + MATCH (m:Person) WHERE m.status = 'active' RETURN m.name AS name ORDER BY name` + ); + assert.deepEqual(union.rows.map(byName), ['Ada', 'Ben']); + + const exists = db.executeGql( + `MATCH (n:Person) + WHERE EXISTS { MATCH (n)-[:WORKS_AT]->(c:Company) RETURN c } + RETURN n.name AS name` + ); + assert.deepEqual(exists.rows, [{ name: 'Ada' }]); + + const call = db.executeGql( + `MATCH (n:Person) + CALL { MATCH (n)-[:WORKS_AT]->(c:Company) RETURN c.name AS company } + RETURN n.name AS name, company` + ); + assert.deepEqual(call.rows, [{ name: 'Ada', company: 'Acme' }]); + }); + + it('returns shortest path objects and helper values through Node', () => { + const result = db.executeGql( + `MATCH (a:Person) WHERE a.name = 'Ada' + WITH a + MATCH (c:Company) WHERE c.name = 'Acme' + WITH a, c + MATCH p = shortestPath((a)-[:WORKS_AT*1..1]->(c)) + RETURN p, + node_ids(p) AS nodeIds, + edge_ids(p) AS edgeIds, + length(p) AS hops, + nodes(p) AS nodeHelper, + relationships(p) AS relationshipHelper, + [p] AS pathList, + {path: p, nodes: nodes(p), relationships: relationships(p)} AS nested`, + null, + { includePlan: true } + ); + + assert.equal(result.rows.length, 1); + assert.equal(result.plan.read.target, 'graph_pipeline_query'); + assert.ok(result.plan.read.projection.some(item => item.includes('ShortestPath'))); + const row = result.rows[0]; + assert.deepEqual(row.p.nodeIds, [ids.ada, ids.acme]); + assert.deepEqual(row.p.edgeIds, [ids.worksAt]); + assert.deepEqual(row.p.nodes.map(node => node.id), [ids.ada, ids.acme]); + assert.equal(row.p.edges[0].id, ids.worksAt); + assert.deepEqual(row.nodeIds, [ids.ada, ids.acme]); + assert.deepEqual(row.edgeIds, [ids.worksAt]); + assert.equal(row.hops, 1); + assert.deepEqual(row.nodeHelper, [ids.ada, ids.acme]); + assert.deepEqual(row.relationshipHelper, [ids.worksAt]); + assert.deepEqual(row.pathList[0].nodeIds, [ids.ada, ids.acme]); + assert.deepEqual(row.pathList[0].edgeIds, [ids.worksAt]); + assert.deepEqual(row.nested.path.nodeIds, [ids.ada, ids.acme]); + assert.deepEqual(row.nested.path.edgeIds, [ids.worksAt]); + assert.deepEqual(row.nested.nodes, [ids.ada, ids.acme]); + assert.deepEqual(row.nested.relationships, [ids.worksAt]); + }); + + it('executes keyed MERGE actions with mutation stats and result shape', () => { + const created = db.executeGql( + `MERGE (n:NodeMergeParity {key: 'node'}) + ON CREATE SET n.status = 'created', n.count = 1 + ON MATCH SET n.status = 'matched', n.count = n.count + 1 + RETURN n.key AS key, n.status AS status, n.count AS count`, + null, + { includePlan: true, profile: true } + ); + assert.equal(created.kind, 'mutation'); + assert.deepEqual(created.rows, [{ key: 'node', status: 'created', count: 1 }]); + assert.equal(created.mutationStats.nodesCreated, 1); + assert.equal(created.mutationStats.nodesUpdated, 0); + assert.equal(created.mutationStats.mutationRows, 1); + assert.equal(created.plan.mutation.usesWriteTxn, true); + + const matched = db.executeGql( + `MERGE (n:NodeMergeParity {key: 'node'}) + ON CREATE SET n.status = 'created-again', n.count = 1 + ON MATCH SET n.status = 'matched', n.count = n.count + 1 + RETURN n.key AS key, n.status AS status, n.count AS count` + ); + assert.equal(matched.kind, 'mutation'); + assert.deepEqual(matched.rows, [{ key: 'node', status: 'matched', count: 2 }]); + assert.equal(matched.mutationStats.nodesCreated, 0); + assert.equal(matched.mutationStats.nodesUpdated, 1); + assert.equal(matched.mutationStats.propertiesSet, 2); + }); + + it('forwards Phase 34 caps and graph-pipeline explain fields through sync and async GQL', async () => { + const options = { + maxPipelineRows: 64, + maxGroups: 8, + maxCollectItems: 8, + maxUnionBranches: 4, + maxSubqueryInvocations: 16, + maxSubqueryDepth: 2, + maxShortestPathPairs: 8, + includePlan: true, + profile: true, + }; + const query = `MATCH (n:Person) + WITH n.group AS group, count(*) AS total + RETURN group, total + ORDER BY group`; + + const result = db.executeGql(query, null, options); + assert.equal(result.plan.read.target, 'graph_pipeline_query'); + assert.equal(result.plan.caps.maxPipelineRows, 64); + assert.equal(result.plan.caps.maxGroups, 8); + assert.equal(result.plan.caps.maxCollectItems, 8); + assert.equal(result.plan.caps.maxUnionBranches, 4); + assert.equal(result.plan.caps.maxSubqueryInvocations, 16); + assert.equal(result.plan.caps.maxSubqueryDepth, 2); + assert.equal(result.plan.caps.maxShortestPathPairs, 8); + assert.equal(result.stats.rowsReturned, 2); + assert.equal(typeof result.stats.dbHits, 'number'); + assert.equal(typeof result.stats.elapsedUs, 'number'); + assert.ok(result.plan.read.projection.some(item => item.includes('graph pipeline stage'))); + + const explain = db.explainGql(query, null, options); + assert.equal(explain.read.target, 'graph_pipeline_query'); + assert.equal(explain.caps.maxPipelineRows, 64); + assert.equal(explain.caps.maxGroups, 8); + assert.equal(explain.caps.maxCollectItems, 8); + assert.equal(explain.caps.maxUnionBranches, 4); + assert.equal(explain.caps.maxSubqueryInvocations, 16); + assert.equal(explain.caps.maxSubqueryDepth, 2); + assert.equal(explain.caps.maxShortestPathPairs, 8); + + const asyncRows = await db.executeGqlAsync(query, null, { ...options, compactRows: true }); + assert.deepEqual(asyncRows.rows, [['core', 2], ['ops', 1]]); + const asyncExplain = await db.explainGqlAsync(query, null, options); + assert.equal(asyncExplain.read.target, 'graph_pipeline_query'); + assert.equal(asyncExplain.caps.maxPipelineRows, 64); + assert.equal(asyncExplain.caps.maxGroups, 8); + assert.equal(asyncExplain.caps.maxCollectItems, 8); + assert.equal(asyncExplain.caps.maxUnionBranches, 4); + assert.equal(asyncExplain.caps.maxSubqueryInvocations, 16); + assert.equal(asyncExplain.caps.maxSubqueryDepth, 2); + assert.equal(asyncExplain.caps.maxShortestPathPairs, 8); + + assert.throws( + () => db.executeGql('MATCH (n:Person) RETURN collect(n.name) AS names', null, { maxCollectItems: 1 }), + /maxCollectItems|max_collect_items/i + ); + assert.throws( + () => db.executeGql( + `MATCH (n:Person) RETURN n.name AS name + UNION ALL + MATCH (m:Company) RETURN m.name AS name`, + null, + { maxUnionBranches: 1 } + ), + /maxUnionBranches|max_union_branches/i + ); + assert.throws( + () => db.executeGql( + `MATCH (a:Person) WHERE a.name = 'Ada' + WITH a + MATCH (c:Company) WHERE c.name = 'Acme' + WITH a, c + MATCH p = shortestPath((a)-[:WORKS_AT*1..1]->(c)) + RETURN p`, + null, + { maxShortestPathPairs: 0 } + ), + /maxShortestPathPairs|max_shortest_path_pairs|path caps/i + ); + }); + it('executes sync CREATE RETURN with mutation stats, bytes, and embedded plan', () => { const result = db.executeGql( `CREATE (n:NodeCreateReturn {key: 'created-one', name: $name, payload: $payload}) diff --git a/overgraph-node/__test__/graph-rows.mjs b/overgraph-node/__test__/graph-rows.mjs index c781fce..14f8080 100644 --- a/overgraph-node/__test__/graph-rows.mjs +++ b/overgraph-node/__test__/graph-rows.mjs @@ -316,4 +316,87 @@ describe('graph row connector API', () => { assert.match(explainText, /Optional|optional/i); assert.equal(explain.cursor.codecImplemented, true); }); + + it('runs structured graph pipeline queries through sync and async connector APIs', async () => { + const pipeline = { + stages: [ + { + kind: 'match', + nodes: [{ alias: 'n', labelFilter: nodeLabels('Person') }], + }, + { + kind: 'project', + projectKind: 'with', + items: [ + { expr: { property: { alias: 'n', key: 'name' } }, as: 'name' }, + { expr: { property: { alias: 'n', key: 'rank' } }, as: 'rank' }, + { expr: { property: { alias: 'n', key: 'status' } }, as: 'status' }, + ], + where: { op: '=', left: { binding: 'status' }, right: 'active' }, + orderBy: [{ expr: { binding: 'rank' }, direction: 'desc' }], + limit: 3, + }, + { + kind: 'project', + projectKind: 'return', + items: [ + { expr: { binding: 'name' }, as: 'name' }, + { expr: { op: '+', left: { binding: 'rank' }, right: 10 }, as: 'score' }, + ], + orderBy: [{ expr: { binding: 'score' }, direction: 'desc' }], + }, + ], + limit: 10, + options: { includePlan: true, profile: true }, + }; + + const result = db.queryGraphPipeline(pipeline); + assert.deepEqual(result.columns, ['name', 'score']); + assert.deepEqual(result.rows, [ + { name: 'Cy', score: 13 }, + { name: 'Ben', score: 12 }, + ]); + assert.equal(result.nextCursor, null); + assert.equal(result.stats.rowsReturned, 2); + assert.equal(result.plan.stages.length, 3); + assert.equal(result.plan.caps.maxPipelineRows, 65536); + + const compact = await db.queryGraphPipelineAsync({ + ...pipeline, + output: { compactRows: true }, + }); + assert.deepEqual(compact.rows, [ + ['Cy', 13], + ['Ben', 12], + ]); + + const explain = await db.explainGraphPipelineAsync(pipeline); + assert.deepEqual(explain.columns, ['name', 'score']); + assert.equal(explain.stages.length, 3); + assert.equal(explain.projection.compactRows, false); + }); + + it('runs structured graph pipeline aggregate projections', () => { + const result = db.queryGraphPipeline({ + stages: [ + { + kind: 'match', + nodes: [{ alias: 'n', labelFilter: nodeLabels('Person') }], + }, + { + kind: 'return', + items: [ + { expr: { aggregate: { function: 'count' } }, as: 'people' }, + { expr: { aggregate: { function: 'collect', arg: { property: { alias: 'n', key: 'status' } }, distinct: true } }, as: 'statuses' }, + ], + }, + ], + limit: 10, + options: { includePlan: true }, + }); + + assert.equal(result.rows[0].people, 4); + assert.deepEqual(new Set(result.rows[0].statuses), new Set(['active', 'inactive'])); + assert.equal(result.plan.stats.groups, 1); + }); }); diff --git a/overgraph-node/__test__/types/declarations.ts b/overgraph-node/__test__/types/declarations.ts index db45d5b..e1a50bd 100644 --- a/overgraph-node/__test__/types/declarations.ts +++ b/overgraph-node/__test__/types/declarations.ts @@ -10,11 +10,17 @@ import type { } from '../../index.js' import type { GraphPathValue, + GraphPipelineRequest, + GraphPipelineResult, GraphRowRequest, GraphRowResult, + GqlEdge, GqlExecutionExplain, GqlExecutionOptions, GqlExecutionResult, + GqlLoweringTarget, + GqlNode, + GqlPath, GqlValue, QueryEdgeRequest, QueryPlanNode, @@ -100,6 +106,27 @@ const graphRowAsyncResult: Promise = db.queryGraphRowsAsync(grap const graphRowExplain = db.explainGraphRows(graphRows) const graphRowExplainAsync = db.explainGraphRowsAsync(graphRows) +const graphPipeline: GraphPipelineRequest = { + stages: [ + { kind: 'match', nodes: [{ alias: 'person', labelFilter: { labels: ['Person'], mode: 'all' } }] }, + { + kind: 'return', + items: [ + { expr: { property: { alias: 'person', key: 'name' } }, as: 'name' }, + { expr: { aggregate: { function: 'count' } }, as: 'count' }, + ], + }, + ], + output: { compactRows: true }, + options: { allowFullScan: true, maxPipelineRows: 1024, includePlan: true }, + limit: 10, +} + +const graphPipelineResult: GraphPipelineResult = db.queryGraphPipeline(graphPipeline) +const graphPipelineAsyncResult: Promise = db.queryGraphPipelineAsync(graphPipeline) +const graphPipelineExplain = db.explainGraphPipeline(graphPipeline) +const graphPipelineExplainAsync = db.explainGraphPipelineAsync(graphPipeline) + // @ts-expect-error Old pattern request types are intentionally not exported. type RemovedGraphNodePattern = QueryTypes.GraphNodePattern // @ts-expect-error Old pattern query APIs are intentionally not exposed. @@ -123,6 +150,13 @@ const gqlOptions: GqlExecutionOptions = { maxCursorBytes: 4096, maxMutationRows: 10, maxMutationOps: 20, + maxPipelineRows: 512, + maxGroups: 128, + maxCollectItems: 64, + maxUnionBranches: 4, + maxSubqueryInvocations: 32, + maxSubqueryDepth: 2, + maxShortestPathPairs: 16, maxParamBytes: 1024, maxAstDepth: 32, maxLiteralItems: 128, @@ -156,8 +190,24 @@ const gqlAsyncResult: Promise = db.executeGqlAsync( { compactRows: true }, ) const gqlExplain: GqlExecutionExplain = db.explainGql('MATCH (n:Person) RETURN n', null, { allowFullScan: true }) +const gqlReadTarget: GqlLoweringTarget | undefined = gqlExplain.read?.target +const gqlPipelineRowCap: number = gqlExplain.caps.maxPipelineRows +const gqlGroupCap: number = gqlExplain.caps.maxGroups +const gqlCollectCap: number = gqlExplain.caps.maxCollectItems +const gqlUnionCap: number = gqlExplain.caps.maxUnionBranches +const gqlSubqueryInvocationCap: number = gqlExplain.caps.maxSubqueryInvocations +const gqlSubqueryDepthCap: number = gqlExplain.caps.maxSubqueryDepth +const gqlShortestPathPairCap: number = gqlExplain.caps.maxShortestPathPairs +const gqlReadRowCap: number | undefined = gqlExplain.read?.caps.maxRows const gqlExplainAsync = db.explainGqlAsync('MATCH (n:Person) RETURN n', null, { allowFullScan: true, + maxPipelineRows: 512, + maxGroups: 128, + maxCollectItems: 64, + maxUnionBranches: 4, + maxSubqueryInvocations: 32, + maxSubqueryDepth: 2, + maxShortestPathPairs: 16, }) const gqlMutationResult: GqlExecutionResult = db.executeGql( "CREATE (n:Person {key: 'new-person', name: 'New'}) RETURN n.name AS name", @@ -165,6 +215,36 @@ const gqlMutationResult: GqlExecutionResult = db.executeGql( { maxMutationRows: 1, maxMutationOps: 1 }, ) const compactRows = gqlMutationResult.rows as Array> +const gqlNodeValue: GqlNode = { + id: 1, + labels: ['Person'], + props: { + nested: { scores: [1, null, { ok: true }] }, + }, +} +const gqlEdgeValue: GqlEdge = { + id: 3, + from: 1, + to: 2, + label: 'KNOWS', + props: { + weights: [0.5, 1], + }, +} +const gqlPathValue: GqlPath = { + nodeIds: [1, 2], + edgeIds: [3], + nodes: [gqlNodeValue], + edges: [gqlEdgeValue], +} +const gqlNestedValue: GqlValue = { + collect: [gqlNodeValue], + path: gqlPathValue, + helpers: { + nodes: gqlPathValue.nodeIds, + relationships: gqlPathValue.edgeIds, + }, +} const mutationStats = gqlMutationResult.mutationStats?.nodesCreated const mutationExplain = db.explainGql( "CREATE (n:Person {key: 'planned-person'}) RETURN n.key AS key", @@ -198,9 +278,12 @@ void fallbackEdgeLabelScan void gqlResult void gqlAsyncResult void gqlExplain +void gqlReadTarget void gqlExplainAsync void gqlMutationResult void compactRows +void gqlReadRowCap +void gqlNestedValue void mutationStats void mutationOperation void mutationReturnColumns diff --git a/overgraph-node/index.d.ts b/overgraph-node/index.d.ts index d7c68a4..0310f64 100644 --- a/overgraph-node/index.d.ts +++ b/overgraph-node/index.d.ts @@ -129,9 +129,11 @@ export declare class OverGraph { queryEdgeIds(request: import('./query-types').QueryEdgeRequest): IdPageResult queryEdges(request: import('./query-types').QueryEdgeRequest): EdgePageResult queryGraphRows(request: import('./query-types').GraphRowRequest): import('./query-types').GraphRowResult + queryGraphPipeline(request: import('./query-types').GraphPipelineRequest): import('./query-types').GraphPipelineResult explainNodeQuery(request: import('./query-types').QueryNodeRequest): import('./query-types').QueryPlan explainEdgeQuery(request: import('./query-types').QueryEdgeRequest): import('./query-types').QueryPlan explainGraphRows(request: import('./query-types').GraphRowRequest): import('./query-types').GraphRowExplain + explainGraphPipeline(request: import('./query-types').GraphPipelineRequest): import('./query-types').GraphPipelineExplain executeGql(query: string, params?: import('./query-types').GqlParams | null, options?: import('./query-types').GqlExecutionOptions | null): import('./query-types').GqlExecutionResult explainGql(query: string, params?: import('./query-types').GqlParams | null, options?: import('./query-types').GqlExecutionOptions | null): import('./query-types').GqlExecutionExplain ensureNodePropertyIndex(label: string, propKey: string, kind: string): NodePropertyIndexInfo @@ -221,9 +223,11 @@ export declare class OverGraph { queryEdgeIdsAsync(request: import('./query-types').QueryEdgeRequest): Promise queryEdgesAsync(request: import('./query-types').QueryEdgeRequest): Promise queryGraphRowsAsync(request: import('./query-types').GraphRowRequest): Promise + queryGraphPipelineAsync(request: import('./query-types').GraphPipelineRequest): Promise explainNodeQueryAsync(request: import('./query-types').QueryNodeRequest): Promise explainEdgeQueryAsync(request: import('./query-types').QueryEdgeRequest): Promise explainGraphRowsAsync(request: import('./query-types').GraphRowRequest): Promise + explainGraphPipelineAsync(request: import('./query-types').GraphPipelineRequest): Promise executeGqlAsync(query: string, params?: import('./query-types').GqlParams | null, options?: import('./query-types').GqlExecutionOptions | null): Promise explainGqlAsync(query: string, params?: import('./query-types').GqlParams | null, options?: import('./query-types').GqlExecutionOptions | null): Promise ensureNodePropertyIndexAsync(label: string, propKey: string, kind: string): Promise @@ -524,6 +528,13 @@ export interface GqlExecutionOptionsInput { maxCursorBytes?: number maxMutationRows?: number maxMutationOps?: number + maxPipelineRows?: number + maxGroups?: number + maxCollectItems?: number + maxUnionBranches?: number + maxSubqueryInvocations?: number + maxSubqueryDepth?: number + maxShortestPathPairs?: number maxIntermediateBindings?: number maxFrontier?: number maxPathHops?: number diff --git a/overgraph-node/index.js b/overgraph-node/index.js index 0b9e19f..93644b2 100644 --- a/overgraph-node/index.js +++ b/overgraph-node/index.js @@ -77,8 +77,8 @@ function requireNative() { try { const binding = require('overgraph-android-arm64') const bindingPackageVersion = require('overgraph-android-arm64/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -93,8 +93,8 @@ function requireNative() { try { const binding = require('overgraph-android-arm-eabi') const bindingPackageVersion = require('overgraph-android-arm-eabi/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -114,8 +114,8 @@ function requireNative() { try { const binding = require('overgraph-win32-x64-gnu') const bindingPackageVersion = require('overgraph-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -130,8 +130,8 @@ function requireNative() { try { const binding = require('overgraph-win32-x64-msvc') const bindingPackageVersion = require('overgraph-win32-x64-msvc/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -147,8 +147,8 @@ function requireNative() { try { const binding = require('overgraph-win32-ia32-msvc') const bindingPackageVersion = require('overgraph-win32-ia32-msvc/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -163,8 +163,8 @@ function requireNative() { try { const binding = require('overgraph-win32-arm64-msvc') const bindingPackageVersion = require('overgraph-win32-arm64-msvc/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -182,8 +182,8 @@ function requireNative() { try { const binding = require('overgraph-darwin-universal') const bindingPackageVersion = require('overgraph-darwin-universal/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -198,8 +198,8 @@ function requireNative() { try { const binding = require('overgraph-darwin-x64') const bindingPackageVersion = require('overgraph-darwin-x64/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -214,8 +214,8 @@ function requireNative() { try { const binding = require('overgraph-darwin-arm64') const bindingPackageVersion = require('overgraph-darwin-arm64/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -234,8 +234,8 @@ function requireNative() { try { const binding = require('overgraph-freebsd-x64') const bindingPackageVersion = require('overgraph-freebsd-x64/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -250,8 +250,8 @@ function requireNative() { try { const binding = require('overgraph-freebsd-arm64') const bindingPackageVersion = require('overgraph-freebsd-arm64/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -271,8 +271,8 @@ function requireNative() { try { const binding = require('overgraph-linux-x64-musl') const bindingPackageVersion = require('overgraph-linux-x64-musl/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -287,8 +287,8 @@ function requireNative() { try { const binding = require('overgraph-linux-x64-gnu') const bindingPackageVersion = require('overgraph-linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -305,8 +305,8 @@ function requireNative() { try { const binding = require('overgraph-linux-arm64-musl') const bindingPackageVersion = require('overgraph-linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -321,8 +321,8 @@ function requireNative() { try { const binding = require('overgraph-linux-arm64-gnu') const bindingPackageVersion = require('overgraph-linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -339,8 +339,8 @@ function requireNative() { try { const binding = require('overgraph-linux-arm-musleabihf') const bindingPackageVersion = require('overgraph-linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -355,8 +355,8 @@ function requireNative() { try { const binding = require('overgraph-linux-arm-gnueabihf') const bindingPackageVersion = require('overgraph-linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -373,8 +373,8 @@ function requireNative() { try { const binding = require('overgraph-linux-loong64-musl') const bindingPackageVersion = require('overgraph-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -389,8 +389,8 @@ function requireNative() { try { const binding = require('overgraph-linux-loong64-gnu') const bindingPackageVersion = require('overgraph-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -407,8 +407,8 @@ function requireNative() { try { const binding = require('overgraph-linux-riscv64-musl') const bindingPackageVersion = require('overgraph-linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -423,8 +423,8 @@ function requireNative() { try { const binding = require('overgraph-linux-riscv64-gnu') const bindingPackageVersion = require('overgraph-linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -440,8 +440,8 @@ function requireNative() { try { const binding = require('overgraph-linux-ppc64-gnu') const bindingPackageVersion = require('overgraph-linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -456,8 +456,8 @@ function requireNative() { try { const binding = require('overgraph-linux-s390x-gnu') const bindingPackageVersion = require('overgraph-linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -476,8 +476,8 @@ function requireNative() { try { const binding = require('overgraph-openharmony-arm64') const bindingPackageVersion = require('overgraph-openharmony-arm64/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -492,8 +492,8 @@ function requireNative() { try { const binding = require('overgraph-openharmony-x64') const bindingPackageVersion = require('overgraph-openharmony-x64/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -508,8 +508,8 @@ function requireNative() { try { const binding = require('overgraph-openharmony-arm') const bindingPackageVersion = require('overgraph-openharmony-arm/package.json').version - if (bindingPackageVersion !== '0.10.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.10.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.11.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.11.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { diff --git a/overgraph-node/package-lock.json b/overgraph-node/package-lock.json index 935b208..a41c4fc 100644 --- a/overgraph-node/package-lock.json +++ b/overgraph-node/package-lock.json @@ -1,12 +1,12 @@ { "name": "overgraph", - "version": "0.10.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "overgraph", - "version": "0.10.0", + "version": "0.11.0", "license": "MIT OR Apache-2.0", "devDependencies": { "@napi-rs/cli": "^3.0.0", diff --git a/overgraph-node/package.json b/overgraph-node/package.json index dff8a44..98e224d 100644 --- a/overgraph-node/package.json +++ b/overgraph-node/package.json @@ -1,6 +1,6 @@ { "name": "overgraph", - "version": "0.10.0", + "version": "0.11.0", "description": "An absurdly fast embedded graph database for Node.js. Sub-microsecond reads, pure Rust core.", "main": "index.js", "types": "index.d.ts", diff --git a/overgraph-node/query-types.d.ts b/overgraph-node/query-types.d.ts index f1b4920..c694b2e 100644 --- a/overgraph-node/query-types.d.ts +++ b/overgraph-node/query-types.d.ts @@ -329,12 +329,74 @@ export type GraphExpr = | { nodeField: { alias: string; field: 'id' | 'labels' | 'key' | 'weight' | 'createdAt' | 'updatedAt' } } | { edgeField: { alias: string; field: 'id' | 'from' | 'to' | 'label' | 'weight' | 'createdAt' | 'updatedAt' | 'validFrom' | 'validTo' } } | { pathField: { alias: string; field: 'nodeIds' | 'edgeIds' | 'length' } } - | { fn: 'id' | 'labels' | 'type' | 'length' | 'startNode' | 'endNode' | 'nodes' | 'relationships' | 'nodeIds' | 'edgeIds'; args: Array } - | { op: 'and' | 'or' | '=' | '==' | 'eq' | '<>' | '!=' | 'neq' | '<' | 'lt' | '<=' | 'lte' | '>' | 'gt' | '>=' | 'gte' | 'in'; left: GraphExpr; right: GraphExpr } - | { op: 'not'; expr: GraphExpr } + | { fn: GraphFunctionName; args: Array } + | { aggregate: { function: GraphAggregateFunction; distinct?: boolean; arg?: GraphExpr | null } } + | { exists: GraphPipelineCallPayload } + | { op: GraphBinaryOperator; left: GraphExpr; right: GraphExpr } + | { op: 'not' | 'neg' | '-'; expr: GraphExpr } + | { case: { operand?: GraphExpr | null; branches: Array<{ when: GraphExpr; then: GraphExpr }>; else?: GraphExpr | null } } | { isNull: GraphExpr } | { isNotNull: GraphExpr } +export type GraphFunctionName = + | 'id' + | 'labels' + | 'type' + | 'length' + | 'startNode' + | 'endNode' + | 'nodes' + | 'relationships' + | 'nodeIds' + | 'edgeIds' + | 'coalesce' + | 'toString' + | 'toInteger' + | 'toFloat' + | 'abs' + | 'floor' + | 'ceil' + | 'round' + | 'lower' + | 'upper' + | 'trim' + | 'substring' + | 'size' + | 'head' + | 'last' + +export type GraphAggregateFunction = 'count' | 'sum' | 'avg' | 'min' | 'max' | 'collect' + +export type GraphBinaryOperator = + | 'and' + | 'or' + | '=' + | '==' + | 'eq' + | '<>' + | '!=' + | 'neq' + | '<' + | 'lt' + | '<=' + | 'lte' + | '>' + | 'gt' + | '>=' + | 'gte' + | 'in' + | '+' + | 'add' + | '-' + | 'sub' + | '*' + | 'mul' + | '/' + | 'div' + | 'startsWith' + | 'endsWith' + | 'contains' + export type GraphElementProjection = 'idOnly' | 'compact' | 'full' export type GraphPropertySelection = boolean | 'all' | 'none' | Array export type GraphVectorSelection = boolean | 'none' | 'dense' | 'sparse' | 'both' @@ -539,6 +601,201 @@ export interface GraphCompactRowsResult { export type GraphRowResult = GraphObjectRowsResult | GraphCompactRowsResult +export interface GraphPipelineMatchStage { + kind: 'match' + optional?: boolean + nodes?: Array + pieces?: Array + where?: GraphExpr | null + optionalCandidateWhere?: GraphExpr | null +} + +export type GraphProjectionItems = 'star' | '*' | Array + +export interface GraphProjectItem { + expr: GraphExpr + as?: string + projection?: GraphReturnProjection +} + +export interface GraphPipelineProjectStage { + kind: 'project' | 'with' | 'return' + projectKind?: 'with' | 'return' + items?: GraphProjectionItems + distinct?: boolean + where?: GraphExpr | null + orderBy?: Array + skip?: GraphExpr | null + limit?: GraphExpr | null +} + +export type GraphShortestPathEndpoint = + | string + | number + | { alias: string } + | { nodeId: number } + | { nodeKey: { label: string; key: string } } + | { expr: GraphExpr } + +export interface GraphPipelineShortestPathStage { + kind: 'shortestPath' | 'shortest_path' + optional?: boolean + outputPathAlias: string + mode?: 'one' | 'all' + from: GraphShortestPathEndpoint + to: GraphShortestPathEndpoint + direction?: 'outgoing' | 'incoming' | 'both' + edgeLabelFilter?: Array + minHops: number + maxHops: number + weightField?: string | null + maxCost?: number | null + maxPaths?: number | null +} + +export interface GraphPipelineCallPayload { + query: GraphPipelineRequest + importAliases?: Array +} + +export interface GraphPipelineCallStage extends GraphPipelineCallPayload { + kind: 'call' +} + +export interface GraphPipelineUnionStage { + kind: 'union' + branches: Array + all?: boolean +} + +export type GraphPipelineStage = + | GraphPipelineMatchStage + | GraphPipelineProjectStage + | GraphPipelineShortestPathStage + | GraphPipelineCallStage + | GraphPipelineUnionStage + +export interface GraphPipelineOptions { + allowFullScan?: boolean + maxRows?: number + maxPipelineRows?: number + maxGroups?: number + maxCollectItems?: number + maxUnionBranches?: number + maxSubqueryInvocations?: number + maxSubqueryDepth?: number + maxShortestPathPairs?: number + maxIntermediateBindings?: number + maxFrontier?: number + maxPathHops?: number + maxPathsPerStart?: number + maxOrderMaterialization?: number + maxSkip?: number + maxCursorBytes?: number + maxQueryBytes?: number + maxParamBytes?: number + maxAstDepth?: number + maxLiteralItems?: number + includePlan?: boolean + profile?: boolean +} + +export interface GraphPipelineRequest { + stages: Array + params?: Record + atEpoch?: number | null + skip?: number + limit?: number + cursor?: string | null + output?: GraphOutputOptions + options?: GraphPipelineOptions +} + +export interface GraphPipelineStats { + rowsReturned: number + rowsEnteredPipeline: number + rowsAfterFilter: number + intermediateRows: number + pipelineRowsMaterialized: number + groups: number + collectItems: number + unionBranches: number + unionDedupKeys: number + subqueryInvocations: number + subqueryCacheHits: number + shortestPathPairs: number + shortestPathCacheHits: number + dbHits: number + elapsedUs: number | null + effectiveAtEpoch: number + warnings: Array +} + +export interface GraphPipelineStageExplain { + index: number + kind: string + detail: string + columns: Array + graphRow: GraphRowExplain | null + warnings: Array + notes: Array +} + +export interface GraphPipelineExplain { + columns: Array + effectiveAtEpoch: number | null + fingerprint: string + stages: Array + rowOps: Array + order: { explicit: boolean; items: number; stableLogicalRowKey: boolean } + cursor: { supplied: boolean; codecImplemented: boolean; message: string | null } + projection: { columns: Array; outputMode: 'ids' | 'elements' | 'projected'; includeVectors: boolean; compactRows: boolean } + caps: { + allowFullScan: boolean + maxRows: number + maxPipelineRows: number + maxGroups: number + maxCollectItems: number + maxUnionBranches: number + maxSubqueryInvocations: number + maxSubqueryDepth: number + maxShortestPathPairs: number + maxIntermediateBindings: number + maxFrontier: number + maxPathHops: number + maxPathsPerStart: number + maxOrderMaterialization: number + maxSkip: number + maxCursorBytes: number + maxQueryBytes: number + maxParamBytes: number + maxAstDepth: number + maxLiteralItems: number + } + summaries: { validationOnly: boolean; rowsPlanned: number; warnings: Array } + stats: GraphPipelineStats + warnings: Array + notes: Array +} + +export interface GraphPipelineObjectRowsResult { + columns: Array + rows: Array> + nextCursor: string | null + stats: GraphPipelineStats + plan: GraphPipelineExplain | null +} + +export interface GraphPipelineCompactRowsResult { + columns: Array + rows: Array> + nextCursor: string | null + stats: GraphPipelineStats + plan: GraphPipelineExplain | null +} + +export type GraphPipelineResult = GraphPipelineObjectRowsResult | GraphPipelineCompactRowsResult + export type QueryPlanKind = 'node_query' | 'edge_query' export type QueryPlanWarning = @@ -635,6 +892,13 @@ export interface GqlExecutionOptions { maxCursorBytes?: number maxMutationRows?: number maxMutationOps?: number + maxPipelineRows?: number + maxGroups?: number + maxCollectItems?: number + maxUnionBranches?: number + maxSubqueryInvocations?: number + maxSubqueryDepth?: number + maxShortestPathPairs?: number maxIntermediateBindings?: number maxFrontier?: number maxPathHops?: number @@ -720,6 +984,13 @@ export interface GqlExecutionCapSummary { maxCursorBytes: number maxMutationRows: number maxMutationOps: number + maxPipelineRows: number + maxGroups: number + maxCollectItems: number + maxUnionBranches: number + maxSubqueryInvocations: number + maxSubqueryDepth: number + maxShortestPathPairs: number maxQueryBytes: number maxParamBytes: number maxAstDepth: number @@ -732,7 +1003,7 @@ export interface GqlExecutionCapSummary { maxSkip: number } -export type GqlLoweringTarget = 'node_query' | 'edge_query' | 'graph_row_query' +export type GqlLoweringTarget = 'node_query' | 'edge_query' | 'graph_row_query' | 'graph_pipeline_query' export type GqlRowOperation = 'residual_filter' | 'projection' | 'sort' | 'skip' | 'limit' diff --git a/overgraph-node/src/lib.rs b/overgraph-node/src/lib.rs index 5105957..82580dd 100644 --- a/overgraph-node/src/lib.rs +++ b/overgraph-node/src/lib.rs @@ -4,7 +4,7 @@ use napi::bindgen_prelude::*; use napi::threadsafe_function::ThreadsafeFunctionCallMode; use napi::JsString; use napi_derive::napi; -use overgraph::types::GqlPath; +use overgraph::types::{GqlPath, GraphAggregateFunction}; use overgraph::{ gql_referenced_param_names, AdjacencyExport as CoreAdjacencyExport, AllShortestPathsOptions as CoreAllShortestPathsOptions, CompactionPhase, @@ -17,29 +17,33 @@ use overgraph::{ GqlCapSummary, GqlEdge, GqlExecutionCapSummary, GqlExecutionExplain, GqlExecutionMode, GqlExecutionOptions, GqlExecutionResult, GqlExecutionStats, GqlExplain, GqlLoweringTarget, GqlNode, GqlParamValue, GqlParams, GqlRow, GqlRowOperation, GqlStatementKind, GqlValue, - GraphBinaryOp, GraphCapExplain, GraphCursorExplain, GraphEdgeField, + GraphBinaryOp, GraphCapExplain, GraphCaseBranch, GraphCursorExplain, GraphEdgeField, GraphEdgePattern as CoreGraphEdgePattern, GraphEdgeValue, GraphElementProjection, GraphExplainNode, GraphExpr, GraphFunction, GraphNodeField, GraphNodePattern as CoreGraphNodePattern, GraphNodeValue, GraphOptionalGroup, GraphOrderDirection, GraphOrderExplain, GraphOrderItem, GraphOutputMode, GraphOutputOptions, GraphPageRequest, GraphParamValue, GraphPatch as CoreGraphPatch, GraphPathField, - GraphPathValue, GraphPatternPiece, GraphProjectionExplain, GraphPropertySelection, - GraphQueryOptions, GraphReturnItem, GraphReturnProjection, GraphRow, GraphRowExplain, - GraphRowOperationExplain, GraphRowQuery, GraphRowResult, GraphRowStats, + GraphPathValue, GraphPatternPiece, GraphPipelineCapExplain, GraphPipelineExplain, + GraphPipelineMatchStage, GraphPipelineOptions, GraphPipelineQuery, GraphPipelineResult, + GraphPipelineStage, GraphPipelineStageExplain, GraphPipelineStats, GraphProjectItem, + GraphProjectKind, GraphProjectStage, GraphProjectionExplain, GraphProjectionItems, + GraphPropertySelection, GraphQueryOptions, GraphReturnItem, GraphReturnProjection, GraphRow, + GraphRowExplain, GraphRowOperationExplain, GraphRowQuery, GraphRowResult, GraphRowStats, GraphSelectedEdgeProjection, GraphSelectedNodeProjection, GraphSelectedPathProjection, - GraphSelectedProjection, GraphUnaryOp, GraphValue, GraphVariableLengthPattern, - GraphVectorSelection, HnswConfig, IsConnectedOptions as CoreIsConnectedOptions, - LabelMatchMode as CoreLabelMatchMode, NeighborEntry as CoreNeighborEntry, NeighborOptions, - NodeFilterExpr, NodeIdMap, NodeInput as CoreNodeInput, NodeKeyQuery, - NodeLabelFilter as CoreNodeLabelFilter, NodeLabelInfo as CoreNodeLabelInfo, - NodePropertyIndexInfo as CoreNodePropertyIndexInfo, NodeQuery, NodeQueryOrder, - NodeView as CoreNodeView, PageRequest, PageResult, PprAlgorithm, PprOptions, - PprResult as CorePprResult, PropValue, PropertyRangeBound as CorePropertyRangeBound, - PropertyRangeCursor as CorePropertyRangeCursor, PropertyRangePageRequest, - PropertyRangePageResult as CorePropertyRangePageResult, PrunePolicy as CorePrunePolicy, - PrunePolicyInfo, PruneResult as CorePruneResult, QueryEdgeIdsResult, QueryEdgesResult, - QueryNodeIdsResult, QueryNodesResult, QueryPlan, QueryPlanKind, QueryPlanNode, - QueryPlanWarning, ScoringMode, ScrubReport as CoreScrubReport, + GraphSelectedProjection, GraphShortestPathEndpoint, GraphShortestPathMode, + GraphShortestPathStage, GraphSubqueryStage, GraphUnaryOp, GraphUnionStage, GraphValue, + GraphVariableLengthPattern, GraphVectorSelection, HnswConfig, + IsConnectedOptions as CoreIsConnectedOptions, LabelMatchMode as CoreLabelMatchMode, + NeighborEntry as CoreNeighborEntry, NeighborOptions, NodeFilterExpr, NodeIdMap, + NodeInput as CoreNodeInput, NodeKeyQuery, NodeLabelFilter as CoreNodeLabelFilter, + NodeLabelInfo as CoreNodeLabelInfo, NodePropertyIndexInfo as CoreNodePropertyIndexInfo, + NodeQuery, NodeQueryOrder, NodeView as CoreNodeView, PageRequest, PageResult, PprAlgorithm, + PprOptions, PprResult as CorePprResult, PropValue, + PropertyRangeBound as CorePropertyRangeBound, PropertyRangeCursor as CorePropertyRangeCursor, + PropertyRangePageRequest, PropertyRangePageResult as CorePropertyRangePageResult, + PrunePolicy as CorePrunePolicy, PrunePolicyInfo, PruneResult as CorePruneResult, + QueryEdgeIdsResult, QueryEdgesResult, QueryNodeIdsResult, QueryNodesResult, QueryPlan, + QueryPlanKind, QueryPlanNode, QueryPlanWarning, ScoringMode, ScrubReport as CoreScrubReport, SecondaryIndexKind as CoreSecondaryIndexKind, SecondaryIndexState, ShortestPath as CoreShortestPath, ShortestPathOptions as CoreShortestPathOptions, Subgraph, SubgraphOptions, TopKOptions, TraversalCursor as CoreTraversalCursor, @@ -102,9 +106,15 @@ pub struct GraphRowResultPayload { compact_rows: bool, } +pub struct GraphPipelineResultPayload { + result: GraphPipelineResult, + compact_rows: bool, +} + struct GraphJsValue(GraphValue); pub struct GraphJsExplain(GraphRowExplain); +pub struct GraphPipelineJsExplain(GraphPipelineExplain); impl TypeName for GqlJsPayload { fn type_name() -> &'static str { @@ -240,6 +250,37 @@ impl ToNapiValue for GraphRowResultPayload { } } +impl TypeName for GraphPipelineResultPayload { + fn type_name() -> &'static str { + "Object" + } + + fn value_type() -> napi::ValueType { + napi::ValueType::Object + } +} + +impl ToNapiValue for GraphPipelineResultPayload { + unsafe fn to_napi_value(env: napi::sys::napi_env, val: Self) -> Result { + let env = Env::from_raw(env); + let mut object = Object::new(&env)?; + object.set("columns", val.result.columns.clone())?; + let rows = + graph_rows_to_js_array(&env, &val.result.columns, val.result.rows, val.compact_rows)?; + object.set("rows", rows)?; + object.set("nextCursor", val.result.next_cursor)?; + object.set( + "stats", + GraphJsValue(graph_pipeline_stats_to_value(val.result.stats)), + )?; + match val.result.plan { + Some(plan) => object.set("plan", GraphPipelineJsExplain(plan))?, + None => object.set("plan", Option::::None)?, + } + unsafe { <&Object<'_> as ToNapiValue>::to_napi_value(env.raw(), &object) } + } +} + impl TypeName for GraphJsExplain { fn type_name() -> &'static str { "Object" @@ -258,6 +299,24 @@ impl ToNapiValue for GraphJsExplain { } } +impl TypeName for GraphPipelineJsExplain { + fn type_name() -> &'static str { + "Object" + } + + fn value_type() -> napi::ValueType { + napi::ValueType::Object + } +} + +impl ToNapiValue for GraphPipelineJsExplain { + unsafe fn to_napi_value(env: napi::sys::napi_env, val: Self) -> Result { + let env = Env::from_raw(env); + let json = graph_pipeline_explain_to_json(val.0)?; + unsafe { serde_json::Value::to_napi_value(env.raw(), json) } + } +} + impl ToNapiValue for GraphJsValue { unsafe fn to_napi_value(env: napi::sys::napi_env, val: Self) -> Result { graph_value_to_napi(env, val.0) @@ -1136,6 +1195,23 @@ impl OverGraph { }) } + #[napi( + ts_args_type = "request: import('./query-types').GraphPipelineRequest", + ts_return_type = "import('./query-types').GraphPipelineResult" + )] + pub fn query_graph_pipeline( + &self, + request: serde_json::Value, + ) -> Result { + let query = parse_js_graph_pipeline_query(&request)?; + let compact_rows = query.output.compact_rows; + let result = with_engine_ref(self, |eng| eng.query_graph_pipeline(&query))?; + Ok(GraphPipelineResultPayload { + result, + compact_rows, + }) + } + #[napi( ts_args_type = "request: import('./query-types').QueryNodeRequest", ts_return_type = "import('./query-types').QueryPlan" @@ -1166,6 +1242,19 @@ impl OverGraph { Ok(GraphJsExplain(explain)) } + #[napi( + ts_args_type = "request: import('./query-types').GraphPipelineRequest", + ts_return_type = "import('./query-types').GraphPipelineExplain" + )] + pub fn explain_graph_pipeline( + &self, + request: serde_json::Value, + ) -> Result { + let query = parse_js_graph_pipeline_query(&request)?; + let explain = with_engine_ref(self, |eng| eng.explain_graph_pipeline(&query))?; + Ok(GraphPipelineJsExplain(explain)) + } + #[napi( ts_args_type = "query: string, params?: import('./query-types').GqlParams | null, options?: import('./query-types').GqlExecutionOptions | null", ts_return_type = "import('./query-types').GqlExecutionResult" @@ -2477,6 +2566,30 @@ impl OverGraph { ))) } + #[napi( + ts_args_type = "request: import('./query-types').GraphPipelineRequest", + ts_return_type = "Promise" + )] + pub fn query_graph_pipeline_async( + &self, + request: serde_json::Value, + ) -> Result>> + { + let query = parse_js_graph_pipeline_query(&request)?; + let compact_rows = query.output.compact_rows; + Ok(AsyncTask::new(EngineReadOp::new( + self.inner.clone(), + move |eng| { + let result = eng.query_graph_pipeline(&query)?; + Ok(GraphPipelineResultPayload { + result, + compact_rows, + }) + }, + napi_identity, + ))) + } + #[napi( ts_args_type = "request: import('./query-types').QueryNodeRequest", ts_return_type = "Promise" @@ -2525,6 +2638,22 @@ impl OverGraph { ))) } + #[napi( + ts_args_type = "request: import('./query-types').GraphPipelineRequest", + ts_return_type = "Promise" + )] + pub fn explain_graph_pipeline_async( + &self, + request: serde_json::Value, + ) -> Result>> { + let query = parse_js_graph_pipeline_query(&request)?; + Ok(AsyncTask::new(EngineReadOp::new( + self.inner.clone(), + move |eng| eng.explain_graph_pipeline(&query), + |explain| Ok(GraphPipelineJsExplain(explain)), + ))) + } + #[napi( ts_args_type = "query: string, params?: import('./query-types').GqlParams | null, options?: import('./query-types').GqlExecutionOptions | null", ts_return_type = "Promise" @@ -3999,6 +4128,13 @@ pub struct GqlExecutionOptionsInput { pub max_cursor_bytes: Option, pub max_mutation_rows: Option, pub max_mutation_ops: Option, + pub max_pipeline_rows: Option, + pub max_groups: Option, + pub max_collect_items: Option, + pub max_union_branches: Option, + pub max_subquery_invocations: Option, + pub max_subquery_depth: Option, + pub max_shortest_path_pairs: Option, pub max_intermediate_bindings: Option, pub max_frontier: Option, pub max_path_hops: Option, @@ -5601,6 +5737,76 @@ fn graph_stats_to_value(stats: GraphRowStats) -> GraphValue { ])) } +fn graph_pipeline_stats_to_value(stats: GraphPipelineStats) -> GraphValue { + GraphValue::Map(BTreeMap::from([ + ( + "rowsReturned".to_string(), + GraphValue::UInt(stats.rows_returned as u64), + ), + ( + "rowsEnteredPipeline".to_string(), + GraphValue::UInt(stats.rows_entered_pipeline as u64), + ), + ( + "rowsAfterFilter".to_string(), + GraphValue::UInt(stats.rows_after_filter as u64), + ), + ( + "intermediateRows".to_string(), + GraphValue::UInt(stats.intermediate_rows as u64), + ), + ( + "pipelineRowsMaterialized".to_string(), + GraphValue::UInt(stats.pipeline_rows_materialized as u64), + ), + ("groups".to_string(), GraphValue::UInt(stats.groups as u64)), + ( + "collectItems".to_string(), + GraphValue::UInt(stats.collect_items as u64), + ), + ( + "unionBranches".to_string(), + GraphValue::UInt(stats.union_branches as u64), + ), + ( + "unionDedupKeys".to_string(), + GraphValue::UInt(stats.union_dedup_keys as u64), + ), + ( + "subqueryInvocations".to_string(), + GraphValue::UInt(stats.subquery_invocations as u64), + ), + ( + "subqueryCacheHits".to_string(), + GraphValue::UInt(stats.subquery_cache_hits as u64), + ), + ( + "shortestPathPairs".to_string(), + GraphValue::UInt(stats.shortest_path_pairs as u64), + ), + ( + "shortestPathCacheHits".to_string(), + GraphValue::UInt(stats.shortest_path_cache_hits as u64), + ), + ("dbHits".to_string(), GraphValue::UInt(stats.db_hits as u64)), + ( + "elapsedUs".to_string(), + stats + .elapsed_us + .map(GraphValue::UInt) + .unwrap_or(GraphValue::Null), + ), + ( + "effectiveAtEpoch".to_string(), + GraphValue::Int(stats.effective_at_epoch), + ), + ( + "warnings".to_string(), + GraphValue::List(stats.warnings.into_iter().map(GraphValue::String).collect()), + ), + ])) +} + fn graph_explain_to_json(explain: GraphRowExplain) -> Result { Ok(serde_json::json!({ "columns": explain.columns, @@ -5618,6 +5824,58 @@ fn graph_explain_to_json(explain: GraphRowExplain) -> Result })) } +fn graph_pipeline_explain_to_json(explain: GraphPipelineExplain) -> Result { + Ok(serde_json::json!({ + "columns": explain.columns, + "effectiveAtEpoch": explain.effective_at_epoch, + "fingerprint": explain.fingerprint, + "stages": explain.stages.into_iter().map(graph_pipeline_stage_explain_to_json).collect::>(), + "rowOps": explain.row_ops.into_iter().map(graph_row_op_to_json).collect::>(), + "order": graph_order_explain_to_json(explain.order), + "cursor": graph_cursor_explain_to_json(explain.cursor), + "projection": graph_projection_explain_to_json(explain.projection), + "caps": graph_pipeline_caps_to_json(explain.caps), + "summaries": graph_summaries_to_json(explain.summaries), + "stats": graph_pipeline_stats_to_json(explain.stats), + "warnings": explain.warnings, + "notes": explain.notes, + })) +} + +fn graph_pipeline_stage_explain_to_json(stage: GraphPipelineStageExplain) -> serde_json::Value { + serde_json::json!({ + "index": stage.index, + "kind": stage.kind, + "detail": stage.detail, + "columns": stage.columns, + "graphRow": stage.graph_row.map(|explain| graph_explain_to_json(*explain)).transpose().ok().flatten(), + "warnings": stage.warnings, + "notes": stage.notes, + }) +} + +fn graph_pipeline_stats_to_json(stats: GraphPipelineStats) -> serde_json::Value { + serde_json::json!({ + "rowsReturned": stats.rows_returned, + "rowsEnteredPipeline": stats.rows_entered_pipeline, + "rowsAfterFilter": stats.rows_after_filter, + "intermediateRows": stats.intermediate_rows, + "pipelineRowsMaterialized": stats.pipeline_rows_materialized, + "groups": stats.groups, + "collectItems": stats.collect_items, + "unionBranches": stats.union_branches, + "unionDedupKeys": stats.union_dedup_keys, + "subqueryInvocations": stats.subquery_invocations, + "subqueryCacheHits": stats.subquery_cache_hits, + "shortestPathPairs": stats.shortest_path_pairs, + "shortestPathCacheHits": stats.shortest_path_cache_hits, + "dbHits": stats.db_hits, + "elapsedUs": stats.elapsed_us, + "effectiveAtEpoch": stats.effective_at_epoch, + "warnings": stats.warnings, + }) +} + fn graph_explain_node_to_json(node: GraphExplainNode) -> serde_json::Value { serde_json::json!({ "kind": node.kind, @@ -5672,6 +5930,31 @@ fn graph_caps_to_json(caps: GraphCapExplain) -> serde_json::Value { }) } +fn graph_pipeline_caps_to_json(caps: GraphPipelineCapExplain) -> serde_json::Value { + serde_json::json!({ + "allowFullScan": caps.allow_full_scan, + "maxRows": caps.max_rows, + "maxPipelineRows": caps.max_pipeline_rows, + "maxGroups": caps.max_groups, + "maxCollectItems": caps.max_collect_items, + "maxUnionBranches": caps.max_union_branches, + "maxSubqueryInvocations": caps.max_subquery_invocations, + "maxSubqueryDepth": caps.max_subquery_depth, + "maxShortestPathPairs": caps.max_shortest_path_pairs, + "maxIntermediateBindings": caps.max_intermediate_bindings, + "maxFrontier": caps.max_frontier, + "maxPathHops": caps.max_path_hops, + "maxPathsPerStart": caps.max_paths_per_start, + "maxOrderMaterialization": caps.max_order_materialization, + "maxSkip": caps.max_skip, + "maxCursorBytes": caps.max_cursor_bytes, + "maxQueryBytes": caps.max_query_bytes, + "maxParamBytes": caps.max_param_bytes, + "maxAstDepth": caps.max_ast_depth, + "maxLiteralItems": caps.max_literal_items, + }) +} + fn graph_summaries_to_json(summaries: overgraph::GraphExecutionSummaries) -> serde_json::Value { serde_json::json!({ "validationOnly": summaries.validation_only, @@ -5860,6 +6143,34 @@ fn gql_execution_caps_to_value(caps: GqlExecutionCapSummary) -> GqlValue { "maxMutationOps".to_string(), GqlValue::UInt(caps.max_mutation_ops as u64), ), + ( + "maxPipelineRows".to_string(), + GqlValue::UInt(caps.max_pipeline_rows as u64), + ), + ( + "maxGroups".to_string(), + GqlValue::UInt(caps.max_groups as u64), + ), + ( + "maxCollectItems".to_string(), + GqlValue::UInt(caps.max_collect_items as u64), + ), + ( + "maxUnionBranches".to_string(), + GqlValue::UInt(caps.max_union_branches as u64), + ), + ( + "maxSubqueryInvocations".to_string(), + GqlValue::UInt(caps.max_subquery_invocations as u64), + ), + ( + "maxSubqueryDepth".to_string(), + GqlValue::UInt(caps.max_subquery_depth as u64), + ), + ( + "maxShortestPathPairs".to_string(), + GqlValue::UInt(caps.max_shortest_path_pairs as u64), + ), ( "maxQueryBytes".to_string(), GqlValue::UInt(caps.max_query_bytes as u64), @@ -5993,6 +6304,7 @@ fn gql_lowering_target_to_js(target: GqlLoweringTarget) -> &'static str { GqlLoweringTarget::NodeQuery => "node_query", GqlLoweringTarget::EdgeQuery => "edge_query", GqlLoweringTarget::GraphRowQuery => "graph_row_query", + GqlLoweringTarget::GraphPipelineQuery => "graph_pipeline_query", } } @@ -6194,6 +6506,27 @@ fn parse_js_gql_options( if let Some(value) = options.max_mutation_ops { parsed.max_mutation_ops = f64_to_usize(value, "GQL maxMutationOps")?; } + if let Some(value) = options.max_pipeline_rows { + parsed.max_pipeline_rows = f64_to_usize(value, "GQL maxPipelineRows")?; + } + if let Some(value) = options.max_groups { + parsed.max_groups = f64_to_usize(value, "GQL maxGroups")?; + } + if let Some(value) = options.max_collect_items { + parsed.max_collect_items = f64_to_usize(value, "GQL maxCollectItems")?; + } + if let Some(value) = options.max_union_branches { + parsed.max_union_branches = f64_to_usize(value, "GQL maxUnionBranches")?; + } + if let Some(value) = options.max_subquery_invocations { + parsed.max_subquery_invocations = f64_to_usize(value, "GQL maxSubqueryInvocations")?; + } + if let Some(value) = options.max_subquery_depth { + parsed.max_subquery_depth = f64_to_usize(value, "GQL maxSubqueryDepth")?; + } + if let Some(value) = options.max_shortest_path_pairs { + parsed.max_shortest_path_pairs = f64_to_usize(value, "GQL maxShortestPathPairs")?; + } if let Some(value) = options.max_intermediate_bindings { parsed.max_intermediate_bindings = f64_to_usize(value, "GQL maxIntermediateBindings")?; } @@ -6639,53 +6972,597 @@ fn parse_js_graph_row_query(value: &serde_json::Value) -> Result }) } -fn parse_js_graph_node_pattern( - value: &serde_json::Value, - context: &str, -) -> Result { - let object = js_object(value, context)?; +fn parse_js_graph_pipeline_query(value: &serde_json::Value) -> Result { + let object = js_object(value, "graph pipeline request")?; ensure_only_js_fields( object, &[ - "alias", - "labelFilter", - "ids", - "keys", - "filter", - "where", - "predicates", + "stages", "params", "atEpoch", "skip", "limit", "cursor", "output", "options", ], - context, + "graph pipeline request", )?; - let label_filter = - parse_js_node_label_filter_field(object, "labelFilter", &format!("{context} labelFilter"))?; - let keys = parse_js_optional_node_keys_field( - object, - "keys", - &format!("{context} keys"), - label_filter.as_ref(), - )?; - Ok(CoreGraphNodePattern { - alias: parse_js_required_string_field(object, "alias", &format!("{context} alias"))?, - label_filter, - ids: parse_js_optional_u64_array_field(object, "ids", &format!("{context} ids"))?, - keys, - filter: parse_js_node_filter(object, "updatedAt", context)?, + let options = parse_js_graph_pipeline_options(js_non_null_field(object, "options"))?; + let output = parse_js_graph_output_options(js_non_null_field(object, "output"))?; + let limit = match js_non_null_field(object, "limit") { + Some(value) => { + let parsed = js_number_to_u64(value, "graph pipeline limit")?; + if parsed == 0 { + return Err(napi::Error::from_reason( + "graph pipeline limit must be > 0".to_string(), + )); + } + usize::try_from(parsed).map_err(|_| { + napi::Error::from_reason("graph pipeline limit is too large".to_string()) + })? + } + None => options.max_rows, + }; + let stages_value = js_non_null_field(object, "stages").ok_or_else(|| { + napi::Error::from_reason("graph pipeline request requires stages".to_string()) + })?; + let stages = js_array(stages_value, "graph pipeline stages")? + .iter() + .enumerate() + .map(|(index, value)| { + parse_js_graph_pipeline_stage(value, &format!("graph pipeline stages[{index}]")) + }) + .collect::>>()?; + Ok(GraphPipelineQuery { + stages, + params: parse_js_graph_params(js_non_null_field(object, "params"))?, + at_epoch: parse_js_optional_i64_field(object, "atEpoch", "graph pipeline atEpoch")?, + page: GraphPageRequest { + skip: js_non_null_field(object, "skip") + .map(|value| js_number_to_usize(value, "graph pipeline skip")) + .transpose()? + .unwrap_or(0), + limit, + cursor: parse_js_optional_string_field(object, "cursor", "graph pipeline cursor")?, + }, + output, + options, }) } -fn parse_js_graph_piece(value: &serde_json::Value, context: &str) -> Result { +fn parse_js_graph_pipeline_stage( + value: &serde_json::Value, + context: &str, +) -> Result { let object = js_object(value, context)?; let kind = parse_js_required_string_field(object, "kind", &format!("{context} kind"))?; match kind.as_str() { - "edge" => parse_js_graph_edge_pattern(object, context).map(GraphPatternPiece::Edge), - "optional" => { - parse_js_graph_optional_group(object, context).map(GraphPatternPiece::Optional) - } - "variableLength" => parse_js_graph_variable_length_pattern(object, context) - .map(GraphPatternPiece::VariableLength), + "match" => parse_js_graph_pipeline_match_stage(object, context).map(GraphPipelineStage::Match), + "project" | "with" | "return" => parse_js_graph_pipeline_project_stage(object, &kind, context) + .map(GraphPipelineStage::Project), + "shortestPath" | "shortest_path" => parse_js_graph_pipeline_shortest_path_stage(object, context) + .map(GraphPipelineStage::ShortestPath), + "call" => parse_js_graph_pipeline_call_stage(object, context).map(GraphPipelineStage::Call), + "union" => parse_js_graph_pipeline_union_stage(object, context).map(GraphPipelineStage::Union), other => Err(napi::Error::from_reason(format!( - "{context} kind must be 'edge', 'optional', or 'variableLength', got '{other}'" + "{context} kind must be 'match', 'project', 'with', 'return', 'shortestPath', 'call', or 'union', got '{other}'" + ))), + } +} + +fn parse_js_graph_pipeline_match_stage( + object: &serde_json::Map, + context: &str, +) -> Result { + ensure_only_js_fields( + object, + &[ + "kind", + "optional", + "nodes", + "pieces", + "where", + "optionalCandidateWhere", + ], + context, + )?; + let nodes = match js_non_null_field(object, "nodes") { + Some(value) => js_array(value, &format!("{context} nodes"))? + .iter() + .enumerate() + .map(|(index, value)| { + parse_js_graph_node_pattern(value, &format!("{context} nodes[{index}]")) + }) + .collect::>>()?, + None => Vec::new(), + }; + let pieces = match js_non_null_field(object, "pieces") { + Some(value) => js_array(value, &format!("{context} pieces"))? + .iter() + .enumerate() + .map(|(index, value)| { + parse_js_graph_piece(value, &format!("{context} pieces[{index}]")) + }) + .collect::>>()?, + None => Vec::new(), + }; + Ok(GraphPipelineMatchStage { + optional: parse_js_optional_bool_field(object, "optional", &format!("{context} optional"))? + .unwrap_or(false), + nodes, + pieces, + where_: js_non_null_field(object, "where") + .map(|value| parse_js_graph_expr(value, &format!("{context} where"))) + .transpose()?, + optional_candidate_where: js_non_null_field(object, "optionalCandidateWhere") + .map(|value| parse_js_graph_expr(value, &format!("{context} optionalCandidateWhere"))) + .transpose()?, + }) +} + +fn parse_js_graph_pipeline_project_stage( + object: &serde_json::Map, + kind: &str, + context: &str, +) -> Result { + ensure_only_js_fields( + object, + &[ + "kind", + "projectKind", + "items", + "distinct", + "where", + "orderBy", + "skip", + "limit", + ], + context, + )?; + let project_kind = match kind { + "with" => GraphProjectKind::With, + "return" => GraphProjectKind::Return, + _ => match parse_js_optional_string_field( + object, + "projectKind", + &format!("{context} projectKind"), + )? + .as_deref() + { + None | Some("return") => GraphProjectKind::Return, + Some("with") => GraphProjectKind::With, + Some(other) => { + return Err(napi::Error::from_reason(format!( + "{context} projectKind must be 'with' or 'return', got '{other}'" + ))); + } + }, + }; + let items = js_non_null_field(object, "items") + .map(|value| parse_js_graph_projection_items(value, &format!("{context} items"))) + .transpose()? + .unwrap_or(GraphProjectionItems::Star); + let order_by = match js_non_null_field(object, "orderBy") { + Some(value) => js_array(value, &format!("{context} orderBy"))? + .iter() + .enumerate() + .map(|(index, value)| { + parse_js_graph_order_item(value, &format!("{context} orderBy[{index}]")) + }) + .collect::>>()?, + None => Vec::new(), + }; + Ok(GraphProjectStage { + kind: project_kind, + items, + distinct: parse_js_optional_bool_field(object, "distinct", &format!("{context} distinct"))? + .unwrap_or(false), + where_: js_non_null_field(object, "where") + .map(|value| parse_js_graph_expr(value, &format!("{context} where"))) + .transpose()?, + order_by, + skip: js_non_null_field(object, "skip") + .map(|value| parse_js_graph_expr(value, &format!("{context} skip"))) + .transpose()?, + limit: js_non_null_field(object, "limit") + .map(|value| parse_js_graph_expr(value, &format!("{context} limit"))) + .transpose()?, + }) +} + +fn parse_js_graph_projection_items( + value: &serde_json::Value, + context: &str, +) -> Result { + if matches!(value.as_str(), Some("star" | "*")) { + return Ok(GraphProjectionItems::Star); + } + Ok(GraphProjectionItems::Items( + js_array(value, context)? + .iter() + .enumerate() + .map(|(index, value)| { + parse_js_graph_project_item(value, &format!("{context}[{index}]")) + }) + .collect::>>()?, + )) +} + +fn parse_js_graph_project_item( + value: &serde_json::Value, + context: &str, +) -> Result { + let object = js_object(value, context)?; + ensure_only_js_fields(object, &["expr", "as", "projection"], context)?; + let expr_value = js_non_null_field(object, "expr") + .ok_or_else(|| napi::Error::from_reason(format!("{context} expr is required")))?; + Ok(GraphProjectItem { + expr: parse_js_graph_expr(expr_value, &format!("{context} expr"))?, + alias: parse_js_optional_string_field(object, "as", &format!("{context} as"))?, + projection: parse_js_graph_return_projection( + js_non_null_field(object, "projection"), + context, + )?, + }) +} + +fn parse_js_graph_pipeline_union_stage( + object: &serde_json::Map, + context: &str, +) -> Result { + ensure_only_js_fields(object, &["kind", "branches", "all"], context)?; + let branches_value = js_non_null_field(object, "branches") + .ok_or_else(|| napi::Error::from_reason(format!("{context} requires branches")))?; + let branches = js_array(branches_value, &format!("{context} branches"))? + .iter() + .enumerate() + .map(|(index, value)| { + parse_js_graph_pipeline_query(value).map_err(|err| { + napi::Error::from_reason(format!("{context} branches[{index}]: {}", err.reason)) + }) + }) + .collect::>>()?; + Ok(GraphUnionStage { + branches, + all: parse_js_optional_bool_field(object, "all", &format!("{context} all"))? + .unwrap_or(false), + }) +} + +fn parse_js_graph_pipeline_call_stage( + object: &serde_json::Map, + context: &str, +) -> Result { + ensure_only_js_fields(object, &["kind", "query", "importAliases"], context)?; + let query_value = js_non_null_field(object, "query") + .ok_or_else(|| napi::Error::from_reason(format!("{context} requires query")))?; + Ok(GraphSubqueryStage { + query: Box::new(parse_js_graph_pipeline_query(query_value)?), + import_aliases: parse_js_optional_string_array_field( + object, + "importAliases", + &format!("{context} importAliases"), + )?, + }) +} + +fn parse_js_graph_pipeline_shortest_path_stage( + object: &serde_json::Map, + context: &str, +) -> Result { + ensure_only_js_fields( + object, + &[ + "kind", + "optional", + "outputPathAlias", + "mode", + "from", + "to", + "direction", + "edgeLabelFilter", + "minHops", + "maxHops", + "weightField", + "maxCost", + "maxPaths", + ], + context, + )?; + let mode = match parse_js_optional_string_field(object, "mode", &format!("{context} mode"))? + .as_deref() + { + None | Some("one") => GraphShortestPathMode::One, + Some("all") => GraphShortestPathMode::All, + Some(other) => { + return Err(napi::Error::from_reason(format!( + "{context} mode must be 'one' or 'all', got '{other}'" + ))); + } + }; + let direction = match js_non_null_field(object, "direction") { + None => Direction::Outgoing, + Some(value) => parse_direction(Some(value.as_str().ok_or_else(|| { + napi::Error::from_reason(format!("{context} direction must be a string")) + })?))?, + }; + let max_cost = match js_non_null_field(object, "maxCost") { + Some(value) => { + let cost = value.as_f64().ok_or_else(|| { + napi::Error::from_reason(format!("{context} maxCost must be a number")) + })?; + if !cost.is_finite() { + return Err(napi::Error::from_reason(format!( + "{context} maxCost must be finite" + ))); + } + Some(cost) + } + None => None, + }; + Ok(GraphShortestPathStage { + optional: parse_js_optional_bool_field(object, "optional", &format!("{context} optional"))? + .unwrap_or(false), + output_path_alias: parse_js_required_string_field( + object, + "outputPathAlias", + &format!("{context} outputPathAlias"), + )?, + mode, + from: parse_js_shortest_path_endpoint( + js_non_null_field(object, "from") + .ok_or_else(|| napi::Error::from_reason(format!("{context} requires from")))?, + &format!("{context} from"), + )?, + to: parse_js_shortest_path_endpoint( + js_non_null_field(object, "to") + .ok_or_else(|| napi::Error::from_reason(format!("{context} requires to")))?, + &format!("{context} to"), + )?, + direction, + edge_label_filter: parse_js_optional_string_array_field( + object, + "edgeLabelFilter", + &format!("{context} edgeLabelFilter"), + )?, + min_hops: parse_js_required_u8_field(object, "minHops", &format!("{context} minHops"))?, + max_hops: parse_js_required_u8_field(object, "maxHops", &format!("{context} maxHops"))?, + weight_field: parse_js_optional_string_field( + object, + "weightField", + &format!("{context} weightField"), + )?, + max_cost, + max_paths: js_non_null_field(object, "maxPaths") + .map(|value| js_number_to_usize(value, &format!("{context} maxPaths"))) + .transpose()?, + }) +} + +fn parse_js_shortest_path_endpoint( + value: &serde_json::Value, + context: &str, +) -> Result { + if let Some(alias) = value.as_str() { + return Ok(GraphShortestPathEndpoint::Alias(alias.to_string())); + } + if value.is_number() { + return Ok(GraphShortestPathEndpoint::NodeId(js_number_to_u64( + value, context, + )?)); + } + let object = js_object(value, context)?; + let tags = ["alias", "nodeId", "nodeKey", "expr"] + .iter() + .filter(|field| object.contains_key(**field)) + .count(); + if tags != 1 { + return Err(napi::Error::from_reason(format!( + "{context} must contain exactly one of alias, nodeId, nodeKey, or expr" + ))); + } + if let Some(value) = js_non_null_field(object, "alias") { + ensure_only_js_fields(object, &["alias"], context)?; + return Ok(GraphShortestPathEndpoint::Alias( + value.as_str().map(ToString::to_string).ok_or_else(|| { + napi::Error::from_reason(format!("{context} alias must be a string")) + })?, + )); + } + if let Some(value) = js_non_null_field(object, "nodeId") { + ensure_only_js_fields(object, &["nodeId"], context)?; + return Ok(GraphShortestPathEndpoint::NodeId(js_number_to_u64( + value, context, + )?)); + } + if let Some(value) = js_non_null_field(object, "nodeKey") { + ensure_only_js_fields(object, &["nodeKey"], context)?; + let payload = js_object(value, &format!("{context} nodeKey"))?; + ensure_only_js_fields(payload, &["label", "key"], &format!("{context} nodeKey"))?; + return Ok(GraphShortestPathEndpoint::NodeKey { + label: parse_js_required_string_field( + payload, + "label", + &format!("{context} nodeKey label"), + )?, + key: parse_js_required_string_field(payload, "key", &format!("{context} nodeKey key"))?, + }); + } + ensure_only_js_fields(object, &["expr"], context)?; + Ok(GraphShortestPathEndpoint::Expr(parse_js_graph_expr( + js_non_null_field(object, "expr") + .ok_or_else(|| napi::Error::from_reason(format!("{context} requires expr")))?, + &format!("{context} expr"), + )?)) +} + +fn parse_js_graph_pipeline_options( + value: Option<&serde_json::Value>, +) -> Result { + let Some(value) = value else { + return Ok(GraphPipelineOptions::default()); + }; + let object = js_object(value, "graph pipeline options")?; + ensure_only_js_fields( + object, + &[ + "allowFullScan", + "maxRows", + "maxPipelineRows", + "maxGroups", + "maxCollectItems", + "maxUnionBranches", + "maxSubqueryInvocations", + "maxSubqueryDepth", + "maxShortestPathPairs", + "maxIntermediateBindings", + "maxFrontier", + "maxPathHops", + "maxPathsPerStart", + "maxOrderMaterialization", + "maxSkip", + "maxCursorBytes", + "maxQueryBytes", + "maxParamBytes", + "maxAstDepth", + "maxLiteralItems", + "includePlan", + "profile", + ], + "graph pipeline options", + )?; + let mut options = GraphPipelineOptions::default(); + if let Some(value) = parse_js_optional_bool_field( + object, + "allowFullScan", + "graph pipeline options allowFullScan", + )? { + options.allow_full_scan = value; + } + if let Some(value) = js_non_null_field(object, "maxRows") { + options.max_rows = js_number_to_usize(value, "graph pipeline options maxRows")?; + } + if let Some(value) = js_non_null_field(object, "maxPipelineRows") { + options.max_pipeline_rows = + js_number_to_usize(value, "graph pipeline options maxPipelineRows")?; + } + if let Some(value) = js_non_null_field(object, "maxGroups") { + options.max_groups = js_number_to_usize(value, "graph pipeline options maxGroups")?; + } + if let Some(value) = js_non_null_field(object, "maxCollectItems") { + options.max_collect_items = + js_number_to_usize(value, "graph pipeline options maxCollectItems")?; + } + if let Some(value) = js_non_null_field(object, "maxUnionBranches") { + options.max_union_branches = + js_number_to_usize(value, "graph pipeline options maxUnionBranches")?; + } + if let Some(value) = js_non_null_field(object, "maxSubqueryInvocations") { + options.max_subquery_invocations = + js_number_to_usize(value, "graph pipeline options maxSubqueryInvocations")?; + } + if let Some(value) = js_non_null_field(object, "maxSubqueryDepth") { + options.max_subquery_depth = + js_number_to_usize(value, "graph pipeline options maxSubqueryDepth")?; + } + if let Some(value) = js_non_null_field(object, "maxShortestPathPairs") { + options.max_shortest_path_pairs = + js_number_to_usize(value, "graph pipeline options maxShortestPathPairs")?; + } + if let Some(value) = js_non_null_field(object, "maxIntermediateBindings") { + options.max_intermediate_bindings = + js_number_to_usize(value, "graph pipeline options maxIntermediateBindings")?; + } + if let Some(value) = js_non_null_field(object, "maxFrontier") { + options.max_frontier = js_number_to_usize(value, "graph pipeline options maxFrontier")?; + } + if let Some(value) = js_non_null_field(object, "maxPathHops") { + options.max_path_hops = parse_js_u8_number(value, "graph pipeline options maxPathHops")?; + } + if let Some(value) = js_non_null_field(object, "maxPathsPerStart") { + options.max_paths_per_start = + js_number_to_usize(value, "graph pipeline options maxPathsPerStart")?; + } + if let Some(value) = js_non_null_field(object, "maxOrderMaterialization") { + options.max_order_materialization = + js_number_to_usize(value, "graph pipeline options maxOrderMaterialization")?; + } + if let Some(value) = js_non_null_field(object, "maxSkip") { + options.max_skip = js_number_to_usize(value, "graph pipeline options maxSkip")?; + } + if let Some(value) = js_non_null_field(object, "maxCursorBytes") { + options.max_cursor_bytes = + js_number_to_usize(value, "graph pipeline options maxCursorBytes")?; + } + if let Some(value) = js_non_null_field(object, "maxQueryBytes") { + options.max_query_bytes = + js_number_to_usize(value, "graph pipeline options maxQueryBytes")?; + } + if let Some(value) = js_non_null_field(object, "maxParamBytes") { + options.max_param_bytes = + js_number_to_usize(value, "graph pipeline options maxParamBytes")?; + } + if let Some(value) = js_non_null_field(object, "maxAstDepth") { + options.max_ast_depth = js_number_to_usize(value, "graph pipeline options maxAstDepth")?; + } + if let Some(value) = js_non_null_field(object, "maxLiteralItems") { + options.max_literal_items = + js_number_to_usize(value, "graph pipeline options maxLiteralItems")?; + } + if let Some(value) = + parse_js_optional_bool_field(object, "includePlan", "graph pipeline options includePlan")? + { + options.include_plan = value; + } + if let Some(value) = + parse_js_optional_bool_field(object, "profile", "graph pipeline options profile")? + { + options.profile = value; + } + Ok(options) +} + +fn parse_js_graph_node_pattern( + value: &serde_json::Value, + context: &str, +) -> Result { + let object = js_object(value, context)?; + ensure_only_js_fields( + object, + &[ + "alias", + "labelFilter", + "ids", + "keys", + "filter", + "where", + "predicates", + ], + context, + )?; + let label_filter = + parse_js_node_label_filter_field(object, "labelFilter", &format!("{context} labelFilter"))?; + let keys = parse_js_optional_node_keys_field( + object, + "keys", + &format!("{context} keys"), + label_filter.as_ref(), + )?; + Ok(CoreGraphNodePattern { + alias: parse_js_required_string_field(object, "alias", &format!("{context} alias"))?, + label_filter, + ids: parse_js_optional_u64_array_field(object, "ids", &format!("{context} ids"))?, + keys, + filter: parse_js_node_filter(object, "updatedAt", context)?, + }) +} + +fn parse_js_graph_piece(value: &serde_json::Value, context: &str) -> Result { + let object = js_object(value, context)?; + let kind = parse_js_required_string_field(object, "kind", &format!("{context} kind"))?; + match kind.as_str() { + "edge" => parse_js_graph_edge_pattern(object, context).map(GraphPatternPiece::Edge), + "optional" => { + parse_js_graph_optional_group(object, context).map(GraphPatternPiece::Optional) + } + "variableLength" => parse_js_graph_variable_length_pattern(object, context) + .map(GraphPatternPiece::VariableLength), + other => Err(napi::Error::from_reason(format!( + "{context} kind must be 'edge', 'optional', or 'variableLength', got '{other}'" ))), } } @@ -7184,7 +8061,10 @@ fn parse_js_graph_expr_object( "edgeField", "pathField", "fn", + "aggregate", + "exists", "op", + "case", "isNull", "isNotNull", ]; @@ -7288,10 +8168,24 @@ fn parse_js_graph_expr_object( ensure_only_js_fields(object, &["fn", "args"], context)?; return parse_js_graph_function_expr(object, context); } + if let Some(value) = object.get("aggregate") { + ensure_only_js_fields(object, &["aggregate"], context)?; + return parse_js_graph_aggregate_expr(value, context); + } + if let Some(value) = object.get("exists") { + ensure_only_js_fields(object, &["exists"], context)?; + let payload = js_object(value, &format!("{context} exists"))?; + let stage = parse_js_graph_pipeline_call_stage(payload, &format!("{context} exists"))?; + return Ok(GraphExpr::ExistsSubquery(stage)); + } if object.contains_key("op") { ensure_only_js_fields(object, &["op", "left", "right", "expr"], context)?; return parse_js_graph_op_expr(object, context); } + if let Some(value) = object.get("case") { + ensure_only_js_fields(object, &["case"], context)?; + return parse_js_graph_case_expr(value, context); + } if let Some(value) = object.get("isNull") { ensure_only_js_fields(object, &["isNull"], context)?; return Ok(GraphExpr::IsNull(Box::new(parse_js_graph_expr( @@ -7368,6 +8262,21 @@ fn parse_js_graph_function_expr( "endNode" | "end_node" => GraphFunction::EndNode, "nodes" => GraphFunction::Nodes, "relationships" => GraphFunction::Relationships, + "coalesce" => GraphFunction::Coalesce, + "toString" | "to_string" => GraphFunction::ToString, + "toInteger" | "to_integer" => GraphFunction::ToInteger, + "toFloat" | "to_float" => GraphFunction::ToFloat, + "abs" => GraphFunction::Abs, + "floor" => GraphFunction::Floor, + "ceil" => GraphFunction::Ceil, + "round" => GraphFunction::Round, + "lower" => GraphFunction::Lower, + "upper" => GraphFunction::Upper, + "trim" => GraphFunction::Trim, + "substring" => GraphFunction::Substring, + "size" => GraphFunction::Size, + "head" => GraphFunction::Head, + "last" => GraphFunction::Last, other => { return Err(napi::Error::from_reason(format!( "{context} unsupported graph function '{other}'" @@ -7378,6 +8287,90 @@ fn parse_js_graph_function_expr( }) } +fn parse_js_graph_aggregate_expr(value: &serde_json::Value, context: &str) -> Result { + let payload = js_object(value, &format!("{context} aggregate"))?; + ensure_only_js_fields( + payload, + &["function", "distinct", "arg"], + &format!("{context} aggregate"), + )?; + let function = parse_js_required_string_field( + payload, + "function", + &format!("{context} aggregate function"), + )?; + let function = match function.as_str() { + "count" => GraphAggregateFunction::Count, + "sum" => GraphAggregateFunction::Sum, + "avg" => GraphAggregateFunction::Avg, + "min" => GraphAggregateFunction::Min, + "max" => GraphAggregateFunction::Max, + "collect" => GraphAggregateFunction::Collect, + other => { + return Err(napi::Error::from_reason(format!( + "{context} aggregate function is unsupported: '{other}'" + ))); + } + }; + Ok(GraphExpr::AggregateCall { + function, + distinct: parse_js_optional_bool_field( + payload, + "distinct", + &format!("{context} aggregate distinct"), + )? + .unwrap_or(false), + arg: js_non_null_field(payload, "arg") + .map(|value| parse_js_graph_expr(value, &format!("{context} aggregate arg"))) + .transpose()? + .map(Box::new), + }) +} + +fn parse_js_graph_case_expr(value: &serde_json::Value, context: &str) -> Result { + let payload = js_object(value, &format!("{context} case"))?; + ensure_only_js_fields( + payload, + &["operand", "branches", "else"], + &format!("{context} case"), + )?; + let branches_value = js_non_null_field(payload, "branches") + .ok_or_else(|| napi::Error::from_reason(format!("{context} case requires branches")))?; + let branches = js_array(branches_value, &format!("{context} case branches"))? + .iter() + .enumerate() + .map(|(index, value)| { + let item = js_object(value, &format!("{context} case branches[{index}]"))?; + ensure_only_js_fields( + item, + &["when", "then"], + &format!("{context} case branches[{index}]"), + )?; + let when = js_non_null_field(item, "when").ok_or_else(|| { + napi::Error::from_reason(format!("{context} case branches[{index}] requires when")) + })?; + let then = js_non_null_field(item, "then").ok_or_else(|| { + napi::Error::from_reason(format!("{context} case branches[{index}] requires then")) + })?; + Ok(GraphCaseBranch { + when: parse_js_graph_expr(when, &format!("{context} case branches[{index}] when"))?, + then: parse_js_graph_expr(then, &format!("{context} case branches[{index}] then"))?, + }) + }) + .collect::>>()?; + Ok(GraphExpr::Case { + operand: js_non_null_field(payload, "operand") + .map(|value| parse_js_graph_expr(value, &format!("{context} case operand"))) + .transpose()? + .map(Box::new), + branches, + else_expr: js_non_null_field(payload, "else") + .map(|value| parse_js_graph_expr(value, &format!("{context} case else"))) + .transpose()? + .map(Box::new), + }) +} + fn parse_js_graph_op_expr( object: &serde_json::Map, context: &str, @@ -7400,6 +8393,20 @@ fn parse_js_graph_op_expr( expr: Box::new(parse_js_graph_expr(expr, &format!("{context} expr"))?), }); } + if op == "neg" || op == "-" && object.contains_key("expr") { + let expr = object + .get("expr") + .ok_or_else(|| napi::Error::from_reason(format!("{context} neg expr is required")))?; + if object.contains_key("left") || object.contains_key("right") { + return Err(napi::Error::from_reason(format!( + "{context} neg expression must not contain left or right" + ))); + } + return Ok(GraphExpr::Unary { + op: GraphUnaryOp::Neg, + expr: Box::new(parse_js_graph_expr(expr, &format!("{context} expr"))?), + }); + } if object.contains_key("expr") { return Err(napi::Error::from_reason(format!( "{context} binary expression must not contain expr" @@ -7423,6 +8430,13 @@ fn parse_js_graph_op_expr( ">" | "gt" => GraphBinaryOp::Gt, ">=" | "gte" => GraphBinaryOp::Ge, "in" => GraphBinaryOp::In, + "+" | "add" => GraphBinaryOp::Add, + "-" | "sub" => GraphBinaryOp::Sub, + "*" | "mul" => GraphBinaryOp::Mul, + "/" | "div" => GraphBinaryOp::Div, + "startsWith" | "starts_with" => GraphBinaryOp::StartsWith, + "endsWith" | "ends_with" => GraphBinaryOp::EndsWith, + "contains" => GraphBinaryOp::Contains, other => { return Err(napi::Error::from_reason(format!( "{context} unsupported graph binary op '{other}'" diff --git a/overgraph-python/Cargo.toml b/overgraph-python/Cargo.toml index 37b2e84..4d12a85 100644 --- a/overgraph-python/Cargo.toml +++ b/overgraph-python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "overgraph-python" -version = "0.10.0" +version = "0.11.0" edition = "2021" description = "Python bindings for OverGraph" diff --git a/overgraph-python/README.md b/overgraph-python/README.md index 237044c..56b8866 100644 --- a/overgraph-python/README.md +++ b/overgraph-python/README.md @@ -43,7 +43,7 @@ Graph structure and vector similarity can live in the same engine, so you can as - **Explicit write transactions.** Stage ordered node and edge mutations locally, read your own staged writes, then commit atomically with optimistic conflict detection through the Python API. - **Native Python, one engine.** Rust core with PyO3 bindings. Not a wrapper around a REST API. Actual FFI into the same Rust engine with minimal overhead. - **Full queries as functions.** Use regular APIs for direct lookups, full boolean node/edge queries, and `query_graph_rows` for row-shaped graph patterns, optional matches, and bounded paths. -- **GQL Beta.** Use `execute_gql` for GQL/Cypher-style graph reads and writes. `MATCH` reads and keyed `CREATE`, `SET`, `REMOVE`, `DELETE r`, and `DETACH DELETE n` mutations run on the same native substrates. +- **GQL Beta.** Use `execute_gql` for GQL/Cypher-style graph reads and writes. Use `MATCH`, `WITH`, `DISTINCT`, aggregation, `UNION`, read-only subqueries, constrained shortest paths, `CREATE`, `MERGE`, `SET`, `REMOVE`, `DELETE r`, `DETACH DELETE n`, and mutation returns. ## Performance @@ -101,15 +101,16 @@ with OverGraph.open("./my-graph", dense_vector_dimension=384) as db: ## GQL Beta -The Python connector includes **GQL Beta**: a GQL/Cypher-style query language for graph reads and writes, backed by the same graph-row read executor and write-transaction machinery as the native APIs. Use it when a query is easier to read as text; keep native APIs such as `query_graph_rows` and explicit write transactions when structured request objects give you better control. +The Python connector includes **GQL Beta**: a GQL/Cypher-style query language for graph reads and writes. Use it when a graph operation is easier to read as text: create records, match patterns, shape rows with `WITH`, aggregate, combine branches with `UNION`, run read-only subqueries, use constrained shortest paths, and return mutation results. ```python created = db.execute_gql( """ - CREATE (p:Person {key: $key, name: $name, status: 'active'}) - RETURN p.name AS name - """, - {"key": "ada", "name": "Ada"}, + CREATE (p:Person {key: 'gql-ada', name: 'Ada', status: 'active'}) + -[r:WORKS_AT {role: 'engineer', since: 2026}]-> + (c:Company {key: 'gql-overgraph', name: 'OverGraph'}) + RETURN p.name AS person, c.name AS company, r.role AS role + """ ) result = db.execute_gql( @@ -133,7 +134,7 @@ print(result["stats"]) print(result["plan"]["read"]["row_ops"]) ``` -`mode="read_only"` rejects mutation statements, and mutation statements do not accept or return cursors. GQL Beta supports `MATCH`, `OPTIONAL MATCH`, bounded paths, path functions, `WHERE`, `RETURN`, `ORDER BY`, `SKIP` / `OFFSET`, `LIMIT`, params, read cursors, compact rows, vector opt-in, explain/profile, `CREATE`, `SET`, `REMOVE`, `DELETE r`, `DETACH DELETE n`, mutation stats, and mutation `RETURN` for `CREATE` / `SET` / `REMOVE`. See the full [GQL Beta API reference](../docs/api-reference.md#gql-beta) for syntax, result shapes, options, examples, and current limitations. +GQL Beta is available across Rust, Node.js, and Python. It supports params, read cursors, compact rows, vector opt-in for returned node values, explain/profile, read-only execution, mutation stats, async connector calls, and consistent result shapes across languages. See the full [GQL Beta API reference](../docs/api-reference.md#gql-beta) for syntax, result shapes, options, and examples. ### Async support @@ -180,7 +181,7 @@ async def read_people(path: str): - **Degree counts.** Count edges, sum weights, and compute averages without materializing neighbor lists. Batch `degrees` for bulk analysis. - **Direct property queries.** `find_nodes` and `find_nodes_paged` do focused equality lookups with semantic numeric equality for finite scalars. `find_nodes_range` and `find_nodes_range_paged` do domainless numeric range scans with exact bound and cursor semantics. - **Optional property indexes.** Declare node or edge equality/range indexes only where they pay off. Range indexes cover finite scalar numeric values across signed integers, unsigned integers, and finite floats; non-finite floats and non-numeric values are excluded. Use `ensure_node_property_index` / `ensure_edge_property_index`, list APIs, and drop APIs to manage them. Public query APIs stay index-transparent: when a matching declaration is `Ready`, OverGraph uses the declaration-backed path; otherwise it falls back to the same public API. -- **Full query APIs.** `query_node_ids`, `query_nodes`, `query_edge_ids`, `query_edges`, `query_graph_rows`, and explain APIs combine IDs, keys, explicit node label filters, edge labels, endpoint constraints, property equality/IN/range/exists/missing filters, edge metadata filters, updated-at ranges, row-shaped graph patterns, optional groups, and bounded paths. `execute_gql` adds GQL Beta for query-string reads and mutations over the same native substrates. OverGraph chooses the cheapest legal path with available indexes and planner stats, then verifies results against visible records. +- **Full query APIs.** `query_node_ids`, `query_nodes`, `query_edge_ids`, `query_edges`, `query_graph_rows`, and explain APIs combine IDs, keys, explicit node label filters, edge labels, endpoint constraints, property equality/IN/range/exists/missing filters, edge metadata filters, updated-at ranges, row-shaped graph patterns, optional groups, and bounded paths. `execute_gql` adds GQL Beta for query-string reads and mutations. OverGraph chooses the cheapest legal path with available indexes and planner stats, then verifies results against visible records. - **Time-range queries.** Find nodes created or updated within a time window. Sorted timestamp index for efficient range scans. ### Pagination diff --git a/overgraph-python/pyproject.toml b/overgraph-python/pyproject.toml index 90c7583..32f8cb1 100644 --- a/overgraph-python/pyproject.toml +++ b/overgraph-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "overgraph" -version = "0.10.0" +version = "0.11.0" description = "An absurdly fast embedded graph database for Python. Sub-microsecond reads, pure Rust core." requires-python = ">=3.10" license = {text = "MIT OR Apache-2.0"} diff --git a/overgraph-python/python/overgraph/__init__.pyi b/overgraph-python/python/overgraph/__init__.pyi index 6ec3897..c54434f 100644 --- a/overgraph-python/python/overgraph/__init__.pyi +++ b/overgraph-python/python/overgraph/__init__.pyi @@ -351,6 +351,7 @@ QueryEdgeRequest = EdgeQueryRequest GraphParamValue = None | bool | int | float | str | bytes | list["GraphParamValue"] | dict[str, "GraphParamValue"] GraphExpr = Any GraphRowRequest = Mapping[str, Any] +GraphPipelineRequest = dict[str, Any] class GraphPathValue(TypedDict, total=False): node_ids: list[int] @@ -381,6 +382,34 @@ class GraphRowResult(TypedDict): GraphRowExplain = dict[str, Any] +class GraphPipelineStats(TypedDict): + rows_returned: int + rows_entered_pipeline: int + rows_after_filter: int + intermediate_rows: int + pipeline_rows_materialized: int + groups: int + collect_items: int + union_branches: int + union_dedup_keys: int + subquery_invocations: int + subquery_cache_hits: int + shortest_path_pairs: int + shortest_path_cache_hits: int + db_hits: int + elapsed_us: int | None + effective_at_epoch: int + warnings: list[str] + +class GraphPipelineResult(TypedDict): + columns: list[str] + rows: list[dict[str, GraphValue]] | list[list[GraphValue]] + next_cursor: str | None + stats: GraphPipelineStats + plan: dict[str, Any] | None + +GraphPipelineExplain = dict[str, Any] + class GqlNode(TypedDict, total=False): id: int labels: list[str] @@ -437,6 +466,13 @@ class GqlExecutionCapSummary(TypedDict): max_cursor_bytes: int max_mutation_rows: int max_mutation_ops: int + max_pipeline_rows: int + max_groups: int + max_collect_items: int + max_union_branches: int + max_subquery_invocations: int + max_subquery_depth: int + max_shortest_path_pairs: int max_query_bytes: int max_param_bytes: int max_ast_depth: int @@ -609,9 +645,11 @@ class OverGraph: def query_edge_ids(self, request: dict[str, Any] | EdgeQueryRequest) -> IdPageResult: ... def query_edges(self, request: dict[str, Any] | EdgeQueryRequest) -> EdgePageResult: ... def query_graph_rows(self, request: dict[str, Any] | GraphRowRequest) -> GraphRowResult: ... + def query_graph_pipeline(self, request: GraphPipelineRequest) -> GraphPipelineResult: ... def explain_node_query(self, request: dict[str, Any] | NodeQueryRequest) -> dict[str, Any]: ... def explain_edge_query(self, request: dict[str, Any] | EdgeQueryRequest) -> dict[str, Any]: ... def explain_graph_rows(self, request: dict[str, Any] | GraphRowRequest) -> GraphRowExplain: ... + def explain_graph_pipeline(self, request: GraphPipelineRequest) -> GraphPipelineExplain: ... def execute_gql( self, query: str, @@ -624,6 +662,13 @@ class OverGraph: max_cursor_bytes: int | None = None, max_mutation_rows: int | None = None, max_mutation_ops: int | None = None, + max_pipeline_rows: int | None = None, + max_groups: int | None = None, + max_collect_items: int | None = None, + max_union_branches: int | None = None, + max_subquery_invocations: int | None = None, + max_subquery_depth: int | None = None, + max_shortest_path_pairs: int | None = None, max_intermediate_bindings: int | None = None, max_frontier: int | None = None, max_path_hops: int | None = None, @@ -651,6 +696,13 @@ class OverGraph: max_cursor_bytes: int | None = None, max_mutation_rows: int | None = None, max_mutation_ops: int | None = None, + max_pipeline_rows: int | None = None, + max_groups: int | None = None, + max_collect_items: int | None = None, + max_union_branches: int | None = None, + max_subquery_invocations: int | None = None, + max_subquery_depth: int | None = None, + max_shortest_path_pairs: int | None = None, max_intermediate_bindings: int | None = None, max_frontier: int | None = None, max_path_hops: int | None = None, @@ -1039,9 +1091,11 @@ class AsyncOverGraph: async def query_edge_ids(self, request: dict[str, Any] | EdgeQueryRequest) -> IdPageResult: ... async def query_edges(self, request: dict[str, Any] | EdgeQueryRequest) -> EdgePageResult: ... async def query_graph_rows(self, request: dict[str, Any] | GraphRowRequest) -> GraphRowResult: ... + async def query_graph_pipeline(self, request: GraphPipelineRequest) -> GraphPipelineResult: ... async def explain_node_query(self, request: dict[str, Any] | NodeQueryRequest) -> dict[str, Any]: ... async def explain_edge_query(self, request: dict[str, Any] | EdgeQueryRequest) -> dict[str, Any]: ... async def explain_graph_rows(self, request: dict[str, Any] | GraphRowRequest) -> GraphRowExplain: ... + async def explain_graph_pipeline(self, request: GraphPipelineRequest) -> GraphPipelineExplain: ... async def execute_gql( self, query: str, @@ -1054,6 +1108,13 @@ class AsyncOverGraph: max_cursor_bytes: int | None = None, max_mutation_rows: int | None = None, max_mutation_ops: int | None = None, + max_pipeline_rows: int | None = None, + max_groups: int | None = None, + max_collect_items: int | None = None, + max_union_branches: int | None = None, + max_subquery_invocations: int | None = None, + max_subquery_depth: int | None = None, + max_shortest_path_pairs: int | None = None, max_intermediate_bindings: int | None = None, max_frontier: int | None = None, max_path_hops: int | None = None, @@ -1081,6 +1142,13 @@ class AsyncOverGraph: max_cursor_bytes: int | None = None, max_mutation_rows: int | None = None, max_mutation_ops: int | None = None, + max_pipeline_rows: int | None = None, + max_groups: int | None = None, + max_collect_items: int | None = None, + max_union_branches: int | None = None, + max_subquery_invocations: int | None = None, + max_subquery_depth: int | None = None, + max_shortest_path_pairs: int | None = None, max_intermediate_bindings: int | None = None, max_frontier: int | None = None, max_path_hops: int | None = None, diff --git a/overgraph-python/python/overgraph/async_api.py b/overgraph-python/python/overgraph/async_api.py index b3fe006..c1824c6 100644 --- a/overgraph-python/python/overgraph/async_api.py +++ b/overgraph-python/python/overgraph/async_api.py @@ -377,6 +377,9 @@ async def query_pattern(self, request: Any) -> dict[str, Any]: async def query_graph_rows(self, request: Any) -> dict[str, Any]: return await asyncio.to_thread(self._db.query_graph_rows, request) + async def query_graph_pipeline(self, request: Any) -> dict[str, Any]: + return await asyncio.to_thread(self._db.query_graph_pipeline, request) + async def explain_node_query(self, request: Any) -> dict[str, Any]: return await asyncio.to_thread(self._db.explain_node_query, request) @@ -389,6 +392,9 @@ async def explain_pattern_query(self, request: Any) -> dict[str, Any]: async def explain_graph_rows(self, request: Any) -> dict[str, Any]: return await asyncio.to_thread(self._db.explain_graph_rows, request) + async def explain_graph_pipeline(self, request: Any) -> dict[str, Any]: + return await asyncio.to_thread(self._db.explain_graph_pipeline, request) + async def execute_gql( self, query: str, diff --git a/overgraph-python/src/lib.rs b/overgraph-python/src/lib.rs index ae754ce..0364690 100644 --- a/overgraph-python/src/lib.rs +++ b/overgraph-python/src/lib.rs @@ -1,6 +1,6 @@ #![allow(clippy::too_many_arguments)] -use eg::types::GqlPath; +use eg::types::{GqlPath, GraphAggregateFunction}; use eg::{ gql_referenced_param_names, AdjacencyExport as CoreAdjacencyExport, AllShortestPathsOptions, CompactionPhase, CompactionProgress as CoreCompactionProgress, @@ -13,15 +13,20 @@ use eg::{ GqlExecutionCapSummary, GqlExecutionExplain, GqlExecutionMode, GqlExecutionOptions, GqlExecutionResult, GqlExecutionStats, GqlExplain, GqlLoweringTarget, GqlNode, GqlParamValue, GqlParams, GqlRowOperation, GqlStatementKind, GqlValue, GraphBinaryOp, GraphCapExplain, - GraphCursorExplain, GraphEdgePattern, GraphEdgeValue, GraphElementProjection, + GraphCaseBranch, GraphCursorExplain, GraphEdgePattern, GraphEdgeValue, GraphElementProjection, GraphExecutionSummaries, GraphExplainNode, GraphExpr, GraphFunction, GraphNodeField, GraphNodePattern, GraphNodeValue, GraphOrderDirection, GraphOrderExplain, GraphOrderItem, GraphOutputMode, GraphOutputOptions, GraphPageRequest, GraphParamValue, GraphPatch, - GraphPathField, GraphPathValue, GraphPatternPiece, GraphProjectionExplain, GraphQueryOptions, - GraphReturnItem, GraphReturnProjection, GraphRowExplain, GraphRowOperationExplain, - GraphRowQuery, GraphRowResult, GraphRowStats, GraphSelectedEdgeProjection, - GraphSelectedNodeProjection, GraphSelectedPathProjection, GraphSelectedProjection, - GraphUnaryOp, GraphValue, GraphVectorSelection, HnswConfig, IsConnectedOptions, LabelMatchMode, + GraphPathField, GraphPathValue, GraphPatternPiece, GraphPipelineCapExplain, + GraphPipelineExplain, GraphPipelineMatchStage, GraphPipelineOptions, GraphPipelineQuery, + GraphPipelineResult, GraphPipelineStage, GraphPipelineStageExplain, GraphPipelineStats, + GraphProjectItem, GraphProjectKind, GraphProjectStage, GraphProjectionExplain, + GraphProjectionItems, GraphQueryOptions, GraphReturnItem, GraphReturnProjection, + GraphRowExplain, GraphRowOperationExplain, GraphRowQuery, GraphRowResult, GraphRowStats, + GraphSelectedEdgeProjection, GraphSelectedNodeProjection, GraphSelectedPathProjection, + GraphSelectedProjection, GraphShortestPathEndpoint, GraphShortestPathMode, + GraphShortestPathStage, GraphSubqueryStage, GraphUnaryOp, GraphUnionStage, GraphValue, + GraphVectorSelection, HnswConfig, IsConnectedOptions, LabelMatchMode, NeighborEntry as CoreNeighborEntry, NeighborOptions, NodeFilterExpr, NodeIdMap, NodeInput, NodeKeyQuery, NodeLabelFilter, NodeLabelInfo as CoreNodeLabelInfo, NodePropertyIndexInfo as CoreNodePropertyIndexInfo, NodeQuery, NodeQueryOrder, @@ -494,7 +499,28 @@ impl OverGraph { graph_row_explain_to_py(py, explain) } - #[pyo3(signature = (query, params=None, *, mode="auto", allow_full_scan=false, max_rows=None, cursor=None, max_cursor_bytes=None, max_mutation_rows=None, max_mutation_ops=None, max_intermediate_bindings=None, max_frontier=None, max_path_hops=None, max_paths_per_start=None, max_order_materialization=None, max_skip=None, max_query_bytes=None, max_param_bytes=None, max_ast_depth=None, max_literal_items=None, include_plan=false, profile=false, compact_rows=false, include_vectors=false))] + fn query_graph_pipeline( + &self, + py: Python<'_>, + request: &Bound<'_, PyAny>, + ) -> PyResult { + let query = parse_py_graph_pipeline_query(py, request)?; + let compact_rows = query.output.compact_rows; + let result = with_engine_ref(self, py, move |eng| eng.query_graph_pipeline(&query))?; + graph_pipeline_result_to_py(py, result, compact_rows) + } + + fn explain_graph_pipeline( + &self, + py: Python<'_>, + request: &Bound<'_, PyAny>, + ) -> PyResult { + let query = parse_py_graph_pipeline_query(py, request)?; + let explain = with_engine_ref(self, py, move |eng| eng.explain_graph_pipeline(&query))?; + graph_pipeline_explain_to_py(py, explain) + } + + #[pyo3(signature = (query, params=None, *, mode="auto", allow_full_scan=false, max_rows=None, cursor=None, max_cursor_bytes=None, max_mutation_rows=None, max_mutation_ops=None, max_pipeline_rows=None, max_groups=None, max_collect_items=None, max_union_branches=None, max_subquery_invocations=None, max_subquery_depth=None, max_shortest_path_pairs=None, max_intermediate_bindings=None, max_frontier=None, max_path_hops=None, max_paths_per_start=None, max_order_materialization=None, max_skip=None, max_query_bytes=None, max_param_bytes=None, max_ast_depth=None, max_literal_items=None, include_plan=false, profile=false, compact_rows=false, include_vectors=false))] fn execute_gql( &self, py: Python<'_>, @@ -507,6 +533,13 @@ impl OverGraph { max_cursor_bytes: Option, max_mutation_rows: Option, max_mutation_ops: Option, + max_pipeline_rows: Option, + max_groups: Option, + max_collect_items: Option, + max_union_branches: Option, + max_subquery_invocations: Option, + max_subquery_depth: Option, + max_shortest_path_pairs: Option, max_intermediate_bindings: Option, max_frontier: Option, max_path_hops: Option, @@ -530,6 +563,13 @@ impl OverGraph { max_cursor_bytes, max_mutation_rows, max_mutation_ops, + max_pipeline_rows, + max_groups, + max_collect_items, + max_union_branches, + max_subquery_invocations, + max_subquery_depth, + max_shortest_path_pairs, max_intermediate_bindings, max_frontier, max_path_hops, @@ -553,7 +593,7 @@ impl OverGraph { gql_result_to_py(py, result, compact_rows) } - #[pyo3(signature = (query, params=None, *, mode="auto", allow_full_scan=false, max_rows=None, cursor=None, max_cursor_bytes=None, max_mutation_rows=None, max_mutation_ops=None, max_intermediate_bindings=None, max_frontier=None, max_path_hops=None, max_paths_per_start=None, max_order_materialization=None, max_skip=None, max_query_bytes=None, max_param_bytes=None, max_ast_depth=None, max_literal_items=None, include_plan=false, profile=false, compact_rows=false, include_vectors=false))] + #[pyo3(signature = (query, params=None, *, mode="auto", allow_full_scan=false, max_rows=None, cursor=None, max_cursor_bytes=None, max_mutation_rows=None, max_mutation_ops=None, max_pipeline_rows=None, max_groups=None, max_collect_items=None, max_union_branches=None, max_subquery_invocations=None, max_subquery_depth=None, max_shortest_path_pairs=None, max_intermediate_bindings=None, max_frontier=None, max_path_hops=None, max_paths_per_start=None, max_order_materialization=None, max_skip=None, max_query_bytes=None, max_param_bytes=None, max_ast_depth=None, max_literal_items=None, include_plan=false, profile=false, compact_rows=false, include_vectors=false))] fn explain_gql( &self, py: Python<'_>, @@ -566,6 +606,13 @@ impl OverGraph { max_cursor_bytes: Option, max_mutation_rows: Option, max_mutation_ops: Option, + max_pipeline_rows: Option, + max_groups: Option, + max_collect_items: Option, + max_union_branches: Option, + max_subquery_invocations: Option, + max_subquery_depth: Option, + max_shortest_path_pairs: Option, max_intermediate_bindings: Option, max_frontier: Option, max_path_hops: Option, @@ -589,6 +636,13 @@ impl OverGraph { max_cursor_bytes, max_mutation_rows, max_mutation_ops, + max_pipeline_rows, + max_groups, + max_collect_items, + max_union_branches, + max_subquery_invocations, + max_subquery_depth, + max_shortest_path_pairs, max_intermediate_bindings, max_frontier, max_path_hops, @@ -3327,6 +3381,13 @@ fn parse_py_gql_options( max_cursor_bytes: Option, max_mutation_rows: Option, max_mutation_ops: Option, + max_pipeline_rows: Option, + max_groups: Option, + max_collect_items: Option, + max_union_branches: Option, + max_subquery_invocations: Option, + max_subquery_depth: Option, + max_shortest_path_pairs: Option, max_intermediate_bindings: Option, max_frontier: Option, max_path_hops: Option, @@ -3364,6 +3425,27 @@ fn parse_py_gql_options( if let Some(max_mutation_ops) = max_mutation_ops { options.max_mutation_ops = max_mutation_ops; } + if let Some(max_pipeline_rows) = max_pipeline_rows { + options.max_pipeline_rows = max_pipeline_rows; + } + if let Some(max_groups) = max_groups { + options.max_groups = max_groups; + } + if let Some(max_collect_items) = max_collect_items { + options.max_collect_items = max_collect_items; + } + if let Some(max_union_branches) = max_union_branches { + options.max_union_branches = max_union_branches; + } + if let Some(max_subquery_invocations) = max_subquery_invocations { + options.max_subquery_invocations = max_subquery_invocations; + } + if let Some(max_subquery_depth) = max_subquery_depth { + options.max_subquery_depth = max_subquery_depth; + } + if let Some(max_shortest_path_pairs) = max_shortest_path_pairs { + options.max_shortest_path_pairs = max_shortest_path_pairs; + } if let Some(max_intermediate_bindings) = max_intermediate_bindings { options.max_intermediate_bindings = max_intermediate_bindings; } @@ -3819,6 +3901,43 @@ fn graph_row_result_to_py( Ok(dict.into_any().unbind()) } +fn graph_pipeline_result_to_py( + py: Python<'_>, + result: GraphPipelineResult, + compact_rows: bool, +) -> PyResult { + let dict = PyDict::new(py); + dict.set_item("columns", result.columns.clone())?; + let rows: PyResult> = result + .rows + .into_iter() + .map(|row| { + if compact_rows { + let values: PyResult> = row + .values + .into_iter() + .map(|value| graph_value_to_py(py, value)) + .collect(); + Ok(PyList::new(py, values?)?.into_any().unbind()) + } else { + let row_dict = PyDict::new(py); + for (column, value) in result.columns.iter().zip(row.values) { + row_dict.set_item(column, graph_value_to_py(py, value)?)?; + } + Ok(row_dict.into_any().unbind()) + } + }) + .collect(); + dict.set_item("rows", rows?)?; + dict.set_item("next_cursor", result.next_cursor)?; + dict.set_item("stats", graph_pipeline_stats_to_py(py, result.stats)?)?; + match result.plan { + Some(plan) => dict.set_item("plan", graph_pipeline_explain_to_py(py, plan)?)?, + None => dict.set_item("plan", py.None())?, + } + Ok(dict.into_any().unbind()) +} + fn graph_value_to_py(py: Python<'_>, value: GraphValue) -> PyResult { match value { GraphValue::Null => Ok(py.None()), @@ -3976,6 +4095,31 @@ fn graph_row_stats_to_py(py: Python<'_>, stats: GraphRowStats) -> PyResult, stats: GraphPipelineStats) -> PyResult { + let dict = PyDict::new(py); + dict.set_item("rows_returned", stats.rows_returned)?; + dict.set_item("rows_entered_pipeline", stats.rows_entered_pipeline)?; + dict.set_item("rows_after_filter", stats.rows_after_filter)?; + dict.set_item("intermediate_rows", stats.intermediate_rows)?; + dict.set_item( + "pipeline_rows_materialized", + stats.pipeline_rows_materialized, + )?; + dict.set_item("groups", stats.groups)?; + dict.set_item("collect_items", stats.collect_items)?; + dict.set_item("union_branches", stats.union_branches)?; + dict.set_item("union_dedup_keys", stats.union_dedup_keys)?; + dict.set_item("subquery_invocations", stats.subquery_invocations)?; + dict.set_item("subquery_cache_hits", stats.subquery_cache_hits)?; + dict.set_item("shortest_path_pairs", stats.shortest_path_pairs)?; + dict.set_item("shortest_path_cache_hits", stats.shortest_path_cache_hits)?; + dict.set_item("db_hits", stats.db_hits)?; + dict.set_item("elapsed_us", stats.elapsed_us)?; + dict.set_item("effective_at_epoch", stats.effective_at_epoch)?; + dict.set_item("warnings", stats.warnings)?; + Ok(dict.into_any().unbind()) +} + fn gql_edge_to_py(py: Python<'_>, edge: GqlEdge) -> PyResult { let dict = PyDict::new(py); if let Some(id) = edge.id { @@ -4156,6 +4300,13 @@ fn gql_execution_caps_to_py(py: Python<'_>, caps: GqlExecutionCapSummary) -> PyR dict.set_item("max_cursor_bytes", caps.max_cursor_bytes)?; dict.set_item("max_mutation_rows", caps.max_mutation_rows)?; dict.set_item("max_mutation_ops", caps.max_mutation_ops)?; + dict.set_item("max_pipeline_rows", caps.max_pipeline_rows)?; + dict.set_item("max_groups", caps.max_groups)?; + dict.set_item("max_collect_items", caps.max_collect_items)?; + dict.set_item("max_union_branches", caps.max_union_branches)?; + dict.set_item("max_subquery_invocations", caps.max_subquery_invocations)?; + dict.set_item("max_subquery_depth", caps.max_subquery_depth)?; + dict.set_item("max_shortest_path_pairs", caps.max_shortest_path_pairs)?; dict.set_item("max_query_bytes", caps.max_query_bytes)?; dict.set_item("max_param_bytes", caps.max_param_bytes)?; dict.set_item("max_ast_depth", caps.max_ast_depth)?; @@ -4187,6 +4338,7 @@ fn gql_lowering_target_to_py(target: GqlLoweringTarget) -> &'static str { GqlLoweringTarget::NodeQuery => "node_query", GqlLoweringTarget::EdgeQuery => "edge_query", GqlLoweringTarget::GraphRowQuery => "graph_row_query", + GqlLoweringTarget::GraphPipelineQuery => "graph_pipeline_query", } } @@ -4223,6 +4375,61 @@ fn graph_row_explain_to_py(py: Python<'_>, explain: GraphRowExplain) -> PyResult Ok(dict.into_any().unbind()) } +fn graph_pipeline_explain_to_py( + py: Python<'_>, + explain: GraphPipelineExplain, +) -> PyResult { + let dict = PyDict::new(py); + dict.set_item("columns", explain.columns)?; + dict.set_item("effective_at_epoch", explain.effective_at_epoch)?; + dict.set_item("fingerprint", explain.fingerprint)?; + let stages = explain + .stages + .into_iter() + .map(|stage| graph_pipeline_stage_explain_to_py(py, stage)) + .collect::>>()?; + dict.set_item("stages", stages)?; + let row_ops = explain + .row_ops + .into_iter() + .map(|op| graph_row_operation_to_py(py, op)) + .collect::>>()?; + dict.set_item("row_ops", row_ops)?; + dict.set_item("order", graph_order_explain_to_py(py, explain.order)?)?; + dict.set_item("cursor", graph_cursor_explain_to_py(py, explain.cursor)?)?; + dict.set_item( + "projection", + graph_projection_explain_to_py(py, explain.projection)?, + )?; + dict.set_item("caps", graph_pipeline_cap_explain_to_py(py, explain.caps)?)?; + dict.set_item( + "summaries", + graph_execution_summaries_to_py(py, explain.summaries)?, + )?; + dict.set_item("stats", graph_pipeline_stats_to_py(py, explain.stats)?)?; + dict.set_item("warnings", explain.warnings)?; + dict.set_item("notes", explain.notes)?; + Ok(dict.into_any().unbind()) +} + +fn graph_pipeline_stage_explain_to_py( + py: Python<'_>, + stage: GraphPipelineStageExplain, +) -> PyResult { + let dict = PyDict::new(py); + dict.set_item("index", stage.index)?; + dict.set_item("kind", stage.kind)?; + dict.set_item("detail", stage.detail)?; + dict.set_item("columns", stage.columns)?; + match stage.graph_row { + Some(explain) => dict.set_item("graph_row", graph_row_explain_to_py(py, *explain)?)?, + None => dict.set_item("graph_row", py.None())?, + } + dict.set_item("warnings", stage.warnings)?; + dict.set_item("notes", stage.notes)?; + Ok(dict.into_any().unbind()) +} + fn graph_explain_node_to_py(py: Python<'_>, node: GraphExplainNode) -> PyResult { let dict = PyDict::new(py); dict.set_item("kind", node.kind)?; @@ -4288,6 +4495,34 @@ fn graph_cap_explain_to_py(py: Python<'_>, caps: GraphCapExplain) -> PyResult, + caps: GraphPipelineCapExplain, +) -> PyResult { + let dict = PyDict::new(py); + dict.set_item("allow_full_scan", caps.allow_full_scan)?; + dict.set_item("max_rows", caps.max_rows)?; + dict.set_item("max_pipeline_rows", caps.max_pipeline_rows)?; + dict.set_item("max_groups", caps.max_groups)?; + dict.set_item("max_collect_items", caps.max_collect_items)?; + dict.set_item("max_union_branches", caps.max_union_branches)?; + dict.set_item("max_subquery_invocations", caps.max_subquery_invocations)?; + dict.set_item("max_subquery_depth", caps.max_subquery_depth)?; + dict.set_item("max_shortest_path_pairs", caps.max_shortest_path_pairs)?; + dict.set_item("max_intermediate_bindings", caps.max_intermediate_bindings)?; + dict.set_item("max_frontier", caps.max_frontier)?; + dict.set_item("max_path_hops", caps.max_path_hops)?; + dict.set_item("max_paths_per_start", caps.max_paths_per_start)?; + dict.set_item("max_order_materialization", caps.max_order_materialization)?; + dict.set_item("max_skip", caps.max_skip)?; + dict.set_item("max_cursor_bytes", caps.max_cursor_bytes)?; + dict.set_item("max_query_bytes", caps.max_query_bytes)?; + dict.set_item("max_param_bytes", caps.max_param_bytes)?; + dict.set_item("max_ast_depth", caps.max_ast_depth)?; + dict.set_item("max_literal_items", caps.max_literal_items)?; + Ok(dict.into_any().unbind()) +} + fn graph_execution_summaries_to_py( py: Python<'_>, summaries: GraphExecutionSummaries, @@ -4646,6 +4881,456 @@ fn parse_py_graph_row_query_dict( }) } +fn parse_py_graph_pipeline_query( + py: Python<'_>, + value: &Bound<'_, PyAny>, +) -> PyResult { + if let Ok(dict) = value.downcast::() { + return parse_py_graph_pipeline_query_dict(py, dict); + } + if value.hasattr("to_dict")? { + let dict_value = value.call_method0("to_dict")?; + let dict = dict_value.downcast::()?; + return parse_py_graph_pipeline_query_dict(py, dict); + } + Err(PyTypeError::new_err( + "graph pipeline request must be a dict or expose to_dict()", + )) +} + +fn parse_py_graph_pipeline_query_dict( + py: Python<'_>, + dict: &Bound<'_, PyDict>, +) -> PyResult { + let options = parse_py_graph_pipeline_options(dict)?; + let page = GraphPageRequest { + skip: py_optional_query_usize(dict, "skip", "graph pipeline skip")?.unwrap_or(0), + limit: py_optional_query_usize(dict, "limit", "graph pipeline limit")? + .unwrap_or(options.max_rows), + cursor: py_optional_extract(dict, "cursor")?, + }; + if page.limit == 0 { + return Err(PyValueError::new_err("graph pipeline limit must be > 0")); + } + let stages_value = py_non_none_item(dict, "stages")? + .ok_or_else(|| PyValueError::new_err("graph pipeline request requires stages"))?; + let stages_list = stages_value.downcast::()?; + let mut stages = Vec::with_capacity(stages_list.len()); + for (index, item) in stages_list.iter().enumerate() { + stages.push(parse_py_graph_pipeline_stage( + py, + item.downcast::()?, + &format!("graph pipeline stages[{index}]"), + )?); + } + Ok(GraphPipelineQuery { + stages, + params: parse_py_graph_params(py, dict)?, + at_epoch: py_optional_query_i64(dict, "at_epoch", "graph pipeline at_epoch")?, + page, + output: parse_py_graph_output_options(dict)?, + options, + }) +} + +fn parse_py_graph_pipeline_stage( + py: Python<'_>, + dict: &Bound<'_, PyDict>, + context: &str, +) -> PyResult { + let kind: String = py_required_extract(dict, "kind")?; + match kind.as_str() { + "match" => Ok(GraphPipelineStage::Match(parse_py_graph_pipeline_match_stage( + py, dict, context, + )?)), + "project" | "with" | "return" => Ok(GraphPipelineStage::Project( + parse_py_graph_pipeline_project_stage(py, dict, &kind, context)?, + )), + "shortest_path" | "shortestPath" => Ok(GraphPipelineStage::ShortestPath( + parse_py_graph_pipeline_shortest_path_stage(py, dict, context)?, + )), + "call" => Ok(GraphPipelineStage::Call(parse_py_graph_pipeline_call_stage( + py, dict, context, + )?)), + "union" => Ok(GraphPipelineStage::Union(parse_py_graph_pipeline_union_stage( + py, dict, context, + )?)), + other => Err(PyValueError::new_err(format!( + "{context} kind must be 'match', 'project', 'with', 'return', 'shortest_path', 'call', or 'union', got '{other}'" + ))), + } +} + +fn parse_py_graph_pipeline_match_stage( + py: Python<'_>, + dict: &Bound<'_, PyDict>, + context: &str, +) -> PyResult { + let mut nodes = Vec::new(); + if let Some(nodes_value) = py_non_none_item(dict, "nodes")? { + let nodes_list = nodes_value.downcast::()?; + nodes.reserve(nodes_list.len()); + for (index, item) in nodes_list.iter().enumerate() { + nodes.push(parse_py_graph_node_pattern( + py, + item.downcast::()?, + &format!("{context} nodes[{index}]"), + )?); + } + } + let mut pieces = Vec::new(); + if let Some(pieces_value) = py_non_none_item(dict, "pieces")? { + let pieces_list = pieces_value.downcast::()?; + pieces.reserve(pieces_list.len()); + for (index, item) in pieces_list.iter().enumerate() { + pieces.push(parse_py_graph_pattern_piece( + py, + item.downcast::()?, + &format!("{context} pieces[{index}]"), + )?); + } + } + Ok(GraphPipelineMatchStage { + optional: py_optional_extract(dict, "optional")?.unwrap_or(false), + nodes, + pieces, + where_: py_non_none_item(dict, "where")? + .map(|value| parse_py_graph_expr(py, &value, &format!("{context} where"))) + .transpose()?, + optional_candidate_where: py_non_none_item(dict, "optional_candidate_where")? + .map(|value| { + parse_py_graph_expr(py, &value, &format!("{context} optional_candidate_where")) + }) + .transpose()?, + }) +} + +fn parse_py_graph_pipeline_project_stage( + py: Python<'_>, + dict: &Bound<'_, PyDict>, + kind: &str, + context: &str, +) -> PyResult { + let project_kind = match kind { + "with" => GraphProjectKind::With, + "return" => GraphProjectKind::Return, + _ => match py_non_none_item(dict, "project_kind")? { + None => GraphProjectKind::Return, + Some(value) => match value.extract::()?.as_str() { + "with" => GraphProjectKind::With, + "return" => GraphProjectKind::Return, + other => { + return Err(PyValueError::new_err(format!( + "{context} project_kind must be 'with' or 'return', got '{other}'" + ))); + } + }, + }, + }; + Ok(GraphProjectStage { + kind: project_kind, + items: parse_py_graph_projection_items(py, dict, context)?, + distinct: py_optional_extract(dict, "distinct")?.unwrap_or(false), + where_: py_non_none_item(dict, "where")? + .map(|value| parse_py_graph_expr(py, &value, &format!("{context} where"))) + .transpose()?, + order_by: parse_py_graph_order_items(py, dict)?, + skip: py_non_none_item(dict, "skip")? + .map(|value| parse_py_graph_expr(py, &value, &format!("{context} skip"))) + .transpose()?, + limit: py_non_none_item(dict, "limit")? + .map(|value| parse_py_graph_expr(py, &value, &format!("{context} limit"))) + .transpose()?, + }) +} + +fn parse_py_graph_projection_items( + py: Python<'_>, + dict: &Bound<'_, PyDict>, + context: &str, +) -> PyResult { + let Some(value) = py_non_none_item(dict, "items")? else { + return Ok(GraphProjectionItems::Star); + }; + if let Ok(name) = value.extract::() { + if name == "star" || name == "*" { + return Ok(GraphProjectionItems::Star); + } + return Err(PyValueError::new_err(format!( + "{context} items string must be 'star' or '*'" + ))); + } + let items = value.downcast::()?; + let mut parsed = Vec::with_capacity(items.len()); + for (index, item) in items.iter().enumerate() { + let item = item.downcast::()?; + let item_context = format!("{context} items[{index}]"); + let expr_value = py_non_none_item(item, "expr")? + .ok_or_else(|| PyValueError::new_err(format!("{item_context} requires expr")))?; + parsed.push(GraphProjectItem { + expr: parse_py_graph_expr(py, &expr_value, &format!("{item_context} expr"))?, + alias: parse_py_graph_return_alias(item, &item_context)?, + projection: parse_py_graph_return_projection(item, &item_context)?, + }); + } + Ok(GraphProjectionItems::Items(parsed)) +} + +fn parse_py_graph_pipeline_union_stage( + py: Python<'_>, + dict: &Bound<'_, PyDict>, + context: &str, +) -> PyResult { + let branches_value = py_non_none_item(dict, "branches")? + .ok_or_else(|| PyValueError::new_err(format!("{context} requires branches")))?; + let branches_list = branches_value.downcast::()?; + let mut branches = Vec::with_capacity(branches_list.len()); + for (index, item) in branches_list.iter().enumerate() { + branches.push( + parse_py_graph_pipeline_query(py, &item).map_err(|err| { + PyValueError::new_err(format!("{context} branches[{index}]: {err}")) + })?, + ); + } + Ok(GraphUnionStage { + branches, + all: py_optional_extract(dict, "all")?.unwrap_or(false), + }) +} + +fn parse_py_graph_pipeline_call_stage( + py: Python<'_>, + dict: &Bound<'_, PyDict>, + context: &str, +) -> PyResult { + let query_value = py_non_none_item(dict, "query")? + .ok_or_else(|| PyValueError::new_err(format!("{context} requires query")))?; + Ok(GraphSubqueryStage { + query: Box::new(parse_py_graph_pipeline_query(py, &query_value)?), + import_aliases: py_optional_extract::>(dict, "import_aliases")? + .unwrap_or_default(), + }) +} + +fn parse_py_graph_pipeline_shortest_path_stage( + py: Python<'_>, + dict: &Bound<'_, PyDict>, + context: &str, +) -> PyResult { + let direction = match py_non_none_item(dict, "direction")? { + None => Direction::Outgoing, + Some(value) => parse_direction(&value.extract::()?)?, + }; + let mode = match py_non_none_item(dict, "mode")? { + None => GraphShortestPathMode::One, + Some(value) => match value.extract::()?.as_str() { + "one" => GraphShortestPathMode::One, + "all" => GraphShortestPathMode::All, + other => { + return Err(PyValueError::new_err(format!( + "{context} mode must be 'one' or 'all', got '{other}'" + ))); + } + }, + }; + let max_cost = py_non_none_item(dict, "max_cost")? + .map(|value| { + let cost: f64 = value.extract()?; + if !cost.is_finite() { + return Err(PyValueError::new_err(format!( + "{context} max_cost must be finite" + ))); + } + Ok(cost) + }) + .transpose()?; + Ok(GraphShortestPathStage { + optional: py_optional_extract(dict, "optional")?.unwrap_or(false), + output_path_alias: py_required_extract(dict, "output_path_alias")?, + mode, + from: parse_py_shortest_path_endpoint( + py, + &py_non_none_item(dict, "from")? + .ok_or_else(|| PyValueError::new_err(format!("{context} requires from")))?, + &format!("{context} from"), + )?, + to: parse_py_shortest_path_endpoint( + py, + &py_non_none_item(dict, "to")? + .ok_or_else(|| PyValueError::new_err(format!("{context} requires to")))?, + &format!("{context} to"), + )?, + direction, + edge_label_filter: py_optional_extract::>(dict, "edge_label_filter")? + .unwrap_or_default(), + min_hops: py_optional_query_u8(dict, "min_hops", &format!("{context} min_hops"))? + .ok_or_else(|| PyValueError::new_err(format!("{context} requires min_hops")))?, + max_hops: py_optional_query_u8(dict, "max_hops", &format!("{context} max_hops"))? + .ok_or_else(|| PyValueError::new_err(format!("{context} requires max_hops")))?, + weight_field: py_optional_extract(dict, "weight_field")?, + max_cost, + max_paths: py_optional_query_usize(dict, "max_paths", &format!("{context} max_paths"))?, + }) +} + +fn parse_py_shortest_path_endpoint( + py: Python<'_>, + value: &Bound<'_, PyAny>, + context: &str, +) -> PyResult { + if let Ok(alias) = value.extract::() { + return Ok(GraphShortestPathEndpoint::Alias(alias)); + } + if let Ok(id) = value.extract::() { + return Ok(GraphShortestPathEndpoint::NodeId(id)); + } + let dict = value + .downcast::() + .map_err(|_| PyTypeError::new_err(format!("{context} must be an endpoint")))?; + let discriminants = ["alias", "node_id", "node_key", "expr"]; + let present = discriminants + .iter() + .map(|field| py_has_field(dict, field)) + .collect::>>()? + .into_iter() + .filter(|value| *value) + .count(); + if present != 1 { + return Err(PyValueError::new_err(format!( + "{context} must contain exactly one of alias, node_id, node_key, or expr" + ))); + } + if let Some(value) = py_non_none_item(dict, "alias")? { + return Ok(GraphShortestPathEndpoint::Alias(value.extract()?)); + } + if let Some(value) = py_non_none_item(dict, "node_id")? { + return Ok(GraphShortestPathEndpoint::NodeId(value.extract()?)); + } + if let Some(value) = py_non_none_item(dict, "node_key")? { + let payload = value.downcast::()?; + return Ok(GraphShortestPathEndpoint::NodeKey { + label: py_required_extract(payload, "label")?, + key: py_required_extract(payload, "key")?, + }); + } + let expr = py_non_none_item(dict, "expr")? + .ok_or_else(|| PyValueError::new_err(format!("{context} requires expr")))?; + Ok(GraphShortestPathEndpoint::Expr(parse_py_graph_expr( + py, + &expr, + &format!("{context} expr"), + )?)) +} + +fn parse_py_graph_pipeline_options(dict: &Bound<'_, PyDict>) -> PyResult { + let mut options = GraphPipelineOptions::default(); + let Some(value) = py_non_none_item(dict, "options")? else { + return Ok(options); + }; + let options_dict = value.downcast::()?; + if let Some(value) = py_optional_extract(options_dict, "allow_full_scan")? { + options.allow_full_scan = value; + } + if let Some(value) = py_optional_query_usize(options_dict, "max_rows", "max_rows")? { + options.max_rows = value; + } + if let Some(value) = + py_optional_query_usize(options_dict, "max_pipeline_rows", "max_pipeline_rows")? + { + options.max_pipeline_rows = value; + } + if let Some(value) = py_optional_query_usize(options_dict, "max_groups", "max_groups")? { + options.max_groups = value; + } + if let Some(value) = + py_optional_query_usize(options_dict, "max_collect_items", "max_collect_items")? + { + options.max_collect_items = value; + } + if let Some(value) = + py_optional_query_usize(options_dict, "max_union_branches", "max_union_branches")? + { + options.max_union_branches = value; + } + if let Some(value) = py_optional_query_usize( + options_dict, + "max_subquery_invocations", + "max_subquery_invocations", + )? { + options.max_subquery_invocations = value; + } + if let Some(value) = + py_optional_query_usize(options_dict, "max_subquery_depth", "max_subquery_depth")? + { + options.max_subquery_depth = value; + } + if let Some(value) = py_optional_query_usize( + options_dict, + "max_shortest_path_pairs", + "max_shortest_path_pairs", + )? { + options.max_shortest_path_pairs = value; + } + if let Some(value) = py_optional_query_usize( + options_dict, + "max_intermediate_bindings", + "max_intermediate_bindings", + )? { + options.max_intermediate_bindings = value; + } + if let Some(value) = py_optional_query_usize(options_dict, "max_frontier", "max_frontier")? { + options.max_frontier = value; + } + if let Some(value) = py_optional_query_u8(options_dict, "max_path_hops", "max_path_hops")? { + options.max_path_hops = value; + } + if let Some(value) = + py_optional_query_usize(options_dict, "max_paths_per_start", "max_paths_per_start")? + { + options.max_paths_per_start = value; + } + if let Some(value) = py_optional_query_usize( + options_dict, + "max_order_materialization", + "max_order_materialization", + )? { + options.max_order_materialization = value; + } + if let Some(value) = py_optional_query_usize(options_dict, "max_skip", "max_skip")? { + options.max_skip = value; + } + if let Some(value) = + py_optional_query_usize(options_dict, "max_cursor_bytes", "max_cursor_bytes")? + { + options.max_cursor_bytes = value; + } + if let Some(value) = + py_optional_query_usize(options_dict, "max_query_bytes", "max_query_bytes")? + { + options.max_query_bytes = value; + } + if let Some(value) = + py_optional_query_usize(options_dict, "max_param_bytes", "max_param_bytes")? + { + options.max_param_bytes = value; + } + if let Some(value) = py_optional_query_usize(options_dict, "max_ast_depth", "max_ast_depth")? { + options.max_ast_depth = value; + } + if let Some(value) = + py_optional_query_usize(options_dict, "max_literal_items", "max_literal_items")? + { + options.max_literal_items = value; + } + if let Some(value) = py_optional_extract(options_dict, "include_plan")? { + options.include_plan = value; + } + if let Some(value) = py_optional_extract(options_dict, "profile")? { + options.profile = value; + } + Ok(options) +} + fn parse_py_graph_node_pattern( py: Python<'_>, dict: &Bound<'_, PyDict>, @@ -5435,7 +6120,10 @@ fn parse_py_graph_expr_dict( "edge_field", "path_field", "fn", + "aggregate", + "exists", "op", + "case", "is_null", "is_not_null", ]; @@ -5549,6 +6237,20 @@ fn parse_py_graph_expr_dict( .collect::>>()?; return parse_py_graph_function_expr(name, args, context); } + if let Some(value) = dict.get_item("aggregate")? { + ensure_only_py_fields(dict, &["aggregate"], context)?; + return parse_py_graph_aggregate_expr(py, value.downcast::()?, context); + } + if let Some(value) = dict.get_item("exists")? { + ensure_only_py_fields(dict, &["exists"], context)?; + return Ok(GraphExpr::ExistsSubquery( + parse_py_graph_pipeline_call_stage( + py, + value.downcast::()?, + &format!("{context} exists"), + )?, + )); + } if let Some(value) = dict.get_item("op")? { let op: String = value.extract()?; if op == "not" { @@ -5561,6 +6263,16 @@ fn parse_py_graph_expr_dict( expr: Box::new(parse_py_graph_expr(py, &expr, &format!("{context} expr"))?), }); } + if op == "neg" || op == "-" && py_has_field(dict, "expr")? { + ensure_only_py_fields(dict, &["op", "expr"], context)?; + let expr = dict + .get_item("expr")? + .ok_or_else(|| PyValueError::new_err(format!("{context} neg requires expr")))?; + return Ok(GraphExpr::Unary { + op: GraphUnaryOp::Neg, + expr: Box::new(parse_py_graph_expr(py, &expr, &format!("{context} expr"))?), + }); + } ensure_only_py_fields(dict, &["op", "left", "right"], context)?; let left = dict .get_item("left")? @@ -5578,6 +6290,10 @@ fn parse_py_graph_expr_dict( )?), }); } + if let Some(value) = dict.get_item("case")? { + ensure_only_py_fields(dict, &["case"], context)?; + return parse_py_graph_case_expr(py, value.downcast::()?, context); + } if let Some(value) = dict.get_item("is_null")? { ensure_only_py_fields(dict, &["is_null"], context)?; return Ok(GraphExpr::IsNull(Box::new(parse_py_graph_expr( @@ -5597,6 +6313,69 @@ fn parse_py_graph_expr_dict( unreachable!("expression discriminant count already checked") } +fn parse_py_graph_aggregate_expr( + py: Python<'_>, + dict: &Bound<'_, PyDict>, + context: &str, +) -> PyResult { + let function: String = py_required_extract(dict, "function")?; + let function = match function.as_str() { + "count" => GraphAggregateFunction::Count, + "sum" => GraphAggregateFunction::Sum, + "avg" => GraphAggregateFunction::Avg, + "min" => GraphAggregateFunction::Min, + "max" => GraphAggregateFunction::Max, + "collect" => GraphAggregateFunction::Collect, + other => { + return Err(PyValueError::new_err(format!( + "{context} aggregate function is unsupported: '{other}'" + ))); + } + }; + Ok(GraphExpr::AggregateCall { + function, + distinct: py_optional_extract(dict, "distinct")?.unwrap_or(false), + arg: py_non_none_item(dict, "arg")? + .map(|value| parse_py_graph_expr(py, &value, &format!("{context} aggregate arg"))) + .transpose()? + .map(Box::new), + }) +} + +fn parse_py_graph_case_expr( + py: Python<'_>, + dict: &Bound<'_, PyDict>, + context: &str, +) -> PyResult { + let branches_value = py_non_none_item(dict, "branches")? + .ok_or_else(|| PyValueError::new_err(format!("{context} case requires branches")))?; + let branches_list = branches_value.downcast::()?; + let mut branches = Vec::with_capacity(branches_list.len()); + for (index, item) in branches_list.iter().enumerate() { + let item = item.downcast::()?; + let item_context = format!("{context} case branches[{index}]"); + let when = py_non_none_item(item, "when")? + .ok_or_else(|| PyValueError::new_err(format!("{item_context} requires when")))?; + let then = py_non_none_item(item, "then")? + .ok_or_else(|| PyValueError::new_err(format!("{item_context} requires then")))?; + branches.push(GraphCaseBranch { + when: parse_py_graph_expr(py, &when, &format!("{item_context} when"))?, + then: parse_py_graph_expr(py, &then, &format!("{item_context} then"))?, + }); + } + Ok(GraphExpr::Case { + operand: py_non_none_item(dict, "operand")? + .map(|value| parse_py_graph_expr(py, &value, &format!("{context} case operand"))) + .transpose()? + .map(Box::new), + branches, + else_expr: py_non_none_item(dict, "else")? + .map(|value| parse_py_graph_expr(py, &value, &format!("{context} case else"))) + .transpose()? + .map(Box::new), + }) +} + fn parse_py_tagged_bytes(dict: &Bound<'_, PyDict>, context: &str) -> PyResult>> { let Some(value) = dict.get_item("bytes")? else { return Ok(None); @@ -5685,6 +6464,21 @@ fn parse_py_graph_function_expr( "end_node" => GraphFunction::EndNode, "nodes" => GraphFunction::Nodes, "relationships" => GraphFunction::Relationships, + "coalesce" => GraphFunction::Coalesce, + "to_string" => GraphFunction::ToString, + "to_integer" => GraphFunction::ToInteger, + "to_float" => GraphFunction::ToFloat, + "abs" => GraphFunction::Abs, + "floor" => GraphFunction::Floor, + "ceil" => GraphFunction::Ceil, + "round" => GraphFunction::Round, + "lower" => GraphFunction::Lower, + "upper" => GraphFunction::Upper, + "trim" => GraphFunction::Trim, + "substring" => GraphFunction::Substring, + "size" => GraphFunction::Size, + "head" => GraphFunction::Head, + "last" => GraphFunction::Last, other => { return Err(PyValueError::new_err(format!( "{context} function is unsupported: '{other}'" @@ -5708,6 +6502,13 @@ fn parse_py_graph_binary_op(name: &str, context: &str) -> PyResult" => Ok(GraphBinaryOp::Gt), ">=" => Ok(GraphBinaryOp::Ge), "in" => Ok(GraphBinaryOp::In), + "+" | "add" => Ok(GraphBinaryOp::Add), + "-" | "sub" => Ok(GraphBinaryOp::Sub), + "*" | "mul" => Ok(GraphBinaryOp::Mul), + "/" | "div" => Ok(GraphBinaryOp::Div), + "starts_with" => Ok(GraphBinaryOp::StartsWith), + "ends_with" => Ok(GraphBinaryOp::EndsWith), + "contains" => Ok(GraphBinaryOp::Contains), other => Err(PyValueError::new_err(format!( "{context} binary op is unsupported: '{other}'" ))), diff --git a/overgraph-python/tests/test_async.py b/overgraph-python/tests/test_async.py index 388c4f5..f1e3208 100644 --- a/overgraph-python/tests/test_async.py +++ b/overgraph-python/tests/test_async.py @@ -347,6 +347,48 @@ async def test_find_nodes(self, async_db): ids = await async_db.find_nodes("Person", "color", "red") assert len(ids) == 1 + @pytest.mark.asyncio + async def test_gql_phase34_options_and_compact_rows(self, async_db): + await async_db.upsert_node( + "PyAsyncPhase34", + "ada", + props={"name": "Ada", "group": "core", "rank": 2}, + ) + await async_db.upsert_node( + "PyAsyncPhase34", + "ben", + props={"name": "Ben", "group": "core", "rank": 1}, + ) + await async_db.upsert_node( + "PyAsyncPhase34", + "cy", + props={"name": "Cy", "group": "ops", "rank": 3}, + ) + + options = { + "max_pipeline_rows": 16, + "max_groups": 8, + "max_collect_items": 8, + "max_union_branches": 4, + "max_subquery_invocations": 8, + "max_subquery_depth": 1, + "max_shortest_path_pairs": 8, + } + query = """ + MATCH (n:PyAsyncPhase34) + WITH n.group AS grp, count(*) AS count, collect(n.name) AS names + WHERE count > 1 + RETURN grp, count, names + """ + result = await async_db.execute_gql(query, compact_rows=True, **options) + assert result["columns"] == ["grp", "count", "names"] + assert result["rows"] == [["core", 2, ["Ada", "Ben"]]] + + explain = await async_db.explain_gql(query, **options) + assert explain["read"]["target"] == "graph_pipeline_query" + for name, value in options.items(): + assert explain["caps"][name] == value + @pytest.mark.asyncio async def test_count_by_type(self, async_db): for i in range(3): diff --git a/overgraph-python/tests/test_gql.py b/overgraph-python/tests/test_gql.py index 6259bf3..53e7df7 100644 --- a/overgraph-python/tests/test_gql.py +++ b/overgraph-python/tests/test_gql.py @@ -55,6 +55,58 @@ async def seed_async(db): return {"ada": ada, "ben": ben, "cy": cy, "acme": acme, "works_at": works_at} +def seed_phase34(db, person_label="PyPhase34Person"): + ada = db.upsert_node( + person_label, + "ada", + props={ + "name": "Ada", + "status": "active", + "rank": 2, + "group": "core", + "age": 37, + "target": "acct-a", + }, + ) + ben = db.upsert_node( + person_label, + "ben", + props={ + "name": "Ben", + "status": "active", + "rank": 1, + "group": "core", + "age": 29, + "target": "acct-a", + }, + ) + cy = db.upsert_node( + person_label, + "cy", + props={ + "name": "Cy", + "status": "inactive", + "rank": 3, + "group": "ops", + "age": 41, + "target": "acct-c", + }, + ) + acme = db.upsert_node("PyPhase34Company", "acme", props={"name": "Acme"}) + knows_ab = db.upsert_edge(ada, ben, "PY34_KNOWS", props={"weight": 1}) + knows_bc = db.upsert_edge(ben, cy, "PY34_KNOWS", props={"weight": 1}) + works_at = db.upsert_edge(ada, acme, "PY34_WORKS_AT", props={"role": "engineer"}) + return { + "ada": ada, + "ben": ben, + "cy": cy, + "acme": acme, + "knows_ab": knows_ab, + "knows_bc": knows_bc, + "works_at": works_at, + } + + def open_vector_db(tmp_dir): return OverGraph.open(os.path.join(tmp_dir, "gql_vector_db"), dense_vector_dimension=3) @@ -179,11 +231,25 @@ def test_gql_caps_full_scan_row_ops_and_profile(db): max_param_bytes=9, max_ast_depth=4, max_literal_items=3, + max_pipeline_rows=11, + max_groups=12, + max_collect_items=13, + max_union_branches=2, + max_subquery_invocations=14, + max_subquery_depth=1, + max_shortest_path_pairs=15, ) assert capped_explain["caps"]["max_query_bytes"] == 128 assert capped_explain["caps"]["max_param_bytes"] == 9 assert capped_explain["caps"]["max_ast_depth"] == 4 assert capped_explain["caps"]["max_literal_items"] == 3 + assert capped_explain["caps"]["max_pipeline_rows"] == 11 + assert capped_explain["caps"]["max_groups"] == 12 + assert capped_explain["caps"]["max_collect_items"] == 13 + assert capped_explain["caps"]["max_union_branches"] == 2 + assert capped_explain["caps"]["max_subquery_invocations"] == 14 + assert capped_explain["caps"]["max_subquery_depth"] == 1 + assert capped_explain["caps"]["max_shortest_path_pairs"] == 15 unused_oversized = db.execute_gql( "MATCH (n:Person) RETURN id(n) LIMIT 1", @@ -260,6 +326,298 @@ async def test_gql_explain_async(async_db): assert explain["columns"] == ["n.name"] +def test_gql_phase34_with_distinct_aggregation_and_compact_rows(db): + seed_phase34(db) + + rich = db.execute_gql( + """ + MATCH (n:PyPhase34Person) + WITH n.name AS name, + lower(n.name) AS slug, + n.rank + 10 AS adjusted, + CASE WHEN n.age > 35 THEN upper(n.name) ELSE 'young' END AS bucket + WHERE slug STARTS WITH 'a' + RETURN name, slug, adjusted, bucket + """, + include_plan=True, + ) + assert rich["rows"] == [ + {"name": "Ada", "slug": "ada", "adjusted": 12, "bucket": "ADA"} + ] + assert rich["plan"]["read"]["target"] == "graph_pipeline_query" + assert any("Project(With)" in item for item in rich["plan"]["read"]["projection"]) + + distinct = db.execute_gql( + """ + MATCH (n:PyPhase34Person) + WITH DISTINCT n.group AS grp + RETURN grp + ORDER BY grp + """ + ) + assert distinct["rows"] == [{"grp": "core"}, {"grp": "ops"}] + + aggregate = db.execute_gql( + """ + MATCH (n:PyPhase34Person) + RETURN n.group AS grp, + count(*) AS count, + sum(n.rank) AS total, + avg(n.rank) AS avg, + collect(n.name) AS names + ORDER BY grp + """, + include_plan=True, + ) + assert aggregate["columns"] == ["grp", "count", "total", "avg", "names"] + assert aggregate["rows"][0]["grp"] == "core" + assert aggregate["rows"][0]["count"] == 2 + assert aggregate["rows"][0]["total"] == 3 + assert aggregate["rows"][0]["avg"] == 1.5 + assert sorted(aggregate["rows"][0]["names"]) == ["Ada", "Ben"] + assert aggregate["rows"][1] == { + "grp": "ops", + "count": 1, + "total": 3, + "avg": 3.0, + "names": ["Cy"], + } + assert any("Aggregate" in item for item in aggregate["plan"]["read"]["projection"]) + + nulls = db.execute_gql( + """ + MATCH (n:PyPhase34Person) + WHERE n.missing IS NULL + RETURN count(n.missing) AS count, + sum(n.missing) AS total, + avg(n.missing) AS avg, + collect(n.missing) AS values + """ + ) + assert nulls["rows"] == [{"count": 0, "total": None, "avg": None, "values": []}] + + compact = db.execute_gql( + """ + MATCH (n:PyPhase34Person) + WITH n.group AS grp, count(*) AS count + WHERE count > 1 + RETURN grp, count + """, + compact_rows=True, + ) + assert compact["columns"] == ["grp", "count"] + assert compact["rows"] == [["core", 2]] + + +def test_gql_phase34_union_and_read_only_subqueries(db): + seed_phase34(db) + + union_all = db.execute_gql( + """ + MATCH (n:PyPhase34Person) + WHERE n.group = 'core' + RETURN n.name AS name + ORDER BY name + UNION ALL + MATCH (n:PyPhase34Person) + WHERE n.group = 'ops' + RETURN n.name AS name + """ + ) + assert union_all["rows"] == [{"name": "Ada"}, {"name": "Ben"}, {"name": "Cy"}] + + union = db.execute_gql( + """ + MATCH (n:PyPhase34Person) + WHERE n.group = 'core' + RETURN n.group AS grp + UNION + MATCH (n:PyPhase34Person) + RETURN n.group AS grp + ORDER BY grp + """, + include_plan=True, + ) + assert union["rows"] == [{"grp": "core"}, {"grp": "ops"}] + assert any("Union" in item for item in union["plan"]["read"]["projection"]) + + subquery = db.execute_gql( + """ + MATCH (n:PyPhase34Person) + WHERE EXISTS { + MATCH (n)-[:PY34_WORKS_AT]->(c:PyPhase34Company) + RETURN c + } + WITH n + CALL { + MATCH (n)-[:PY34_WORKS_AT]->(c:PyPhase34Company) + RETURN c.name AS company + } + RETURN n.name AS name, company + """, + include_plan=True, + ) + assert subquery["rows"] == [{"name": "Ada", "company": "Acme"}] + assert any("EXISTS subquery" in note for note in subquery["plan"]["notes"]) + assert any("CallSubquery" in item for item in subquery["plan"]["read"]["projection"]) + + +def test_gql_phase34_shortest_path_and_nested_value_conversion(db): + ids = seed_phase34(db) + + path_result = db.execute_gql( + f""" + MATCH (a:PyPhase34Person) + WHERE id(a) = {ids["ada"]} + WITH a + MATCH (b:PyPhase34Person) + WHERE id(b) = {ids["cy"]} + WITH a, b + MATCH p = shortestPath((a)-[:PY34_KNOWS*1..3]->(b)) + RETURN p, + node_ids(p) AS node_ids, + edge_ids(p) AS edge_ids, + length(p) AS length, + nodes(p) AS nodes, + relationships(p) AS relationships, + [p] AS path_list, + {{path: p, nested: [node_ids(p), {{edges: edge_ids(p)}}]}} AS wrapped + """, + include_plan=True, + ) + row = path_result["rows"][0] + assert row["p"]["node_ids"] == [ids["ada"], ids["ben"], ids["cy"]] + assert row["p"]["edge_ids"] == [ids["knows_ab"], ids["knows_bc"]] + assert row["p"]["nodes"][0]["props"]["name"] == "Ada" + assert row["p"]["edges"][0]["label"] == "PY34_KNOWS" + assert row["node_ids"] == [ids["ada"], ids["ben"], ids["cy"]] + assert row["edge_ids"] == [ids["knows_ab"], ids["knows_bc"]] + assert row["length"] == 2 + assert row["nodes"] == [ids["ada"], ids["ben"], ids["cy"]] + assert row["relationships"] == [ids["knows_ab"], ids["knows_bc"]] + assert row["path_list"][0]["node_ids"] == [ids["ada"], ids["ben"], ids["cy"]] + assert row["wrapped"]["path"]["edge_ids"] == [ids["knows_ab"], ids["knows_bc"]] + assert row["wrapped"]["nested"][1]["edges"] == [ids["knows_ab"], ids["knows_bc"]] + assert any("ShortestPath" in item for item in path_result["plan"]["read"]["projection"]) + + collected = db.execute_gql("MATCH (n:PyPhase34Person) RETURN collect(n) AS people") + assert sorted(collected["rows"][0]["people"]) == sorted([ids["ada"], ids["ben"], ids["cy"]]) + + +def test_gql_phase34_keyed_merge_on_create_on_match_stats(db): + db.upsert_node("PyPhase34MergeSource", "source", props={"target": "acct-a"}) + + query = """ + MATCH (s:PyPhase34MergeSource) + WITH s.target AS target + MERGE (a:PyPhase34Account {key: target}) + ON CREATE SET a.status = 'created', a.count = 1 + ON MATCH SET a.status = 'matched', a.count = coalesce(a.count, 0) + 1 + RETURN a.key AS key, a.status AS status, a.count AS count + """ + created = db.execute_gql(query, include_plan=True) + assert created["kind"] == "mutation" + assert created["rows"] == [{"key": "acct-a", "status": "created", "count": 1}] + assert created["mutation_stats"]["nodes_created"] == 1 + assert created["mutation_stats"]["nodes_updated"] == 0 + assert created["mutation_stats"]["mutation_rows"] == 1 + assert created["plan"]["mutation"]["uses_write_txn"] is True + assert any(op["op"] == "MERGE NODE" for op in created["plan"]["mutation"]["operations"]) + + matched = db.execute_gql(query) + assert matched["rows"] == [{"key": "acct-a", "status": "matched", "count": 2}] + assert matched["mutation_stats"]["nodes_created"] == 0 + assert matched["mutation_stats"]["nodes_updated"] == 1 + assert matched["mutation_stats"]["properties_set"] == 2 + + +def test_gql_phase34_cap_forwarding_and_explain_fields(db): + seed_phase34(db, person_label="PyPhase34CapPerson") + explain = db.explain_gql( + """ + MATCH (n:PyPhase34CapPerson) + WITH n.group AS grp, count(*) AS count + WHERE count > 1 + RETURN grp, count + """, + max_pipeline_rows=7, + max_groups=8, + max_collect_items=9, + max_union_branches=10, + max_subquery_invocations=11, + max_subquery_depth=1, + max_shortest_path_pairs=12, + ) + assert explain["read"]["target"] == "graph_pipeline_query" + assert explain["caps"]["max_pipeline_rows"] == 7 + assert explain["caps"]["max_groups"] == 8 + assert explain["caps"]["max_collect_items"] == 9 + assert explain["caps"]["max_union_branches"] == 10 + assert explain["caps"]["max_subquery_invocations"] == 11 + assert explain["caps"]["max_subquery_depth"] == 1 + assert explain["caps"]["max_shortest_path_pairs"] == 12 + assert any("Aggregate" in item for item in explain["read"]["projection"]) + + with pytest.raises(Exception, match="cap 1|max_intermediate_bindings|max_pipeline"): + db.execute_gql( + "MATCH (n:PyPhase34CapPerson) WITH n RETURN n", + max_pipeline_rows=1, + ) + with pytest.raises(Exception, match="max_groups"): + db.execute_gql( + "MATCH (n:PyPhase34CapPerson) RETURN n.group AS grp, count(*) AS count", + max_groups=1, + ) + with pytest.raises(Exception, match="max_collect_items"): + db.execute_gql( + "MATCH (n:PyPhase34CapPerson) RETURN collect(n.name) AS names", + max_collect_items=1, + ) + with pytest.raises(Exception, match="max_union_branches"): + db.execute_gql( + """ + MATCH (n:PyPhase34CapPerson) RETURN n.name AS name + UNION ALL + MATCH (n:PyPhase34CapPerson) RETURN n.name AS name + """, + max_union_branches=1, + ) + with pytest.raises(Exception, match="max_subquery_invocations"): + db.execute_gql( + """ + MATCH (n:PyPhase34CapPerson) + WHERE EXISTS { + MATCH (m:PyPhase34CapPerson) + WHERE m.group = n.group + RETURN m + } + RETURN n.name AS name + """, + max_subquery_invocations=1, + ) + with pytest.raises(Exception, match="max_subquery_depth"): + db.execute_gql( + """ + MATCH (n:PyPhase34CapPerson) + WHERE EXISTS { MATCH (m:PyPhase34CapPerson) RETURN m } + RETURN n.name AS name + """, + max_subquery_depth=0, + ) + with pytest.raises(Exception, match="max_shortest_path_pairs"): + db.execute_gql( + f""" + MATCH (a:PyPhase34CapPerson) + WITH a + MATCH (b:PyPhase34CapPerson) + WITH a, b + MATCH p = shortestPath((a)-[:PY34_KNOWS*1..3]->(b)) + RETURN p + """, + max_shortest_path_pairs=1, + ) + + def test_gql_sync_create_return_mutation_stats_bytes_and_plan(db): result = db.execute_gql( """ @@ -585,10 +943,22 @@ def test_gql_stub_and_signature_smoke(): assert "cursor: str | None" in text assert "max_cursor_bytes: int | None" in text assert "max_mutation_rows: int | None" in text + assert "max_pipeline_rows: int | None" in text + assert "max_groups: int | None" in text + assert "max_collect_items: int | None" in text + assert "max_union_branches: int | None" in text + assert "max_subquery_invocations: int | None" in text + assert "max_subquery_depth: int | None" in text + assert "max_shortest_path_pairs: int | None" in text assert "class GqlExecutionResult" in text assert "class GqlExecutionExplain" in text assert "class GqlMutationStats" in text assert "async def execute_gql" in text + assert "def query_graph_pipeline" in text + assert "def explain_graph_pipeline" in text + assert "async def query_graph_pipeline" in text + assert hasattr(OverGraph, "query_graph_pipeline") + assert hasattr(OverGraph, "explain_graph_pipeline") assert "gql_query" not in text assert "explain_gql_query" not in text assert "class GqlResult" not in text diff --git a/overgraph-python/tests/test_graph_rows.py b/overgraph-python/tests/test_graph_rows.py index 2911a5e..2607d3f 100644 --- a/overgraph-python/tests/test_graph_rows.py +++ b/overgraph-python/tests/test_graph_rows.py @@ -35,6 +35,27 @@ def seed_graph(db, include_vectors=False): } +async def seed_graph_async(db): + ada = await db.upsert_node("Person", "ada", props={"name": "Ada", "rank": 2, "status": "active"}) + ben = await db.upsert_node("Person", "ben", props={"name": "Ben", "rank": 1, "status": "active"}) + cy = await db.upsert_node("Person", "cy", props={"name": "Cy", "rank": 3, "status": "inactive"}) + acme = await db.upsert_node("Company", "acme", props={"name": "Acme"}) + works = await db.upsert_edge(ada, acme, "WORKS_AT", props={"role": "engineer"}) + reports = await db.upsert_edge(ada, ben, "REPORTS_TO") + knows_ab = await db.upsert_edge(ada, ben, "KNOWS") + knows_bc = await db.upsert_edge(ben, cy, "KNOWS") + return { + "ada": ada, + "ben": ben, + "cy": cy, + "acme": acme, + "works": works, + "reports": reports, + "knows_ab": knows_ab, + "knows_bc": knows_bc, + } + + def fixed_request(ids): return { "nodes": [ @@ -392,6 +413,102 @@ def test_query_graph_rows_explain_params_and_expression_tags(tmp_dir): db.close(force=True) +@pytest.mark.asyncio +async def test_query_graph_pipeline_sync_and_async_connector_boundary(tmp_dir): + db = OverGraph.open(os.path.join(tmp_dir, "graph_pipeline")) + try: + seed_graph(db) + pipeline = { + "stages": [ + { + "kind": "match", + "nodes": [{"alias": "n", "label_filter": lf("Person")}], + }, + { + "kind": "project", + "project_kind": "with", + "items": [ + {"expr": {"property": {"alias": "n", "key": "name"}}, "as": "name"}, + {"expr": {"property": {"alias": "n", "key": "rank"}}, "as": "rank"}, + {"expr": {"property": {"alias": "n", "key": "status"}}, "as": "status"}, + ], + "where": {"op": "=", "left": {"binding": "status"}, "right": "active"}, + "order_by": [{"expr": {"binding": "rank"}, "direction": "desc"}], + "limit": 3, + }, + { + "kind": "project", + "project_kind": "return", + "items": [ + {"expr": {"binding": "name"}, "as": "name"}, + {"expr": {"op": "+", "left": {"binding": "rank"}, "right": 10}, "as": "score"}, + ], + "order_by": [{"expr": {"binding": "score"}, "direction": "desc"}], + }, + ], + "limit": 10, + "options": {"include_plan": True, "profile": True}, + } + + result = db.query_graph_pipeline(pipeline) + assert result["columns"] == ["name", "score"] + assert result["rows"] == [{"name": "Ada", "score": 12}, {"name": "Ben", "score": 11}] + assert result["next_cursor"] is None + assert result["stats"]["rows_returned"] == 2 + assert len(result["plan"]["stages"]) == 3 + assert result["plan"]["caps"]["max_pipeline_rows"] == 65536 + + async_db = await AsyncOverGraph.open(os.path.join(tmp_dir, "graph_pipeline_async")) + try: + await seed_graph_async(async_db) + compact = await async_db.query_graph_pipeline( + {**pipeline, "output": {"compact_rows": True}} + ) + assert compact["rows"] == [["Ada", 12], ["Ben", 11]] + explain = await async_db.explain_graph_pipeline(pipeline) + assert explain["columns"] == ["name", "score"] + assert len(explain["stages"]) == 3 + finally: + await async_db.close(force=True) + finally: + db.close(force=True) + + +def test_query_graph_pipeline_aggregate_projection(db): + seed_graph(db) + result = db.query_graph_pipeline( + { + "stages": [ + { + "kind": "match", + "nodes": [{"alias": "n", "label_filter": lf("Person")}], + }, + { + "kind": "return", + "items": [ + {"expr": {"aggregate": {"function": "count"}}, "as": "people"}, + { + "expr": { + "aggregate": { + "function": "collect", + "arg": {"property": {"alias": "n", "key": "status"}}, + "distinct": True, + } + }, + "as": "statuses", + }, + ], + }, + ], + "limit": 10, + "options": {"include_plan": True}, + } + ) + assert result["rows"][0]["people"] == 3 + assert set(result["rows"][0]["statuses"]) == {"active", "inactive"} + assert result["plan"]["stats"]["groups"] == 1 + + def test_old_pattern_surface_is_explicitly_unsupported(db): seed_graph(db) with pytest.raises(Exception, match="unsupported; use query_graph_rows"): diff --git a/src/engine/graph_ops.rs b/src/engine/graph_ops.rs index dee981b..85a5ef3 100644 --- a/src/engine/graph_ops.rs +++ b/src/engine/graph_ops.rs @@ -5771,7 +5771,7 @@ impl ReadView { // Extract the top-k indices from the heap let mut top_indices: Vec<(u64, usize)> = heap.into_iter().map(|Reverse(x)| x).collect(); // Sort descending by score - top_indices.sort_by(|a, b| b.0.cmp(&a.0)); + top_indices.sort_by_key(|entry| std::cmp::Reverse(entry.0)); let results: Vec = top_indices .into_iter() diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 7b699ab..c397c28 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -11526,6 +11526,8 @@ include!("query_ir.rs"); include!("query_plan.rs"); include!("projection.rs"); include!("query_exec.rs"); +include!("pipeline_ir.rs"); +include!("pipeline_exec.rs"); include!("query.rs"); #[cfg(test)] diff --git a/src/engine/pipeline_exec.rs b/src/engine/pipeline_exec.rs new file mode 100644 index 0000000..45739e6 --- /dev/null +++ b/src/engine/pipeline_exec.rs @@ -0,0 +1,4746 @@ +#[derive(Clone, Debug)] +struct GraphPipelineCursorPayload { + effective_at_epoch: i64, + original_skip: u64, + rows_emitted_after_skip: u64, + query_fingerprint: u128, + order_fingerprint: u128, + output_fingerprint: u128, + params_fingerprint: u128, + last_sort_key: Vec, + last_logical_row_key: Vec, +} + +#[derive(Clone, Debug)] +struct GraphPipelineCursorState { + decoded: Option, + effective_at_epoch: i64, + original_skip: u64, + rows_emitted_after_skip: u64, +} + +impl GraphPipelineCursorState { + fn is_cursor_page(&self) -> bool { + self.decoded.is_some() + } +} + +#[derive(Debug)] +struct GraphRowStageExecution { + rows: Vec, + followups: Vec, + explain: Option, + warnings: Vec, + rows_after_filter: usize, + intermediate_peak: usize, +} + +#[derive(Debug)] +struct PipelineExistsExecution { + exists: bool, + followups: Vec, + stats: GraphPipelineStats, +} + +#[derive(Debug)] +struct PipelineProjectStageExecution { + rows: Vec, + followups: Vec, + groups: usize, + collect_items: usize, + aggregate_distinct_keys: usize, + subquery_invocations: usize, + subquery_cache_hits: usize, + nested_stats: GraphPipelineStats, +} + +#[derive(Debug)] +struct PipelineOptionalCandidateFilterExecution { + rows: Vec, + followups: Vec, + subquery_invocations: usize, + subquery_cache_hits: usize, + nested_stats: GraphPipelineStats, + input_rows: usize, + candidate_rows: usize, + passed_rows: usize, + preserved_miss_rows: usize, + synthesized_miss_rows: usize, +} + +#[derive(Debug)] +struct PipelineShortestPathStageExecution { + rows: Vec, + pair_count: usize, + cache_hits: usize, + no_path_count: usize, + emitted_path_count: usize, +} + +#[derive(Debug)] +struct PipelineCallStageExecution { + rows: Vec, + followups: Vec, + subquery_invocations: usize, + subquery_cache_hits: usize, + nested_stats: GraphPipelineStats, +} + +struct PipelineExistsProbePlan<'a> { + match_stage: Option<&'a NormalizedPipelineMatchStage>, + always_false: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PipelineSubqueryRowMode { + Exists, + Call, +} + +#[derive(Clone, Debug)] +struct PipelineCallRepresentative { + index: usize, + outer_count: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct ShortestPathPairKey { + from_id: u64, + to_id: u64, + direction: u8, + edge_label_filter: Vec, + min_hops: u8, + max_hops: u8, + weight_field: Option, + max_cost_bits: Option, + max_paths: Option, +} + +#[derive(Clone, Debug)] +enum ResolvedShortestPathEndpoint { + Alias { + slot: crate::graph_row::GraphBindingSlotRef, + }, + Static(Option), +} + +#[derive(Debug)] +struct PipelineStagesExecution { + rows: Vec, + row_projections: Option>>, + followups: Vec, + stage_explains: Vec, + warnings: Vec, + stats: GraphPipelineStats, +} + +#[derive(Debug)] +struct PipelineUnionStageExecution { + rows: Vec, + row_projections: Vec>, + followups: Vec, + stage_explains: Vec, + warnings: Vec, + stats: GraphPipelineStats, +} + +#[derive(Clone, Debug)] +struct PipelineUnionBranchExplainSummary { + branch_index: usize, + stages: Vec, + row_ops: Vec, + warnings: Vec, +} + +#[derive(Debug)] +struct PipelineSubqueryEvalStats { + invocations: usize, + cache_hits: usize, + followups: Vec, + nested_stats: GraphPipelineStats, +} + +#[derive(Debug)] +struct SubqueryInvocationBudget { + max: usize, + used: usize, +} + +impl SubqueryInvocationBudget { + fn new(max: usize) -> Self { + Self { max, used: 0 } + } + + fn reserve(&mut self, operator: &str) -> Result<(), EngineError> { + if self.used >= self.max { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline {operator} exceeded max_subquery_invocations {}", + self.max + ))); + } + self.used = self.used.saturating_add(1); + Ok(()) + } +} + +impl Default for PipelineSubqueryEvalStats { + fn default() -> Self { + Self { + invocations: 0, + cache_hits: 0, + followups: Vec::new(), + nested_stats: empty_graph_pipeline_stats(0), + } + } +} + +#[derive(Clone, Debug)] +struct PipelineFinalRow { + row: crate::graph_row::GraphBindingRow, + sort_key: Vec, + logical_key: Vec, + projections: Option>, +} + +impl GraphPipelineStats { + fn merge_from(&mut self, other: &GraphPipelineStats) { + // rows_after_filter belongs to the execution context that owns this stats value. + // Nested pipeline stats contribute counters and peaks, but must not redefine the + // parent pipeline's final row count. + self.intermediate_rows = self.intermediate_rows.max(other.intermediate_rows); + self.pipeline_rows_materialized = self + .pipeline_rows_materialized + .max(other.pipeline_rows_materialized); + self.groups = self.groups.saturating_add(other.groups); + self.collect_items = self.collect_items.saturating_add(other.collect_items); + self.union_branches = self.union_branches.saturating_add(other.union_branches); + self.union_dedup_keys = self + .union_dedup_keys + .saturating_add(other.union_dedup_keys); + self.subquery_invocations = self + .subquery_invocations + .saturating_add(other.subquery_invocations); + self.subquery_cache_hits = self + .subquery_cache_hits + .saturating_add(other.subquery_cache_hits); + self.shortest_path_pairs = self + .shortest_path_pairs + .saturating_add(other.shortest_path_pairs); + self.shortest_path_cache_hits = self + .shortest_path_cache_hits + .saturating_add(other.shortest_path_cache_hits); + self.db_hits = self.db_hits.saturating_add(other.db_hits); + self.warnings.extend(other.warnings.iter().cloned()); + } +} + +fn empty_graph_pipeline_stats(effective_at_epoch: i64) -> GraphPipelineStats { + GraphPipelineStats { + rows_returned: 0, + rows_entered_pipeline: 0, + rows_after_filter: 0, + intermediate_rows: 0, + pipeline_rows_materialized: 0, + groups: 0, + collect_items: 0, + union_branches: 0, + union_dedup_keys: 0, + subquery_invocations: 0, + subquery_cache_hits: 0, + shortest_path_pairs: 0, + shortest_path_cache_hits: 0, + db_hits: 0, + elapsed_us: None, + effective_at_epoch, + warnings: Vec::new(), + } +} + +impl ReadView { + fn explain_graph_pipeline_normalized( + &self, + pipeline: &NormalizedGraphPipeline, + cursor_state: GraphPipelineCursorState, + ) -> Result { + let fingerprints = graph_pipeline_cursor_fingerprints( + pipeline, + cursor_state.effective_at_epoch, + cursor_state.original_skip, + ); + if let Some(cursor) = cursor_state.decoded.as_ref() { + graph_pipeline_validate_cursor_fingerprints(cursor, &fingerprints)?; + graph_pipeline_validate_cursor_shape(pipeline, cursor)?; + } + + let mut warnings = Vec::new(); + let mut stage_explains = Vec::with_capacity(pipeline.stages.len()); + for (stage_index, stage) in pipeline.stages.iter().enumerate() { + match stage { + NormalizedGraphPipelineStage::Match(stage) => { + let graph_row_cursor_state = GraphRowCursorState { + decoded: None, + effective_at_epoch: cursor_state.effective_at_epoch, + original_skip: 0, + rows_emitted_after_skip: 0, + }; + let graph_row = self.explain_graph_rows_normalized( + &stage.query, + graph_row_cursor_state, + )?; + warnings.extend(graph_row.warnings.clone()); + stage_explains.push(GraphPipelineStageExplain { + index: stage_index, + kind: if stage.optional { + "OptionalMatch".to_string() + } else { + "Match".to_string() + }, + detail: pipeline_match_stage_detail(stage, None), + columns: pipeline_schema_columns(&stage.output_schema), + warnings: graph_row.warnings.clone(), + notes: pipeline_match_stage_notes(stage, None), + graph_row: Some(Box::new(graph_row)), + }); + } + NormalizedGraphPipelineStage::ShortestPath(stage) => { + stage_explains.push(GraphPipelineStageExplain { + index: stage_index, + kind: if stage.optional { + "OptionalShortestPath".to_string() + } else { + "ShortestPath".to_string() + }, + detail: pipeline_shortest_path_stage_detail( + stage, + &pipeline.options, + None, + ), + columns: pipeline_schema_columns(&stage.output_schema), + graph_row: None, + warnings: Vec::new(), + notes: vec![ + "shortest-path stage uses native graph algorithms".to_string(), + ], + }); + } + NormalizedGraphPipelineStage::Project(stage) => { + stage_explains.push(GraphPipelineStageExplain { + index: stage_index, + kind: match stage.kind { + GraphProjectKind::With => "Project(With)".to_string(), + GraphProjectKind::Return => "Project(Return)".to_string(), + }, + detail: pipeline_project_stage_detail(stage, None, None, None, None), + columns: stage.columns.clone(), + graph_row: None, + warnings: Vec::new(), + notes: pipeline_project_stage_notes(stage), + }); + } + NormalizedGraphPipelineStage::Call(stage) => { + let nested = self.explain_graph_pipeline_normalized( + &stage.query, + GraphPipelineCursorState { + decoded: None, + effective_at_epoch: cursor_state.effective_at_epoch, + original_skip: 0, + rows_emitted_after_skip: 0, + }, + )?; + warnings.extend(nested.warnings.clone()); + stage_explains.push(GraphPipelineStageExplain { + index: stage_index, + kind: "Call".to_string(), + detail: pipeline_call_stage_detail(stage, None, None, None), + columns: pipeline_schema_columns(&stage.output_schema), + graph_row: None, + warnings: nested.warnings.clone(), + notes: pipeline_call_stage_notes(stage, Some(&nested)), + }); + } + NormalizedGraphPipelineStage::Union(stage) => { + let mut branch_summaries = Vec::with_capacity(stage.branches.len()); + let mut stage_warnings = Vec::new(); + for (branch_index, branch) in stage.branches.iter().enumerate() { + let branch_explain = self.explain_graph_pipeline_normalized( + &branch.pipeline, + GraphPipelineCursorState { + decoded: None, + effective_at_epoch: cursor_state.effective_at_epoch, + original_skip: 0, + rows_emitted_after_skip: 0, + }, + )?; + stage_warnings.extend(branch_explain.warnings.clone()); + branch_summaries.push(PipelineUnionBranchExplainSummary { + branch_index, + stages: branch_explain.stages, + row_ops: branch_explain.row_ops, + warnings: branch_explain.warnings, + }); + } + warnings.extend(stage_warnings.clone()); + stage_explains.push(GraphPipelineStageExplain { + index: stage_index, + kind: if stage.all { + "UnionAll".to_string() + } else { + "Union".to_string() + }, + detail: pipeline_union_stage_detail(stage, None, None), + columns: stage.columns.clone(), + graph_row: None, + warnings: stage_warnings, + notes: pipeline_union_stage_notes(stage, &branch_summaries), + }); + } + } + } + let stats = GraphPipelineStats { + rows_returned: 0, + rows_entered_pipeline: 1, + rows_after_filter: 0, + intermediate_rows: 0, + pipeline_rows_materialized: 0, + groups: 0, + collect_items: 0, + union_branches: pipeline_union_branch_count(pipeline), + union_dedup_keys: 0, + subquery_invocations: 0, + subquery_cache_hits: 0, + shortest_path_pairs: 0, + shortest_path_cache_hits: 0, + db_hits: 0, + elapsed_us: None, + effective_at_epoch: cursor_state.effective_at_epoch, + warnings: warnings.clone(), + }; + Ok(graph_pipeline_explain_from_normalized( + pipeline, + stage_explains, + stats, + fingerprints, + warnings, + )) + } + + fn query_graph_pipeline_normalized( + &self, + pipeline: &NormalizedGraphPipeline, + cursor_state: GraphPipelineCursorState, + ) -> Result, EngineError> { + let started_at = std::time::Instant::now(); + let mut followups = Vec::new(); + let mut stage_explains = Vec::new(); + let mut warnings = Vec::new(); + let fingerprints = graph_pipeline_cursor_fingerprints( + pipeline, + cursor_state.effective_at_epoch, + cursor_state.original_skip, + ); + if let Some(cursor) = cursor_state.decoded.as_ref() { + graph_pipeline_validate_cursor_fingerprints(cursor, &fingerprints)?; + graph_pipeline_validate_cursor_shape(pipeline, cursor)?; + } + let mut execution = self.execute_graph_pipeline_stages( + pipeline, + cursor_state.effective_at_epoch, + pipeline.options.include_plan, + )?; + let row_projections = execution.row_projections.take(); + followups.append(&mut execution.followups); + stage_explains.append(&mut execution.stage_explains); + warnings.append(&mut execution.warnings); + let mut stats = execution.stats; + let rows = execution.rows; + + let mut final_rows = self.pipeline_prepare_final_rows( + rows, + pipeline, + cursor_state.decoded.as_ref(), + &fingerprints, + row_projections, + )?; + stats.pipeline_rows_materialized = stats.pipeline_rows_materialized.max(final_rows.len()); + let page_start = if cursor_state.is_cursor_page() { + 0 + } else { + cursor_state.original_skip as usize + }; + if page_start > pipeline.options.max_skip { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline page skip {page_start} exceeds max_skip {}", + pipeline.options.max_skip + ))); + } + let total_after_cursor = final_rows.len(); + let skipped = page_start.min(final_rows.len()); + final_rows.drain(0..skipped); + let limit = pipeline.page.limit.min(pipeline.options.max_rows); + let has_more = final_rows.len() > limit; + if has_more { + final_rows.truncate(limit); + } + + let graph_rows = if final_rows.iter().any(|row| row.projections.is_some()) { + self.pipeline_project_output_rows_with_row_projections( + &final_rows, + &pipeline.terminal_return_items, + &pipeline.output, + )? + } else { + let mut output_rows = final_rows + .iter() + .map(|candidate| candidate.row.clone()) + .collect::>(); + self.hydrate_graph_rows_for_needs( + &mut output_rows, + &pipeline.terminal_schema, + &pipeline.terminal_output_needs, + )?; + self.pipeline_project_output_rows( + &output_rows, + &pipeline.terminal_return_items, + &pipeline.output, + )? + }; + let next_cursor = if has_more { + let last = final_rows.last().ok_or_else(|| { + EngineError::InvalidOperation( + "graph pipeline cannot emit a cursor without a final row".to_string(), + ) + })?; + let payload = GraphPipelineCursorPayload { + effective_at_epoch: cursor_state.effective_at_epoch, + original_skip: cursor_state.original_skip, + rows_emitted_after_skip: cursor_state + .rows_emitted_after_skip + .saturating_add(graph_rows.len() as u64), + query_fingerprint: fingerprints.query, + order_fingerprint: fingerprints.order, + output_fingerprint: fingerprints.output, + params_fingerprint: fingerprints.params, + last_sort_key: last.sort_key.clone(), + last_logical_row_key: last.logical_key.clone(), + }; + Some(graph_pipeline_encode_logical_cursor( + &payload, + pipeline.options.max_cursor_bytes, + )?) + } else { + None + }; + + stats.rows_returned = graph_rows.len(); + stats.rows_after_filter = total_after_cursor; + stats.elapsed_us = if pipeline.options.profile { + started_at.elapsed().as_micros().try_into().ok() + } else { + None + }; + stats.warnings = warnings.clone(); + let plan = pipeline.options.include_plan.then(|| { + graph_pipeline_explain_from_normalized( + pipeline, + stage_explains, + stats.clone(), + fingerprints, + warnings.clone(), + ) + }); + Ok(QueryExecutionOutcome { + value: GraphPipelineResult { + columns: pipeline.columns.clone(), + rows: graph_rows, + next_cursor, + stats, + plan, + }, + followups, + }) + } + + fn execute_graph_pipeline_stages( + &self, + pipeline: &NormalizedGraphPipeline, + effective_at_epoch: i64, + include_plan: bool, + ) -> Result { + let rows = vec![pipeline.initial_schema.empty_row()]; + self.execute_graph_pipeline_stages_with_rows( + pipeline, + effective_at_epoch, + include_plan, + rows, + ) + } + + fn execute_graph_pipeline_stages_with_rows( + &self, + pipeline: &NormalizedGraphPipeline, + effective_at_epoch: i64, + include_plan: bool, + initial_rows: Vec, + ) -> Result { + let mut subquery_budget = + SubqueryInvocationBudget::new(pipeline.options.max_subquery_invocations); + self.execute_graph_pipeline_stages_with_rows_budget( + pipeline, + effective_at_epoch, + include_plan, + initial_rows, + &mut subquery_budget, + ) + } + + fn execute_graph_pipeline_stages_with_rows_budget( + &self, + pipeline: &NormalizedGraphPipeline, + effective_at_epoch: i64, + include_plan: bool, + initial_rows: Vec, + subquery_budget: &mut SubqueryInvocationBudget, + ) -> Result { + let mut followups = Vec::new(); + let mut stage_explains = Vec::new(); + let mut warnings = Vec::new(); + let initial_row_count = initial_rows.len(); + let mut stats = GraphPipelineStats { + rows_returned: 0, + rows_entered_pipeline: initial_row_count, + rows_after_filter: initial_row_count, + intermediate_rows: initial_row_count, + pipeline_rows_materialized: initial_row_count, + groups: 0, + collect_items: 0, + union_branches: 0, + union_dedup_keys: 0, + subquery_invocations: 0, + subquery_cache_hits: 0, + shortest_path_pairs: 0, + shortest_path_cache_hits: 0, + db_hits: 0, + elapsed_us: None, + effective_at_epoch, + warnings: Vec::new(), + }; + + let mut current_schema = pipeline.initial_schema.clone(); + let mut rows = initial_rows; + let mut row_projections = None; + for (stage_index, stage) in pipeline.stages.iter().enumerate() { + match stage { + NormalizedGraphPipelineStage::Match(stage) => { + row_projections = None; + let bridged_initial_rows = pipeline_bridge_rows( + &rows, + ¤t_schema, + &stage.query.binding_schema, + &stage.input_mappings, + )?; + let optional_left_rows = if current_schema.slots().is_empty() { + vec![stage.query.binding_schema.empty_row()] + } else { + bridged_initial_rows.clone() + }; + let initial_rows = if current_schema.slots().is_empty() { + None + } else { + Some(bridged_initial_rows) + }; + let execution = self.execute_graph_row_stage( + &stage.query, + initial_rows, + effective_at_epoch, + include_plan, + stage.optional, + )?; + followups.extend(execution.followups); + stats.intermediate_rows = + stats.intermediate_rows.max(execution.intermediate_peak); + stats.rows_after_filter = execution.rows_after_filter; + stats.db_hits = stats.db_hits.saturating_add(if pipeline.options.profile { + execution.rows_after_filter + } else { + 0 + }); + let stage_warnings = execution.warnings.clone(); + warnings.extend(stage_warnings.clone()); + let mut graph_rows = execution.rows; + let optional_filter_execution = if stage.optional_candidate_filter.is_some() { + let mut filter_execution = self.apply_pipeline_optional_candidate_filter( + stage, + &optional_left_rows, + graph_rows, + effective_at_epoch, + subquery_budget, + )?; + followups.append(&mut filter_execution.followups); + warnings.extend(filter_execution.nested_stats.warnings.iter().cloned()); + stats.merge_from(&filter_execution.nested_stats); + stats.subquery_invocations = stats + .subquery_invocations + .saturating_add(filter_execution.subquery_invocations); + stats.subquery_cache_hits = stats + .subquery_cache_hits + .saturating_add(filter_execution.subquery_cache_hits); + graph_rows = std::mem::take(&mut filter_execution.rows); + Some(filter_execution) + } else { + None + }; + rows = pipeline_attach_cursor_keys( + graph_rows, + &stage.query.binding_schema, + &stage.output_schema, + &stage.output_mappings, + stage.cursor_slot, + )?; + current_schema = stage.output_schema.clone(); + pipeline_enforce_intermediate_rows( + rows.len(), + &pipeline.options, + "max_pipeline_rows", + )?; + if include_plan { + stage_explains.push(GraphPipelineStageExplain { + index: stage_index, + kind: if stage.optional { + "OptionalMatch".to_string() + } else { + "Match".to_string() + }, + detail: pipeline_match_stage_detail(stage, Some(rows.len())), + columns: pipeline_schema_columns(¤t_schema), + graph_row: execution.explain.map(Box::new), + warnings: stage_warnings, + notes: pipeline_match_stage_notes( + stage, + optional_filter_execution.as_ref(), + ), + }); + } + } + NormalizedGraphPipelineStage::ShortestPath(stage) => { + row_projections = None; + let before = rows.len(); + let execution = self.execute_pipeline_shortest_path_stage( + stage, + rows, + effective_at_epoch, + &pipeline.options, + )?; + let stage_detail = if include_plan { + Some(pipeline_shortest_path_stage_detail( + stage, + &pipeline.options, + Some(&execution), + )) + } else { + None + }; + rows = execution.rows; + current_schema = stage.output_schema.clone(); + stats.shortest_path_pairs = stats + .shortest_path_pairs + .saturating_add(execution.pair_count); + stats.shortest_path_cache_hits = stats + .shortest_path_cache_hits + .saturating_add(execution.cache_hits); + stats.rows_after_filter = rows.len(); + stats.intermediate_rows = stats.intermediate_rows.max(rows.len()); + stats.pipeline_rows_materialized = + stats.pipeline_rows_materialized.max(rows.len()); + pipeline_enforce_intermediate_rows( + rows.len(), + &pipeline.options, + "max_pipeline_rows", + )?; + if include_plan { + stage_explains.push(GraphPipelineStageExplain { + index: stage_index, + kind: if stage.optional { + "OptionalShortestPath".to_string() + } else { + "ShortestPath".to_string() + }, + detail: stage_detail.expect("shortest-path detail prepared"), + columns: pipeline_schema_columns(¤t_schema), + graph_row: None, + warnings: Vec::new(), + notes: vec![format!( + "native shortest-path stage consumed {before} row(s)" + )], + }); + } + } + NormalizedGraphPipelineStage::Project(stage) => { + row_projections = None; + let before = rows.len(); + let execution = self.execute_pipeline_project_stage( + stage, + rows, + effective_at_epoch, + &pipeline.options, + subquery_budget, + )?; + let aggregate_distinct_keys = execution.aggregate_distinct_keys; + rows = execution.rows; + stats.groups = stats.groups.saturating_add(execution.groups); + stats.collect_items = + stats.collect_items.saturating_add(execution.collect_items); + stats.subquery_invocations = stats + .subquery_invocations + .saturating_add(execution.subquery_invocations); + stats.subquery_cache_hits = stats + .subquery_cache_hits + .saturating_add(execution.subquery_cache_hits); + followups.extend(execution.followups); + warnings.extend(execution.nested_stats.warnings.iter().cloned()); + stats.merge_from(&execution.nested_stats); + if stats.subquery_invocations > pipeline.options.max_subquery_invocations { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline exceeded max_subquery_invocations {}", + pipeline.options.max_subquery_invocations + ))); + } + current_schema = stage.output_schema.clone(); + stats.rows_after_filter = rows.len(); + stats.intermediate_rows = stats.intermediate_rows.max(rows.len()); + stats.pipeline_rows_materialized = + stats.pipeline_rows_materialized.max(rows.len()); + // Project(Return) is capped during final cursor/page emission by max_rows. + // max_pipeline_rows is the hard cap for intermediate pipeline stages. + if stage.kind != GraphProjectKind::Return { + pipeline_enforce_intermediate_rows( + rows.len(), + &pipeline.options, + "max_pipeline_rows", + )?; + } + if include_plan { + stage_explains.push(GraphPipelineStageExplain { + index: stage_index, + kind: match stage.kind { + GraphProjectKind::With => "Project(With)".to_string(), + GraphProjectKind::Return => "Project(Return)".to_string(), + }, + detail: pipeline_project_stage_detail( + stage, + Some(before), + Some(rows.len()), + Some(aggregate_distinct_keys), + Some(( + execution.subquery_invocations, + execution.subquery_cache_hits, + )), + ), + columns: stage.columns.clone(), + graph_row: None, + warnings: Vec::new(), + notes: pipeline_project_stage_notes(stage), + }); + } + } + NormalizedGraphPipelineStage::Call(stage) => { + row_projections = None; + let before = rows.len(); + let execution = self.execute_pipeline_call_stage( + stage, + rows, + effective_at_epoch, + &pipeline.options, + subquery_budget, + )?; + rows = execution.rows; + stats.subquery_invocations = stats + .subquery_invocations + .saturating_add(execution.subquery_invocations); + stats.subquery_cache_hits = stats + .subquery_cache_hits + .saturating_add(execution.subquery_cache_hits); + followups.extend(execution.followups); + warnings.extend(execution.nested_stats.warnings.iter().cloned()); + stats.merge_from(&execution.nested_stats); + if stats.subquery_invocations > pipeline.options.max_subquery_invocations { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline exceeded max_subquery_invocations {}", + pipeline.options.max_subquery_invocations + ))); + } + current_schema = stage.output_schema.clone(); + stats.rows_after_filter = rows.len(); + stats.intermediate_rows = stats.intermediate_rows.max(rows.len()); + stats.pipeline_rows_materialized = + stats.pipeline_rows_materialized.max(rows.len()); + pipeline_enforce_intermediate_rows( + rows.len(), + &pipeline.options, + "max_pipeline_rows", + )?; + if include_plan { + stage_explains.push(GraphPipelineStageExplain { + index: stage_index, + kind: "Call".to_string(), + detail: pipeline_call_stage_detail( + stage, + Some(before), + Some(rows.len()), + Some(( + execution.subquery_invocations, + execution.subquery_cache_hits, + )), + ), + columns: pipeline_schema_columns(¤t_schema), + graph_row: None, + warnings: Vec::new(), + notes: pipeline_call_stage_notes(stage, None), + }); + } + } + NormalizedGraphPipelineStage::Union(stage) => { + let execution = self.execute_pipeline_union_stage( + stage, + effective_at_epoch, + include_plan, + stage_index, + rows, + subquery_budget, + )?; + followups.extend(execution.followups); + warnings.extend(execution.warnings.clone()); + stats.merge_from(&execution.stats); + rows = execution.rows; + row_projections = Some(execution.row_projections); + current_schema = stage.output_schema.clone(); + stats.rows_after_filter = rows.len(); + stats.intermediate_rows = stats.intermediate_rows.max(rows.len()); + stats.pipeline_rows_materialized = + stats.pipeline_rows_materialized.max(rows.len()); + pipeline_enforce_intermediate_rows( + rows.len(), + &pipeline.options, + "max_pipeline_rows", + )?; + if include_plan { + stage_explains.extend(execution.stage_explains); + } + } + } + } + stats.warnings = warnings.clone(); + Ok(PipelineStagesExecution { + rows, + row_projections, + followups, + stage_explains, + warnings, + stats, + }) + } + + fn execute_graph_row_stage( + &self, + query: &NormalizedGraphRowQuery, + initial_rows: Option>, + effective_at_epoch: i64, + include_plan: bool, + optional_stage: bool, + ) -> Result { + #[cfg(test)] + self.query_execution_counters + .graph_row_query_calls + .fetch_add(1, Ordering::Relaxed); + let runtime = self.normalize_graph_row_runtime_plan(query)?; + let physical_plan = self.plan_graph_row_physical(query, &runtime)?; + let policy_cutoffs = self.query_policy_cutoffs(); + let cursor_state = GraphRowCursorState { + decoded: None, + effective_at_epoch, + original_skip: 0, + rows_emitted_after_skip: 0, + }; + let mut explain_trace = if include_plan { + let mut trace = GraphRowExplainTrace::default(); + self.populate_graph_row_explain_trace_from_runtime( + query, + &cursor_state, + &runtime, + &physical_plan, + &mut trace, + )?; + Some(trace) + } else { + None + }; + let mut followups = Vec::new(); + let initial_row_count = initial_rows.as_ref().map_or(0, Vec::len); + let mut optional_seed_misses = Vec::new(); + let initial_rows = match initial_rows { + Some(rows) => { + let (valid_rows, invalid_rows) = self + .graph_row_partition_initial_bound_node_constraint_rows( + query, + &runtime, + rows, + policy_cutoffs.as_ref(), + )?; + if optional_stage { + optional_seed_misses = invalid_rows + .into_iter() + .map(|row| self.graph_row_null_extend_initial_optional_miss_row(query, row)) + .collect::, EngineError>>()?; + } + Some(valid_rows) + } + None => None, + }; + let mut intermediate_peak = initial_row_count.max(optional_seed_misses.len()); + let mut frontier_peak = 0usize; + let mut paths_enumerated = 0usize; + let mut rows = self.graph_row_execute_runtime_plan( + query, + &runtime, + &physical_plan, + initial_rows, + GraphRowRuntimeGoal::AllRows, + effective_at_epoch, + policy_cutoffs.as_ref(), + &mut followups, + &mut frontier_peak, + &mut intermediate_peak, + &mut paths_enumerated, + explain_trace.as_mut(), + )?; + if !optional_seed_misses.is_empty() { + rows.extend(optional_seed_misses); + graph_row_record_cap_peak( + &mut intermediate_peak, + rows.len(), + "max_intermediate_bindings", + query.options.max_intermediate_bindings, + )?; + } + let residual_needs = query.projection_needs.residual.clone(); + if rows.len() > query.options.max_order_materialization + && graph_row_entity_needs_require_selected_field_reads(&residual_needs) + { + return Err(graph_row_cap_error( + "max_order_materialization", + query.options.max_order_materialization, + )); + } + self.hydrate_graph_rows_for_needs(&mut rows, &query.binding_schema, &residual_needs)?; + let mut filtered = Vec::with_capacity(rows.len()); + for row in rows { + if let Some(where_expr) = query.bound_where.as_ref() { + let context = crate::graph_row::BoundGraphEvalContext { row: &row }; + if !crate::graph_row::eval_bound_graph_predicate(where_expr, &context)? { + continue; + } + } + filtered.push(row); + } + let rows_after_filter = filtered.len(); + let explain = if include_plan { + Some(build_graph_row_explain( + query, + Some(effective_at_epoch), + &cursor_state, + explain_trace, + Some(GraphRowExplainRuntimeStats { + rows_returned: rows_after_filter, + rows_after_filter, + rows_seen_for_page: rows_after_filter, + intermediate_bindings_peak: intermediate_peak, + frontier_peak, + paths_enumerated, + next_cursor: false, + }), + )) + } else { + None + }; + Ok(GraphRowStageExecution { + rows: filtered, + followups, + explain, + warnings: graph_row_runtime_warnings(&runtime.warnings), + rows_after_filter, + intermediate_peak, + }) + } + + fn execute_graph_row_stage_exists( + &self, + query: &NormalizedGraphRowQuery, + initial_rows: Option>, + effective_at_epoch: i64, + ) -> Result { + #[cfg(test)] + self.query_execution_counters + .graph_row_query_calls + .fetch_add(1, Ordering::Relaxed); + let runtime = self.normalize_graph_row_runtime_plan(query)?; + let physical_plan = self.plan_graph_row_physical(query, &runtime)?; + let policy_cutoffs = self.query_policy_cutoffs(); + let mut followups = Vec::new(); + let initial_row_count = initial_rows.as_ref().map_or(0, Vec::len); + let initial_rows = match initial_rows { + Some(rows) => { + let (valid_rows, _invalid_rows) = self + .graph_row_partition_initial_bound_node_constraint_rows( + query, + &runtime, + rows, + policy_cutoffs.as_ref(), + )?; + Some(valid_rows) + } + None => None, + }; + let mut intermediate_peak = initial_row_count; + let mut frontier_peak = 0usize; + let mut paths_enumerated = 0usize; + let rows = self.graph_row_execute_runtime_plan( + query, + &runtime, + &physical_plan, + initial_rows, + GraphRowRuntimeGoal::ExistsOne, + effective_at_epoch, + policy_cutoffs.as_ref(), + &mut followups, + &mut frontier_peak, + &mut intermediate_peak, + &mut paths_enumerated, + None, + )?; + let exists = !rows.is_empty(); + let row_count = usize::from(exists); + Ok(GraphRowStageExecution { + rows: if exists { + rows.into_iter().take(1).collect() + } else { + Vec::new() + }, + followups, + explain: None, + warnings: graph_row_runtime_warnings(&runtime.warnings), + rows_after_filter: row_count, + intermediate_peak: intermediate_peak.max(row_count), + }) + } + + fn execute_pipeline_project_stage( + &self, + stage: &NormalizedPipelineProjectStage, + mut rows: Vec, + effective_at_epoch: i64, + options: &GraphPipelineOptions, + subquery_budget: &mut SubqueryInvocationBudget, + ) -> Result { + self.hydrate_graph_rows_for_needs(&mut rows, &stage.input_schema, &stage.input_needs)?; + let mut groups = 0; + let mut collect_items = 0; + let mut aggregate_distinct_keys = 0; + let mut projected = if let Some(aggregate) = stage.aggregate.as_ref() { + let outcome = + execute_pipeline_aggregate_stage(stage, aggregate, rows, options)?; + groups = outcome.groups; + collect_items = outcome.collect_items; + aggregate_distinct_keys = outcome.aggregate_distinct_keys; + outcome.rows + } else { + execute_pipeline_scalar_project_stage(stage, &rows, options)? + }; + + let distinct_keys = if stage.distinct { + pipeline_apply_distinct(stage, &mut projected, options)? + } else { + 0 + }; + groups = groups.saturating_add(distinct_keys); + + if !stage.order_by.is_empty() { + if projected.len() > options.max_order_materialization { + return Err(graph_row_cap_error( + "max_order_materialization", + options.max_order_materialization, + )); + } + if !pipeline_projection_needs_is_empty(&stage.order_needs) { + self.hydrate_graph_rows_for_needs( + &mut projected, + &stage.output_schema, + &stage.order_needs, + )?; + } + sort_pipeline_rows(&mut projected, &stage.output_schema, &stage.order_by)?; + } + + if stage.skip > options.max_skip { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline projection SKIP {} exceeds max_skip {}", + stage.skip, options.max_skip + ))); + } + if stage.skip > 0 { + let skip = stage.skip.min(projected.len()); + projected.drain(0..skip); + } + if let Some(limit) = stage.limit { + projected.truncate(limit); + } + + if !pipeline_projection_needs_is_empty(&stage.filter_needs) { + self.hydrate_graph_rows_for_needs( + &mut projected, + &stage.output_schema, + &stage.filter_needs, + )?; + } + let exists_stats = if stage.exists_predicates.is_empty() { + PipelineSubqueryEvalStats::default() + } else { + self.evaluate_pipeline_exists_predicates( + stage, + &mut projected, + effective_at_epoch, + subquery_budget, + )? + }; + if let Some(where_expr) = stage.where_expr.as_ref() { + let mut filtered = Vec::with_capacity(projected.len()); + for row in projected { + let context = crate::graph_row::BoundGraphEvalContext { row: &row }; + if crate::graph_row::eval_bound_graph_predicate(where_expr, &context)? { + filtered.push(row); + } + } + projected = filtered; + } + + Ok(PipelineProjectStageExecution { + rows: projected, + followups: exists_stats.followups, + groups, + collect_items, + aggregate_distinct_keys, + subquery_invocations: exists_stats.invocations, + subquery_cache_hits: exists_stats.cache_hits, + nested_stats: exists_stats.nested_stats, + }) + } + + fn apply_pipeline_optional_candidate_filter( + &self, + stage: &NormalizedPipelineMatchStage, + left_rows: &[crate::graph_row::GraphBindingRow], + rows: Vec, + effective_at_epoch: i64, + subquery_budget: &mut SubqueryInvocationBudget, + ) -> Result { + let filter = stage.optional_candidate_filter.as_ref().ok_or_else(|| { + EngineError::InvalidOperation( + "optional candidate filter execution requires a normalized filter".to_string(), + ) + })?; + let input_rows = left_rows.len(); + let raw_rows = rows.len(); + let mut miss_rows_by_key: BTreeMap< + Vec, + Vec, + > = BTreeMap::new(); + let mut candidate_rows = Vec::new(); + for row in rows { + let key = pipeline_optional_input_key(stage, &row)?; + if pipeline_optional_row_is_miss(stage, &row)? { + miss_rows_by_key.entry(key).or_default().push(row); + } else { + candidate_rows.push(row); + } + } + + let mut eval_rows = candidate_rows + .iter() + .map(|row| { + pipeline_copy_row_to_schema( + &stage.query.binding_schema, + &filter.eval_schema, + row, + ) + }) + .collect::, EngineError>>()?; + if !pipeline_projection_needs_is_empty(&filter.filter_needs) { + self.hydrate_graph_rows_for_needs( + &mut eval_rows, + &filter.eval_schema, + &filter.filter_needs, + )?; + } + let exists_stats = if filter.exists_predicates.is_empty() { + PipelineSubqueryEvalStats { + nested_stats: empty_graph_pipeline_stats(effective_at_epoch), + ..PipelineSubqueryEvalStats::default() + } + } else { + self.evaluate_pipeline_exists_predicates_for_schema( + &filter.eval_schema, + &filter.exists_predicates, + &mut eval_rows, + effective_at_epoch, + subquery_budget, + )? + }; + + let mut hits_by_key: BTreeMap< + Vec, + Vec, + > = BTreeMap::new(); + let mut passed_rows = 0usize; + for (row, eval_row) in candidate_rows.into_iter().zip(eval_rows) { + let context = crate::graph_row::BoundGraphEvalContext { row: &eval_row }; + if crate::graph_row::eval_bound_graph_predicate(&filter.where_expr, &context)? { + passed_rows = passed_rows.saturating_add(1); + let key = pipeline_optional_input_key(stage, &row)?; + hits_by_key.entry(key).or_default().push(row); + } + } + + let mut output = Vec::new(); + let mut preserved_miss_rows = 0usize; + let mut synthesized_miss_rows = 0usize; + for left in left_rows { + let key = pipeline_optional_input_key(stage, left)?; + if let Some(hits) = hits_by_key.get(&key).filter(|hits| !hits.is_empty()) { + output.extend(hits.iter().cloned()); + } else if let Some(misses) = miss_rows_by_key.get(&key).filter(|misses| !misses.is_empty()) { + preserved_miss_rows = preserved_miss_rows.saturating_add(misses.len()); + output.extend(misses.iter().cloned()); + } else { + synthesized_miss_rows = synthesized_miss_rows.saturating_add(1); + output.push(pipeline_optional_null_extend_row(stage, left)?); + } + } + + Ok(PipelineOptionalCandidateFilterExecution { + rows: output, + followups: exists_stats.followups, + subquery_invocations: exists_stats.invocations, + subquery_cache_hits: exists_stats.cache_hits, + nested_stats: exists_stats.nested_stats, + input_rows, + candidate_rows: raw_rows, + passed_rows, + preserved_miss_rows, + synthesized_miss_rows, + }) + } + + fn execute_pipeline_shortest_path_stage( + &self, + stage: &NormalizedPipelineShortestPathStage, + rows: Vec, + effective_at_epoch: i64, + options: &GraphPipelineOptions, + ) -> Result { + let effective_max_paths = match stage.mode { + GraphShortestPathMode::One => None, + GraphShortestPathMode::All => { + Some(stage.max_paths.unwrap_or(options.max_paths_per_start)) + } + }; + let from_endpoint = self.prepare_shortest_path_endpoint(&stage.from)?; + let to_endpoint = self.prepare_shortest_path_endpoint(&stage.to)?; + let max_cost_bits = stage.max_cost.map(f64::to_bits); + let mut row_keys = Vec::with_capacity(rows.len()); + let mut distinct_pairs = BTreeSet::new(); + let mut cache_hits = 0usize; + for row in &rows { + let from_id = resolve_shortest_path_endpoint(&from_endpoint, row)?; + let to_id = resolve_shortest_path_endpoint(&to_endpoint, row)?; + let Some((from_id, to_id)) = from_id.zip(to_id) else { + row_keys.push(None); + continue; + }; + let key = ShortestPathPairKey { + from_id, + to_id, + direction: shortest_path_direction_code(stage.direction), + edge_label_filter: stage.edge_label_filter.clone(), + min_hops: stage.min_hops, + max_hops: stage.max_hops, + weight_field: stage.weight_field.clone(), + max_cost_bits, + max_paths: effective_max_paths, + }; + if !distinct_pairs.insert(key.clone()) { + cache_hits = cache_hits.saturating_add(1); + } + row_keys.push(Some(key)); + } + + if distinct_pairs.len() > options.max_shortest_path_pairs { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline shortest-path stage resolved {} distinct endpoint pair(s), exceeding max_shortest_path_pairs {}", + distinct_pairs.len(), + options.max_shortest_path_pairs + ))); + } + + let mut cache: BTreeMap> = BTreeMap::new(); + for key in distinct_pairs { + let edge_label_filter = if key.edge_label_filter.is_empty() { + None + } else { + Some(key.edge_label_filter.clone()) + }; + let paths = match stage.mode { + GraphShortestPathMode::One => { + let options = ShortestPathOptions { + direction: stage.direction, + edge_label_filter, + weight_field: key.weight_field.clone(), + at_epoch: Some(effective_at_epoch), + max_depth: Some(key.max_hops as u32), + max_cost: key.max_cost_bits.map(f64::from_bits), + }; + self.shortest_path(key.from_id, key.to_id, &options)? + .into_iter() + .filter(|path| path.edges.len() >= key.min_hops as usize) + .collect::>() + } + GraphShortestPathMode::All => { + let options = AllShortestPathsOptions { + direction: stage.direction, + edge_label_filter, + weight_field: key.weight_field.clone(), + at_epoch: Some(effective_at_epoch), + max_depth: Some(key.max_hops as u32), + max_cost: key.max_cost_bits.map(f64::from_bits), + max_paths: key.max_paths, + }; + self.all_shortest_paths(key.from_id, key.to_id, &options)? + .into_iter() + .filter(|path| path.edges.len() >= key.min_hops as usize) + .collect::>() + } + }; + cache.insert(key, paths); + } + + let pair_count = cache.len(); + let mut output_rows = Vec::new(); + let mut no_path_count = 0usize; + let mut emitted_path_count = 0usize; + for (row, key) in rows.into_iter().zip(row_keys) { + let Some(key) = key else { + no_path_count = no_path_count.saturating_add(1); + if stage.optional { + pipeline_enforce_intermediate_rows( + output_rows.len().saturating_add(1), + options, + "max_pipeline_rows", + )?; + output_rows.push(shortest_path_null_output_row(stage, &row)?); + } + continue; + }; + let paths = cache.get(&key).expect("shortest-path pair key cached"); + if paths.is_empty() { + no_path_count = no_path_count.saturating_add(1); + if stage.optional { + pipeline_enforce_intermediate_rows( + output_rows.len().saturating_add(1), + options, + "max_pipeline_rows", + )?; + output_rows.push(shortest_path_null_output_row(stage, &row)?); + } + continue; + } + for path in paths { + pipeline_enforce_intermediate_rows( + output_rows.len().saturating_add(1), + options, + "max_pipeline_rows", + )?; + output_rows.push(shortest_path_output_row(stage, &row, path.clone())?); + emitted_path_count = emitted_path_count.saturating_add(1); + if stage.mode == GraphShortestPathMode::One { + break; + } + } + } + + Ok(PipelineShortestPathStageExecution { + rows: output_rows, + pair_count, + cache_hits, + no_path_count, + emitted_path_count, + }) + } + + fn prepare_shortest_path_endpoint( + &self, + endpoint: &NormalizedShortestPathEndpoint, + ) -> Result { + match endpoint { + NormalizedShortestPathEndpoint::Alias { slot, .. } => { + Ok(ResolvedShortestPathEndpoint::Alias { slot: *slot }) + } + NormalizedShortestPathEndpoint::NodeId(id) => { + Ok(ResolvedShortestPathEndpoint::Static(Some(*id))) + } + NormalizedShortestPathEndpoint::NodeKey { label, key } => { + let Some(label_id) = self.label_catalog.resolve_node_label_for_read(label)? else { + return Ok(ResolvedShortestPathEndpoint::Static(None)); + }; + let resolved = self + .sources() + .find_node_ids_by_label_keys(&[(label_id, key.as_str())])? + .pop() + .flatten(); + let Some(node_id) = resolved else { + return Ok(ResolvedShortestPathEndpoint::Static(None)); + }; + if self.policy_excluded_node_ids(&[node_id])?.contains(&node_id) { + return Ok(ResolvedShortestPathEndpoint::Static(None)); + } + Ok(ResolvedShortestPathEndpoint::Static(Some(node_id))) + } + } + } + + fn execute_pipeline_union_stage( + &self, + stage: &NormalizedPipelineUnionStage, + effective_at_epoch: i64, + include_plan: bool, + stage_index: usize, + initial_rows: Vec, + subquery_budget: &mut SubqueryInvocationBudget, + ) -> Result { + let mut followups = Vec::new(); + let mut warnings = Vec::new(); + let initial_row_count = initial_rows.len(); + let mut stats = GraphPipelineStats { + rows_returned: 0, + rows_entered_pipeline: initial_row_count, + rows_after_filter: 0, + intermediate_rows: initial_row_count, + pipeline_rows_materialized: initial_row_count, + groups: 0, + collect_items: 0, + union_branches: stage.branches.len(), + union_dedup_keys: 0, + subquery_invocations: 0, + subquery_cache_hits: 0, + shortest_path_pairs: 0, + shortest_path_cache_hits: 0, + db_hits: 0, + elapsed_us: None, + effective_at_epoch, + warnings: Vec::new(), + }; + let mut rows = Vec::new(); + let mut row_projections = Vec::new(); + let mut ordinal = 0u64; + let mut branch_summaries = Vec::new(); + let branch_count = stage.branches.len(); + let mut reusable_initial_rows = Some(initial_rows); + for (branch_index, branch) in stage.branches.iter().enumerate() { + let branch_initial_rows = if branch_index + 1 == branch_count { + reusable_initial_rows + .take() + .expect("initial union rows available for final branch") + } else { + reusable_initial_rows + .as_ref() + .expect("initial union rows available for branch") + .clone() + }; + let mut branch_execution = self.execute_graph_pipeline_stages_with_rows_budget( + &branch.pipeline, + effective_at_epoch, + include_plan, + branch_initial_rows, + subquery_budget, + )?; + let branch_warnings = branch_execution.warnings.clone(); + if include_plan { + branch_summaries.push(PipelineUnionBranchExplainSummary { + branch_index, + stages: std::mem::take(&mut branch_execution.stage_explains), + row_ops: pipeline_row_ops(&branch.pipeline), + warnings: branch_warnings.clone(), + }); + } + followups.append(&mut branch_execution.followups); + warnings.extend(branch_warnings); + stats.merge_from(&branch_execution.stats); + let branch_fingerprints = + graph_pipeline_cursor_fingerprints(&branch.pipeline, effective_at_epoch, 0); + let branch_rows = self.pipeline_prepare_final_rows( + branch_execution.rows, + &branch.pipeline, + None, + &branch_fingerprints, + branch_execution.row_projections, + )?; + let branch_projections = pipeline_union_branch_return_projections(branch); + stats.pipeline_rows_materialized = + stats.pipeline_rows_materialized.max(branch_rows.len()); + for branch_row in branch_rows { + let projections = branch_row + .projections + .unwrap_or_else(|| Arc::clone(&branch_projections)); + let output = pipeline_union_output_row( + stage, + branch, + branch_row.row, + ordinal, + )?; + ordinal = ordinal.checked_add(1).ok_or_else(|| { + EngineError::InvalidOperation( + "GraphUnionStage internal ordinal overflowed".to_string(), + ) + })?; + rows.push(output); + row_projections.push(projections); + } + } + + let dedup_keys = if stage.all { + 0 + } else { + pipeline_apply_union_distinct(stage, &mut rows, &mut row_projections)? + }; + stats.union_dedup_keys = stats.union_dedup_keys.saturating_add(dedup_keys); + stats.rows_after_filter = rows.len(); + stats.intermediate_rows = stats.intermediate_rows.max(rows.len()); + stats.pipeline_rows_materialized = stats.pipeline_rows_materialized.max(rows.len()); + stats.warnings = warnings.clone(); + let stage_explains = if include_plan { { + vec![GraphPipelineStageExplain { + index: stage_index, + kind: if stage.all { + "UnionAll".to_string() + } else { + "Union".to_string() + }, + detail: pipeline_union_stage_detail(stage, Some(rows.len()), Some(dedup_keys)), + columns: stage.columns.clone(), + graph_row: None, + warnings: warnings.clone(), + notes: pipeline_union_stage_notes(stage, &branch_summaries), + }] + } } else { Default::default() }; + Ok(PipelineUnionStageExecution { + rows, + row_projections, + followups, + stage_explains, + warnings, + stats, + }) + } + + fn execute_pipeline_call_stage( + &self, + stage: &NormalizedPipelineCallStage, + rows: Vec, + effective_at_epoch: i64, + options: &GraphPipelineOptions, + subquery_budget: &mut SubqueryInvocationBudget, + ) -> Result { + let mut row_keys = Vec::with_capacity(rows.len()); + let mut representatives: BTreeMap< + Vec, + PipelineCallRepresentative, + > = BTreeMap::new(); + let mut representative_count = 0usize; + let mut cache_hits = 0usize; + for (index, row) in rows.iter().enumerate() { + let key = crate::graph_row::graph_canonical_key_for_row_slots( + row, + &stage.import_slots, + )?; + match representatives.entry(key.clone()) { + std::collections::btree_map::Entry::Occupied(mut entry) => { + cache_hits = cache_hits.saturating_add(1); + entry.get_mut().outer_count = entry.get().outer_count.saturating_add(1); + } + std::collections::btree_map::Entry::Vacant(entry) => { + if representative_count >= options.max_subquery_invocations { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline CALL exceeded max_subquery_invocations {}", + options.max_subquery_invocations + ))); + } + subquery_budget.reserve("CALL")?; + entry.insert(PipelineCallRepresentative { + index, + outer_count: 1, + }); + representative_count = representative_count.saturating_add(1); + } + } + row_keys.push(key); + } + + let mut followups = Vec::new(); + let mut nested_stats = empty_graph_pipeline_stats(effective_at_epoch); + let mut cache: BTreeMap< + Vec, + Vec, + > = BTreeMap::new(); + let mut cached_subquery_rows = 0usize; + let mut projected_join_rows = 0usize; + for (key, representative) in representatives.iter() { + let initial_rows = pipeline_bridge_rows( + std::slice::from_ref(&rows[representative.index]), + &stage.input_schema, + &stage.query.initial_schema, + &stage.import_mappings, + )?; + let (subquery_rows, mut subquery_followups, subquery_stats) = + self.execute_pipeline_subquery_rows( + &stage.query, + effective_at_epoch, + initial_rows, + subquery_budget, + PipelineSubqueryRowMode::Call, + )?; + followups.append(&mut subquery_followups); + nested_stats.merge_from(&subquery_stats); + cached_subquery_rows = cached_subquery_rows + .checked_add(subquery_rows.len()) + .ok_or_else(|| graph_row_cap_error("max_pipeline_rows", options.max_pipeline_rows))?; + pipeline_enforce_intermediate_rows( + cached_subquery_rows, + options, + "max_pipeline_rows", + )?; + let contribution = subquery_rows + .len() + .checked_mul(representative.outer_count) + .ok_or_else(|| graph_row_cap_error("max_pipeline_rows", options.max_pipeline_rows))?; + projected_join_rows = projected_join_rows + .checked_add(contribution) + .ok_or_else(|| graph_row_cap_error("max_pipeline_rows", options.max_pipeline_rows))?; + pipeline_enforce_intermediate_rows( + projected_join_rows, + options, + "max_pipeline_rows", + )?; + cache.insert(key.clone(), subquery_rows); + } + + let mut output_rows = Vec::new(); + for (row, key) in rows.iter().zip(row_keys.iter()) { + let Some(subquery_rows) = cache.get(key) else { + return Err(EngineError::InvalidOperation( + "graph pipeline CALL cache is missing a correlation key".to_string(), + )); + }; + for subquery_row in subquery_rows { + pipeline_enforce_intermediate_rows( + output_rows.len().saturating_add(1), + options, + "max_pipeline_rows", + )?; + output_rows.push(pipeline_call_output_row(stage, row, subquery_row)?); + } + } + + Ok(PipelineCallStageExecution { + rows: output_rows, + followups, + subquery_invocations: cache.len(), + subquery_cache_hits: cache_hits, + nested_stats, + }) + } + + fn evaluate_pipeline_exists_predicates( + &self, + stage: &NormalizedPipelineProjectStage, + rows: &mut [crate::graph_row::GraphBindingRow], + effective_at_epoch: i64, + subquery_budget: &mut SubqueryInvocationBudget, + ) -> Result { + self.evaluate_pipeline_exists_predicates_for_schema( + &stage.output_schema, + &stage.exists_predicates, + rows, + effective_at_epoch, + subquery_budget, + ) + } + + fn evaluate_pipeline_exists_predicates_for_schema( + &self, + schema: &crate::graph_row::GraphBindingSchema, + predicates: &[NormalizedPipelineExistsPredicate], + rows: &mut [crate::graph_row::GraphBindingRow], + effective_at_epoch: i64, + subquery_budget: &mut SubqueryInvocationBudget, + ) -> Result { + let mut stats = PipelineSubqueryEvalStats { + nested_stats: empty_graph_pipeline_stats(effective_at_epoch), + ..PipelineSubqueryEvalStats::default() + }; + for predicate in predicates { + let mut row_keys = Vec::with_capacity(rows.len()); + let mut representatives: BTreeMap< + Vec, + usize, + > = BTreeMap::new(); + for (index, row) in rows.iter().enumerate() { + let key = crate::graph_row::graph_canonical_key_for_row_slots( + row, + &predicate.import_slots, + )?; + if representatives.contains_key(&key) { + stats.cache_hits = stats.cache_hits.saturating_add(1); + } else { + subquery_budget.reserve("EXISTS")?; + representatives.insert(key.clone(), index); + } + row_keys.push(key); + } + + let mut cache: BTreeMap, bool> = + BTreeMap::new(); + for (key, index) in representatives.iter() { + let initial_rows = pipeline_bridge_rows( + std::slice::from_ref(&rows[*index]), + schema, + &predicate.query.initial_schema, + &predicate.import_mappings, + )?; + let mut execution = self.execute_pipeline_subquery_exists( + &predicate.query, + effective_at_epoch, + initial_rows, + subquery_budget, + )?; + stats.followups.append(&mut execution.followups); + stats.nested_stats.merge_from(&execution.stats); + cache.insert(key.clone(), execution.exists); + } + stats.invocations = stats.invocations.saturating_add(cache.len()); + + for (row, key) in rows.iter_mut().zip(row_keys.iter()) { + let exists = *cache.get(key).ok_or_else(|| { + EngineError::InvalidOperation( + "graph pipeline EXISTS cache is missing a correlation key".to_string(), + ) + })?; + bind_pipeline_value_to_slot( + schema, + row, + predicate.output_slot, + crate::graph_row::GraphEvalValue::Bool(exists), + )?; + } + } + Ok(stats) + } + + fn execute_pipeline_subquery_exists( + &self, + pipeline: &NormalizedGraphPipeline, + effective_at_epoch: i64, + initial_rows: Vec, + subquery_budget: &mut SubqueryInvocationBudget, + ) -> Result { + if matches!( + pipeline.stages.as_slice(), + [NormalizedGraphPipelineStage::Union(_)] + ) { + return self.execute_pipeline_subquery_union_exists( + pipeline, + effective_at_epoch, + initial_rows, + subquery_budget, + ); + } + if let Some(execution) = + self.execute_pipeline_subquery_exists_probe(pipeline, effective_at_epoch, &initial_rows)? + { + return Ok(execution); + } + + let (rows, followups, stats) = self.execute_pipeline_subquery_rows( + pipeline, + effective_at_epoch, + initial_rows, + subquery_budget, + PipelineSubqueryRowMode::Exists, + )?; + Ok(PipelineExistsExecution { + exists: !rows.is_empty(), + followups, + stats, + }) + } + + fn execute_pipeline_subquery_union_exists( + &self, + pipeline: &NormalizedGraphPipeline, + effective_at_epoch: i64, + initial_rows: Vec, + subquery_budget: &mut SubqueryInvocationBudget, + ) -> Result { + let [NormalizedGraphPipelineStage::Union(stage)] = pipeline.stages.as_slice() else { + return Err(EngineError::InvalidOperation( + "graph pipeline EXISTS union probe requires a terminal union stage".to_string(), + )); + }; + let mut stats = empty_graph_pipeline_stats(effective_at_epoch); + stats.rows_entered_pipeline = initial_rows.len(); + stats.intermediate_rows = initial_rows.len(); + stats.pipeline_rows_materialized = initial_rows.len(); + stats.union_branches = stage.branches.len(); + if initial_rows.is_empty() { + return Ok(PipelineExistsExecution { + exists: false, + followups: Vec::new(), + stats, + }); + } + + let mut followups = Vec::new(); + let branch_count = stage.branches.len(); + let mut reusable_initial_rows = Some(initial_rows); + for (branch_index, branch) in stage.branches.iter().enumerate() { + let branch_initial_rows = if branch_index + 1 == branch_count { + reusable_initial_rows + .take() + .expect("initial union rows available for final EXISTS branch") + } else { + reusable_initial_rows + .as_ref() + .expect("initial union rows available for EXISTS branch") + .clone() + }; + let mut branch_execution = self.execute_pipeline_subquery_exists( + &branch.pipeline, + effective_at_epoch, + branch_initial_rows, + subquery_budget, + )?; + followups.append(&mut branch_execution.followups); + stats.merge_from(&branch_execution.stats); + if branch_execution.exists { + stats.rows_returned = 1; + stats.rows_after_filter = 1; + stats.pipeline_rows_materialized = stats.pipeline_rows_materialized.max(1); + return Ok(PipelineExistsExecution { + exists: true, + followups, + stats, + }); + } + } + + stats.rows_returned = 0; + stats.rows_after_filter = 0; + Ok(PipelineExistsExecution { + exists: false, + followups, + stats, + }) + } + + fn execute_pipeline_subquery_exists_probe( + &self, + pipeline: &NormalizedGraphPipeline, + effective_at_epoch: i64, + initial_rows: &[crate::graph_row::GraphBindingRow], + ) -> Result, EngineError> { + let Some(plan) = pipeline_exists_probe_plan(pipeline) else { + return Ok(None); + }; + if plan.always_false || initial_rows.is_empty() { + let mut stats = empty_graph_pipeline_stats(effective_at_epoch); + stats.rows_entered_pipeline = initial_rows.len(); + return Ok(Some(PipelineExistsExecution { + exists: false, + followups: Vec::new(), + stats, + })); + } + + let Some(match_stage) = plan.match_stage else { + let mut stats = empty_graph_pipeline_stats(effective_at_epoch); + stats.rows_entered_pipeline = initial_rows.len(); + stats.rows_returned = 1; + stats.rows_after_filter = 1; + stats.intermediate_rows = 1; + stats.pipeline_rows_materialized = 1; + return Ok(Some(PipelineExistsExecution { + exists: true, + followups: Vec::new(), + stats, + })); + }; + + let bridged_rows = pipeline_bridge_rows( + initial_rows, + &pipeline.initial_schema, + &match_stage.query.binding_schema, + &match_stage.input_mappings, + )?; + let bridged_rows = if pipeline.initial_schema.slots().is_empty() { + None + } else { + Some(bridged_rows) + }; + let execution = self.execute_graph_row_stage_exists( + &match_stage.query, + bridged_rows, + effective_at_epoch, + )?; + let exists = !execution.rows.is_empty(); + let mut stats = empty_graph_pipeline_stats(effective_at_epoch); + stats.rows_entered_pipeline = initial_rows.len(); + stats.rows_returned = usize::from(exists); + stats.rows_after_filter = execution.rows_after_filter; + stats.intermediate_rows = execution.intermediate_peak; + stats.pipeline_rows_materialized = usize::from(exists); + stats.db_hits = if pipeline.options.profile { + execution.rows_after_filter + } else { + 0 + }; + stats.warnings = execution.warnings.clone(); + Ok(Some(PipelineExistsExecution { + exists, + followups: execution.followups, + stats, + })) + } + + fn execute_pipeline_subquery_rows( + &self, + pipeline: &NormalizedGraphPipeline, + effective_at_epoch: i64, + initial_rows: Vec, + subquery_budget: &mut SubqueryInvocationBudget, + mode: PipelineSubqueryRowMode, + ) -> Result< + ( + Vec, + Vec, + GraphPipelineStats, + ), + EngineError, + > { + let mut execution = self.execute_graph_pipeline_stages_with_rows_budget( + pipeline, + effective_at_epoch, + false, + initial_rows, + subquery_budget, + )?; + let fingerprints = graph_pipeline_cursor_fingerprints(pipeline, effective_at_epoch, 0); + let mut final_rows = self.pipeline_prepare_final_rows( + execution.rows, + pipeline, + None, + &fingerprints, + execution.row_projections.take(), + )?; + let total_after_cursor = final_rows.len(); + match mode { + PipelineSubqueryRowMode::Exists => { + let limit = pipeline.page.limit.min(pipeline.options.max_rows); + if final_rows.len() > limit { + final_rows.truncate(limit); + } + } + PipelineSubqueryRowMode::Call => { + if final_rows.len() > pipeline.options.max_pipeline_rows { + return Err(graph_row_cap_error( + "max_pipeline_rows", + pipeline.options.max_pipeline_rows, + )); + } + } + } + let rows = final_rows + .into_iter() + .map(|row| row.row) + .collect::>(); + let mut stats = execution.stats; + stats.rows_returned = rows.len(); + stats.rows_after_filter = total_after_cursor; + stats.pipeline_rows_materialized = stats.pipeline_rows_materialized.max(total_after_cursor); + Ok((rows, execution.followups, stats)) + } + + fn pipeline_prepare_final_rows( + &self, + rows: Vec, + pipeline: &NormalizedGraphPipeline, + cursor: Option<&GraphPipelineCursorPayload>, + fingerprints: &GraphPipelineFingerprints, + row_projections: Option>>, + ) -> Result, EngineError> { + if rows.len() > pipeline.options.max_order_materialization { + return Err(graph_row_cap_error( + "max_order_materialization", + pipeline.options.max_order_materialization, + )); + } + if let Some(projections) = row_projections.as_ref() { + if projections.len() != rows.len() { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline row projection sidecar length {} does not match row length {}", + projections.len(), + rows.len() + ))); + } + } + let mut rows = rows; + let order_needs = pipeline_order_needs(&pipeline.terminal_schema, &pipeline.terminal_order_by)?; + if !pipeline_projection_needs_is_empty(&order_needs) { + self.hydrate_graph_rows_for_needs(&mut rows, &pipeline.terminal_schema, &order_needs)?; + } + let directions = graph_row_order_directions(&pipeline.terminal_order_by); + let mut final_rows = match row_projections { + Some(projections) => rows + .into_iter() + .zip(projections) + .enumerate() + .map(|(ordinal, (row, projections))| { + let sort_key = pipeline_explicit_sort_key(&pipeline.terminal_order_by, &row)?; + let logical_key = pipeline_final_logical_key(pipeline, ordinal, &row)?; + Ok(PipelineFinalRow { + row, + sort_key, + logical_key, + projections: Some(projections), + }) + }) + .collect::, EngineError>>()?, + None => rows + .into_iter() + .enumerate() + .map(|(ordinal, row)| { + let sort_key = pipeline_explicit_sort_key(&pipeline.terminal_order_by, &row)?; + let logical_key = pipeline_final_logical_key(pipeline, ordinal, &row)?; + Ok(PipelineFinalRow { + row, + sort_key, + logical_key, + projections: None, + }) + }) + .collect::, EngineError>>()?, + }; + final_rows.sort_by(|left, right| { + compare_graph_final_keys_by_directions( + &left.sort_key, + &left.logical_key, + &right.sort_key, + &right.logical_key, + &directions, + ) + }); + if let Some(cursor) = cursor { + graph_pipeline_validate_cursor_fingerprints(cursor, fingerprints)?; + graph_pipeline_validate_cursor_shape(pipeline, cursor)?; + final_rows.retain(|row| { + compare_graph_final_keys_by_directions( + &row.sort_key, + &row.logical_key, + &cursor.last_sort_key, + &cursor.last_logical_row_key, + &directions, + ) + .is_gt() + }); + } + Ok(final_rows) + } + + fn pipeline_project_output_rows( + &self, + rows: &[crate::graph_row::GraphBindingRow], + return_items: &[crate::graph_row::BoundGraphReturnItem], + output: &GraphOutputOptions, + ) -> Result, EngineError> { + let mut eval_rows = Vec::with_capacity(rows.len()); + let mut hydration_needs = PipelineNestedGraphHydrationNeeds::default(); + for row in rows { + let context = crate::graph_row::BoundGraphEvalContext { row }; + let mut values = Vec::with_capacity(return_items.len()); + for item in return_items { + let value = crate::graph_row::eval_bound_graph_expr(&item.expr, &context)?; + pipeline_collect_output_value_hydration_needs( + &value, + &item.projection, + output, + &mut hydration_needs, + )?; + values.push(value); + } + eval_rows.push(values); + } + + let (nodes_by_id, edges_by_id) = + self.pipeline_fetch_nested_graph_values(&hydration_needs)?; + eval_rows + .into_iter() + .map(|values| { + let values = values + .into_iter() + .zip(return_items) + .map(|(value, item)| { + let value = + pipeline_hydrate_output_value(value, &nodes_by_id, &edges_by_id)?; + crate::graph_row::graph_eval_to_output_value( + &value, + &item.projection, + output, + ) + }) + .collect::, EngineError>>()?; + Ok(GraphRow { values }) + }) + .collect() + } + + fn pipeline_project_output_rows_with_row_projections( + &self, + rows: &[PipelineFinalRow], + return_items: &[crate::graph_row::BoundGraphReturnItem], + output: &GraphOutputOptions, + ) -> Result, EngineError> { + let mut eval_rows = Vec::with_capacity(rows.len()); + let mut hydration_needs = PipelineNestedGraphHydrationNeeds::default(); + for row in rows { + if let Some(projections) = row.projections.as_ref() { + if projections.len() != return_items.len() { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline row projection sidecar has {} projection(s), expected {}", + projections.len(), + return_items.len() + ))); + } + } + let context = crate::graph_row::BoundGraphEvalContext { row: &row.row }; + let mut values = Vec::with_capacity(return_items.len()); + for (index, item) in return_items.iter().enumerate() { + let projection = row_projection_for_item(row, item, index)?; + let value = crate::graph_row::eval_bound_graph_expr(&item.expr, &context)?; + pipeline_collect_output_value_hydration_needs( + &value, + projection, + output, + &mut hydration_needs, + )?; + values.push(value); + } + eval_rows.push((values, row.projections.clone())); + } + + let (nodes_by_id, edges_by_id) = + self.pipeline_fetch_nested_graph_values(&hydration_needs)?; + eval_rows + .into_iter() + .map(|(values, projections)| { + let values = values + .into_iter() + .zip(return_items) + .enumerate() + .map(|(index, (value, item))| { + let value = + pipeline_hydrate_output_value(value, &nodes_by_id, &edges_by_id)?; + let projection = projections + .as_deref() + .and_then(|items| items.get(index)) + .unwrap_or(&item.projection); + crate::graph_row::graph_eval_to_output_value( + &value, + projection, + output, + ) + }) + .collect::, EngineError>>()?; + Ok(GraphRow { values }) + }) + .collect() + } + + fn pipeline_fetch_nested_graph_values( + &self, + needs: &PipelineNestedGraphHydrationNeeds, + ) -> Result<(NodeIdMap, NodeIdMap), EngineError> { + let mut nodes_by_id = NodeIdMap::default(); + for (node_needs, ids) in pipeline_group_node_hydration_needs(&needs.node_needs_by_id) { + if !ids.is_empty() { + let selected = self.sources().find_node_projected_fields(&ids, &node_needs)?; + if nodes_by_id.capacity() == 0 { + nodes_by_id = NodeIdMap::with_capacity_and_hasher( + needs.node_needs_by_id.len(), + Default::default(), + ); + } + for (node_id, fields) in ids.into_iter().zip(selected) { + if let Some(fields) = fields { + nodes_by_id.insert( + node_id, + graph_node_value_from_selected( + node_id, + &fields, + &self.label_catalog, + )?, + ); + } + } + } + } + + let mut edges_by_id = NodeIdMap::default(); + for (edge_needs, ids) in pipeline_group_edge_hydration_needs(&needs.edge_needs_by_id) { + if !ids.is_empty() { + let selected = self.sources().find_edge_projected_fields(&ids, &edge_needs)?; + if edges_by_id.capacity() == 0 { + edges_by_id = NodeIdMap::with_capacity_and_hasher( + needs.edge_needs_by_id.len(), + Default::default(), + ); + } + for (edge_id, fields) in ids.into_iter().zip(selected) { + if let Some(fields) = fields { + edges_by_id.insert( + edge_id, + graph_edge_value_from_selected( + edge_id, + &fields, + &self.label_catalog, + )?, + ); + } + } + } + } + + Ok((nodes_by_id, edges_by_id)) + } +} + +#[derive(Debug)] +struct PipelineAggregateOutcome { + rows: Vec, + groups: usize, + collect_items: usize, + aggregate_distinct_keys: usize, +} + +struct PipelineAggregateGroup { + values: Vec, + states: Vec, +} + +struct PipelineAggregateState { + function: GraphAggregateFunction, + distinct_seen: Option>>, + inner: PipelineAggregateInner, +} + +enum PipelineAggregateInner { + Count(u64), + Sum(PipelineSumState), + Avg { sum: f64, count: u64 }, + MinMax(Option), + Collect(Vec), +} + +enum PipelineSumState { + Empty, + Int(i64), + Float(f64), +} + +#[derive(Default)] +struct PipelineNestedGraphHydrationNeeds { + node_needs_by_id: BTreeMap, + edge_needs_by_id: BTreeMap, +} + +impl PipelineNestedGraphHydrationNeeds { + fn add_node( + &mut self, + node_id: u64, + needs: &NodeSelectedFieldNeeds, + ) -> Result<(), EngineError> { + let mut merged = self.node_needs_by_id.get(&node_id).cloned().unwrap_or_default(); + merged.merge_from(needs, ProjectionNeedClass::Output)?; + self.node_needs_by_id.insert(node_id, merged); + Ok(()) + } + + fn add_edge( + &mut self, + edge_id: u64, + needs: &EdgeSelectedFieldNeeds, + ) -> Result<(), EngineError> { + let mut merged = self.edge_needs_by_id.get(&edge_id).cloned().unwrap_or_default(); + merged.merge_from(needs, ProjectionNeedClass::Output)?; + self.edge_needs_by_id.insert(edge_id, merged); + Ok(()) + } +} + +fn pipeline_group_node_hydration_needs( + needs_by_id: &BTreeMap, +) -> Vec<(NodeSelectedFieldNeeds, Vec)> { + let mut groups: Vec<(NodeSelectedFieldNeeds, Vec)> = Vec::new(); + for (id, needs) in needs_by_id { + if let Some((_, ids)) = groups + .iter_mut() + .find(|(group_needs, _)| group_needs == needs) + { + ids.push(*id); + } else { + groups.push((needs.clone(), vec![*id])); + } + } + groups +} + +fn pipeline_group_edge_hydration_needs( + needs_by_id: &BTreeMap, +) -> Vec<(EdgeSelectedFieldNeeds, Vec)> { + let mut groups: Vec<(EdgeSelectedFieldNeeds, Vec)> = Vec::new(); + for (id, needs) in needs_by_id { + if let Some((_, ids)) = groups + .iter_mut() + .find(|(group_needs, _)| group_needs == needs) + { + ids.push(*id); + } else { + groups.push((needs.clone(), vec![*id])); + } + } + groups +} + +fn row_projection_for_item<'a>( + row: &'a PipelineFinalRow, + item: &'a crate::graph_row::BoundGraphReturnItem, + index: usize, +) -> Result<&'a GraphReturnProjection, EngineError> { + match row.projections.as_ref() { + Some(projections) => projections.get(index).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "graph pipeline row projection sidecar is missing projection {index}" + )) + }), + None => Ok(&item.projection), + } +} + +fn pipeline_collect_output_value_hydration_needs( + value: &crate::graph_row::GraphEvalValue, + projection: &GraphReturnProjection, + output: &GraphOutputOptions, + needs: &mut PipelineNestedGraphHydrationNeeds, +) -> Result<(), EngineError> { + match value { + crate::graph_row::GraphEvalValue::Node(node) => { + if node.element.is_none() { + if let Some(node_needs) = pipeline_node_output_hydration_needs(projection, output) { + needs.add_node(node.id, &node_needs)?; + } + } + } + crate::graph_row::GraphEvalValue::Edge(edge) => { + if edge.element.is_none() { + if let Some(edge_needs) = pipeline_edge_output_hydration_needs(projection, output) { + needs.add_edge(edge.id, &edge_needs)?; + } + } + } + crate::graph_row::GraphEvalValue::Path(path) => { + if let Some(path_needs) = pipeline_path_output_hydration_needs(projection, output) { + if let Some(node_needs) = graph_row_path_node_hydration_needs(&path_needs)? { + for node in &path.nodes { + if node.element.is_none() { + needs.add_node(node.id, &node_needs)?; + } + } + } + if let Some(edge_needs) = graph_row_path_edge_hydration_needs(&path_needs)? { + for edge in &path.edges { + if edge.element.is_none() { + needs.add_edge(edge.id, &edge_needs)?; + } + } + } + } + } + crate::graph_row::GraphEvalValue::List(values) => { + for value in values { + pipeline_collect_output_value_hydration_needs(value, projection, output, needs)?; + } + } + crate::graph_row::GraphEvalValue::Map(values) => { + for value in values.values() { + pipeline_collect_output_value_hydration_needs(value, projection, output, needs)?; + } + } + crate::graph_row::GraphEvalValue::Null + | crate::graph_row::GraphEvalValue::Bool(_) + | crate::graph_row::GraphEvalValue::Int(_) + | crate::graph_row::GraphEvalValue::UInt(_) + | crate::graph_row::GraphEvalValue::Float(_) + | crate::graph_row::GraphEvalValue::String(_) + | crate::graph_row::GraphEvalValue::Bytes(_) => {} + } + Ok(()) +} + +fn pipeline_node_output_hydration_needs( + projection: &GraphReturnProjection, + output: &GraphOutputOptions, +) -> Option { + match projection { + GraphReturnProjection::IdOnly => None, + GraphReturnProjection::Auto => match output.mode { + GraphOutputMode::Ids | GraphOutputMode::Projected => None, + GraphOutputMode::Elements => crate::graph_row::node_source_needs_from_element( + GraphElementProjection::Full, + output.include_vectors, + ), + }, + GraphReturnProjection::Element(element) => { + crate::graph_row::node_source_needs_from_element( + element.clone(), + output.include_vectors, + ) + } + GraphReturnProjection::Selected(GraphSelectedProjection::Node(selected)) => { + crate::graph_row::node_source_needs_from_selected(selected) + } + GraphReturnProjection::Selected(_) => None, + } +} + +fn pipeline_edge_output_hydration_needs( + projection: &GraphReturnProjection, + output: &GraphOutputOptions, +) -> Option { + match projection { + GraphReturnProjection::IdOnly => None, + GraphReturnProjection::Auto => match output.mode { + GraphOutputMode::Ids | GraphOutputMode::Projected => None, + GraphOutputMode::Elements => { + crate::graph_row::edge_source_needs_from_element(GraphElementProjection::Full) + } + }, + GraphReturnProjection::Element(element) => { + crate::graph_row::edge_source_needs_from_element(element.clone()) + } + GraphReturnProjection::Selected(GraphSelectedProjection::Edge(selected)) => { + crate::graph_row::edge_source_needs_from_selected(selected) + } + GraphReturnProjection::Selected(_) => None, + } +} + +fn pipeline_path_output_hydration_needs( + projection: &GraphReturnProjection, + output: &GraphOutputOptions, +) -> Option { + match projection { + GraphReturnProjection::IdOnly => None, + GraphReturnProjection::Auto => match output.mode { + GraphOutputMode::Ids | GraphOutputMode::Projected => None, + GraphOutputMode::Elements => crate::graph_row::path_source_needs_from_element( + GraphElementProjection::Full, + output.include_vectors, + ), + }, + GraphReturnProjection::Element(element) => { + crate::graph_row::path_source_needs_from_element( + element.clone(), + output.include_vectors, + ) + } + GraphReturnProjection::Selected(GraphSelectedProjection::Path(selected)) => { + crate::graph_row::path_source_needs_from_selected(selected) + } + GraphReturnProjection::Selected(_) => None, + } +} + +fn pipeline_hydrate_output_value( + value: crate::graph_row::GraphEvalValue, + nodes_by_id: &NodeIdMap, + edges_by_id: &NodeIdMap, +) -> Result { + Ok(match value { + crate::graph_row::GraphEvalValue::Node(node) => { + if let Some(element) = nodes_by_id.get(&node.id) { + crate::graph_row::GraphEvalValue::Node( + crate::graph_row::GraphBoundNode::with_element(node.id, element.clone()), + ) + } else { + crate::graph_row::GraphEvalValue::Node(node) + } + } + crate::graph_row::GraphEvalValue::Edge(edge) => { + if let Some(element) = edges_by_id.get(&edge.id) { + crate::graph_row::GraphEvalValue::Edge( + crate::graph_row::GraphBoundEdge::with_element(edge.id, element.clone()), + ) + } else { + crate::graph_row::GraphEvalValue::Edge(edge) + } + } + crate::graph_row::GraphEvalValue::Path(path) => { + let graph_path = path.path.clone(); + let nodes = path + .nodes + .into_iter() + .map(|node| { + if let Some(element) = nodes_by_id.get(&node.id) { + crate::graph_row::GraphBoundNode::with_element(node.id, element.clone()) + } else { + node + } + }) + .collect::>(); + let edges = path + .edges + .into_iter() + .map(|edge| { + if let Some(element) = edges_by_id.get(&edge.id) { + crate::graph_row::GraphBoundEdge::with_element(edge.id, element.clone()) + } else { + edge + } + }) + .collect::>(); + crate::graph_row::GraphEvalValue::Path(crate::graph_row::GraphBoundPath::with_values( + graph_path, + nodes, + edges, + )?) + } + crate::graph_row::GraphEvalValue::List(values) => crate::graph_row::GraphEvalValue::List( + values + .into_iter() + .map(|value| pipeline_hydrate_output_value(value, nodes_by_id, edges_by_id)) + .collect::, EngineError>>()?, + ), + crate::graph_row::GraphEvalValue::Map(values) => crate::graph_row::GraphEvalValue::Map( + values + .into_iter() + .map(|(key, value)| { + Ok(( + key, + pipeline_hydrate_output_value(value, nodes_by_id, edges_by_id)?, + )) + }) + .collect::, EngineError>>()?, + ), + crate::graph_row::GraphEvalValue::Null + | crate::graph_row::GraphEvalValue::Bool(_) + | crate::graph_row::GraphEvalValue::Int(_) + | crate::graph_row::GraphEvalValue::UInt(_) + | crate::graph_row::GraphEvalValue::Float(_) + | crate::graph_row::GraphEvalValue::String(_) + | crate::graph_row::GraphEvalValue::Bytes(_) => value, + }) +} + +fn execute_pipeline_scalar_project_stage( + stage: &NormalizedPipelineProjectStage, + rows: &[crate::graph_row::GraphBindingRow], + options: &GraphPipelineOptions, +) -> Result, EngineError> { + let mut projected = Vec::with_capacity(rows.len()); + for row in rows { + let mut output = stage.output_schema.empty_row(); + for mapping in &stage.internal_mappings { + let value = row.value_for_slot(mapping.source)?; + bind_pipeline_value_to_slot(&stage.output_schema, &mut output, mapping.target, value)?; + } + for item in &stage.items { + let value = match (item.source_slot, item.expr.as_ref()) { + (Some(source), None) => row.value_for_slot(source)?, + (None, Some(expr)) => { + let context = crate::graph_row::BoundGraphEvalContext { row }; + crate::graph_row::eval_bound_graph_expr(expr, &context)? + } + _ => { + return Err(EngineError::InvalidOperation( + "graph pipeline projection item has invalid source/expression state" + .to_string(), + )); + } + }; + bind_pipeline_value_to_slot(&stage.output_schema, &mut output, item.output_slot, value)?; + } + projected.push(output); + // Terminal projections cannot expand row count; final result emission applies max_rows. + if stage.kind != GraphProjectKind::Return && projected.len() > options.max_pipeline_rows { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline exceeded max_pipeline_rows {}", + options.max_pipeline_rows + ))); + } + } + Ok(projected) +} + +fn execute_pipeline_aggregate_stage( + stage: &NormalizedPipelineProjectStage, + aggregate: &NormalizedPipelineAggregate, + rows: Vec, + options: &GraphPipelineOptions, +) -> Result { + let mut groups: BTreeMap, PipelineAggregateGroup> = + BTreeMap::new(); + for row in &rows { + let context = crate::graph_row::BoundGraphEvalContext { row }; + let mut values = Vec::with_capacity(aggregate.group_keys.len()); + for group in &aggregate.group_keys { + values.push(crate::graph_row::eval_bound_graph_expr(&group.expr, &context)?); + } + let key = values + .iter() + .map(crate::graph_row::graph_canonical_key_for_value) + .collect::, _>>()?; + if !groups.contains_key(&key) && groups.len() >= options.max_groups { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline exceeded max_groups {}", + options.max_groups + ))); + } + let group = groups.entry(key).or_insert_with(|| PipelineAggregateGroup { + values, + states: aggregate + .calls + .iter() + .map(PipelineAggregateState::new) + .collect(), + }); + for (call, state) in aggregate.calls.iter().zip(group.states.iter_mut()) { + state.accumulate(call, row, options)?; + } + } + if rows.is_empty() && aggregate.group_keys.is_empty() { + groups.insert( + Vec::new(), + PipelineAggregateGroup { + values: Vec::new(), + states: aggregate + .calls + .iter() + .map(PipelineAggregateState::new) + .collect(), + }, + ); + } + + let mut collect_items = 0usize; + let mut aggregate_distinct_keys = 0usize; + let group_count = groups.len(); + let mut output_rows = Vec::with_capacity(group_count); + for (key, group) in groups { + let mut eval_row = aggregate.eval_schema.empty_row(); + for (value, group_key) in group.values.into_iter().zip(&aggregate.group_keys) { + bind_pipeline_value_to_slot( + &aggregate.eval_schema, + &mut eval_row, + group_key.eval_slot, + value, + )?; + } + for (state, call) in group.states.into_iter().zip(&aggregate.calls) { + aggregate_distinct_keys = + aggregate_distinct_keys.saturating_add(state.distinct_key_count()); + let value = state.final_value()?; + if let crate::graph_row::GraphEvalValue::List(items) = &value { + if call.function == GraphAggregateFunction::Collect { + collect_items = collect_items.saturating_add(items.len()); + } + } + bind_pipeline_value_to_slot(&aggregate.eval_schema, &mut eval_row, call.eval_slot, value)?; + } + + let eval_context = crate::graph_row::BoundGraphEvalContext { row: &eval_row }; + let mut output = stage.output_schema.empty_row(); + if let Some(slot) = aggregate.internal_cursor_slot { + output.bind_scalar( + slot, + crate::graph_row::GraphEvalValue::Bytes( + crate::graph_row::encode_graph_canonical_keys(&key)?, + ), + )?; + } + for order in &aggregate.order_outputs { + let value = crate::graph_row::eval_bound_graph_expr(&order.expr, &eval_context)?; + bind_pipeline_value_to_slot(&stage.output_schema, &mut output, order.output_slot, value)?; + } + for item in &stage.items { + let Some(expr) = item.aggregate_expr.as_ref() else { + return Err(EngineError::InvalidOperation( + "graph pipeline aggregate projection item is missing aggregate expression" + .to_string(), + )); + }; + let value = crate::graph_row::eval_bound_graph_expr(expr, &eval_context)?; + bind_pipeline_value_to_slot(&stage.output_schema, &mut output, item.output_slot, value)?; + } + output_rows.push(output); + // Terminal aggregate output is bounded by max_groups and final max_rows pagination. + if stage.kind != GraphProjectKind::Return && output_rows.len() > options.max_pipeline_rows { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline exceeded max_pipeline_rows {}", + options.max_pipeline_rows + ))); + } + } + Ok(PipelineAggregateOutcome { + rows: output_rows, + groups: group_count, + collect_items, + aggregate_distinct_keys, + }) +} + +impl PipelineAggregateState { + fn new(call: &NormalizedPipelineAggregateCall) -> Self { + let inner = match call.function { + GraphAggregateFunction::Count => PipelineAggregateInner::Count(0), + GraphAggregateFunction::Sum => PipelineAggregateInner::Sum(PipelineSumState::Empty), + GraphAggregateFunction::Avg => PipelineAggregateInner::Avg { sum: 0.0, count: 0 }, + GraphAggregateFunction::Min | GraphAggregateFunction::Max => { + PipelineAggregateInner::MinMax(None) + } + GraphAggregateFunction::Collect => PipelineAggregateInner::Collect(Vec::new()), + }; + Self { + function: call.function, + distinct_seen: call.distinct.then(BTreeSet::new), + inner, + } + } + + fn accumulate( + &mut self, + call: &NormalizedPipelineAggregateCall, + row: &crate::graph_row::GraphBindingRow, + options: &GraphPipelineOptions, + ) -> Result<(), EngineError> { + let value = match call.arg.as_ref() { + Some(arg) => { + let context = crate::graph_row::BoundGraphEvalContext { row }; + crate::graph_row::eval_bound_graph_expr(arg, &context)? + } + None => crate::graph_row::GraphEvalValue::Null, + }; + if call.arg.is_some() && value.is_null() { + return Ok(()); + } + if let Some(seen) = self.distinct_seen.as_mut() { + let key = vec![crate::graph_row::graph_canonical_key_for_value(&value)?]; + if !seen.contains(&key) && seen.len() >= options.max_groups { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline aggregate DISTINCT exceeded max_groups {}", + options.max_groups + ))); + } + if !seen.insert(key) { + return Ok(()); + } + } + match &mut self.inner { + PipelineAggregateInner::Count(count) => { + *count = count.checked_add(1).ok_or_else(|| { + EngineError::InvalidOperation("graph pipeline count overflow".to_string()) + })?; + } + PipelineAggregateInner::Sum(sum) => sum.accumulate(&value)?, + PipelineAggregateInner::Avg { sum, count } => { + let value = aggregate_numeric_as_f64(&value, "avg")?; + *sum = checked_finite_aggregate_float(*sum + value, "avg result")?; + *count = count.checked_add(1).ok_or_else(|| { + EngineError::InvalidOperation("graph pipeline avg count overflow".to_string()) + })?; + } + PipelineAggregateInner::MinMax(current) => { + validate_min_max_value(&value)?; + if let Some(existing) = current.as_ref() { + let ordering = partial_cmp_aggregate_values(&value, existing)?; + let replace = match self.function { + GraphAggregateFunction::Min => ordering.is_lt(), + GraphAggregateFunction::Max => ordering.is_gt(), + _ => false, + }; + if replace { + *current = Some(value); + } + } else { + *current = Some(value); + } + } + PipelineAggregateInner::Collect(items) => { + if items.len() >= options.max_collect_items { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline collect exceeded max_collect_items {}", + options.max_collect_items + ))); + } + items.push(value); + } + } + Ok(()) + } + + fn final_value(self) -> Result { + Ok(match self.inner { + PipelineAggregateInner::Count(count) => crate::graph_row::GraphEvalValue::UInt(count), + PipelineAggregateInner::Sum(sum) => sum.final_value()?, + PipelineAggregateInner::Avg { sum, count } => { + if count == 0 { + crate::graph_row::GraphEvalValue::Null + } else { + let count = crate::property_value_semantics::exact_u64_to_f64( + count, + "graph pipeline avg count", + )?; + crate::graph_row::GraphEvalValue::Float(checked_finite_aggregate_float( + sum / count, + "avg result", + )?) + } + } + PipelineAggregateInner::MinMax(value) => { + value.unwrap_or(crate::graph_row::GraphEvalValue::Null) + } + PipelineAggregateInner::Collect(items) => crate::graph_row::GraphEvalValue::List(items), + }) + } + + fn distinct_key_count(&self) -> usize { + self.distinct_seen.as_ref().map_or(0, BTreeSet::len) + } +} + +impl PipelineSumState { + fn accumulate(&mut self, value: &crate::graph_row::GraphEvalValue) -> Result<(), EngineError> { + match self { + PipelineSumState::Empty => match value { + crate::graph_row::GraphEvalValue::Int(value) => *self = PipelineSumState::Int(*value), + crate::graph_row::GraphEvalValue::UInt(value) => { + let value = i64::try_from(*value).map_err(|_| { + EngineError::InvalidOperation( + "graph pipeline sum unsigned value does not fit signed output" + .to_string(), + ) + })?; + *self = PipelineSumState::Int(value); + } + crate::graph_row::GraphEvalValue::Float(value) => { + *self = PipelineSumState::Float(checked_finite_aggregate_float( + *value, + "sum input", + )?) + } + _ => return Err(aggregate_numeric_error("sum")), + }, + PipelineSumState::Int(current) => match value { + crate::graph_row::GraphEvalValue::Int(value) => { + *current = current.checked_add(*value).ok_or_else(|| { + EngineError::InvalidOperation("graph pipeline sum overflow".to_string()) + })?; + } + crate::graph_row::GraphEvalValue::UInt(value) => { + let value = i64::try_from(*value).map_err(|_| { + EngineError::InvalidOperation( + "graph pipeline sum unsigned value does not fit signed output" + .to_string(), + ) + })?; + *current = current.checked_add(value).ok_or_else(|| { + EngineError::InvalidOperation("graph pipeline sum overflow".to_string()) + })?; + } + crate::graph_row::GraphEvalValue::Float(value) => { + *self = PipelineSumState::Float(checked_finite_aggregate_float( + crate::property_value_semantics::exact_i64_to_f64( + *current, + "graph pipeline sum integer input", + )? + + checked_finite_aggregate_float(*value, "sum input")?, + "sum result", + )?); + } + _ => return Err(aggregate_numeric_error("sum")), + }, + PipelineSumState::Float(current) => { + let value = aggregate_numeric_as_f64(value, "sum")?; + *current = checked_finite_aggregate_float(*current + value, "sum result")?; + } + } + Ok(()) + } + + fn final_value(self) -> Result { + Ok(match self { + PipelineSumState::Empty => crate::graph_row::GraphEvalValue::Null, + PipelineSumState::Int(value) => crate::graph_row::GraphEvalValue::Int(value), + PipelineSumState::Float(value) => crate::graph_row::GraphEvalValue::Float( + checked_finite_aggregate_float(value, "sum result")?, + ), + }) + } +} + +fn aggregate_numeric_as_f64( + value: &crate::graph_row::GraphEvalValue, + function: &str, +) -> Result { + match value { + crate::graph_row::GraphEvalValue::Int(value) => { + crate::property_value_semantics::exact_i64_to_f64( + *value, + "graph pipeline aggregate integer input", + ) + } + crate::graph_row::GraphEvalValue::UInt(value) => { + if function == "sum" { + let value = i64::try_from(*value).map_err(|_| { + EngineError::InvalidOperation( + "graph pipeline sum unsigned value does not fit signed output" + .to_string(), + ) + })?; + crate::property_value_semantics::exact_i64_to_f64( + value, + "graph pipeline sum unsigned input", + ) + } else { + crate::property_value_semantics::exact_u64_to_f64( + *value, + "graph pipeline aggregate unsigned input", + ) + } + } + crate::graph_row::GraphEvalValue::Float(value) => { + checked_finite_aggregate_float(*value, "aggregate float input") + } + _ => Err(aggregate_numeric_error(function)), + } +} + +fn checked_finite_aggregate_float(value: f64, context: &str) -> Result { + if value.is_finite() { + Ok(value) + } else { + Err(EngineError::InvalidOperation(format!( + "graph pipeline aggregate {context} must be finite" + ))) + } +} + +fn aggregate_numeric_error(function: &str) -> EngineError { + EngineError::InvalidOperation(format!( + "graph pipeline {function} accepts numeric inputs only" + )) +} + +fn validate_min_max_value(value: &crate::graph_row::GraphEvalValue) -> Result<(), EngineError> { + match value { + crate::graph_row::GraphEvalValue::Bool(_) + | crate::graph_row::GraphEvalValue::Int(_) + | crate::graph_row::GraphEvalValue::UInt(_) + | crate::graph_row::GraphEvalValue::String(_) => Ok(()), + crate::graph_row::GraphEvalValue::Float(value) => { + checked_finite_aggregate_float(*value, "min/max input").map(|_| ()) + } + _ => Err(EngineError::InvalidOperation( + "graph pipeline min/max support numeric, string, and bool inputs only".to_string(), + )), + } +} + +fn partial_cmp_aggregate_values( + left: &crate::graph_row::GraphEvalValue, + right: &crate::graph_row::GraphEvalValue, +) -> Result { + let left_numeric = aggregate_numeric_key(left)?; + let right_numeric = aggregate_numeric_key(right)?; + match (left_numeric, right_numeric) { + (Some(left), Some(right)) => { + return Ok(crate::property_value_semantics::compare_numeric_keys(left, right)); + } + (Some(_), None) | (None, Some(_)) => { + return Err(EngineError::InvalidOperation( + "graph pipeline min/max cannot mix incompatible numeric and non-numeric domains" + .to_string(), + )); + } + _ => {} + } + match (left, right) { + ( + crate::graph_row::GraphEvalValue::Bool(left), + crate::graph_row::GraphEvalValue::Bool(right), + ) => Ok(left.cmp(right)), + ( + crate::graph_row::GraphEvalValue::String(left), + crate::graph_row::GraphEvalValue::String(right), + ) => Ok(left.cmp(right)), + _ => Err(EngineError::InvalidOperation( + "graph pipeline min/max cannot mix incompatible value domains".to_string(), + )), + } +} + +fn aggregate_numeric_key( + value: &crate::graph_row::GraphEvalValue, +) -> Result, EngineError> { + Ok(match value { + crate::graph_row::GraphEvalValue::Int(value) => { + Some(crate::property_value_semantics::numeric_key_from_i64(*value)) + } + crate::graph_row::GraphEvalValue::UInt(value) => { + Some(crate::property_value_semantics::numeric_key_from_u64(*value)) + } + crate::graph_row::GraphEvalValue::Float(value) => Some( + crate::property_value_semantics::numeric_key_from_f64(*value).ok_or_else(|| { + EngineError::InvalidOperation( + "graph pipeline min/max float input must be finite".to_string(), + ) + })?, + ), + _ => None, + }) +} + +fn pipeline_apply_distinct( + stage: &NormalizedPipelineProjectStage, + rows: &mut Vec, + options: &GraphPipelineOptions, +) -> Result { + let mut seen = BTreeSet::new(); + let mut unique = Vec::with_capacity(rows.len()); + for row in rows.drain(..) { + let key = + crate::graph_row::graph_canonical_key_for_row_slots(&row, &stage.distinct_slots)?; + if !seen.contains(&key) && seen.len() >= options.max_groups { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline DISTINCT exceeded max_groups {}", + options.max_groups + ))); + } + if seen.insert(key) { + unique.push(row); + } + } + let key_count = seen.len(); + *rows = unique; + Ok(key_count) +} + +fn pipeline_union_branch_return_projections( + branch: &NormalizedPipelineUnionBranch, +) -> Arc<[GraphReturnProjection]> { + branch + .pipeline + .terminal_return_items + .iter() + .map(|item| item.projection.clone()) + .collect::>() + .into() +} + +fn pipeline_apply_union_distinct( + stage: &NormalizedPipelineUnionStage, + rows: &mut Vec, + row_projections: &mut Vec>, +) -> Result { + if row_projections.len() != rows.len() { + return Err(EngineError::InvalidOperation(format!( + "GraphUnionStage projection sidecar length {} does not match row length {}", + row_projections.len(), + rows.len() + ))); + } + let mut seen = BTreeSet::new(); + let mut unique = Vec::with_capacity(rows.len()); + let mut unique_projections = Vec::with_capacity(row_projections.len()); + for (row, projections) in rows.drain(..).zip(row_projections.drain(..)) { + let key = + crate::graph_row::graph_canonical_key_for_row_slots(&row, &stage.distinct_slots)?; + if !seen.contains(&key) && seen.len() >= stage.branches[0].pipeline.options.max_groups { + return Err(EngineError::InvalidOperation(format!( + "GraphUnionStage DISTINCT exceeded max_groups {}", + stage.branches[0].pipeline.options.max_groups + ))); + } + if seen.insert(key) { + unique.push(row); + unique_projections.push(projections); + } + } + let key_count = seen.len(); + *rows = unique; + *row_projections = unique_projections; + Ok(key_count) +} + +fn pipeline_union_output_row( + stage: &NormalizedPipelineUnionStage, + branch: &NormalizedPipelineUnionBranch, + row: crate::graph_row::GraphBindingRow, + ordinal: u64, +) -> Result { + let mut output = stage.output_schema.empty_row(); + output.bind_scalar( + stage.ordinal_slot, + crate::graph_row::GraphEvalValue::UInt(ordinal), + )?; + for mapping in &branch.output_mappings { + let value = row.value_for_slot(mapping.source)?; + bind_pipeline_value_to_slot(&stage.output_schema, &mut output, mapping.target, value)?; + } + Ok(output) +} + +fn pipeline_final_logical_key( + pipeline: &NormalizedGraphPipeline, + ordinal: usize, + row: &crate::graph_row::GraphBindingRow, +) -> Result, EngineError> { + if pipeline_uses_pipeline_order_cursor(pipeline) { + let ordinal: u64 = ordinal.try_into().map_err(|_| { + EngineError::InvalidOperation( + "graph pipeline output ordinal does not fit cursor key".to_string(), + ) + })?; + return Ok(vec![crate::graph_row::GraphSortAtom::Bytes( + ordinal.to_be_bytes().to_vec(), + )]); + } + row.logical_sort_key(&pipeline.terminal_schema) +} + +fn pipeline_uses_pipeline_order_cursor(pipeline: &NormalizedGraphPipeline) -> bool { + pipeline.preserve_pipeline_order && pipeline.terminal_order_by.is_empty() +} + +fn pipeline_bridge_rows( + rows: &[crate::graph_row::GraphBindingRow], + input_schema: &crate::graph_row::GraphBindingSchema, + output_schema: &crate::graph_row::GraphBindingSchema, + mappings: &[PipelineSlotMapping], +) -> Result, EngineError> { + rows.iter() + .map(|row| { + let mut output = output_schema.empty_row(); + for mapping in mappings { + let value = row.value_for_slot(mapping.source)?; + let source_info = input_schema.slot(mapping.source).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "graph pipeline source slot {:?}:{} is missing", + mapping.source.kind, mapping.source.index + )) + })?; + let target_info = output_schema.slot(mapping.target).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "graph pipeline target slot {:?}:{} is missing", + mapping.target.kind, mapping.target.index + )) + })?; + if source_info.kind != target_info.kind { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline slot kind mismatch for alias '{}'", + source_info.name + ))); + } + bind_pipeline_value_to_slot(output_schema, &mut output, mapping.target, value)?; + } + Ok(output) + }) + .collect() +} + +fn pipeline_copy_row_to_schema( + input_schema: &crate::graph_row::GraphBindingSchema, + output_schema: &crate::graph_row::GraphBindingSchema, + row: &crate::graph_row::GraphBindingRow, +) -> Result { + let mut output = output_schema.empty_row(); + let slots = input_schema + .slots() + .iter() + .map(|slot| crate::graph_row::GraphBindingSlotRef { + kind: slot.kind, + index: slot.index, + }) + .collect::>(); + output.copy_slots_from(row, &slots)?; + Ok(output) +} + +fn pipeline_optional_input_key( + stage: &NormalizedPipelineMatchStage, + row: &crate::graph_row::GraphBindingRow, +) -> Result, EngineError> { + crate::graph_row::graph_canonical_key_for_row_slots(row, &stage.input_slots) +} + +fn pipeline_optional_row_is_miss( + stage: &NormalizedPipelineMatchStage, + row: &crate::graph_row::GraphBindingRow, +) -> Result { + if stage.optional_slots.is_empty() { + return Ok(false); + } + stage + .optional_slots + .iter() + .map(|slot| row.slot_is_null(*slot)) + .try_fold(true, |all_null, is_null| Ok(all_null && is_null?)) +} + +fn pipeline_optional_null_extend_row( + stage: &NormalizedPipelineMatchStage, + row: &crate::graph_row::GraphBindingRow, +) -> Result { + let mut output = row.clone(); + for slot in &stage.optional_slots { + if !output.slot_is_null(*slot)? { + output.set_null(&stage.query.binding_schema, *slot)?; + } + } + Ok(output) +} + +fn pipeline_attach_cursor_keys( + rows: Vec, + source_schema: &crate::graph_row::GraphBindingSchema, + output_schema: &crate::graph_row::GraphBindingSchema, + mappings: &[PipelineSlotMapping], + cursor_slot: crate::graph_row::GraphBindingSlotRef, +) -> Result, EngineError> { + rows.into_iter() + .map(|row| { + let cursor_key = pipeline_cursor_key_bytes(&row, source_schema)?; + let mut output = output_schema.empty_row(); + for mapping in mappings { + if mapping.target == cursor_slot { + continue; + } + let value = row.value_for_slot(mapping.source)?; + bind_pipeline_value_to_slot(output_schema, &mut output, mapping.target, value)?; + } + output.bind_scalar( + cursor_slot, + crate::graph_row::GraphEvalValue::Bytes(cursor_key), + )?; + Ok(output) + }) + .collect() +} + +fn pipeline_cursor_key_bytes( + row: &crate::graph_row::GraphBindingRow, + schema: &crate::graph_row::GraphBindingSchema, +) -> Result, EngineError> { + let key = row.logical_sort_key(schema)?; + let mut bytes = Vec::new(); + encode_graph_sort_atoms(&mut bytes, &key)?; + Ok(bytes) +} + +fn shortest_path_output_row( + stage: &NormalizedPipelineShortestPathStage, + input: &crate::graph_row::GraphBindingRow, + path: ShortestPath, +) -> Result { + let mut output = shortest_path_copy_input_row(stage, input)?; + output.bind_path( + stage.output_path_slot, + crate::graph_row::GraphBoundPath::id_only(GraphPath { + nodes: path.nodes, + edges: path.edges, + })?, + )?; + Ok(output) +} + +fn shortest_path_null_output_row( + stage: &NormalizedPipelineShortestPathStage, + input: &crate::graph_row::GraphBindingRow, +) -> Result { + let mut output = shortest_path_copy_input_row(stage, input)?; + output.set_null(&stage.output_schema, stage.output_path_slot)?; + Ok(output) +} + +fn resolve_shortest_path_endpoint( + endpoint: &ResolvedShortestPathEndpoint, + row: &crate::graph_row::GraphBindingRow, +) -> Result, EngineError> { + match endpoint { + ResolvedShortestPathEndpoint::Alias { slot } => row.node_id_for_slot_if_bound(*slot), + ResolvedShortestPathEndpoint::Static(id) => Ok(*id), + } +} + +fn shortest_path_copy_input_row( + stage: &NormalizedPipelineShortestPathStage, + input: &crate::graph_row::GraphBindingRow, +) -> Result { + let mut output = stage.output_schema.empty_row(); + for mapping in &stage.input_mappings { + let value = input.value_for_slot(mapping.source)?; + bind_pipeline_value_to_slot(&stage.output_schema, &mut output, mapping.target, value)?; + } + Ok(output) +} + +fn pipeline_call_output_row( + stage: &NormalizedPipelineCallStage, + outer: &crate::graph_row::GraphBindingRow, + subquery: &crate::graph_row::GraphBindingRow, +) -> Result { + let mut output = stage.output_schema.empty_row(); + for mapping in &stage.input_mappings { + let value = outer.value_for_slot(mapping.source)?; + bind_pipeline_value_to_slot(&stage.output_schema, &mut output, mapping.target, value)?; + } + for mapping in &stage.output_mappings { + let value = subquery.value_for_slot(mapping.source)?; + bind_pipeline_value_to_slot(&stage.output_schema, &mut output, mapping.target, value)?; + } + Ok(output) +} + +fn bind_pipeline_value_to_slot( + schema: &crate::graph_row::GraphBindingSchema, + row: &mut crate::graph_row::GraphBindingRow, + slot: crate::graph_row::GraphBindingSlotRef, + value: crate::graph_row::GraphEvalValue, +) -> Result<(), EngineError> { + if value.is_null() { + return row.set_null(schema, slot); + } + match (slot.kind, value) { + (crate::graph_row::GraphBindingSlotKind::Node, crate::graph_row::GraphEvalValue::Node(value)) => row.bind_node(slot, value), + (crate::graph_row::GraphBindingSlotKind::Edge, crate::graph_row::GraphEvalValue::Edge(value)) => row.bind_edge(slot, value), + (crate::graph_row::GraphBindingSlotKind::Path, crate::graph_row::GraphEvalValue::Path(value)) => row.bind_path(slot, value), + (crate::graph_row::GraphBindingSlotKind::Scalar, value) => row.bind_scalar(slot, value), + (kind, _) => Err(EngineError::InvalidOperation(format!( + "graph pipeline cannot bind value to {kind:?} slot" + ))), + } +} + +fn sort_pipeline_rows( + rows: &mut [crate::graph_row::GraphBindingRow], + schema: &crate::graph_row::GraphBindingSchema, + order_by: &[crate::graph_row::BoundGraphOrderItem], +) -> Result<(), EngineError> { + let directions = graph_row_order_directions(order_by); + let mut keyed = rows + .iter() + .cloned() + .map(|row| { + let sort_key = pipeline_explicit_sort_key(order_by, &row)?; + let logical_key = row.logical_sort_key(schema)?; + Ok((row, sort_key, logical_key)) + }) + .collect::, EngineError>>()?; + keyed.sort_by(|left, right| { + compare_graph_final_keys_by_directions( + &left.1, + &left.2, + &right.1, + &right.2, + &directions, + ) + }); + for (target, (row, _, _)) in rows.iter_mut().zip(keyed) { + *target = row; + } + Ok(()) +} + +fn pipeline_explicit_sort_key( + order_by: &[crate::graph_row::BoundGraphOrderItem], + row: &crate::graph_row::GraphBindingRow, +) -> Result, EngineError> { + if order_by.is_empty() { + return Ok(Vec::new()); + } + let context = crate::graph_row::BoundGraphEvalContext { row }; + order_by + .iter() + .map(|item| { + let value = crate::graph_row::eval_bound_graph_expr(&item.expr, &context)?; + crate::graph_row::graph_sort_atom_for_value(&value) + }) + .collect() +} + +fn pipeline_order_needs( + _schema: &crate::graph_row::GraphBindingSchema, + _order_by: &[crate::graph_row::BoundGraphOrderItem], +) -> Result { + Ok(EntityProjectionNeeds::default()) +} + +fn pipeline_projection_needs_is_empty(needs: &EntityProjectionNeeds) -> bool { + needs.nodes.is_empty() + && needs.edges.is_empty() + && needs.paths.is_empty() + && needs.hidden_edges.is_empty() + && needs.hidden_paths.is_empty() +} + +fn pipeline_enforce_intermediate_rows( + len: usize, + options: &GraphPipelineOptions, + cap: &str, +) -> Result<(), EngineError> { + let limit = match cap { + "max_pipeline_rows" => options.max_pipeline_rows, + _ => options.max_intermediate_bindings, + }; + if len > limit { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline exceeded {cap} {limit}" + ))); + } + Ok(()) +} + +fn pipeline_schema_columns(schema: &crate::graph_row::GraphBindingSchema) -> Vec { + schema + .slots() + .iter() + .filter(|slot| pipeline_slot_is_user_visible(slot)) + .filter_map(|slot| slot.user_alias.clone()) + .collect() +} + +fn pipeline_union_branch_count(pipeline: &NormalizedGraphPipeline) -> usize { + pipeline + .stages + .iter() + .filter_map(|stage| match stage { + NormalizedGraphPipelineStage::Union(stage) => Some(stage.branches.len()), + _ => None, + }) + .sum() +} + +fn pipeline_match_stage_detail( + stage: &NormalizedPipelineMatchStage, + output_rows: Option, +) -> String { + let seeded = pipeline_match_seeded_node_aliases(stage); + let carried = pipeline_match_carried_aliases(stage, &seeded); + format!( + "graph-row-backed match stage; seeded_node_aliases={}; carried_aliases={}; rows={}; optional_candidate_filter={}; optional_candidate_exists_predicates={}", + seeded.join(","), + carried.join(","), + output_rows + .map(|value| value.to_string()) + .unwrap_or_else(|| "n/a".to_string()), + stage.optional_candidate_filter.is_some(), + stage + .optional_candidate_filter + .as_ref() + .map(|filter| filter.exists_predicates.len()) + .unwrap_or(0) + ) +} + +fn pipeline_match_stage_notes( + stage: &NormalizedPipelineMatchStage, + optional_filter_execution: Option<&PipelineOptionalCandidateFilterExecution>, +) -> Vec { + let mut notes = vec![ + "match executed through the Phase 32 graph-row runtime".to_string(), + ]; + let Some(filter) = stage.optional_candidate_filter.as_ref() else { + return notes; + }; + let (input_rows, candidate_rows, passed_rows, preserved_miss_rows, synthesized_miss_rows) = + optional_filter_execution + .map(|execution| { + ( + execution.input_rows.to_string(), + execution.candidate_rows.to_string(), + execution.passed_rows.to_string(), + execution.preserved_miss_rows.to_string(), + execution.synthesized_miss_rows.to_string(), + ) + }) + .unwrap_or_else(|| { + ( + "n/a".to_string(), + "n/a".to_string(), + "n/a".to_string(), + "n/a".to_string(), + "n/a".to_string(), + ) + }); + let (subquery_invocations, subquery_cache_hits) = optional_filter_execution + .map(|execution| { + ( + execution.subquery_invocations.to_string(), + execution.subquery_cache_hits.to_string(), + ) + }) + .unwrap_or_else(|| ("n/a".to_string(), "n/a".to_string())); + notes.push(format!( + "optional candidate filter: input_rows={input_rows}; candidate_rows={candidate_rows}; passed_rows={passed_rows}; preserved_miss_rows={preserved_miss_rows}; synthesized_miss_rows={synthesized_miss_rows}; subquery_invocations={subquery_invocations}; subquery_cache_hits={subquery_cache_hits}; left_outer=true" + )); + for predicate in &filter.exists_predicates { + notes.push(format!( + "optional EXISTS subquery {}: mode={}, imports={}, internal_limit={}, physical_exists_probe={}, invocation_cap={}, cache=canonical-correlation-key", + predicate.output_alias, + pipeline_subquery_correlation_mode(&predicate.import_aliases), + pipeline_alias_list(&predicate.import_aliases), + predicate.internal_limit, + pipeline_exists_probe_plan(&predicate.query).is_some(), + predicate.query.options.max_subquery_invocations + )); + notes.push(format!( + "optional EXISTS subquery {} nested stages: {}", + predicate.output_alias, + pipeline_stage_kind_summary(&predicate.query) + )); + } + notes +} + +fn pipeline_shortest_path_stage_detail( + stage: &NormalizedPipelineShortestPathStage, + options: &GraphPipelineOptions, + execution: Option<&PipelineShortestPathStageExecution>, +) -> String { + let algorithm = if stage.weight_field.is_some() { + "bidirectional_dijkstra" + } else { + "bidirectional_bfs" + }; + let endpoint_mode = format!( + "{}->{}", + shortest_path_endpoint_detail(&stage.from), + shortest_path_endpoint_detail(&stage.to) + ); + let max_cost = stage + .max_cost + .map(|cost| cost.to_string()) + .unwrap_or_else(|| "none".to_string()); + let max_paths = pipeline_shortest_path_effective_max_paths(stage, options) + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()); + let (pair_count, cache_hits, no_path_count, emitted_path_count) = execution + .map(|execution| { + ( + execution.pair_count.to_string(), + execution.cache_hits.to_string(), + execution.no_path_count.to_string(), + execution.emitted_path_count.to_string(), + ) + }) + .unwrap_or_else(|| { + ( + "n/a".to_string(), + "n/a".to_string(), + "n/a".to_string(), + "n/a".to_string(), + ) + }); + format!( + "algorithm={algorithm}; mode={:?}; endpoint_mode={endpoint_mode}; direction={:?}; labels={}; min_hops={}; max_depth={}; max_cost={max_cost}; max_paths={max_paths}; output_path_alias={}; distinct_pair_count={pair_count}; cache_hits={cache_hits}; no_path_count={no_path_count}; emitted_path_count={emitted_path_count}", + stage.mode, + stage.direction, + if stage.edge_label_filter.is_empty() { + "*".to_string() + } else { + stage.edge_label_filter.join("|") + }, + stage.min_hops, + stage.max_hops, + stage.output_path_alias, + ) +} + +fn pipeline_shortest_path_effective_max_paths( + stage: &NormalizedPipelineShortestPathStage, + options: &GraphPipelineOptions, +) -> Option { + match stage.mode { + GraphShortestPathMode::One => None, + GraphShortestPathMode::All => Some(stage.max_paths.unwrap_or(options.max_paths_per_start)), + } +} + +fn shortest_path_endpoint_detail(endpoint: &NormalizedShortestPathEndpoint) -> String { + match endpoint { + NormalizedShortestPathEndpoint::Alias { alias, .. } => format!("alias({alias})"), + NormalizedShortestPathEndpoint::NodeId(_) => "node_id".to_string(), + NormalizedShortestPathEndpoint::NodeKey { .. } => "node_key".to_string(), + } +} + +fn shortest_path_direction_code(direction: Direction) -> u8 { + match direction { + Direction::Outgoing => 1, + Direction::Incoming => 2, + Direction::Both => 3, + } +} + +fn pipeline_match_seeded_node_aliases(stage: &NormalizedPipelineMatchStage) -> Vec { + let referenced = pipeline_match_referenced_node_aliases(&stage.query); + stage + .input_mappings + .iter() + .filter_map(|mapping| stage.query.binding_schema.slot(mapping.target)) + .filter(|slot| slot.kind == crate::graph_row::GraphBindingSlotKind::Node) + .filter(|slot| { + slot.user_alias + .as_ref() + .is_some_and(|alias| referenced.contains(alias)) + }) + .filter_map(|slot| slot.user_alias.clone()) + .collect::>() + .into_iter() + .collect() +} + +fn pipeline_match_referenced_node_aliases(query: &NormalizedGraphRowQuery) -> BTreeSet { + let mut aliases = BTreeSet::new(); + for piece in &query.pieces { + pipeline_collect_match_piece_node_aliases(piece, &mut aliases); + } + for node in &query.nodes { + if graph_node_pattern_has_structural_anchor(node) { + aliases.insert(node.alias.clone()); + } + } + aliases +} + +fn pipeline_collect_match_piece_node_aliases( + piece: &GraphPatternPiece, + aliases: &mut BTreeSet, +) { + match piece { + GraphPatternPiece::Edge(edge) => { + aliases.insert(edge.from_alias.clone()); + aliases.insert(edge.to_alias.clone()); + } + GraphPatternPiece::Optional(group) => { + for child in &group.pieces { + pipeline_collect_match_piece_node_aliases(child, aliases); + } + } + GraphPatternPiece::VariableLength(path) => { + aliases.insert(path.from_alias.clone()); + aliases.insert(path.to_alias.clone()); + } + } +} + +fn pipeline_match_carried_aliases( + stage: &NormalizedPipelineMatchStage, + seeded_node_aliases: &[String], +) -> Vec { + let seeded = seeded_node_aliases + .iter() + .cloned() + .collect::>(); + stage + .input_mappings + .iter() + .filter_map(|mapping| stage.query.binding_schema.slot(mapping.target)) + .filter_map(|slot| slot.user_alias.clone()) + .filter(|alias| !seeded.contains(alias)) + .collect::>() + .into_iter() + .collect() +} + +fn graph_row_runtime_warnings(warnings: &[QueryPlanWarning]) -> Vec { + warnings + .iter() + .map(|warning| format!("{warning:?}")) + .collect::>() + .into_iter() + .collect() +} + +fn pipeline_project_stage_detail( + stage: &NormalizedPipelineProjectStage, + input_rows: Option, + output_rows: Option, + aggregate_distinct_keys: Option, + subquery_stats: Option<(usize, usize)>, +) -> String { + let (group_keys, aggregate_calls, aggregate_order_outputs) = stage + .aggregate + .as_ref() + .map(|aggregate| { + ( + aggregate.group_keys.len(), + aggregate.calls.len(), + aggregate.order_outputs.len(), + ) + }) + .unwrap_or((0, 0, 0)); + let (subquery_invocations, subquery_cache_hits) = subquery_stats + .map(|(invocations, cache_hits)| (invocations.to_string(), cache_hits.to_string())) + .unwrap_or_else(|| ("n/a".to_string(), "n/a".to_string())); + format!( + "columns={}; input_rows={}; output_rows={}; distinct={}; aggregate={}; group_keys={}; aggregate_calls={}; aggregate_order_outputs={}; aggregate_distinct_keys={}; filter={}; exists_predicates={}; subquery_invocations={}; subquery_cache_hits={}; order_items={}; skip={}; limit={}; preserved={}; created_scalars={}; dropped={}", + stage.columns.join(","), + input_rows + .map(|value| value.to_string()) + .unwrap_or_else(|| "n/a".to_string()), + output_rows + .map(|value| value.to_string()) + .unwrap_or_else(|| "n/a".to_string()), + stage.distinct, + stage.aggregate.is_some(), + group_keys, + aggregate_calls, + aggregate_order_outputs, + aggregate_distinct_keys + .map(|value| value.to_string()) + .unwrap_or_else(|| "n/a".to_string()), + stage.where_expr.is_some(), + stage.exists_predicates.len(), + subquery_invocations, + subquery_cache_hits, + stage.order_by.len(), + stage.skip, + stage + .limit + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()), + pipeline_project_preserved_aliases(stage).join(","), + pipeline_project_created_scalar_aliases(stage).join(","), + pipeline_project_dropped_aliases(stage).join(",") + ) +} + +fn pipeline_project_stage_notes(stage: &NormalizedPipelineProjectStage) -> Vec { + let mut notes = Vec::new(); + let preserved = pipeline_project_preserved_aliases(stage); + if !preserved.is_empty() { + notes.push(format!("preserved aliases: {}", preserved.join(", "))); + } + let created = pipeline_project_created_scalar_aliases(stage); + if !created.is_empty() { + notes.push(format!("created scalar aliases: {}", created.join(", "))); + } + let scalar_exprs = pipeline_project_scalar_expression_summaries(stage); + if !scalar_exprs.is_empty() { + notes.push(format!("scalar expressions: {}", scalar_exprs.join(", "))); + } + if stage.distinct { + notes.push(format!( + "DISTINCT uses {} visible output slot(s) and preserves first occurrence order before projection-local row ops", + stage.distinct_slots.len() + )); + } + if let Some(aggregate) = stage.aggregate.as_ref() { + if aggregate.group_keys.is_empty() { + notes.push("aggregate grouping: global group".to_string()); + } else { + notes.push(format!( + "aggregate group keys: {}", + aggregate + .group_keys + .iter() + .map(|key| key.summary.clone()) + .collect::>() + .join(", ") + )); + } + notes.push(format!( + "aggregate calls: {}", + aggregate + .calls + .iter() + .map(|call| call.summary.clone()) + .collect::>() + .join(", ") + )); + if aggregate.calls.iter().any(|call| call.distinct) { + notes.push( + "aggregate DISTINCT uses per-group canonical key sets capped by max_groups" + .to_string(), + ); + } + if !aggregate.order_outputs.is_empty() { + notes.push(format!( + "aggregate ORDER BY materializes {} hidden output slot(s)", + aggregate.order_outputs.len() + )); + } + } + let dropped = pipeline_project_dropped_aliases(stage); + if !dropped.is_empty() { + notes.push(format!("dropped aliases: {}", dropped.join(", "))); + } + if stage.where_expr.is_some() { + notes.push("projection filter evaluated after output aliases are bound".to_string()); + } + if !stage.exists_predicates.is_empty() { + for predicate in &stage.exists_predicates { + notes.push(format!( + "EXISTS subquery {}: mode={}, imports={}, internal_limit={}, physical_exists_probe={}, invocation_cap={}, cache=canonical-correlation-key", + predicate.output_alias, + pipeline_subquery_correlation_mode(&predicate.import_aliases), + pipeline_alias_list(&predicate.import_aliases), + predicate.internal_limit, + pipeline_exists_probe_plan(&predicate.query).is_some(), + predicate.query.options.max_subquery_invocations + )); + notes.push(format!( + "EXISTS subquery {} nested stages: {}", + predicate.output_alias, + pipeline_stage_kind_summary(&predicate.query) + )); + } + } + if !stage.order_by.is_empty() || stage.skip > 0 || stage.limit.is_some() { + notes.push(format!( + "row ops: order_items={}, skip={}, limit={}", + stage.order_by.len(), + stage.skip, + stage + .limit + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()) + )); + } + if !stage.internal_mappings.is_empty() { + notes.push("internal cursor key preserved for deterministic paging".to_string()); + } + notes +} + +fn pipeline_exists_probe_plan( + pipeline: &NormalizedGraphPipeline, +) -> Option> { + let match_count = pipeline + .stages + .iter() + .filter(|stage| matches!(stage, NormalizedGraphPipelineStage::Match(_))) + .count(); + if match_count > 1 { + return None; + } + let mut match_stage = None; + for stage in &pipeline.stages { + match stage { + NormalizedGraphPipelineStage::Match(stage) => { + if stage.optional + || !graph_row_query_allows_physical_exists_probe(&stage.query) + { + return None; + } + match_stage = Some(stage); + } + NormalizedGraphPipelineStage::Project(stage) => { + if match_stage.is_none() && match_count == 1 { + return None; + } + if !pipeline_project_allows_physical_exists_probe(stage) { + return None; + } + if stage.limit == Some(0) { + return Some(PipelineExistsProbePlan { + match_stage, + always_false: true, + }); + } + } + NormalizedGraphPipelineStage::ShortestPath(_) + | NormalizedGraphPipelineStage::Call(_) + | NormalizedGraphPipelineStage::Union(_) => return None, + } + } + Some(PipelineExistsProbePlan { + match_stage, + always_false: false, + }) +} + +fn graph_row_query_allows_physical_exists_probe(query: &NormalizedGraphRowQuery) -> bool { + query.bound_where.is_none() + && query.bound_order_by.is_empty() + && !query + .pieces + .iter() + .any(graph_pattern_piece_blocks_physical_exists_probe) +} + +fn graph_pattern_piece_blocks_physical_exists_probe(piece: &GraphPatternPiece) -> bool { + match piece { + GraphPatternPiece::Edge(_) | GraphPatternPiece::VariableLength(_) => false, + GraphPatternPiece::Optional(_) => true, + } +} + +fn pipeline_project_allows_physical_exists_probe(stage: &NormalizedPipelineProjectStage) -> bool { + !stage.distinct + && stage.aggregate.is_none() + && stage.where_expr.is_none() + && stage.exists_predicates.is_empty() + && stage.order_by.is_empty() + && stage.skip == 0 + && stage + .items + .iter() + .all(pipeline_project_item_allows_physical_exists_probe) +} + +fn pipeline_project_item_allows_physical_exists_probe( + item: &NormalizedPipelineProjectItem, +) -> bool { + item.aggregate_expr.is_none() + && match item.expr.as_ref() { + None => true, + Some(expr) => bound_graph_expr_allows_physical_exists_probe(expr), + } +} + +fn bound_graph_expr_allows_physical_exists_probe( + expr: &crate::graph_row::BoundGraphExpr, +) -> bool { + match expr { + crate::graph_row::BoundGraphExpr::Null + | crate::graph_row::BoundGraphExpr::Bool(_) + | crate::graph_row::BoundGraphExpr::Int(_) + | crate::graph_row::BoundGraphExpr::UInt(_) + | crate::graph_row::BoundGraphExpr::Float(_) + | crate::graph_row::BoundGraphExpr::String(_) + | crate::graph_row::BoundGraphExpr::Bytes(_) => true, + crate::graph_row::BoundGraphExpr::List(items) => items + .iter() + .all(bound_graph_expr_allows_physical_exists_probe), + crate::graph_row::BoundGraphExpr::Map(items) => items + .values() + .all(bound_graph_expr_allows_physical_exists_probe), + crate::graph_row::BoundGraphExpr::Binding(_) + | crate::graph_row::BoundGraphExpr::Property { .. } + | crate::graph_row::BoundGraphExpr::NodeField { .. } + | crate::graph_row::BoundGraphExpr::EdgeField { .. } + | crate::graph_row::BoundGraphExpr::PathField { .. } => true, + crate::graph_row::BoundGraphExpr::Function { name, args } => { + *name == GraphFunction::Id + && args + .iter() + .all(bound_graph_expr_allows_physical_exists_probe) + } + crate::graph_row::BoundGraphExpr::IsNull(expr) + | crate::graph_row::BoundGraphExpr::IsNotNull(expr) => { + bound_graph_expr_allows_physical_exists_probe(expr) + } + crate::graph_row::BoundGraphExpr::Unary { .. } + | crate::graph_row::BoundGraphExpr::Binary { .. } + | crate::graph_row::BoundGraphExpr::Case { .. } => false, + } +} + +fn pipeline_call_stage_detail( + stage: &NormalizedPipelineCallStage, + input_rows: Option, + output_rows: Option, + invocation_stats: Option<(usize, usize)>, +) -> String { + let (invocations, cache_hits) = invocation_stats + .map(|(invocations, cache_hits)| (invocations.to_string(), cache_hits.to_string())) + .unwrap_or_else(|| ("n/a".to_string(), "n/a".to_string())); + format!( + "mode=inner_apply; correlation={}; imports={}; columns={}; input_rows={}; output_rows={}; invocations={}; cache_hits={}; invocation_cap={}; nested_stages={}", + pipeline_subquery_correlation_mode(&stage.import_aliases), + pipeline_alias_list(&stage.import_aliases), + stage.columns.join(","), + input_rows + .map(|value| value.to_string()) + .unwrap_or_else(|| "n/a".to_string()), + output_rows + .map(|value| value.to_string()) + .unwrap_or_else(|| "n/a".to_string()), + invocations, + cache_hits, + stage.query.options.max_subquery_invocations, + pipeline_stage_kind_summary(&stage.query), + ) +} + +fn pipeline_call_stage_notes( + stage: &NormalizedPipelineCallStage, + nested: Option<&GraphPipelineExplain>, +) -> Vec { + let mut notes = vec![ + "CALL uses native inner-apply subquery execution".to_string(), + "correlation cache key uses shared canonical graph key semantics".to_string(), + format!( + "import aliases: {}", + pipeline_alias_list(&stage.import_aliases) + ), + format!("output columns: {}", pipeline_alias_list(&stage.columns)), + ]; + if let Some(nested) = nested { + notes.push(format!( + "nested explain stages: {}", + nested + .stages + .iter() + .map(|stage| format!("{}: {}", stage.kind, stage.detail)) + .collect::>() + .join(" | ") + )); + } + notes +} + +fn pipeline_subquery_correlation_mode(import_aliases: &[String]) -> &'static str { + if import_aliases.is_empty() { + "uncorrelated" + } else { + "correlated" + } +} + +fn pipeline_alias_list(aliases: &[String]) -> String { + if aliases.is_empty() { + "none".to_string() + } else { + aliases.join(",") + } +} + +fn pipeline_stage_kind_summary(pipeline: &NormalizedGraphPipeline) -> String { + pipeline + .stages + .iter() + .map(|stage| match stage { + NormalizedGraphPipelineStage::Match(_) => "Match", + NormalizedGraphPipelineStage::ShortestPath(_) => "ShortestPath", + NormalizedGraphPipelineStage::Project(stage) => match stage.kind { + GraphProjectKind::With => "Project(With)", + GraphProjectKind::Return => "Project(Return)", + }, + NormalizedGraphPipelineStage::Call(_) => "Call", + NormalizedGraphPipelineStage::Union(stage) => { + if stage.all { + "UnionAll" + } else { + "Union" + } + } + }) + .collect::>() + .join(">") +} + +fn pipeline_union_stage_detail( + stage: &NormalizedPipelineUnionStage, + output_rows: Option, + dedup_keys: Option, +) -> String { + format!( + "branches={}; all={}; columns={}; output_rows={}; dedupe_keys={}; dedupe_cap={}", + stage.branches.len(), + stage.all, + stage.columns.join(","), + output_rows + .map(|value| value.to_string()) + .unwrap_or_else(|| "n/a".to_string()), + dedup_keys + .map(|value| value.to_string()) + .unwrap_or_else(|| "n/a".to_string()), + stage.branches[0].pipeline.options.max_groups, + ) +} + +fn pipeline_union_stage_notes( + stage: &NormalizedPipelineUnionStage, + branch_summaries: &[PipelineUnionBranchExplainSummary], +) -> Vec { + let mut notes = vec![format!( + "branch columns: {}", + stage.columns.join(", ") + )]; + if stage.all { + notes.push("UNION ALL appends branch rows in source branch order".to_string()); + } else { + notes.push(format!( + "UNION dedupes {} visible output slot(s) with canonical row keys and preserves first occurrence order", + stage.distinct_slots.len() + )); + } + notes.push( + "internal ordinal key preserves union order for final cursor paging".to_string(), + ); + notes.extend(pipeline_union_branch_summary_notes(branch_summaries)); + notes +} + +fn pipeline_union_branch_summary_notes( + branch_summaries: &[PipelineUnionBranchExplainSummary], +) -> Vec { + let mut notes = Vec::new(); + for branch in branch_summaries { + let branch_number = branch.branch_index + 1; + let stages = branch + .stages + .iter() + .map(|stage| format!("{}: {}", stage.kind, stage.detail)) + .collect::>(); + if !stages.is_empty() { + notes.push(format!( + "branch {branch_number} stages: {}", + stages.join(" | ") + )); + } + for row_op in &branch.row_ops { + notes.push(format!( + "branch {branch_number} row op: {}: {}", + row_op.kind, row_op.detail + )); + } + for warning in &branch.warnings { + notes.push(format!("branch {branch_number} warning: {warning}")); + } + } + notes +} + +fn pipeline_project_scalar_expression_summaries( + stage: &NormalizedPipelineProjectStage, +) -> Vec { + stage + .items + .iter() + .filter_map(|item| { + item.expr_summary + .as_ref() + .map(|expr| format!("{} := {expr}", item.output_name)) + }) + .collect() +} + +fn pipeline_project_preserved_aliases(stage: &NormalizedPipelineProjectStage) -> Vec { + stage + .items + .iter() + .filter(|item| item.source_slot.is_some()) + .map(|item| item.output_name.clone()) + .collect() +} + +fn pipeline_project_created_scalar_aliases(stage: &NormalizedPipelineProjectStage) -> Vec { + stage + .items + .iter() + .filter(|item| item.expr.is_some() || item.aggregate_expr.is_some()) + .map(|item| item.output_name.clone()) + .collect() +} + +fn pipeline_project_dropped_aliases(stage: &NormalizedPipelineProjectStage) -> Vec { + let output = stage + .output_schema + .slots() + .iter() + .filter_map(|slot| slot.user_alias.as_ref()) + .cloned() + .collect::>(); + stage + .input_schema + .slots() + .iter() + .filter_map(|slot| slot.user_alias.as_ref()) + .filter(|alias| !output.contains(*alias)) + .cloned() + .collect() +} + +fn graph_pipeline_explain_from_normalized( + pipeline: &NormalizedGraphPipeline, + stages: Vec, + stats: GraphPipelineStats, + fingerprints: GraphPipelineFingerprints, + warnings: Vec, +) -> GraphPipelineExplain { + GraphPipelineExplain { + columns: pipeline.columns.clone(), + effective_at_epoch: Some(stats.effective_at_epoch), + fingerprint: format!("{:032x}", fingerprints.query), + stages, + row_ops: pipeline_row_ops(pipeline), + order: GraphOrderExplain { + explicit: !pipeline.terminal_order_by.is_empty(), + items: pipeline.terminal_order_by.len(), + stable_logical_row_key: true, + }, + cursor: GraphCursorExplain { + supplied: pipeline.page.cursor.is_some(), + codec_implemented: true, + message: Some("logical graph pipeline cursor".to_string()), + }, + projection: GraphProjectionExplain { + columns: pipeline.columns.clone(), + output_mode: pipeline.output.mode.clone(), + include_vectors: pipeline.output.include_vectors, + compact_rows: pipeline.output.compact_rows, + }, + caps: graph_pipeline_cap_explain(&pipeline.options), + summaries: GraphExecutionSummaries { + validation_only: false, + rows_planned: stats.intermediate_rows, + warnings: warnings.clone(), + }, + stats, + warnings, + notes: vec![ + "pipeline match stages are graph-row-backed; projection stages use native scalar slots" + .to_string(), + ], + } +} + +fn graph_pipeline_cursor_fingerprints( + pipeline: &NormalizedGraphPipeline, + effective_at_epoch: i64, + original_skip: u64, +) -> GraphPipelineFingerprints { + let mut query_writer = GraphRowFingerprintWriter::new("pipeline_query_cursor"); + query_writer.u16(1); + query_writer.i64(effective_at_epoch); + query_writer.u64(original_skip); + query_writer.raw_bytes(&pipeline.fingerprint_shape.query_shape.to_be_bytes()); + GraphPipelineFingerprints { + query: query_writer.finish(), + order: pipeline.fingerprint_shape.order, + output: pipeline.fingerprint_shape.output, + params: pipeline.fingerprint_shape.params, + } +} + +fn pipeline_row_ops(pipeline: &NormalizedGraphPipeline) -> Vec { + let mut ops = Vec::new(); + for stage in &pipeline.stages { + match stage { + NormalizedGraphPipelineStage::ShortestPath(shortest) => { + ops.push(GraphRowOperationExplain { + kind: "ShortestPath".to_string(), + detail: format!( + "{:?} {} min_hops={} max_hops={} algorithm={}", + shortest.mode, + shortest.output_path_alias, + shortest.min_hops, + shortest.max_hops, + if shortest.weight_field.is_some() { + "bidirectional_dijkstra" + } else { + "bidirectional_bfs" + } + ), + }); + } + NormalizedGraphPipelineStage::Project(project) => { + if let Some(aggregate) = project.aggregate.as_ref() { + ops.push(GraphRowOperationExplain { + kind: "Aggregate".to_string(), + detail: format!( + "{:?} group_keys={} aggregate_calls={}", + project.kind, + aggregate.group_keys.len(), + aggregate.calls.len() + ), + }); + } + if project.distinct { + ops.push(GraphRowOperationExplain { + kind: "Distinct".to_string(), + detail: format!( + "{:?} DISTINCT visible_slots={}", + project.kind, + project.distinct_slots.len() + ), + }); + } + if project.where_expr.is_some() { + ops.push(GraphRowOperationExplain { + kind: "ProjectFilter".to_string(), + detail: format!("{:?} WHERE", project.kind), + }); + } + if !project.order_by.is_empty() { + ops.push(GraphRowOperationExplain { + kind: "Sort".to_string(), + detail: format!( + "{:?} ORDER BY {} item(s)", + project.kind, + project.order_by.len() + ), + }); + } + if project.skip > 0 { + ops.push(GraphRowOperationExplain { + kind: "Skip".to_string(), + detail: format!("{:?} SKIP {}", project.kind, project.skip), + }); + } + if let Some(limit) = project.limit { + ops.push(GraphRowOperationExplain { + kind: "Limit".to_string(), + detail: format!("{:?} LIMIT {}", project.kind, limit), + }); + } + } + NormalizedGraphPipelineStage::Call(call) => { + ops.push(GraphRowOperationExplain { + kind: "CallSubquery".to_string(), + detail: format!( + "imports={} nested_stages={}", + pipeline_alias_list(&call.import_aliases), + pipeline_stage_kind_summary(&call.query) + ), + }); + } + NormalizedGraphPipelineStage::Match(_) | NormalizedGraphPipelineStage::Union(_) => {} + } + } + ops +} + +const GRAPH_PIPELINE_LOGICAL_CURSOR_MAGIC: &[u8; 8] = b"OGR34PL1"; +const GRAPH_PIPELINE_LOGICAL_CURSOR_VERSION: u8 = 1; + +fn graph_pipeline_cursor_state_from_decoded( + decoded_cursor: Option, + page: &GraphPageRequest, + at_epoch: Option, + max_skip: usize, +) -> Result { + let effective_at_epoch = match (decoded_cursor.as_ref(), at_epoch) { + (None, Some(epoch)) => epoch, + (None, None) => now_millis(), + (Some(cursor), None) => cursor.effective_at_epoch, + (Some(cursor), Some(epoch)) if epoch == cursor.effective_at_epoch => epoch, + (Some(cursor), Some(epoch)) => { + return Err(invalid_graph_pipeline_cursor(format!( + "explicit at_epoch {epoch} does not match cursor epoch {}", + cursor.effective_at_epoch + ))); + } + }; + let original_skip = match decoded_cursor.as_ref() { + Some(cursor) => { + let current_skip = page.skip as u64; + if current_skip != 0 && current_skip != cursor.original_skip { + return Err(invalid_graph_pipeline_cursor(format!( + "cursor page skip {current_skip} does not match original skip {}", + cursor.original_skip + ))); + } + cursor.original_skip + } + None => page.skip as u64, + }; + if original_skip > max_skip as u64 { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline cursor original skip {original_skip} exceeds max_skip {max_skip}" + ))); + } + Ok(GraphPipelineCursorState { + decoded: decoded_cursor.clone(), + effective_at_epoch, + original_skip, + rows_emitted_after_skip: decoded_cursor.map_or(0, |cursor| cursor.rows_emitted_after_skip), + }) +} + +fn graph_pipeline_encode_logical_cursor( + cursor: &GraphPipelineCursorPayload, + max_cursor_bytes: usize, +) -> Result { + let mut bytes = Vec::new(); + bytes.extend_from_slice(GRAPH_PIPELINE_LOGICAL_CURSOR_MAGIC); + push_u8(&mut bytes, GRAPH_PIPELINE_LOGICAL_CURSOR_VERSION); + push_i64(&mut bytes, cursor.effective_at_epoch); + push_u64(&mut bytes, cursor.original_skip); + push_u64(&mut bytes, cursor.rows_emitted_after_skip); + push_u128(&mut bytes, cursor.query_fingerprint); + push_u128(&mut bytes, cursor.order_fingerprint); + push_u128(&mut bytes, cursor.output_fingerprint); + push_u128(&mut bytes, cursor.params_fingerprint); + encode_graph_sort_atoms(&mut bytes, &cursor.last_sort_key)?; + encode_graph_sort_atoms(&mut bytes, &cursor.last_logical_row_key)?; + let checksum = crate::types::fnv1a(&bytes); + push_u64(&mut bytes, checksum); + if bytes.len() > max_cursor_bytes { + return Err(invalid_graph_pipeline_cursor(format!( + "emitted graph pipeline cursor payload is {} bytes, exceeding max_cursor_bytes {}", + bytes.len(), + max_cursor_bytes + ))); + } + Ok(format!( + "{GRAPH_PIPELINE_CURSOR_PREFIX}{}", + base64url_no_pad_encode(&bytes) + )) +} + +fn graph_pipeline_decode_logical_cursor( + cursor: &str, + max_cursor_bytes: usize, +) -> Result { + let Some(encoded) = cursor.strip_prefix(GRAPH_PIPELINE_CURSOR_PREFIX) else { + return Err(invalid_graph_pipeline_cursor( + "invalid graph pipeline cursor prefix", + )); + }; + let transport_limit = graph_pipeline_encoded_cursor_transport_limit(max_cursor_bytes); + if cursor.len() > transport_limit { + return Err(invalid_graph_pipeline_cursor(format!( + "encoded graph pipeline cursor is too large to decode within max_cursor_bytes {}", + max_cursor_bytes + ))); + } + let bytes = base64url_no_pad_decode(encoded)?; + if bytes.len() > max_cursor_bytes { + return Err(invalid_graph_pipeline_cursor(format!( + "decoded graph pipeline cursor is {} bytes, exceeding max_cursor_bytes {}", + bytes.len(), + max_cursor_bytes + ))); + } + if bytes.len() < GRAPH_PIPELINE_LOGICAL_CURSOR_MAGIC.len() + 1 + 8 { + return Err(invalid_graph_pipeline_cursor( + "graph pipeline cursor payload is too short", + )); + } + let checksum_offset = bytes + .len() + .checked_sub(8) + .ok_or_else(|| invalid_graph_pipeline_cursor("graph pipeline cursor is missing checksum"))?; + let checksum = u64::from_be_bytes( + bytes[checksum_offset..] + .try_into() + .map_err(|_| invalid_graph_pipeline_cursor("graph pipeline cursor checksum is malformed"))?, + ); + if crate::types::fnv1a(&bytes[..checksum_offset]) != checksum { + return Err(invalid_graph_pipeline_cursor( + "graph pipeline cursor checksum mismatch", + )); + } + let mut reader = CursorPayloadReader::new(&bytes[..checksum_offset]); + if reader.take(GRAPH_PIPELINE_LOGICAL_CURSOR_MAGIC.len())? + != GRAPH_PIPELINE_LOGICAL_CURSOR_MAGIC + { + return Err(invalid_graph_pipeline_cursor( + "graph pipeline cursor magic mismatch", + )); + } + let version = reader.read_u8()?; + if version != GRAPH_PIPELINE_LOGICAL_CURSOR_VERSION { + return Err(invalid_graph_pipeline_cursor(format!( + "unsupported graph pipeline cursor version {version}" + ))); + } + let payload = GraphPipelineCursorPayload { + effective_at_epoch: reader.read_i64()?, + original_skip: reader.read_u64()?, + rows_emitted_after_skip: reader.read_u64()?, + query_fingerprint: reader.read_u128()?, + order_fingerprint: reader.read_u128()?, + output_fingerprint: reader.read_u128()?, + params_fingerprint: reader.read_u128()?, + last_sort_key: decode_graph_sort_atoms(&mut reader)?, + last_logical_row_key: decode_graph_sort_atoms(&mut reader)?, + }; + if !reader.is_finished() { + return Err(invalid_graph_pipeline_cursor( + "graph pipeline cursor payload has trailing bytes", + )); + } + Ok(payload) +} + +fn graph_pipeline_validate_cursor_fingerprints( + cursor: &GraphPipelineCursorPayload, + fingerprints: &GraphPipelineFingerprints, +) -> Result<(), EngineError> { + if cursor.query_fingerprint != fingerprints.query { + return Err(invalid_graph_pipeline_cursor( + "graph pipeline cursor query fingerprint mismatch", + )); + } + if cursor.order_fingerprint != fingerprints.order { + return Err(invalid_graph_pipeline_cursor( + "graph pipeline cursor order fingerprint mismatch", + )); + } + if cursor.output_fingerprint != fingerprints.output { + return Err(invalid_graph_pipeline_cursor( + "graph pipeline cursor output fingerprint mismatch", + )); + } + if cursor.params_fingerprint != fingerprints.params { + return Err(invalid_graph_pipeline_cursor( + "graph pipeline cursor params fingerprint mismatch", + )); + } + Ok(()) +} + +fn graph_pipeline_validate_cursor_shape( + pipeline: &NormalizedGraphPipeline, + cursor: &GraphPipelineCursorPayload, +) -> Result<(), EngineError> { + if cursor.last_sort_key.len() != pipeline.terminal_order_by.len() { + return Err(invalid_graph_pipeline_cursor(format!( + "graph pipeline cursor sort key has {} atom(s), expected {}", + cursor.last_sort_key.len(), + pipeline.terminal_order_by.len() + ))); + } + if pipeline_uses_pipeline_order_cursor(pipeline) { + if cursor.last_logical_row_key.len() != 1 { + return Err(invalid_graph_pipeline_cursor(format!( + "graph pipeline cursor logical row key has {} atom(s), expected 1 pipeline-order atom", + cursor.last_logical_row_key.len() + ))); + } + if !matches!( + cursor.last_logical_row_key.first(), + Some(crate::graph_row::GraphSortAtom::Bytes(value)) if value.len() == 8 + ) { + return Err(invalid_graph_pipeline_cursor( + "graph pipeline cursor logical row key does not contain a pipeline-order atom", + )); + } + return Ok(()); + } + if cursor.last_logical_row_key.len() != pipeline.terminal_schema.slots().len() { + return Err(invalid_graph_pipeline_cursor(format!( + "graph pipeline cursor logical row key has {} atom(s), expected {}", + cursor.last_logical_row_key.len(), + pipeline.terminal_schema.slots().len() + ))); + } + for (slot, atom) in pipeline + .terminal_schema + .slots() + .iter() + .zip(cursor.last_logical_row_key.iter()) + { + if pipeline_internal_cursor_slot_info(slot) + && !matches!(atom, crate::graph_row::GraphSortAtom::Bytes(_)) + { + return Err(invalid_graph_pipeline_cursor(format!( + "graph pipeline cursor internal cursor key atom does not match slot '{}'", + slot.name + ))); + } + if !graph_pipeline_cursor_atom_matches_slot(pipeline, atom, slot) { + return Err(invalid_graph_pipeline_cursor(format!( + "graph pipeline cursor logical row key atom does not match slot '{}'", + slot.name + ))); + } + if let crate::graph_row::GraphSortAtom::Path { + hop_count, + nodes, + edges, + } = atom + { + graph_row_validate_cursor_path_atom(*hop_count, nodes, edges)?; + } + } + for (index, (item, atom)) in pipeline + .terminal_order_by + .iter() + .zip(cursor.last_sort_key.iter()) + .enumerate() + { + let expectation = if graph_pipeline_order_expr_is_any_value_slot(pipeline, &item.expr) { + GraphRowCursorAtomExpectation::AnyOrderable + } else { + graph_row_cursor_order_atom_expectation(&item.expr, &pipeline.terminal_schema)? + }; + if !graph_row_cursor_atom_matches_expectation(atom, expectation) { + return Err(invalid_graph_pipeline_cursor(format!( + "graph pipeline cursor order key atom {} does not match order expression result kind", + index + 1 + ))); + } + if let crate::graph_row::GraphSortAtom::Path { + hop_count, + nodes, + edges, + } = atom + { + graph_row_validate_cursor_path_atom(*hop_count, nodes, edges)?; + } + } + Ok(()) +} + +fn graph_pipeline_cursor_atom_matches_slot( + pipeline: &NormalizedGraphPipeline, + atom: &crate::graph_row::GraphSortAtom, + slot: &crate::graph_row::GraphBindingSlot, +) -> bool { + if graph_row_cursor_atom_matches_slot(atom, slot) { + return true; + } + if slot.kind != crate::graph_row::GraphBindingSlotKind::Scalar { + return false; + } + let slot_ref = crate::graph_row::GraphBindingSlotRef { + kind: slot.kind, + index: slot.index, + }; + pipeline.terminal_any_value_slots.contains(&slot_ref) + && matches!( + atom, + crate::graph_row::GraphSortAtom::Node(_) + | crate::graph_row::GraphSortAtom::Edge(_) + | crate::graph_row::GraphSortAtom::Path { .. } + ) +} + +fn graph_pipeline_order_expr_is_any_value_slot( + pipeline: &NormalizedGraphPipeline, + expr: &crate::graph_row::BoundGraphExpr, +) -> bool { + let crate::graph_row::BoundGraphExpr::Binding(slot) = expr else { + return false; + }; + pipeline.terminal_any_value_slots.contains(slot) +} diff --git a/src/engine/pipeline_ir.rs b/src/engine/pipeline_ir.rs new file mode 100644 index 0000000..dd9800b --- /dev/null +++ b/src/engine/pipeline_ir.rs @@ -0,0 +1,2826 @@ +#[derive(Clone, Debug)] +struct NormalizedGraphPipeline { + initial_schema: crate::graph_row::GraphBindingSchema, + stages: Vec, + columns: Vec, + terminal_schema: crate::graph_row::GraphBindingSchema, + terminal_any_value_slots: Vec, + terminal_return_items: Vec, + terminal_output_needs: EntityProjectionNeeds, + terminal_order_by: Vec, + page: GraphPageRequest, + output: GraphOutputOptions, + options: GraphPipelineOptions, + fingerprint_shape: GraphPipelineFingerprintShape, + preserve_pipeline_order: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct GraphPipelineFingerprints { + query: u128, + order: u128, + output: u128, + params: u128, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct GraphPipelineFingerprintShape { + query_shape: u128, + order: u128, + output: u128, + params: u128, +} + +#[allow(clippy::large_enum_variant)] +#[derive(Clone, Debug)] +enum NormalizedGraphPipelineStage { + Match(NormalizedPipelineMatchStage), + ShortestPath(NormalizedPipelineShortestPathStage), + Project(NormalizedPipelineProjectStage), + Call(NormalizedPipelineCallStage), + Union(NormalizedPipelineUnionStage), +} + +#[derive(Clone, Debug)] +struct NormalizedPipelineMatchStage { + optional: bool, + query: NormalizedGraphRowQuery, + output_schema: crate::graph_row::GraphBindingSchema, + output_mappings: Vec, + cursor_slot: crate::graph_row::GraphBindingSlotRef, + input_mappings: Vec, + input_slots: Vec, + optional_slots: Vec, + optional_candidate_filter: Option, +} + +#[derive(Clone, Debug)] +struct NormalizedPipelineProjectStage { + kind: GraphProjectKind, + distinct: bool, + input_schema: crate::graph_row::GraphBindingSchema, + output_schema: crate::graph_row::GraphBindingSchema, + items: Vec, + internal_mappings: Vec, + distinct_slots: Vec, + aggregate: Option, + input_needs: EntityProjectionNeeds, + filter_needs: EntityProjectionNeeds, + order_needs: EntityProjectionNeeds, + where_expr: Option, + exists_predicates: Vec, + order_by: Vec, + skip: usize, + limit: Option, + columns: Vec, +} + +#[derive(Clone, Debug)] +struct NormalizedPipelineExistsPredicate { + output_slot: crate::graph_row::GraphBindingSlotRef, + output_alias: String, + import_aliases: Vec, + import_slots: Vec, + import_mappings: Vec, + query: NormalizedGraphPipeline, + internal_limit: bool, +} + +#[derive(Clone, Debug)] +struct NormalizedPipelineOptionalCandidateFilter { + eval_schema: crate::graph_row::GraphBindingSchema, + filter_needs: EntityProjectionNeeds, + where_expr: crate::graph_row::BoundGraphExpr, + exists_predicates: Vec, +} + +#[derive(Clone, Debug)] +struct NormalizedPipelineProjectItem { + output_name: String, + output_slot: crate::graph_row::GraphBindingSlotRef, + source_slot: Option, + expr: Option, + aggregate_expr: Option, + expr_summary: Option, + projection: GraphReturnProjection, +} + +#[derive(Clone, Debug)] +struct NormalizedPipelineShortestPathStage { + optional: bool, + mode: GraphShortestPathMode, + output_schema: crate::graph_row::GraphBindingSchema, + input_mappings: Vec, + output_path_slot: crate::graph_row::GraphBindingSlotRef, + output_path_alias: String, + from: NormalizedShortestPathEndpoint, + to: NormalizedShortestPathEndpoint, + direction: Direction, + edge_label_filter: Vec, + min_hops: u8, + max_hops: u8, + weight_field: Option, + max_cost: Option, + max_paths: Option, +} + +#[derive(Clone, Debug)] +enum NormalizedShortestPathEndpoint { + Alias { + alias: String, + slot: crate::graph_row::GraphBindingSlotRef, + }, + NodeId(u64), + NodeKey { label: String, key: String }, +} + +#[derive(Clone, Debug)] +struct NormalizedPipelineAggregate { + eval_schema: crate::graph_row::GraphBindingSchema, + group_keys: Vec, + calls: Vec, + order_outputs: Vec, + internal_cursor_slot: Option, +} + +#[derive(Clone, Debug)] +struct NormalizedPipelineGroupKey { + expr: crate::graph_row::BoundGraphExpr, + eval_slot: crate::graph_row::GraphBindingSlotRef, + summary: String, +} + +#[derive(Clone, Debug)] +struct NormalizedPipelineAggregateCall { + function: GraphAggregateFunction, + distinct: bool, + arg: Option, + eval_slot: crate::graph_row::GraphBindingSlotRef, + summary: String, +} + +#[derive(Clone, Debug)] +struct NormalizedPipelineAggregateOrderOutput { + expr: crate::graph_row::BoundGraphExpr, + output_slot: crate::graph_row::GraphBindingSlotRef, +} + +#[derive(Clone, Debug)] +struct NormalizedPipelineUnionStage { + all: bool, + branches: Vec, + output_schema: crate::graph_row::GraphBindingSchema, + ordinal_slot: crate::graph_row::GraphBindingSlotRef, + cursor_any_value_slots: Vec, + distinct_slots: Vec, + columns: Vec, + projections: Vec, +} + +#[derive(Clone, Debug)] +struct NormalizedPipelineCallStage { + input_schema: crate::graph_row::GraphBindingSchema, + output_schema: crate::graph_row::GraphBindingSchema, + input_mappings: Vec, + import_aliases: Vec, + import_slots: Vec, + import_mappings: Vec, + query: NormalizedGraphPipeline, + output_mappings: Vec, + columns: Vec, +} + +#[derive(Clone, Debug)] +struct NormalizedPipelineUnionBranch { + pipeline: NormalizedGraphPipeline, + output_mappings: Vec, +} + +#[derive(Clone, Debug)] +struct PipelineSlotMapping { + source: crate::graph_row::GraphBindingSlotRef, + target: crate::graph_row::GraphBindingSlotRef, +} + +const PIPELINE_CURSOR_KEY_SLOT: &str = "__og_pipeline_cursor_key"; +const PIPELINE_UNION_ORDER_SLOT: &str = "__og_union_order"; + +fn normalize_graph_pipeline_query( + query: &GraphPipelineQuery, +) -> Result { + normalize_graph_pipeline_query_with_initial_schema( + query, + crate::graph_row::GraphBindingSchema::new(), + 0, + ) +} + +fn normalize_graph_pipeline_query_with_initial_schema( + query: &GraphPipelineQuery, + initial_schema: crate::graph_row::GraphBindingSchema, + subquery_depth: usize, +) -> Result { + validate_graph_pipeline_request(query)?; + if subquery_depth > query.options.max_subquery_depth { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline subquery depth {subquery_depth} exceeds max_subquery_depth {}", + query.options.max_subquery_depth + ))); + } + let referenced_params = collect_graph_pipeline_referenced_params(query)?; + validate_graph_pipeline_referenced_params(&referenced_params, &query.options)?; + + let mut current_schema = initial_schema.clone(); + let mut stages = Vec::with_capacity(query.stages.len()); + let mut terminal_seen = false; + let mut terminal_schema = None; + let mut terminal_return_items = None; + let mut terminal_output_needs = None; + let mut terminal_order_by = None; + let mut columns = Vec::new(); + let mut current_any_value_slots = Vec::new(); + + for (index, stage) in query.stages.iter().enumerate() { + if terminal_seen { + return Err(EngineError::InvalidOperation( + "graph pipeline stages cannot appear after terminal Project(Return)".to_string(), + )); + } + match stage { + GraphPipelineStage::Match(match_stage) => { + let normalized = normalize_pipeline_match_stage( + match_stage, + ¤t_schema, + query, + subquery_depth, + )?; + let bridged_any_value_slots = pipeline_remap_any_value_slots( + ¤t_any_value_slots, + &normalized.input_mappings, + ); + current_any_value_slots = pipeline_remap_any_value_slots( + &bridged_any_value_slots, + &normalized.output_mappings, + ); + current_schema = normalized.output_schema.clone(); + stages.push(NormalizedGraphPipelineStage::Match(normalized)); + } + GraphPipelineStage::ShortestPath(shortest_stage) => { + let normalized = normalize_pipeline_shortest_path_stage( + shortest_stage, + ¤t_schema, + query, + )?; + current_any_value_slots = pipeline_remap_any_value_slots( + ¤t_any_value_slots, + &normalized.input_mappings, + ); + current_schema = normalized.output_schema.clone(); + stages.push(NormalizedGraphPipelineStage::ShortestPath(normalized)); + } + GraphPipelineStage::Project(project_stage) => { + let is_terminal = project_stage.kind == GraphProjectKind::Return; + if is_terminal && index + 1 != query.stages.len() { + return Err(EngineError::InvalidOperation( + "terminal Project(Return) must be the final graph pipeline stage" + .to_string(), + )); + } + let normalized = normalize_pipeline_project_stage( + project_stage, + ¤t_schema, + query, + subquery_depth, + )?; + if is_terminal { + columns = normalized.columns.clone(); + terminal_schema = Some(normalized.output_schema.clone()); + let terminal_graph_items = pipeline_terminal_graph_return_items(&normalized.items); + terminal_output_needs = Some( + crate::graph_row::collect_graph_row_projection_needs( + &normalized.output_schema, + &[], + &[], + None, + &[], + &terminal_graph_items, + &query.output, + )? + .output, + ); + terminal_return_items = Some(pipeline_terminal_return_items( + &normalized.output_schema, + &normalized.items, + )?); + terminal_order_by = Some(normalized.order_by.clone()); + terminal_seen = true; + } + current_any_value_slots = pipeline_project_any_value_slots( + ¤t_any_value_slots, + &normalized, + ); + current_schema = normalized.output_schema.clone(); + stages.push(NormalizedGraphPipelineStage::Project(normalized)); + } + GraphPipelineStage::Union(union_stage) => { + if index + 1 != query.stages.len() { + return Err(EngineError::InvalidOperation( + "GraphUnionStage must be the final graph pipeline stage".to_string(), + )); + } + let normalized = normalize_pipeline_union_stage( + union_stage, + ¤t_schema, + query, + subquery_depth, + )?; + columns = normalized.columns.clone(); + terminal_schema = Some(normalized.output_schema.clone()); + terminal_output_needs = Some( + crate::graph_row::collect_graph_row_projection_needs( + &normalized.output_schema, + &[], + &[], + None, + &[], + &pipeline_union_terminal_graph_return_items(&normalized), + &query.output, + )? + .output, + ); + terminal_return_items = + Some(pipeline_union_terminal_return_items(&normalized)?); + terminal_order_by = Some(Vec::new()); + terminal_seen = true; + current_any_value_slots = normalized.cursor_any_value_slots.clone(); + current_schema = normalized.output_schema.clone(); + stages.push(NormalizedGraphPipelineStage::Union(normalized)); + } + GraphPipelineStage::Call(call_stage) => { + let normalized = normalize_pipeline_call_stage( + call_stage, + ¤t_schema, + query, + subquery_depth, + )?; + current_any_value_slots = pipeline_call_any_value_slots( + ¤t_any_value_slots, + &normalized, + ); + current_schema = normalized.output_schema.clone(); + stages.push(NormalizedGraphPipelineStage::Call(normalized)); + } + } + } + + if !terminal_seen { + return Err(EngineError::InvalidOperation( + "graph pipeline requires a terminal Project(Return) stage".to_string(), + )); + } + + let terminal_schema = terminal_schema.expect("terminal schema recorded"); + let terminal_return_items = terminal_return_items.expect("terminal return items recorded"); + let terminal_output_needs = terminal_output_needs.expect("terminal output needs recorded"); + let terminal_order_by = terminal_order_by.expect("terminal order recorded"); + let fingerprint_shape = graph_pipeline_fingerprint_shape( + query, + &columns, + &referenced_params, + &terminal_order_by, + ); + let preserve_pipeline_order = stages + .iter() + .any(|stage| matches!(stage, NormalizedGraphPipelineStage::Call(_))); + + Ok(NormalizedGraphPipeline { + initial_schema, + stages, + columns, + terminal_schema, + terminal_any_value_slots: current_any_value_slots, + terminal_return_items, + terminal_output_needs, + terminal_order_by, + page: query.page.clone(), + output: query.output.clone(), + options: query.options.clone(), + fingerprint_shape, + preserve_pipeline_order, + }) +} + +fn validate_graph_pipeline_request(query: &GraphPipelineQuery) -> Result<(), EngineError> { + if query.stages.is_empty() { + return Err(EngineError::InvalidOperation( + "graph pipeline requires at least one stage".to_string(), + )); + } + if query.page.limit == 0 { + return Err(EngineError::InvalidOperation( + "graph pipeline page limit must be greater than zero".to_string(), + )); + } + if query.page.limit > query.options.max_rows { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline page limit {} exceeds max_rows {}", + query.page.limit, query.options.max_rows + ))); + } + if query.page.skip > query.options.max_skip { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline page skip {} exceeds max_skip {}", + query.page.skip, query.options.max_skip + ))); + } + if query.options.max_rows == 0 + || query.options.max_pipeline_rows == 0 + || query.options.max_groups == 0 + || query.options.max_shortest_path_pairs == 0 + || query.options.max_paths_per_start == 0 + || query.options.max_intermediate_bindings == 0 + { + return Err(EngineError::InvalidOperation( + "graph pipeline row, group, and path caps must be greater than zero".to_string(), + )); + } + Ok(()) +} + +fn normalize_pipeline_union_stage( + stage: &GraphUnionStage, + input_schema: &crate::graph_row::GraphBindingSchema, + parent: &GraphPipelineQuery, + subquery_depth: usize, +) -> Result { + if stage.branches.len() < 2 { + return Err(EngineError::InvalidOperation( + "GraphUnionStage requires at least two branches".to_string(), + )); + } + if stage.branches.len() > parent.options.max_union_branches { + return Err(EngineError::InvalidOperation(format!( + "GraphUnionStage has {} branch(es), exceeding max_union_branches {}", + stage.branches.len(), + parent.options.max_union_branches + ))); + } + + let mut branches = Vec::with_capacity(stage.branches.len()); + for (index, branch) in stage.branches.iter().enumerate() { + validate_pipeline_union_branch_request(index, branch, parent)?; + let mut branch_query = branch.clone(); + branch_query.params = parent.params.clone(); + branch_query.at_epoch = None; + branch_query.page = GraphPageRequest { + skip: 0, + limit: parent.options.max_rows.max(1), + cursor: None, + }; + branch_query.output = parent.output.clone(); + branch_query.options = parent.options.clone(); + branches.push(normalize_graph_pipeline_query_with_initial_schema( + &branch_query, + input_schema.clone(), + subquery_depth, + )?); + } + + let first = branches.first().expect("branch count checked"); + let columns = first.columns.clone(); + let column_metadata = pipeline_union_column_metadata(&branches, &columns)?; + let projections = column_metadata + .iter() + .map(|metadata| metadata.projection.clone()) + .collect::>(); + let mut output_schema = crate::graph_row::GraphBindingSchema::new(); + let ordinal_slot = output_schema.add_internal_scalar( + PIPELINE_UNION_ORDER_SLOT.to_string(), + false, + )?; + let mut cursor_any_value_slots = Vec::new(); + for (column, metadata) in columns.iter().zip(column_metadata.iter()) { + let slot = add_pipeline_output_slot( + &mut output_schema, + column, + metadata.kind, + metadata.nullable, + )?; + if metadata.kind == crate::graph_row::GraphBindingSlotKind::Scalar + && (metadata.mixed_kinds || metadata.any_value) + { + cursor_any_value_slots.push(slot); + } + } + let distinct_slots = pipeline_visible_slots(&output_schema); + + let normalized_branches = branches + .into_iter() + .enumerate() + .map(|(index, pipeline)| { + validate_pipeline_union_branch_columns(index, &columns, &output_schema, &pipeline)?; + let output_mappings = + pipeline_union_branch_output_mappings(&pipeline.terminal_schema, &output_schema, &columns)?; + Ok(NormalizedPipelineUnionBranch { + pipeline, + output_mappings, + }) + }) + .collect::, EngineError>>()?; + + Ok(NormalizedPipelineUnionStage { + all: stage.all, + branches: normalized_branches, + output_schema, + ordinal_slot, + cursor_any_value_slots, + distinct_slots, + columns, + projections, + }) +} + +fn validate_pipeline_union_branch_request( + index: usize, + branch: &GraphPipelineQuery, + parent: &GraphPipelineQuery, +) -> Result<(), EngineError> { + if branch.page.cursor.is_some() { + return Err(EngineError::InvalidOperation(format!( + "GraphUnionStage branch {} cannot supply a raw cursor", + index + 1 + ))); + } + if branch.page.skip != 0 { + return Err(EngineError::InvalidOperation(format!( + "GraphUnionStage branch {} cannot use public page skip", + index + 1 + ))); + } + if branch.at_epoch.is_some() { + return Err(EngineError::InvalidOperation(format!( + "GraphUnionStage branch {} cannot override the parent at_epoch", + index + 1 + ))); + } + if !branch.params.is_empty() && branch.params != parent.params { + return Err(EngineError::InvalidOperation(format!( + "GraphUnionStage branch {} params must match the parent pipeline params", + index + 1 + ))); + } + Ok(()) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct PipelineUnionColumnMetadata { + kind: crate::graph_row::GraphBindingSlotKind, + nullable: bool, + projection: GraphReturnProjection, + mixed_kinds: bool, + any_value: bool, +} + +fn pipeline_union_column_metadata( + branches: &[NormalizedGraphPipeline], + columns: &[String], +) -> Result, EngineError> { + columns + .iter() + .enumerate() + .map(|(column_index, column)| { + let first = branches.first().expect("branch count checked"); + let first_source = first.terminal_schema.slot_for_alias(column).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphUnionStage first branch terminal schema is missing column '{column}'" + )) + })?; + let first_info = first.terminal_schema.slot(first_source).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphUnionStage first branch column '{column}' has no slot metadata" + )) + })?; + if first_info.kind == crate::graph_row::GraphBindingSlotKind::HiddenOccurrence { + return Err(EngineError::InvalidOperation(format!( + "GraphUnionStage branch 1 column '{column}' cannot expose a hidden occurrence slot" + ))); + } + let mut nullable = first_info.nullable; + let mut kinds = vec![first_info.kind]; + let mut any_value = first.terminal_any_value_slots.contains(&first_source); + let first_projection = + first.terminal_return_items.get(column_index).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphUnionStage first branch is missing return projection for column '{column}'" + )) + })?; + let mut projections = vec![first_projection.projection.clone()]; + for (index, branch) in branches.iter().enumerate().skip(1) { + if branch.columns.len() != columns.len() || branch.columns != columns { + continue; + } + let source = branch.terminal_schema.slot_for_alias(column).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphUnionStage branch {} terminal schema is missing column '{column}'", + index + 1 + )) + })?; + let source_info = branch.terminal_schema.slot(source).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphUnionStage branch {} column '{column}' has no slot metadata", + index + 1 + )) + })?; + if source_info.kind == crate::graph_row::GraphBindingSlotKind::HiddenOccurrence { + return Err(EngineError::InvalidOperation(format!( + "GraphUnionStage branch {} column '{column}' cannot expose a hidden occurrence slot", + index + 1 + ))); + } + nullable |= source_info.nullable; + kinds.push(source_info.kind); + any_value |= branch.terminal_any_value_slots.contains(&source); + let projection = branch.terminal_return_items.get(column_index).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphUnionStage branch {} is missing return projection for column '{column}'", + index + 1 + )) + })?; + projections.push(projection.projection.clone()); + } + Ok(PipelineUnionColumnMetadata { + kind: pipeline_union_output_kind(&kinds), + nullable, + projection: pipeline_union_output_projection(&projections), + mixed_kinds: pipeline_union_kinds_are_mixed(&kinds), + any_value, + }) + }) + .collect() +} + +fn pipeline_union_kinds_are_mixed(kinds: &[crate::graph_row::GraphBindingSlotKind]) -> bool { + let first = kinds.first().copied().expect("at least one branch kind"); + kinds.iter().any(|kind| *kind != first) +} + +fn pipeline_union_output_kind( + kinds: &[crate::graph_row::GraphBindingSlotKind], +) -> crate::graph_row::GraphBindingSlotKind { + let first = kinds.first().copied().expect("at least one branch kind"); + if kinds.iter().all(|kind| *kind == first) { + first + } else { + crate::graph_row::GraphBindingSlotKind::Scalar + } +} + +fn pipeline_union_output_projection(projections: &[GraphReturnProjection]) -> GraphReturnProjection { + let first = projections + .first() + .cloned() + .expect("at least one branch projection"); + if projections.iter().all(|projection| projection == &first) { + return first; + } + // Actual union output uses branch-aware row projection sidecars. This fallback is + // intentionally non-escalating so metadata never widens Selected(...) to Full. + GraphReturnProjection::Auto +} + +fn validate_pipeline_union_branch_columns( + index: usize, + columns: &[String], + output_schema: &crate::graph_row::GraphBindingSchema, + branch: &NormalizedGraphPipeline, +) -> Result<(), EngineError> { + if branch.columns.len() != columns.len() { + return Err(EngineError::InvalidOperation(format!( + "GraphUnionStage branch {} returns {} column(s), expected {}", + index + 1, + branch.columns.len(), + columns.len() + ))); + } + if branch.columns != columns { + return Err(EngineError::InvalidOperation(format!( + "GraphUnionStage branch {} columns {:?} do not match {:?}", + index + 1, + branch.columns, + columns + ))); + } + for column in columns { + let source = branch.terminal_schema.slot_for_alias(column).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphUnionStage branch {} terminal schema is missing column '{column}'", + index + 1 + )) + })?; + let target = output_schema.slot_for_alias(column).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphUnionStage output schema is missing column '{column}'" + )) + })?; + let source_info = branch.terminal_schema.slot(source).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphUnionStage branch {} column '{column}' has no slot metadata", + index + 1 + )) + })?; + let target_info = output_schema.slot(target).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphUnionStage output column '{column}' has no slot metadata" + )) + })?; + if source_info.kind == crate::graph_row::GraphBindingSlotKind::HiddenOccurrence { + return Err(EngineError::InvalidOperation(format!( + "GraphUnionStage branch {} column '{column}' cannot expose a hidden occurrence slot", + index + 1 + ))); + } + if target_info.kind != crate::graph_row::GraphBindingSlotKind::Scalar + && source_info.kind != target_info.kind + { + return Err(EngineError::InvalidOperation(format!( + "GraphUnionStage branch {} column '{column}' has kind {:?}, expected {:?}", + index + 1, + source_info.kind, + target_info.kind + ))); + } + } + Ok(()) +} + +fn pipeline_union_branch_output_mappings( + branch_schema: &crate::graph_row::GraphBindingSchema, + output_schema: &crate::graph_row::GraphBindingSchema, + columns: &[String], +) -> Result, EngineError> { + columns + .iter() + .map(|column| { + let source = branch_schema.slot_for_alias(column).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphUnionStage branch schema is missing column '{column}'" + )) + })?; + let target = output_schema.slot_for_alias(column).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphUnionStage output schema is missing column '{column}'" + )) + })?; + Ok(PipelineSlotMapping { source, target }) + }) + .collect() +} + +fn normalize_pipeline_match_stage( + stage: &GraphPipelineMatchStage, + input_schema: &crate::graph_row::GraphBindingSchema, + query: &GraphPipelineQuery, + subquery_depth: usize, +) -> Result { + if !stage.optional && stage.optional_candidate_where.is_some() { + return Err(EngineError::InvalidOperation( + "GraphPipelineMatchStage optional_candidate_where requires optional=true".to_string(), + )); + } + let mut nodes = stage.nodes.clone(); + let mut pieces = pipeline_match_stage_pieces(stage, input_schema)?; + let where_ = if stage.optional { + None + } else { + stage + .where_ + .as_ref() + .map(|expr| resolve_graph_expr_params(expr, &query.params)) + .transpose()? + }; + if stage.optional { + let optional_where = stage + .where_ + .as_ref() + .map(|expr| resolve_graph_expr_params(expr, &query.params)) + .transpose()?; + pieces = vec![GraphPatternPiece::Optional(GraphOptionalGroup { + pieces, + where_: optional_where, + })]; + } + pipeline_add_input_node_patterns(input_schema, &mut nodes); + validate_pipeline_node_aliases(&nodes)?; + validate_pipeline_piece_aliases(&pieces)?; + let mut options = graph_query_options_from_pipeline(&query.options); + options.max_page_limit = options + .max_page_limit + .max(query.options.max_intermediate_bindings.max(1)); + let stage_page_limit = query.options.max_intermediate_bindings.saturating_sub(1).max(1); + let graph_row_query = GraphRowQuery { + nodes, + pieces, + where_, + return_items: None, + order_by: Vec::new(), + page: GraphPageRequest { + skip: 0, + limit: stage_page_limit, + cursor: None, + }, + at_epoch: query.at_epoch, + params: query.params.clone(), + output: query.output.clone(), + options, + }; + let normalized = normalize_graph_row_query_with_pipeline_input( + &graph_row_query, + &BTreeMap::new(), + None, + &[], + input_schema, + )?; + let mut output_schema = normalized.binding_schema.clone(); + let cursor_slot = ensure_pipeline_cursor_key_slot(&mut output_schema)?; + let input_mappings = pipeline_slot_mappings(input_schema, &normalized.binding_schema)?; + let output_mappings = pipeline_slot_mappings(&normalized.binding_schema, &output_schema)?; + let input_slots = input_mappings + .iter() + .map(|mapping| mapping.target) + .collect::>(); + let optional_slots = pipeline_optional_match_introduced_slots( + &normalized.binding_schema, + &input_slots, + ); + let optional_candidate_filter = stage + .optional_candidate_where + .as_ref() + .map(|expr| { + normalize_pipeline_optional_candidate_filter( + expr, + &normalized.binding_schema, + query, + subquery_depth, + ) + }) + .transpose()?; + Ok(NormalizedPipelineMatchStage { + optional: stage.optional, + query: normalized, + output_schema, + output_mappings, + cursor_slot, + input_mappings, + input_slots, + optional_slots, + optional_candidate_filter, + }) +} + +fn pipeline_optional_match_introduced_slots( + schema: &crate::graph_row::GraphBindingSchema, + input_slots: &[crate::graph_row::GraphBindingSlotRef], +) -> Vec { + schema + .slots() + .iter() + .filter_map(|slot| { + let slot_ref = crate::graph_row::GraphBindingSlotRef { + kind: slot.kind, + index: slot.index, + }; + if slot.nullable && !input_slots.contains(&slot_ref) { + Some(slot_ref) + } else { + None + } + }) + .collect() +} + +fn normalize_pipeline_optional_candidate_filter( + expr: &GraphExpr, + base_schema: &crate::graph_row::GraphBindingSchema, + query: &GraphPipelineQuery, + subquery_depth: usize, +) -> Result { + let mut eval_schema = base_schema.clone(); + let mut where_expr = Some(resolve_graph_expr_params(expr, &query.params)?); + let exists_predicates = normalize_pipeline_exists_predicates( + &mut where_expr, + &mut eval_schema, + query, + subquery_depth, + )?; + let where_expr = where_expr.ok_or_else(|| { + EngineError::InvalidOperation( + "optional candidate filter normalization lost the predicate".to_string(), + ) + })?; + let filter_needs = crate::graph_row::collect_graph_expr_projection_needs( + &eval_schema, + &where_expr, + ProjectionNeedClass::Residual, + )?; + let where_expr = crate::graph_row::bind_graph_expr(&eval_schema, &where_expr)?; + Ok(NormalizedPipelineOptionalCandidateFilter { + eval_schema, + filter_needs, + where_expr, + exists_predicates, + }) +} + +fn pipeline_match_stage_pieces( + stage: &GraphPipelineMatchStage, + input_schema: &crate::graph_row::GraphBindingSchema, +) -> Result, EngineError> { + if !stage.pieces.is_empty() { + return Ok(stage.pieces.clone()); + } + let new_nodes = stage + .nodes + .iter() + .filter(|node| input_schema.slot_for_alias(&node.alias).is_none()) + .collect::>(); + if new_nodes.len() > 1 { + return Err(EngineError::InvalidOperation( + "graph pipeline node-only Match stages may introduce at most one unconnected node alias" + .to_string(), + )); + } + Ok(Vec::new()) +} + +fn normalize_pipeline_shortest_path_stage( + stage: &GraphShortestPathStage, + input_schema: &crate::graph_row::GraphBindingSchema, + query: &GraphPipelineQuery, +) -> Result { + validate_graph_pipeline_user_alias(&stage.output_path_alias, "shortest-path alias")?; + if stage.min_hops > stage.max_hops { + return Err(EngineError::InvalidOperation(format!( + "GraphShortestPathStage min_hops {} exceeds max_hops {}", + stage.min_hops, stage.max_hops + ))); + } + if stage.max_hops > query.options.max_path_hops { + return Err(EngineError::InvalidOperation(format!( + "GraphShortestPathStage max_hops {} exceeds max_path_hops {}", + stage.max_hops, query.options.max_path_hops + ))); + } + if stage.mode == GraphShortestPathMode::All + && stage.max_paths.unwrap_or(query.options.max_paths_per_start) == 0 + { + return Err(EngineError::InvalidOperation( + "GraphShortestPathStage max_paths must be greater than zero".to_string(), + )); + } + let mut output_schema = input_schema.clone(); + let output_path_slot = output_schema.add_path_alias(stage.output_path_alias.clone(), stage.optional)?; + let input_mappings = pipeline_slot_mappings(input_schema, &output_schema)?; + let from = normalize_shortest_path_endpoint(&stage.from, input_schema, &query.params)?; + let to = normalize_shortest_path_endpoint(&stage.to, input_schema, &query.params)?; + let mut edge_label_filter = stage.edge_label_filter.clone(); + edge_label_filter.sort(); + edge_label_filter.dedup(); + Ok(NormalizedPipelineShortestPathStage { + optional: stage.optional, + mode: stage.mode, + output_schema, + input_mappings, + output_path_slot, + output_path_alias: stage.output_path_alias.clone(), + from, + to, + direction: stage.direction, + edge_label_filter, + min_hops: stage.min_hops, + max_hops: stage.max_hops, + weight_field: stage.weight_field.clone(), + max_cost: stage.max_cost, + max_paths: stage.max_paths, + }) +} + +fn normalize_shortest_path_endpoint( + endpoint: &GraphShortestPathEndpoint, + input_schema: &crate::graph_row::GraphBindingSchema, + params: &BTreeMap, +) -> Result { + match endpoint { + GraphShortestPathEndpoint::Alias(alias) => { + validate_graph_pipeline_user_alias(alias, "shortest-path endpoint alias")?; + let slot = input_schema.slot_for_alias(alias).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphShortestPathStage endpoint alias '{alias}' is not available in pipeline scope" + )) + })?; + let slot_info = input_schema.slot(slot).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphShortestPathStage endpoint alias '{alias}' slot is missing" + )) + })?; + if slot_info.kind != crate::graph_row::GraphBindingSlotKind::Node { + return Err(EngineError::InvalidOperation(format!( + "GraphShortestPathStage endpoint alias '{alias}' must be a node alias" + ))); + } + Ok(NormalizedShortestPathEndpoint::Alias { + alias: alias.clone(), + slot, + }) + } + GraphShortestPathEndpoint::NodeId(id) => Ok(NormalizedShortestPathEndpoint::NodeId(*id)), + GraphShortestPathEndpoint::NodeKey { label, key } => { + if label.is_empty() || key.is_empty() { + return Err(EngineError::InvalidOperation( + "GraphShortestPathStage NodeKey endpoints require non-empty label and key" + .to_string(), + )); + } + Ok(NormalizedShortestPathEndpoint::NodeKey { + label: label.clone(), + key: key.clone(), + }) + } + GraphShortestPathEndpoint::Expr(expr) => { + let resolved = resolve_graph_expr_params(expr, params)?; + match resolved { + GraphExpr::UInt(id) => Ok(NormalizedShortestPathEndpoint::NodeId(id)), + GraphExpr::Int(id) if id >= 0 => Ok(NormalizedShortestPathEndpoint::NodeId(id as u64)), + _ => Err(EngineError::InvalidOperation( + "GraphShortestPathStage expression endpoints must resolve to constant node IDs" + .to_string(), + )), + } + } + } +} + +fn normalize_pipeline_call_stage( + stage: &GraphSubqueryStage, + input_schema: &crate::graph_row::GraphBindingSchema, + parent: &GraphPipelineQuery, + subquery_depth: usize, +) -> Result { + let next_depth = subquery_depth.saturating_add(1); + if next_depth > parent.options.max_subquery_depth { + return Err(EngineError::InvalidOperation(format!( + "GraphSubqueryStage depth {next_depth} exceeds max_subquery_depth {}", + parent.options.max_subquery_depth + ))); + } + let (subquery_input_schema, import_slots, import_mappings) = + pipeline_import_schema_and_mappings(input_schema, &stage.import_aliases)?; + let mut subquery = (*stage.query).clone(); + subquery.params = parent.params.clone(); + subquery.at_epoch = None; + subquery.page = GraphPageRequest { + skip: 0, + limit: parent.options.max_pipeline_rows.max(1), + cursor: None, + }; + subquery.output = parent.output.clone(); + subquery.options = parent.options.clone(); + subquery.options.max_rows = parent.options.max_pipeline_rows.max(1); + let query = normalize_graph_pipeline_query_with_initial_schema( + &subquery, + subquery_input_schema, + next_depth, + )?; + + let mut output_schema = input_schema.clone(); + let input_mappings = pipeline_slot_mappings(input_schema, &output_schema)?; + let mut output_mappings = Vec::with_capacity(query.columns.len()); + for column in &query.columns { + if output_schema.slot_for_alias(column).is_some() { + return Err(EngineError::InvalidOperation(format!( + "GraphSubqueryStage output '{column}' collides with an incoming alias" + ))); + } + let source = query.terminal_schema.slot_for_alias(column).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphSubqueryStage terminal schema is missing output column '{column}'" + )) + })?; + let source_info = query.terminal_schema.slot(source).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphSubqueryStage output column '{column}' has no slot metadata" + )) + })?; + if source_info.kind == crate::graph_row::GraphBindingSlotKind::HiddenOccurrence { + return Err(EngineError::InvalidOperation(format!( + "GraphSubqueryStage output column '{column}' cannot expose a hidden occurrence slot" + ))); + } + let target = add_pipeline_output_slot( + &mut output_schema, + column, + source_info.kind, + source_info.nullable, + )?; + output_mappings.push(PipelineSlotMapping { source, target }); + } + Ok(NormalizedPipelineCallStage { + input_schema: input_schema.clone(), + output_schema, + input_mappings, + import_aliases: stage.import_aliases.clone(), + import_slots, + import_mappings, + output_mappings, + columns: query.columns.clone(), + query, + }) +} + +fn normalize_pipeline_exists_predicates( + expr: &mut Option, + schema: &mut crate::graph_row::GraphBindingSchema, + parent: &GraphPipelineQuery, + subquery_depth: usize, +) -> Result, EngineError> { + let Some(expr) = expr.as_mut() else { + return Ok(Vec::new()); + }; + let mut predicates = Vec::new(); + rewrite_pipeline_exists_expr(expr, schema, parent, subquery_depth, &mut predicates)?; + Ok(predicates) +} + +fn rewrite_pipeline_exists_expr( + expr: &mut GraphExpr, + schema: &mut crate::graph_row::GraphBindingSchema, + parent: &GraphPipelineQuery, + subquery_depth: usize, + predicates: &mut Vec, +) -> Result<(), EngineError> { + match expr { + GraphExpr::ExistsSubquery(stage) => { + let next_depth = subquery_depth.saturating_add(1); + if next_depth > parent.options.max_subquery_depth { + return Err(EngineError::InvalidOperation(format!( + "EXISTS subquery depth {next_depth} exceeds max_subquery_depth {}", + parent.options.max_subquery_depth + ))); + } + let (subquery_input_schema, import_slots, import_mappings) = + pipeline_import_schema_and_mappings(schema, &stage.import_aliases)?; + let mut subquery = (*stage.query).clone(); + subquery.params = parent.params.clone(); + subquery.at_epoch = None; + subquery.page = GraphPageRequest { + skip: 0, + limit: 1, + cursor: None, + }; + subquery.output = parent.output.clone(); + subquery.options = parent.options.clone(); + let query = normalize_graph_pipeline_query_with_initial_schema( + &subquery, + subquery_input_schema, + next_depth, + )?; + let mut output_index = predicates.len(); + let output_alias = loop { + let candidate = format!("__og_exists_{output_index}"); + if schema.slot_for_alias(&candidate).is_none() { + break candidate; + } + output_index = output_index.saturating_add(1); + }; + let output_slot = schema.add_scalar_alias(output_alias.clone(), false)?; + let internal_limit = exists_query_has_internal_limit(&stage.query); + predicates.push(NormalizedPipelineExistsPredicate { + output_slot, + output_alias: output_alias.clone(), + import_aliases: stage.import_aliases.clone(), + import_slots, + import_mappings, + query, + internal_limit, + }); + *expr = GraphExpr::Binding(output_alias); + } + GraphExpr::List(items) => { + for item in items { + rewrite_pipeline_exists_expr(item, schema, parent, subquery_depth, predicates)?; + } + } + GraphExpr::Map(items) => { + for item in items.values_mut() { + rewrite_pipeline_exists_expr(item, schema, parent, subquery_depth, predicates)?; + } + } + GraphExpr::Function { args, .. } => { + for arg in args { + rewrite_pipeline_exists_expr(arg, schema, parent, subquery_depth, predicates)?; + } + } + GraphExpr::AggregateCall { arg, .. } => { + if let Some(arg) = arg.as_mut() { + rewrite_pipeline_exists_expr(arg, schema, parent, subquery_depth, predicates)?; + } + } + GraphExpr::Unary { expr, .. } | GraphExpr::IsNull(expr) | GraphExpr::IsNotNull(expr) => { + rewrite_pipeline_exists_expr(expr, schema, parent, subquery_depth, predicates)?; + } + GraphExpr::Binary { left, right, .. } => { + rewrite_pipeline_exists_expr(left, schema, parent, subquery_depth, predicates)?; + rewrite_pipeline_exists_expr(right, schema, parent, subquery_depth, predicates)?; + } + GraphExpr::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand.as_mut() { + rewrite_pipeline_exists_expr(operand, schema, parent, subquery_depth, predicates)?; + } + for branch in branches { + rewrite_pipeline_exists_expr( + &mut branch.when, + schema, + parent, + subquery_depth, + predicates, + )?; + rewrite_pipeline_exists_expr( + &mut branch.then, + schema, + parent, + subquery_depth, + predicates, + )?; + } + if let Some(else_expr) = else_expr.as_mut() { + rewrite_pipeline_exists_expr( + else_expr, + schema, + parent, + subquery_depth, + predicates, + )?; + } + } + GraphExpr::Null + | GraphExpr::Bool(_) + | GraphExpr::Int(_) + | GraphExpr::UInt(_) + | GraphExpr::Float(_) + | GraphExpr::String(_) + | GraphExpr::Bytes(_) + | GraphExpr::Param(_) + | GraphExpr::Binding(_) + | GraphExpr::Property { .. } + | GraphExpr::NodeField { .. } + | GraphExpr::EdgeField { .. } + | GraphExpr::PathField { .. } => {} + } + Ok(()) +} + +fn exists_query_has_internal_limit(query: &GraphPipelineQuery) -> bool { + query.stages.iter().rev().any(|stage| { + match stage { + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + limit: Some(GraphExpr::UInt(1)), + .. + }) => true, + GraphPipelineStage::Union(union) => union + .branches + .iter() + .all(exists_query_has_internal_limit), + _ => false, + } + }) +} + +fn pipeline_import_schema_and_mappings( + input_schema: &crate::graph_row::GraphBindingSchema, + import_aliases: &[String], +) -> Result< + ( + crate::graph_row::GraphBindingSchema, + Vec, + Vec, + ), + EngineError, +> { + let mut seen = BTreeSet::new(); + let mut import_schema = crate::graph_row::GraphBindingSchema::new(); + let mut import_slots = Vec::with_capacity(import_aliases.len()); + let mut mappings = Vec::with_capacity(import_aliases.len()); + for alias in import_aliases { + validate_graph_pipeline_user_alias(alias, "subquery import alias")?; + if !seen.insert(alias.clone()) { + return Err(EngineError::InvalidOperation(format!( + "GraphSubqueryStage imports alias '{alias}' more than once" + ))); + } + let source = input_schema.slot_for_alias(alias).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphSubqueryStage import alias '{alias}' is not available in pipeline scope" + )) + })?; + let source_info = input_schema.slot(source).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GraphSubqueryStage import alias '{alias}' slot is missing" + )) + })?; + if source_info.kind == crate::graph_row::GraphBindingSlotKind::HiddenOccurrence { + return Err(EngineError::InvalidOperation(format!( + "GraphSubqueryStage import alias '{alias}' cannot reference a hidden occurrence" + ))); + } + let target = add_pipeline_output_slot( + &mut import_schema, + alias, + source_info.kind, + source_info.nullable, + )?; + import_slots.push(source); + mappings.push(PipelineSlotMapping { source, target }); + } + Ok((import_schema, import_slots, mappings)) +} + +fn validate_pipeline_node_aliases(nodes: &[GraphNodePattern]) -> Result<(), EngineError> { + for node in nodes { + if graph_pipeline_alias_is_generated_anonymous_node(&node.alias) { + continue; + } + validate_graph_pipeline_user_alias(&node.alias, "node alias")?; + } + Ok(()) +} + +fn validate_pipeline_piece_aliases(pieces: &[GraphPatternPiece]) -> Result<(), EngineError> { + for piece in pieces { + match piece { + GraphPatternPiece::Edge(edge) => { + if let Some(alias) = edge.alias.as_ref() { + validate_graph_pipeline_user_alias(alias, "edge alias")?; + } + } + GraphPatternPiece::Optional(group) => validate_pipeline_piece_aliases(&group.pieces)?, + GraphPatternPiece::VariableLength(path) => { + if let Some(alias) = path.path_alias.as_ref() { + validate_graph_pipeline_user_alias(alias, "path alias")?; + } + if let Some(alias) = path.edge_alias.as_ref() { + validate_graph_pipeline_user_alias(alias, "edge alias")?; + } + } + } + } + Ok(()) +} + +fn validate_graph_pipeline_user_alias(alias: &str, context: &str) -> Result<(), EngineError> { + if alias.starts_with("__gql_") || alias.starts_with("__og_") || alias == PIPELINE_CURSOR_KEY_SLOT { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline {context} '{alias}' uses a reserved internal alias prefix" + ))); + } + Ok(()) +} + +fn graph_pipeline_alias_is_generated_anonymous_node(alias: &str) -> bool { + alias + .strip_prefix("__gql_anon_node_") + .is_some_and(|suffix| !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())) +} + +fn pipeline_add_input_node_patterns( + input_schema: &crate::graph_row::GraphBindingSchema, + nodes: &mut Vec, +) { + let mut seen = nodes + .iter() + .map(|node| node.alias.clone()) + .collect::>(); + for slot in input_schema.slots() { + if slot.kind != crate::graph_row::GraphBindingSlotKind::Node { + continue; + } + let Some(alias) = slot.user_alias.as_ref() else { + continue; + }; + if seen.insert(alias.clone()) { + nodes.push(GraphNodePattern { + alias: alias.clone(), + label_filter: None, + ids: Vec::new(), + keys: Vec::new(), + filter: None, + }); + } + } +} + +fn pipeline_slot_mappings( + input: &crate::graph_row::GraphBindingSchema, + output: &crate::graph_row::GraphBindingSchema, +) -> Result, EngineError> { + let mut mappings = Vec::new(); + for slot in input.slots() { + let source = if let Some(alias) = slot.user_alias.as_ref() { + input.slot_for_alias(alias).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "graph pipeline input schema is missing alias '{alias}'" + )) + })? + } else if pipeline_internal_cursor_slot_info(slot) { + crate::graph_row::GraphBindingSlotRef { + kind: slot.kind, + index: slot.index, + } + } else { + continue; + }; + let target = if let Some(alias) = slot.user_alias.as_ref() { + output.slot_for_alias(alias).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "graph pipeline stage schema dropped incoming alias '{alias}'" + )) + })? + } else { + pipeline_internal_cursor_slot(output).ok_or_else(|| { + EngineError::InvalidOperation( + "graph pipeline stage schema dropped internal cursor key".to_string(), + ) + })? + }; + mappings.push(PipelineSlotMapping { source, target }); + } + Ok(mappings) +} + +fn pipeline_remap_any_value_slots( + any_value_slots: &[crate::graph_row::GraphBindingSlotRef], + mappings: &[PipelineSlotMapping], +) -> Vec { + let mut remapped = Vec::new(); + for mapping in mappings { + if any_value_slots.contains(&mapping.source) && !remapped.contains(&mapping.target) { + remapped.push(mapping.target); + } + } + remapped +} + +fn pipeline_project_any_value_slots( + input_any_value_slots: &[crate::graph_row::GraphBindingSlotRef], + stage: &NormalizedPipelineProjectStage, +) -> Vec { + let mut output_any_value_slots = + pipeline_remap_any_value_slots(input_any_value_slots, &stage.internal_mappings); + for item in &stage.items { + if item + .source_slot + .is_some_and(|source| input_any_value_slots.contains(&source)) + && !output_any_value_slots.contains(&item.output_slot) + { + output_any_value_slots.push(item.output_slot); + } + } + output_any_value_slots +} + +fn pipeline_call_any_value_slots( + input_any_value_slots: &[crate::graph_row::GraphBindingSlotRef], + stage: &NormalizedPipelineCallStage, +) -> Vec { + let mut output_any_value_slots = + pipeline_remap_any_value_slots(input_any_value_slots, &stage.input_mappings); + for mapping in &stage.output_mappings { + if stage.query.terminal_any_value_slots.contains(&mapping.source) + && !output_any_value_slots.contains(&mapping.target) + { + output_any_value_slots.push(mapping.target); + } + } + output_any_value_slots +} + +fn normalize_pipeline_project_stage( + stage: &GraphProjectStage, + input_schema: &crate::graph_row::GraphBindingSchema, + query: &GraphPipelineQuery, + subquery_depth: usize, +) -> Result { + let params = &query.params; + let mut output_schema = crate::graph_row::GraphBindingSchema::new(); + let mut items = Vec::new(); + let mut internal_mappings = Vec::new(); + if pipeline_internal_cursor_slot(input_schema).is_some() { + ensure_pipeline_cursor_key_slot(&mut output_schema)?; + internal_mappings = pipeline_internal_cursor_mappings(input_schema, &output_schema)?; + } + let project_items = pipeline_project_items(stage, input_schema)? + .into_iter() + .map(|mut item| { + item.expr = resolve_graph_expr_params(&item.expr, params)?; + Ok(item) + }) + .collect::, EngineError>>()?; + let resolved_order = stage + .order_by + .iter() + .map(|item| { + Ok(GraphOrderItem { + expr: resolve_graph_expr_params(&item.expr, params)?, + direction: item.direction, + }) + }) + .collect::, EngineError>>()?; + let contains_aggregate = project_items + .iter() + .any(|item| graph_expr_contains_aggregate(&item.expr)) + || resolved_order + .iter() + .any(|item| graph_expr_contains_aggregate(&item.expr)); + if contains_aggregate { + if matches!(stage.items, GraphProjectionItems::Star) { + return Err(EngineError::InvalidOperation( + "graph pipeline * projections cannot be mixed with aggregate calls".to_string(), + )); + } + return normalize_pipeline_aggregate_project_stage( + stage, + input_schema, + output_schema, + internal_mappings, + project_items, + resolved_order, + params, + query, + subquery_depth, + ); + } + let mut input_needs = EntityProjectionNeeds::default(); + + for item in project_items { + let output_name = pipeline_project_output_name(&item)?; + let source_slot = match &item.expr { + GraphExpr::Binding(alias) => input_schema.slot_for_alias(alias), + _ => None, + }; + let (output_slot, bound_expr, expr_summary) = match source_slot { + Some(source) => { + let source_info = input_schema.slot(source).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "graph pipeline source slot {:?}:{} is missing", + source.kind, source.index + )) + })?; + let output_slot = add_pipeline_output_slot( + &mut output_schema, + &output_name, + source.kind, + source_info.nullable, + )?; + (output_slot, None, None) + } + None => { + let needs = crate::graph_row::collect_graph_expr_projection_needs( + input_schema, + &item.expr, + ProjectionNeedClass::Residual, + )?; + input_needs.merge_from(&needs, ProjectionNeedClass::Residual)?; + let output_slot = output_schema.add_scalar_alias(output_name.clone(), true)?; + let bound = crate::graph_row::bind_graph_expr(input_schema, &item.expr)?; + (output_slot, Some(bound), Some(format!("{:?}", item.expr))) + } + }; + items.push(NormalizedPipelineProjectItem { + output_name, + output_slot, + source_slot, + expr: bound_expr, + aggregate_expr: None, + expr_summary, + projection: item.projection, + }); + } + + let mut where_expr = stage + .where_ + .as_ref() + .map(|expr| resolve_graph_expr_params(expr, params)) + .transpose()?; + let exists_predicates = normalize_pipeline_exists_predicates( + &mut where_expr, + &mut output_schema, + query, + subquery_depth, + )?; + let mut filter_needs = EntityProjectionNeeds::default(); + if let Some(expr) = where_expr.as_ref() { + filter_needs = crate::graph_row::collect_graph_expr_projection_needs( + &output_schema, + expr, + ProjectionNeedClass::Residual, + )?; + } + let where_expr = where_expr + .as_ref() + .map(|expr| crate::graph_row::bind_graph_expr(&output_schema, expr)) + .transpose()?; + + let mut order_needs = EntityProjectionNeeds::default(); + for item in &resolved_order { + let needs = crate::graph_row::collect_graph_expr_projection_needs( + &output_schema, + &item.expr, + ProjectionNeedClass::Order, + )?; + order_needs.merge_from(&needs, ProjectionNeedClass::Order)?; + } + let order_by = crate::graph_row::bind_graph_order_items(&output_schema, &resolved_order)?; + let skip = stage + .skip + .as_ref() + .map(|expr| pipeline_count_expr(expr, params, "SKIP")) + .transpose()? + .unwrap_or(0); + let limit = stage + .limit + .as_ref() + .map(|expr| pipeline_count_expr(expr, params, "LIMIT")) + .transpose()?; + + let columns = items + .iter() + .map(|item| item.output_name.clone()) + .collect::>(); + let distinct_slots = pipeline_visible_slots(&output_schema); + Ok(NormalizedPipelineProjectStage { + kind: stage.kind, + distinct: stage.distinct, + input_schema: input_schema.clone(), + output_schema, + items, + internal_mappings, + distinct_slots, + aggregate: None, + input_needs, + filter_needs, + order_needs, + where_expr, + exists_predicates, + order_by, + skip, + limit, + columns, + }) +} + +#[allow(clippy::too_many_arguments)] +fn normalize_pipeline_aggregate_project_stage( + stage: &GraphProjectStage, + input_schema: &crate::graph_row::GraphBindingSchema, + mut output_schema: crate::graph_row::GraphBindingSchema, + internal_mappings: Vec, + project_items: Vec, + resolved_order: Vec, + params: &BTreeMap, + query: &GraphPipelineQuery, + subquery_depth: usize, +) -> Result { + let mut analysis = PipelineAggregateAnalysis::default(); + for item in &project_items { + aggregate_collect_group_exprs(&item.expr, false, &mut analysis.group_exprs); + } + let output_names = project_items + .iter() + .map(pipeline_project_output_name) + .collect::, _>>()?; + let project_item_output_names = project_items + .iter() + .map(|item| Ok((item.expr.clone(), pipeline_project_output_name(item)?))) + .collect::, EngineError>>()?; + for item in &resolved_order { + if graph_expr_contains_aggregate(&item.expr) { + aggregate_collect_group_exprs(&item.expr, false, &mut analysis.group_exprs); + } else if !matches!(&item.expr, GraphExpr::Binding(alias) if output_names.contains(alias)) { + aggregate_push_unique_expr(&mut analysis.group_exprs, item.expr.clone()); + } + } + + let mut eval_schema = crate::graph_row::GraphBindingSchema::new(); + let internal_cursor_slot = if pipeline_internal_cursor_slot(input_schema).is_some() { + Some(ensure_pipeline_cursor_key_slot(&mut output_schema)?) + } else { + None + }; + + let mut input_needs = EntityProjectionNeeds::default(); + let mut group_keys = Vec::with_capacity(analysis.group_exprs.len()); + for (index, expr) in analysis.group_exprs.iter().enumerate() { + let needs = crate::graph_row::collect_graph_expr_projection_needs( + input_schema, + expr, + ProjectionNeedClass::Residual, + )?; + input_needs.merge_from(&needs, ProjectionNeedClass::Residual)?; + let eval_slot = eval_schema.add_scalar_alias(format!("__gql_group_{index}"), true)?; + group_keys.push(NormalizedPipelineGroupKey { + expr: crate::graph_row::bind_graph_expr(input_schema, expr)?, + eval_slot, + summary: format!("{expr:?}"), + }); + } + + let mut rewritten_item_exprs = Vec::with_capacity(project_items.len()); + for item in project_items { + let output_name = pipeline_project_output_name(&item)?; + let rewritten = aggregate_rewrite_expr(&item.expr, &mut analysis)?; + let source_slot = match &item.expr { + GraphExpr::Binding(alias) if !graph_expr_contains_aggregate(&item.expr) => { + input_schema.slot_for_alias(alias) + } + _ => None, + }; + let output_slot = match source_slot { + Some(source) => { + let source_info = input_schema.slot(source).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "graph pipeline source slot {:?}:{} is missing", + source.kind, source.index + )) + })?; + add_pipeline_output_slot( + &mut output_schema, + &output_name, + source.kind, + source_info.nullable, + )? + } + None => output_schema.add_scalar_alias(output_name.clone(), true)?, + }; + rewritten_item_exprs.push(( + output_name, + output_slot, + rewritten, + Some(format!("{:?}", item.expr)), + item.projection, + )); + } + + let rewritten_order = resolved_order + .iter() + .map(|item| { + let expr = if let Some(output_name) = project_item_output_names + .iter() + .find(|(expr, _)| expr == &item.expr) + .map(|(_, output_name)| output_name.clone()) + { + GraphExpr::Binding(output_name) + } else if matches!(&item.expr, GraphExpr::Binding(alias) if output_names.contains(alias)) { + item.expr.clone() + } else if graph_expr_contains_aggregate(&item.expr) + || !matches!(&item.expr, GraphExpr::Binding(alias) if output_names.contains(alias)) + { + aggregate_rewrite_expr(&item.expr, &mut analysis)? + } else { + item.expr.clone() + }; + Ok(GraphOrderItem { + expr, + direction: item.direction, + }) + }) + .collect::, EngineError>>()?; + + let mut calls = Vec::with_capacity(analysis.aggregate_calls.len()); + for (index, call) in analysis.aggregate_calls.iter().enumerate() { + let arg = call + .arg + .as_ref() + .map(|arg| { + let needs = crate::graph_row::collect_graph_expr_projection_needs( + input_schema, + arg, + ProjectionNeedClass::Residual, + )?; + input_needs.merge_from(&needs, ProjectionNeedClass::Residual)?; + crate::graph_row::bind_graph_expr(input_schema, arg) + }) + .transpose()?; + let eval_slot = eval_schema.add_scalar_alias(format!("__gql_agg_{index}"), true)?; + calls.push(NormalizedPipelineAggregateCall { + function: call.function, + distinct: call.distinct, + arg, + eval_slot, + summary: aggregate_call_summary(call), + }); + } + + let rewritten_items = rewritten_item_exprs + .into_iter() + .map( + |(output_name, output_slot, rewritten, expr_summary, projection)| { + Ok(NormalizedPipelineProjectItem { + output_name, + output_slot, + source_slot: None, + expr: None, + aggregate_expr: Some(crate::graph_row::bind_graph_expr( + &eval_schema, + &rewritten, + )?), + expr_summary, + projection, + }) + }, + ) + .collect::, EngineError>>()?; + + let mut where_expr = stage + .where_ + .as_ref() + .map(|expr| resolve_graph_expr_params(expr, params)) + .transpose()?; + let exists_predicates = normalize_pipeline_exists_predicates( + &mut where_expr, + &mut output_schema, + query, + subquery_depth, + )?; + let mut filter_needs = EntityProjectionNeeds::default(); + if let Some(expr) = where_expr.as_ref() { + filter_needs = crate::graph_row::collect_graph_expr_projection_needs( + &output_schema, + expr, + ProjectionNeedClass::Residual, + )?; + } + let where_expr = where_expr + .as_ref() + .map(|expr| crate::graph_row::bind_graph_expr(&output_schema, expr)) + .transpose()?; + + let mut final_order = Vec::with_capacity(rewritten_order.len()); + let mut order_outputs = Vec::new(); + for (index, item) in rewritten_order.iter().enumerate() { + if crate::graph_row::bind_graph_expr(&output_schema, &item.expr).is_ok() { + final_order.push(item.clone()); + continue; + } + let bound = crate::graph_row::bind_graph_expr(&eval_schema, &item.expr)?; + let alias = format!("__gql_order_{index}"); + let output_slot = output_schema.add_scalar_alias(alias.clone(), true)?; + order_outputs.push(NormalizedPipelineAggregateOrderOutput { + expr: bound, + output_slot, + }); + final_order.push(GraphOrderItem { + expr: GraphExpr::Binding(alias), + direction: item.direction, + }); + } + let mut order_needs = EntityProjectionNeeds::default(); + for item in &final_order { + let needs = crate::graph_row::collect_graph_expr_projection_needs( + &output_schema, + &item.expr, + ProjectionNeedClass::Order, + )?; + order_needs.merge_from(&needs, ProjectionNeedClass::Order)?; + } + let order_by = crate::graph_row::bind_graph_order_items(&output_schema, &final_order)?; + let skip = stage + .skip + .as_ref() + .map(|expr| pipeline_count_expr(expr, params, "SKIP")) + .transpose()? + .unwrap_or(0); + let limit = stage + .limit + .as_ref() + .map(|expr| pipeline_count_expr(expr, params, "LIMIT")) + .transpose()?; + let columns = rewritten_items + .iter() + .map(|item| item.output_name.clone()) + .collect::>(); + let distinct_slots = pipeline_visible_slots(&output_schema); + Ok(NormalizedPipelineProjectStage { + kind: stage.kind, + distinct: stage.distinct, + input_schema: input_schema.clone(), + output_schema, + items: rewritten_items, + internal_mappings, + distinct_slots, + aggregate: Some(NormalizedPipelineAggregate { + eval_schema, + group_keys, + calls, + order_outputs, + internal_cursor_slot, + }), + input_needs, + filter_needs, + order_needs, + where_expr, + exists_predicates, + order_by, + skip, + limit, + columns, + }) +} + +#[derive(Default)] +struct PipelineAggregateAnalysis { + group_exprs: Vec, + aggregate_calls: Vec, +} + +#[derive(Clone)] +struct PipelineAggregateCallExpr { + function: GraphAggregateFunction, + distinct: bool, + arg: Option, +} + +fn graph_expr_contains_aggregate(expr: &GraphExpr) -> bool { + match expr { + GraphExpr::AggregateCall { .. } => true, + GraphExpr::ExistsSubquery(stage) => stage + .query + .stages + .iter() + .any(graph_pipeline_stage_contains_aggregate), + GraphExpr::List(items) => items.iter().any(graph_expr_contains_aggregate), + GraphExpr::Map(items) => items.values().any(graph_expr_contains_aggregate), + GraphExpr::Function { args, .. } => args.iter().any(graph_expr_contains_aggregate), + GraphExpr::Unary { expr, .. } | GraphExpr::IsNull(expr) | GraphExpr::IsNotNull(expr) => { + graph_expr_contains_aggregate(expr) + } + GraphExpr::Binary { left, right, .. } => { + graph_expr_contains_aggregate(left) || graph_expr_contains_aggregate(right) + } + GraphExpr::Case { + operand, + branches, + else_expr, + } => { + operand + .as_ref() + .is_some_and(|expr| graph_expr_contains_aggregate(expr)) + || branches.iter().any(|branch| { + graph_expr_contains_aggregate(&branch.when) + || graph_expr_contains_aggregate(&branch.then) + }) + || else_expr + .as_ref() + .is_some_and(|expr| graph_expr_contains_aggregate(expr)) + } + GraphExpr::Null + | GraphExpr::Bool(_) + | GraphExpr::Int(_) + | GraphExpr::UInt(_) + | GraphExpr::Float(_) + | GraphExpr::String(_) + | GraphExpr::Bytes(_) + | GraphExpr::Param(_) + | GraphExpr::Binding(_) + | GraphExpr::Property { .. } + | GraphExpr::NodeField { .. } + | GraphExpr::EdgeField { .. } + | GraphExpr::PathField { .. } => false, + } +} + +fn graph_pipeline_stage_contains_aggregate(stage: &GraphPipelineStage) -> bool { + match stage { + GraphPipelineStage::Match(stage) => { + stage + .where_ + .as_ref() + .is_some_and(graph_expr_contains_aggregate) + || stage + .optional_candidate_where + .as_ref() + .is_some_and(graph_expr_contains_aggregate) + } + GraphPipelineStage::Project(stage) => { + (match &stage.items { + GraphProjectionItems::Star => false, + GraphProjectionItems::Items(items) => items + .iter() + .any(|item| graph_expr_contains_aggregate(&item.expr)), + }) || stage + .where_ + .as_ref() + .is_some_and(graph_expr_contains_aggregate) + || stage + .order_by + .iter() + .any(|item| graph_expr_contains_aggregate(&item.expr)) + || stage + .skip + .as_ref() + .is_some_and(graph_expr_contains_aggregate) + || stage + .limit + .as_ref() + .is_some_and(graph_expr_contains_aggregate) + } + GraphPipelineStage::Call(stage) => stage + .query + .stages + .iter() + .any(graph_pipeline_stage_contains_aggregate), + GraphPipelineStage::Union(stage) => stage.branches.iter().any(|branch| { + branch + .stages + .iter() + .any(graph_pipeline_stage_contains_aggregate) + }), + GraphPipelineStage::ShortestPath(_) => false, + } +} + +fn aggregate_collect_group_exprs( + expr: &GraphExpr, + inside_aggregate: bool, + group_exprs: &mut Vec, +) { + if inside_aggregate { + return; + } + if !graph_expr_contains_aggregate(expr) { + if !graph_expr_is_literal_only(expr) { + aggregate_push_unique_expr(group_exprs, expr.clone()); + } + return; + } + match expr { + GraphExpr::AggregateCall { .. } | GraphExpr::ExistsSubquery(_) => {} + GraphExpr::List(items) => { + for item in items { + aggregate_collect_group_exprs(item, false, group_exprs); + } + } + GraphExpr::Map(items) => { + for item in items.values() { + aggregate_collect_group_exprs(item, false, group_exprs); + } + } + GraphExpr::Function { args, .. } => { + for arg in args { + aggregate_collect_group_exprs(arg, false, group_exprs); + } + } + GraphExpr::Unary { expr, .. } | GraphExpr::IsNull(expr) | GraphExpr::IsNotNull(expr) => { + aggregate_collect_group_exprs(expr, false, group_exprs); + } + GraphExpr::Binary { left, right, .. } => { + aggregate_collect_group_exprs(left, false, group_exprs); + aggregate_collect_group_exprs(right, false, group_exprs); + } + GraphExpr::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand { + aggregate_collect_group_exprs(operand, false, group_exprs); + } + for branch in branches { + aggregate_collect_group_exprs(&branch.when, false, group_exprs); + aggregate_collect_group_exprs(&branch.then, false, group_exprs); + } + if let Some(else_expr) = else_expr { + aggregate_collect_group_exprs(else_expr, false, group_exprs); + } + } + GraphExpr::Null + | GraphExpr::Bool(_) + | GraphExpr::Int(_) + | GraphExpr::UInt(_) + | GraphExpr::Float(_) + | GraphExpr::String(_) + | GraphExpr::Bytes(_) + | GraphExpr::Param(_) + | GraphExpr::Binding(_) + | GraphExpr::Property { .. } + | GraphExpr::NodeField { .. } + | GraphExpr::EdgeField { .. } + | GraphExpr::PathField { .. } => {} + } +} + +fn aggregate_push_unique_expr(group_exprs: &mut Vec, expr: GraphExpr) { + if !group_exprs.iter().any(|existing| existing == &expr) { + group_exprs.push(expr); + } +} + +fn graph_expr_is_literal_only(expr: &GraphExpr) -> bool { + match expr { + GraphExpr::Null + | GraphExpr::Bool(_) + | GraphExpr::Int(_) + | GraphExpr::UInt(_) + | GraphExpr::Float(_) + | GraphExpr::String(_) + | GraphExpr::Bytes(_) => true, + GraphExpr::List(items) => items.iter().all(graph_expr_is_literal_only), + GraphExpr::Map(items) => items.values().all(graph_expr_is_literal_only), + _ => false, + } +} + +fn aggregate_rewrite_expr( + expr: &GraphExpr, + analysis: &mut PipelineAggregateAnalysis, +) -> Result { + if let Some(index) = analysis.group_exprs.iter().position(|group| group == expr) { + return Ok(GraphExpr::Binding(format!("__gql_group_{index}"))); + } + Ok(match expr { + GraphExpr::AggregateCall { + function, + distinct, + arg, + } => { + if *distinct && arg.is_none() { + return Err(EngineError::InvalidOperation( + "graph pipeline aggregate DISTINCT requires an argument".to_string(), + )); + } + if *function != GraphAggregateFunction::Count && arg.is_none() { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline {} aggregate requires an argument", + graph_aggregate_function_name(*function) + ))); + } + let index = analysis.aggregate_calls.len(); + analysis.aggregate_calls.push(PipelineAggregateCallExpr { + function: *function, + distinct: *distinct, + arg: arg.as_ref().map(|arg| (**arg).clone()), + }); + GraphExpr::Binding(format!("__gql_agg_{index}")) + } + GraphExpr::ExistsSubquery(_) => expr.clone(), + GraphExpr::List(items) => GraphExpr::List( + items + .iter() + .map(|item| aggregate_rewrite_expr(item, analysis)) + .collect::, _>>()?, + ), + GraphExpr::Map(items) => GraphExpr::Map( + items + .iter() + .map(|(key, value)| Ok((key.clone(), aggregate_rewrite_expr(value, analysis)?))) + .collect::, EngineError>>()?, + ), + GraphExpr::Function { name, args } => GraphExpr::Function { + name: *name, + args: args + .iter() + .map(|arg| aggregate_rewrite_expr(arg, analysis)) + .collect::, _>>()?, + }, + GraphExpr::Unary { op, expr } => GraphExpr::Unary { + op: *op, + expr: Box::new(aggregate_rewrite_expr(expr, analysis)?), + }, + GraphExpr::Binary { left, op, right } => GraphExpr::Binary { + left: Box::new(aggregate_rewrite_expr(left, analysis)?), + op: *op, + right: Box::new(aggregate_rewrite_expr(right, analysis)?), + }, + GraphExpr::Case { + operand, + branches, + else_expr, + } => GraphExpr::Case { + operand: operand + .as_ref() + .map(|operand| aggregate_rewrite_expr(operand, analysis).map(Box::new)) + .transpose()?, + branches: branches + .iter() + .map(|branch| { + Ok(GraphCaseBranch { + when: aggregate_rewrite_expr(&branch.when, analysis)?, + then: aggregate_rewrite_expr(&branch.then, analysis)?, + }) + }) + .collect::, EngineError>>()?, + else_expr: else_expr + .as_ref() + .map(|else_expr| aggregate_rewrite_expr(else_expr, analysis).map(Box::new)) + .transpose()?, + }, + GraphExpr::IsNull(expr) => GraphExpr::IsNull(Box::new(aggregate_rewrite_expr(expr, analysis)?)), + GraphExpr::IsNotNull(expr) => { + GraphExpr::IsNotNull(Box::new(aggregate_rewrite_expr(expr, analysis)?)) + } + GraphExpr::Null + | GraphExpr::Bool(_) + | GraphExpr::Int(_) + | GraphExpr::UInt(_) + | GraphExpr::Float(_) + | GraphExpr::String(_) + | GraphExpr::Bytes(_) + | GraphExpr::Param(_) + | GraphExpr::Binding(_) + | GraphExpr::Property { .. } + | GraphExpr::NodeField { .. } + | GraphExpr::EdgeField { .. } + | GraphExpr::PathField { .. } => expr.clone(), + }) +} + +fn aggregate_call_summary(call: &PipelineAggregateCallExpr) -> String { + let name = graph_aggregate_function_name(call.function); + let distinct = if call.distinct { "DISTINCT " } else { "" }; + let arg = call + .arg + .as_ref() + .map(|arg| format!("{arg:?}")) + .unwrap_or_else(|| "*".to_string()); + format!("{name}({distinct}{arg})") +} + +fn graph_aggregate_function_name(function: GraphAggregateFunction) -> &'static str { + match function { + GraphAggregateFunction::Count => "count", + GraphAggregateFunction::Sum => "sum", + GraphAggregateFunction::Avg => "avg", + GraphAggregateFunction::Min => "min", + GraphAggregateFunction::Max => "max", + GraphAggregateFunction::Collect => "collect", + } +} + +fn pipeline_visible_slots( + schema: &crate::graph_row::GraphBindingSchema, +) -> Vec { + schema + .slots() + .iter() + .filter(|slot| pipeline_slot_is_user_visible(slot)) + .map(|slot| crate::graph_row::GraphBindingSlotRef { + kind: slot.kind, + index: slot.index, + }) + .collect() +} + +fn pipeline_slot_is_user_visible(slot: &crate::graph_row::GraphBindingSlot) -> bool { + slot.user_alias + .as_ref() + .is_some_and(|alias| !alias.starts_with("__gql_") && !alias.starts_with("__og_")) +} + +fn ensure_pipeline_cursor_key_slot( + schema: &mut crate::graph_row::GraphBindingSchema, +) -> Result { + if let Some(slot) = pipeline_internal_cursor_slot(schema) { + return Ok(slot); + } + schema.add_internal_scalar(PIPELINE_CURSOR_KEY_SLOT.to_string(), false) +} + +fn pipeline_internal_cursor_mappings( + input: &crate::graph_row::GraphBindingSchema, + output: &crate::graph_row::GraphBindingSchema, +) -> Result, EngineError> { + let Some(source) = pipeline_internal_cursor_slot(input) else { + return Ok(Vec::new()); + }; + let target = pipeline_internal_cursor_slot(output).ok_or_else(|| { + EngineError::InvalidOperation( + "graph pipeline stage schema dropped internal cursor key".to_string(), + ) + })?; + Ok(vec![PipelineSlotMapping { source, target }]) +} + +fn pipeline_internal_cursor_slot( + schema: &crate::graph_row::GraphBindingSchema, +) -> Option { + schema.slots().iter().find_map(|slot| { + if pipeline_internal_cursor_slot_info(slot) { + Some(crate::graph_row::GraphBindingSlotRef { + kind: slot.kind, + index: slot.index, + }) + } else { + None + } + }) +} + +fn pipeline_internal_cursor_slot_info(slot: &crate::graph_row::GraphBindingSlot) -> bool { + slot.kind == crate::graph_row::GraphBindingSlotKind::Scalar + && slot.user_alias.is_none() + && slot.name == PIPELINE_CURSOR_KEY_SLOT +} + +fn pipeline_project_items( + stage: &GraphProjectStage, + input_schema: &crate::graph_row::GraphBindingSchema, +) -> Result, EngineError> { + match &stage.items { + GraphProjectionItems::Star => { + let mut items = Vec::new(); + for slot in input_schema.slots() { + if slot.kind == crate::graph_row::GraphBindingSlotKind::HiddenOccurrence { + continue; + } + if !pipeline_slot_is_user_visible(slot) { + continue; + } + let Some(alias) = slot.user_alias.as_ref() else { + continue; + }; + items.push(GraphProjectItem { + expr: GraphExpr::Binding(alias.clone()), + alias: Some(alias.clone()), + projection: GraphReturnProjection::Auto, + }); + } + if items.is_empty() { + if pipeline_project_stage_allows_empty_star_filter(stage) { + return Ok(items); + } + return Err(EngineError::InvalidOperation( + "graph pipeline WITH * requires at least one visible alias".to_string(), + )); + } + Ok(items) + } + GraphProjectionItems::Items(items) => { + if items.is_empty() { + return Err(EngineError::InvalidOperation( + "graph pipeline Project items must not be empty".to_string(), + )); + } + Ok(items.clone()) + } + } +} + +fn pipeline_project_stage_allows_empty_star_filter(stage: &GraphProjectStage) -> bool { + stage.kind == GraphProjectKind::With + && !stage.distinct + && stage.where_.is_some() + && stage.order_by.is_empty() + && stage.skip.is_none() + && stage.limit.is_none() +} + +fn pipeline_project_output_name(item: &GraphProjectItem) -> Result { + if let Some(alias) = item.alias.as_ref() { + validate_graph_pipeline_user_alias(alias, "projection alias")?; + return Ok(alias.clone()); + } + let output_name = match &item.expr { + GraphExpr::Binding(alias) => Ok(alias.clone()), + GraphExpr::Property { alias, key } => Ok(format!("{alias}.{key}")), + GraphExpr::NodeField { alias, field } => Ok(format!("{alias}.{}", graph_node_field_name(*field))), + GraphExpr::EdgeField { alias, field } => Ok(format!("{alias}.{}", graph_edge_field_name(*field))), + GraphExpr::PathField { alias, field } => Ok(format!("{alias}.{}", graph_path_field_name(*field))), + _ => Err(EngineError::InvalidOperation( + "graph pipeline complex Project expressions require an alias".to_string(), + )), + }?; + validate_graph_pipeline_user_alias(&output_name, "projection alias")?; + Ok(output_name) +} + +fn add_pipeline_output_slot( + schema: &mut crate::graph_row::GraphBindingSchema, + alias: &str, + kind: crate::graph_row::GraphBindingSlotKind, + nullable: bool, +) -> Result { + match kind { + crate::graph_row::GraphBindingSlotKind::Node => schema.add_node_alias(alias.to_string(), nullable), + crate::graph_row::GraphBindingSlotKind::Edge => schema.add_edge_alias(alias.to_string(), nullable), + crate::graph_row::GraphBindingSlotKind::Path => schema.add_path_alias(alias.to_string(), nullable), + crate::graph_row::GraphBindingSlotKind::Scalar => { + schema.add_scalar_alias(alias.to_string(), nullable) + } + crate::graph_row::GraphBindingSlotKind::HiddenOccurrence => Err( + EngineError::InvalidOperation( + "graph pipeline projection cannot expose hidden occurrence slots".to_string(), + ), + ), + } +} + +fn pipeline_count_expr( + expr: &GraphExpr, + params: &BTreeMap, + context: &str, +) -> Result { + let resolved = resolve_graph_expr_params(expr, params)?; + match resolved { + GraphExpr::Int(value) if value >= 0 => usize::try_from(value).map_err(|_| { + EngineError::InvalidOperation(format!( + "graph pipeline {context} value does not fit usize" + )) + }), + GraphExpr::UInt(value) => usize::try_from(value).map_err(|_| { + EngineError::InvalidOperation(format!( + "graph pipeline {context} value does not fit usize" + )) + }), + GraphExpr::Int(_) => Err(EngineError::InvalidOperation(format!( + "graph pipeline {context} value must be non-negative" + ))), + _ => Err(EngineError::InvalidOperation(format!( + "graph pipeline {context} must be a non-negative integer literal or parameter" + ))), + } +} + +fn pipeline_terminal_return_items( + schema: &crate::graph_row::GraphBindingSchema, + items: &[NormalizedPipelineProjectItem], +) -> Result, EngineError> { + let return_items = items + .iter() + .map(|item| GraphReturnItem { + expr: GraphExpr::Binding(item.output_name.clone()), + alias: Some(item.output_name.clone()), + projection: item.projection.clone(), + }) + .collect::>(); + crate::graph_row::bind_graph_return_items(schema, &return_items) +} + +fn pipeline_union_terminal_return_items( + stage: &NormalizedPipelineUnionStage, +) -> Result, EngineError> { + let return_items = stage + .columns + .iter() + .zip(stage.projections.iter()) + .map(|(column, projection)| GraphReturnItem { + expr: GraphExpr::Binding(column.clone()), + alias: Some(column.clone()), + projection: projection.clone(), + }) + .collect::>(); + crate::graph_row::bind_graph_return_items(&stage.output_schema, &return_items) +} + +fn collect_graph_pipeline_referenced_params( + query: &GraphPipelineQuery, +) -> Result, EngineError> { + let mut names = BTreeSet::new(); + for stage in &query.stages { + collect_graph_pipeline_stage_param_names(stage, &mut names); + } + names + .into_iter() + .map(|name| { + let value = query.params.get(&name).cloned().ok_or_else(|| { + EngineError::InvalidOperation(format!( + "graph pipeline expression references missing param '{name}'" + )) + })?; + Ok((name, value)) + }) + .collect() +} + +fn pipeline_terminal_graph_return_items( + items: &[NormalizedPipelineProjectItem], +) -> Vec { + items + .iter() + .map(|item| GraphReturnItem { + expr: GraphExpr::Binding(item.output_name.clone()), + alias: Some(item.output_name.clone()), + projection: item.projection.clone(), + }) + .collect() +} + +fn pipeline_union_terminal_graph_return_items( + stage: &NormalizedPipelineUnionStage, +) -> Vec { + stage + .columns + .iter() + .zip(stage.projections.iter()) + .map(|(column, projection)| GraphReturnItem { + expr: GraphExpr::Binding(column.clone()), + alias: Some(column.clone()), + projection: projection.clone(), + }) + .collect() +} + +fn collect_graph_pipeline_stage_param_names(stage: &GraphPipelineStage, names: &mut BTreeSet) { + match stage { + GraphPipelineStage::Match(stage) => { + collect_graph_piece_param_names(&stage.pieces, names); + if let Some(expr) = stage.where_.as_ref() { + collect_graph_expr_param_names(expr, names); + } + if let Some(expr) = stage.optional_candidate_where.as_ref() { + collect_graph_expr_param_names(expr, names); + } + } + GraphPipelineStage::Project(stage) => { + match &stage.items { + GraphProjectionItems::Star => {} + GraphProjectionItems::Items(items) => { + for item in items { + collect_graph_expr_param_names(&item.expr, names); + } + } + } + if let Some(expr) = stage.where_.as_ref() { + collect_graph_expr_param_names(expr, names); + } + for item in &stage.order_by { + collect_graph_expr_param_names(&item.expr, names); + } + if let Some(expr) = stage.skip.as_ref() { + collect_graph_expr_param_names(expr, names); + } + if let Some(expr) = stage.limit.as_ref() { + collect_graph_expr_param_names(expr, names); + } + } + GraphPipelineStage::Union(union) => { + for branch in &union.branches { + for stage in &branch.stages { + collect_graph_pipeline_stage_param_names(stage, names); + } + } + } + GraphPipelineStage::Call(call) => { + for stage in &call.query.stages { + collect_graph_pipeline_stage_param_names(stage, names); + } + } + GraphPipelineStage::ShortestPath(stage) => { + if let GraphShortestPathEndpoint::Expr(expr) = &stage.from { + collect_graph_expr_param_names(expr, names); + } + if let GraphShortestPathEndpoint::Expr(expr) = &stage.to { + collect_graph_expr_param_names(expr, names); + } + } + } +} + +fn validate_graph_pipeline_referenced_params( + referenced_params: &[(String, GraphParamValue)], + options: &GraphPipelineOptions, +) -> Result<(), EngineError> { + let mut total_items = 0usize; + let mut total_bytes = 0usize; + for (name, value) in referenced_params { + graph_pipeline_validate_param_value( + name, + value, + options, + &mut total_items, + &mut total_bytes, + )?; + } + Ok(()) +} + +fn graph_pipeline_fingerprint_shape( + query: &GraphPipelineQuery, + columns: &[String], + referenced_params: &[(String, GraphParamValue)], + terminal_order_by: &[crate::graph_row::BoundGraphOrderItem], +) -> GraphPipelineFingerprintShape { + let mut query_writer = GraphRowFingerprintWriter::new("pipeline_query"); + query_writer.u16(1); + graph_pipeline_fingerprint_stages(&mut query_writer, &query.stages); + + let mut order_writer = GraphRowFingerprintWriter::new("pipeline_order"); + order_writer.len(terminal_order_by.len()); + if let Some(GraphPipelineStage::Project(project)) = query.stages.last() { + graph_row_fingerprint_order_items(&mut order_writer, &project.order_by); + } + + let mut output_writer = GraphRowFingerprintWriter::new("pipeline_output"); + graph_row_fingerprint_string_vec(&mut output_writer, columns); + graph_row_fingerprint_output_options(&mut output_writer, &query.output); + + let mut params_writer = GraphRowFingerprintWriter::new("pipeline_params"); + params_writer.len(referenced_params.len()); + for (name, value) in referenced_params { + params_writer.str(name); + graph_row_fingerprint_param_value(&mut params_writer, value); + } + + GraphPipelineFingerprintShape { + query_shape: query_writer.finish(), + order: order_writer.finish(), + output: output_writer.finish(), + params: params_writer.finish(), + } +} + +fn graph_pipeline_fingerprint_stages( + writer: &mut GraphRowFingerprintWriter, + stages: &[GraphPipelineStage], +) { + writer.len(stages.len()); + for stage in stages { + match stage { + GraphPipelineStage::Match(stage) => { + writer.tag(1); + writer.bool(stage.optional); + graph_row_fingerprint_node_patterns(writer, &stage.nodes); + graph_row_fingerprint_pattern_pieces(writer, &stage.pieces); + graph_row_fingerprint_option_expr(writer, stage.where_.as_ref()); + graph_row_fingerprint_option_expr( + writer, + stage.optional_candidate_where.as_ref(), + ); + } + GraphPipelineStage::Project(stage) => { + writer.tag(2); + writer.tag(match stage.kind { + GraphProjectKind::With => 1, + GraphProjectKind::Return => 2, + }); + writer.bool(stage.distinct); + graph_pipeline_fingerprint_projection_items(writer, &stage.items); + graph_row_fingerprint_option_expr(writer, stage.where_.as_ref()); + graph_row_fingerprint_order_items(writer, &stage.order_by); + graph_row_fingerprint_option_expr(writer, stage.skip.as_ref()); + graph_row_fingerprint_option_expr(writer, stage.limit.as_ref()); + } + GraphPipelineStage::Union(stage) => { + writer.tag(3); + writer.bool(stage.all); + writer.len(stage.branches.len()); + for branch in &stage.branches { + graph_pipeline_fingerprint_stages(writer, &branch.stages); + graph_row_fingerprint_string_vec( + writer, + &graph_pipeline_declared_branch_columns(branch), + ); + } + } + GraphPipelineStage::ShortestPath(stage) => { + writer.tag(4); + writer.bool(stage.optional); + writer.str(&stage.output_path_alias); + writer.tag(match stage.mode { + GraphShortestPathMode::One => 1, + GraphShortestPathMode::All => 2, + }); + graph_pipeline_fingerprint_shortest_endpoint(writer, &stage.from); + graph_pipeline_fingerprint_shortest_endpoint(writer, &stage.to); + writer.tag(graph_pipeline_direction_tag(stage.direction)); + graph_row_fingerprint_string_vec(writer, &stage.edge_label_filter); + writer.u64(stage.min_hops as u64); + writer.u64(stage.max_hops as u64); + if let Some(weight_field) = stage.weight_field.as_ref() { + writer.bool(true); + writer.str(weight_field); + } else { + writer.bool(false); + } + if let Some(max_cost) = stage.max_cost { + writer.bool(true); + writer.u64(max_cost.to_bits()); + } else { + writer.bool(false); + } + if let Some(max_paths) = stage.max_paths { + writer.bool(true); + writer.u64(max_paths as u64); + } else { + writer.bool(false); + } + } + GraphPipelineStage::Call(stage) => { + writer.tag(5); + graph_row_fingerprint_string_vec(writer, &stage.import_aliases); + graph_pipeline_fingerprint_stages(writer, &stage.query.stages); + graph_row_fingerprint_string_vec( + writer, + &graph_pipeline_declared_branch_columns(&stage.query), + ); + } + } + } +} + +fn graph_pipeline_fingerprint_shortest_endpoint( + writer: &mut GraphRowFingerprintWriter, + endpoint: &GraphShortestPathEndpoint, +) { + match endpoint { + GraphShortestPathEndpoint::Alias(alias) => { + writer.tag(1); + writer.str(alias); + } + GraphShortestPathEndpoint::NodeId(id) => { + writer.tag(2); + writer.u64(*id); + } + GraphShortestPathEndpoint::NodeKey { label, key } => { + writer.tag(3); + writer.str(label); + writer.str(key); + } + GraphShortestPathEndpoint::Expr(expr) => { + writer.tag(4); + graph_row_fingerprint_expr(writer, expr); + } + } +} + +fn graph_pipeline_direction_tag(direction: Direction) -> u8 { + match direction { + Direction::Outgoing => 1, + Direction::Incoming => 2, + Direction::Both => 3, + } +} + +fn graph_pipeline_declared_branch_columns(query: &GraphPipelineQuery) -> Vec { + query + .stages + .iter() + .rev() + .find_map(|stage| match stage { + GraphPipelineStage::Project(project) if project.kind == GraphProjectKind::Return => { + match &project.items { + GraphProjectionItems::Star => Some(vec!["*".to_string()]), + GraphProjectionItems::Items(items) => Some( + items + .iter() + .map(|item| { + item.alias.clone().unwrap_or_else(|| match &item.expr { + GraphExpr::Binding(alias) => alias.clone(), + GraphExpr::Property { alias, key } => { + format!("{alias}.{key}") + } + GraphExpr::NodeField { alias, field } => { + format!("{alias}.{}", graph_node_field_name(*field)) + } + GraphExpr::EdgeField { alias, field } => { + format!("{alias}.{}", graph_edge_field_name(*field)) + } + GraphExpr::PathField { alias, field } => { + format!("{alias}.{}", graph_path_field_name(*field)) + } + _ => "".to_string(), + }) + }) + .collect(), + ), + } + } + _ => None, + }) + .unwrap_or_default() +} + +fn graph_pipeline_fingerprint_projection_items( + writer: &mut GraphRowFingerprintWriter, + items: &GraphProjectionItems, +) { + match items { + GraphProjectionItems::Star => writer.tag(1), + GraphProjectionItems::Items(items) => { + writer.tag(2); + writer.len(items.len()); + for item in items { + graph_row_fingerprint_expr(writer, &item.expr); + match item.alias.as_ref() { + Some(alias) => { + writer.tag(1); + writer.str(alias); + } + None => writer.tag(0), + } + graph_row_fingerprint_return_projection(writer, &item.projection); + } + } + } +} diff --git a/src/engine/projection.rs b/src/engine/projection.rs index dc2ff03..178832b 100644 --- a/src/engine/projection.rs +++ b/src/engine/projection.rs @@ -116,7 +116,7 @@ impl ReadView { let selected = self.sources().find_node_projected_fields(&unique_ids, needs)?; let mut by_id = NodeIdMap::with_capacity_and_hasher(unique_ids.len(), Default::default()); - for (node_id, fields) in unique_ids.into_iter().zip(selected.into_iter()) { + for (node_id, fields) in unique_ids.into_iter().zip(selected) { if let Some(fields) = fields { by_id.insert(node_id, fields); } @@ -133,7 +133,7 @@ impl ReadView { let selected = self.sources().find_edge_projected_fields(&unique_ids, needs)?; let mut by_id = NodeIdMap::with_capacity_and_hasher(unique_ids.len(), Default::default()); - for (edge_id, fields) in unique_ids.into_iter().zip(selected.into_iter()) { + for (edge_id, fields) in unique_ids.into_iter().zip(selected) { if let Some(fields) = fields { by_id.insert(edge_id, fields); } diff --git a/src/engine/query.rs b/src/engine/query.rs index 9934ed8..9fbda36 100644 --- a/src/engine/query.rs +++ b/src/engine/query.rs @@ -10,7 +10,8 @@ use crate::gql::lower::{ gql_expr_to_graph_expr, gql_order_direction_to_graph, lower_mutation, lower_semantic_plan, GqlCreateEdgePlan, GqlCreateNodePlan, GqlCreatePatternPlan, GqlDeleteTargetPlan, GqlLoweredPlan, GqlMutationClausePlan, GqlMutationInternalColumn, GqlMutationPlan, - GqlNativeTarget, GqlNativeTargetKind, GqlRemoveItemPlan, GqlSetItemPlan, + GqlMergePatternPlan, GqlMergePlan, GqlMutationExprRef, GqlNativeTarget, GqlNativeTargetKind, + GqlRemoveItemPlan, GqlSetItemPlan, }; use crate::gql::parser::{parse_statement, GqlParseOptions}; use crate::gql::params::validate_referenced_gql_params; @@ -19,7 +20,11 @@ use crate::gql::semantic::{ bind_query, gql_semantic_error, GqlAliasKind, GqlAliasOrigin, GqlReturnPlan, GqlSemanticPlan, }; -use crate::graph_row::{eval_graph_expr, GraphBindingSchema, GraphEvalContext, GraphEvalValue}; +use crate::graph_row::{ + eval_graph_binary_values, eval_graph_expr, eval_graph_scalar_function_values, + eval_graph_unary_value, graph_canonical_key_for_value, GraphBindingSchema, GraphCanonicalKey, + GraphEvalContext, GraphEvalValue, +}; use std::time::Instant; impl DatabaseEngine { @@ -77,6 +82,9 @@ impl DatabaseEngine { validate_referenced_gql_params(&semantic, params, options)?; let mut lowered = lower_semantic_plan(semantic, params, options)?; let return_exprs = return_exprs(&lowered.semantic); + if matches!(&lowered.native_target, GqlNativeTarget::GraphPipeline { .. }) { + return self.execute_gql_pipeline_target(lowered, started_at, options); + } let resolved_order_by = resolve_order_by_return_aliases(&lowered)?; validate_gql_row_independent_order_keys(&resolved_order_by, &lowered, params)?; let row_counts = evaluate_gql_row_counts(&lowered, params, options)?; @@ -228,6 +236,9 @@ impl DatabaseEngine { validate_referenced_gql_params(&semantic, params, options)?; let mut lowered = lower_semantic_plan(semantic, params, options)?; let return_exprs = return_exprs(&lowered.semantic); + if matches!(&lowered.native_target, GqlNativeTarget::GraphPipeline { .. }) { + return self.explain_gql_pipeline_target(&lowered, options); + } let resolved_order_by = resolve_order_by_return_aliases(&lowered)?; validate_gql_row_independent_order_keys(&resolved_order_by, &lowered, params)?; let row_counts = evaluate_gql_row_counts(&lowered, params, options)?; @@ -251,6 +262,92 @@ impl DatabaseEngine { )?, options)) } + fn execute_gql_pipeline_target( + &self, + lowered: GqlLoweredPlan, + started_at: Instant, + options: &GqlExecutionOptions, + ) -> Result { + let GqlNativeTarget::GraphPipeline { query } = &lowered.native_target else { + return Err(EngineError::InvalidOperation( + "GQL pipeline execution received a non-pipeline target".to_string(), + )); + }; + let graph_result = self + .query_graph_pipeline(query) + .map_err(|err| graph_pipeline_execution_error_to_gql(err, &lowered))?; + let mut warnings = lowered.warnings.clone(); + warnings.extend(graph_result.stats.warnings.iter().cloned()); + warnings.sort(); + warnings.dedup(); + let plan = if options.include_plan { + graph_result + .plan + .as_ref() + .map(|plan| build_gql_pipeline_execution_explain(&lowered, plan, options)) + } else { + None + }; + let columns = graph_result.columns.clone(); + let stats = graph_result.stats.clone(); + let projected = graph_result + .rows + .into_iter() + .map(|row| { + Ok(GqlRow { + values: row + .values + .into_iter() + .map(graph_value_to_gql_value) + .collect::, EngineError>>()?, + }) + }) + .collect::, EngineError>>()?; + let rows_returned = projected.len(); + let elapsed_us = if options.profile { + started_at.elapsed().as_micros().try_into().ok() + } else { + None + }; + Ok(GqlExecutionResult { + kind: GqlStatementKind::Query, + columns, + rows: projected, + next_cursor: graph_result.next_cursor, + stats: GqlExecutionStats { + rows_returned, + rows_matched: stats.intermediate_rows.max(stats.rows_after_filter), + rows_after_filter: stats.rows_after_filter, + intermediate_bindings: stats.intermediate_rows, + db_hits: stats.db_hits, + elapsed_us, + warnings, + }, + mutation_stats: None, + plan, + }) + } + + fn explain_gql_pipeline_target( + &self, + lowered: &GqlLoweredPlan, + options: &GqlExecutionOptions, + ) -> Result { + let GqlNativeTarget::GraphPipeline { query } = &lowered.native_target else { + return Err(EngineError::InvalidOperation( + "GQL pipeline explain received a non-pipeline target".to_string(), + )); + }; + let explain = self + .explain_graph_pipeline(query) + .map_err(|err| graph_pipeline_execution_error_to_gql(err, lowered))?; + Ok(build_gql_pipeline_execution_explain( + lowered, + &explain, + options, + )) + } + pub fn query_node_ids( &self, query: &NodeQuery, @@ -359,121 +456,831 @@ impl DatabaseEngine { .explain_graph_rows_normalized(&normalized, cursor_state) } -} + pub fn query_graph_pipeline( + &self, + query: &GraphPipelineQuery, + ) -> Result { + if graph_pipeline_legacy_fast_path_eligible(query) { + return self.query_graph_pipeline_one_stage(query); + } -fn execute_gql_mutation_unsupported_error(plan: &GqlMutationPlan) -> EngineError { - EngineError::GqlUnsupported { - feature: "GQL mutation execution".to_string(), - message: "GQL mutation execution for the supplied clause combination is not supported by the current implementation".to_string(), - span: plan.semantic.statement.span.clone(), + let normalized = normalize_graph_pipeline_query(query)?; + let decoded_cursor = query + .page + .cursor + .as_deref() + .map(|cursor| graph_pipeline_decode_logical_cursor(cursor, query.options.max_cursor_bytes)) + .transpose()?; + let (_guard, published) = self.runtime.published_snapshot()?; + let cursor_state = + graph_pipeline_cursor_state_from_decoded( + decoded_cursor, + &query.page, + query.at_epoch, + query.options.max_skip, + )?; + let outcome = published + .view + .query_graph_pipeline_normalized(&normalized, cursor_state)?; + for followup in outcome.followups { + self.runtime.enqueue_secondary_index_read_followup(followup); + } + Ok(outcome.value) } -} -impl DatabaseEngine { - fn execute_gql_mutation( + fn query_graph_pipeline_one_stage( &self, - mutation: GqlMutationStatement, - params: &GqlParams, - options: &GqlExecutionOptions, - started_at: Instant, - ) -> Result { - if options.cursor.is_some() { - return Err(EngineError::InvalidCursor { - message: "GQL mutation statements do not accept cursors".into(), - }); - } - if options.mode == GqlExecutionMode::ReadOnly { - return Err(gql_read_only_mutation_error(&mutation.span)); + query: &GraphPipelineQuery, + ) -> Result { + let mut graph_row_query = graph_pipeline_one_stage_graph_row_query(query)?; + graph_row_query.page.cursor = graph_pipeline_decode_request_cursor( + query.page.cursor.as_deref(), + query.options.max_cursor_bytes, + )?; + let decoded_cursor = + graph_row_decode_request_cursor(&graph_row_query.page, &graph_row_query.options)?; + let (_guard, published) = self.runtime.published_snapshot()?; + let cursor_state = graph_row_cursor_state_from_decoded( + decoded_cursor, + &graph_row_query.page, + graph_row_query.at_epoch, + )?; + graph_pipeline_validate_cursor_state(&cursor_state, &query.options)?; + let normalized = normalize_graph_row_query(&graph_row_query)?; + let outcome = published + .view + .query_graph_rows_outcome(&normalized, cursor_state)?; + let mut result = outcome.value; + graph_pipeline_enforce_result_caps(query, &result)?; + result.next_cursor = graph_pipeline_encode_cursor( + result.next_cursor.take(), + query.options.max_cursor_bytes, + )?; + for followup in outcome.followups { + self.runtime.enqueue_secondary_index_read_followup(followup); } - let plan = lower_mutation(mutation, params, options)?; - validate_gql_mutation_plan_for_execution(&plan)?; - if !gql_mutation_plan_is_executable(&plan) { - return Err(execute_gql_mutation_unsupported_error(&plan)); + Ok(graph_pipeline_result_from_graph_row_result( + query, + result, + )) + } + + pub fn explain_graph_pipeline( + &self, + query: &GraphPipelineQuery, + ) -> Result { + if !graph_pipeline_legacy_fast_path_eligible(query) { + let mut normalized = normalize_graph_pipeline_query(query)?; + normalized.options.include_plan = true; + let decoded_cursor = query + .page + .cursor + .as_deref() + .map(|cursor| { + graph_pipeline_decode_logical_cursor(cursor, query.options.max_cursor_bytes) + }) + .transpose()?; + let (_guard, published) = self.runtime.published_snapshot()?; + let cursor_state = graph_pipeline_cursor_state_from_decoded( + decoded_cursor, + &query.page, + query.at_epoch, + query.options.max_skip, + )?; + if normalized.options.profile { + let outcome = published + .view + .query_graph_pipeline_normalized(&normalized, cursor_state)?; + return outcome.value.plan.ok_or_else(|| { + EngineError::InvalidOperation( + "graph pipeline explain did not produce a plan".to_string(), + ) + }); + } + return published + .view + .explain_graph_pipeline_normalized(&normalized, cursor_state); } - self.execute_gql_create_mutation(&plan, params, options, started_at) + + let mut graph_row_query = graph_pipeline_one_stage_graph_row_query(query)?; + graph_row_query.page.cursor = graph_pipeline_decode_request_cursor( + query.page.cursor.as_deref(), + query.options.max_cursor_bytes, + )?; + let decoded_cursor = + graph_row_decode_request_cursor(&graph_row_query.page, &graph_row_query.options)?; + let (_guard, published) = self.runtime.published_snapshot()?; + let cursor_state = graph_row_cursor_state_from_decoded( + decoded_cursor, + &graph_row_query.page, + graph_row_query.at_epoch, + )?; + graph_pipeline_validate_cursor_state(&cursor_state, &query.options)?; + let normalized = normalize_graph_row_query(&graph_row_query)?; + let graph_row_explain = published + .view + .explain_graph_rows_normalized(&normalized, cursor_state)?; + Ok(graph_pipeline_explain_from_graph_row_explain( + query, + graph_row_explain, + None, + )) } + } -fn gql_mutation_plan_is_executable(plan: &GqlMutationPlan) -> bool { - !plan.clauses.is_empty() - && plan - .clauses +fn graph_pipeline_legacy_fast_path_eligible(query: &GraphPipelineQuery) -> bool { + let [GraphPipelineStage::Match(match_stage), GraphPipelineStage::Project(project_stage)] = + query.stages.as_slice() + else { + return false; + }; + !match_stage.optional + && project_stage.kind == GraphProjectKind::Return + && !project_stage.distinct + && project_stage.where_.is_none() + && project_stage.skip.is_none() + && project_stage.limit.is_none() + && !graph_project_stage_contains_aggregate(project_stage) +} + +fn graph_project_stage_contains_aggregate(stage: &GraphProjectStage) -> bool { + let item_contains_aggregate = match &stage.items { + GraphProjectionItems::Star => false, + GraphProjectionItems::Items(items) => items .iter() - .all(|clause| { - matches!( - clause, - GqlMutationClausePlan::Create(_) - | GqlMutationClausePlan::Set(_) - | GqlMutationClausePlan::Remove(_) - | GqlMutationClausePlan::Delete { .. } - ) - }) + .any(|item| graph_expr_contains_aggregate(&item.expr)), + }; + item_contains_aggregate || stage + .order_by + .iter() + .any(|item| graph_expr_contains_aggregate(&item.expr)) } -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -enum GqlCreateEndpointKey { - Id(u64), - Local(TxnLocalRef), -} +fn graph_pipeline_one_stage_graph_row_query( + query: &GraphPipelineQuery, +) -> Result { + if query.page.skip > query.options.max_skip { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline page skip {} exceeds max_skip {}", + query.page.skip, query.options.max_skip + ))); + } -#[derive(Clone)] -struct GqlCreateExecutionRow { - read_nodes: BTreeMap>, - read_edges: BTreeMap>, - read_paths: BTreeMap>, - expr_values: Vec>, - created_nodes: BTreeMap, - created_edges: BTreeMap, - produced_write: bool, -} + let [GraphPipelineStage::Match(match_stage), GraphPipelineStage::Project(project_stage)] = + query.stages.as_slice() + else { + return Err(graph_pipeline_cp34_1_unsupported( + "expected exactly Match followed by terminal Project", + )); + }; -struct GqlMutationInputRows { - rows: Vec, - db_hits: usize, + if match_stage.optional { + return Err(graph_pipeline_cp34_1_unsupported( + "top-level optional Match stages are deferred", + )); + } + if project_stage.kind != GraphProjectKind::Return { + return Err(graph_pipeline_cp34_1_unsupported( + "terminal Project must have kind Return", + )); + } + if project_stage.distinct { + return Err(graph_pipeline_cp34_1_unsupported( + "Project DISTINCT is deferred", + )); + } + if project_stage.where_.is_some() { + return Err(graph_pipeline_cp34_1_unsupported( + "Project WHERE filters are deferred", + )); + } + if project_stage.skip.is_some() || project_stage.limit.is_some() { + return Err(graph_pipeline_cp34_1_unsupported( + "Project-local SKIP/LIMIT are deferred; use GraphPipelineQuery.page for CP34.1", + )); + } + validate_pipeline_node_aliases(&match_stage.nodes)?; + validate_pipeline_piece_aliases(&match_stage.pieces)?; + + let return_items = match &project_stage.items { + GraphProjectionItems::Star => None, + GraphProjectionItems::Items(items) => { + if items.is_empty() { + return Err(EngineError::InvalidOperation( + "graph pipeline Project items must not be empty".to_string(), + )); + } + for item in items { + if let Some(alias) = item.alias.as_ref() { + validate_graph_pipeline_user_alias(alias, "projection alias")?; + } + } + Some( + items + .iter() + .map(|item| GraphReturnItem { + expr: item.expr.clone(), + alias: item.alias.clone(), + projection: item.projection.clone(), + }) + .collect(), + ) + } + }; + + let graph_row_query = GraphRowQuery { + nodes: match_stage.nodes.clone(), + pieces: match_stage.pieces.clone(), + where_: match_stage.where_.clone(), + return_items, + order_by: project_stage.order_by.clone(), + page: query.page.clone(), + at_epoch: query.at_epoch, + params: query.params.clone(), + output: query.output.clone(), + options: graph_query_options_from_pipeline(&query.options), + }; + graph_pipeline_validate_referenced_params(&graph_row_query, &query.options)?; + Ok(graph_row_query) } -#[derive(Clone, Debug, PartialEq, Eq)] -struct GqlPathIdentity { - node_ids: Vec, - edge_ids: Vec, +fn graph_pipeline_cp34_1_unsupported(detail: &str) -> EngineError { + EngineError::InvalidOperation(format!( + "graph pipeline shape is not supported in CP34.1: {detail}" + )) } -struct GqlCreatedNodeExecution { - local: TxnLocalRef, - labels: Vec, - key: String, - props: BTreeMap, - weight: f32, +fn graph_query_options_from_pipeline(options: &GraphPipelineOptions) -> GraphQueryOptions { + GraphQueryOptions { + allow_full_scan: options.allow_full_scan, + max_intermediate_bindings: options + .max_intermediate_bindings + .min(options.max_pipeline_rows), + max_frontier: options.max_frontier, + max_path_hops: options.max_path_hops, + max_paths_per_start: options.max_paths_per_start, + max_page_limit: options.max_rows, + max_order_materialization: options.max_order_materialization, + max_cursor_bytes: options.max_cursor_bytes, + max_query_bytes: options.max_query_bytes, + include_plan: options.include_plan, + profile: options.profile, + } } -struct GqlCreatedEdgeExecution { - alias: Option, - local: Option, - from: TxnNodeRef, - to: TxnNodeRef, - label: String, - props: BTreeMap, - weight: f32, - valid_from: Option, - valid_to: Option, +const GRAPH_PIPELINE_CURSOR_PREFIX: &str = "ogr34p1_"; +const GRAPH_PIPELINE_CURSOR_MAGIC: &[u8; 8] = b"OGR34PC1"; +const GRAPH_PIPELINE_CURSOR_VERSION: u8 = 1; + +fn graph_pipeline_decode_request_cursor( + cursor: Option<&str>, + max_cursor_bytes: usize, +) -> Result, EngineError> { + cursor + .map(|cursor| graph_pipeline_decode_cursor(cursor, max_cursor_bytes)) + .transpose() } -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -enum GqlMutationTargetKey { - CreatedNode(usize), - CreatedEdge(usize), - ExistingNode(u64), - ExistingEdge(u64), +fn graph_pipeline_decode_cursor( + cursor: &str, + max_cursor_bytes: usize, +) -> Result { + let Some(encoded) = cursor.strip_prefix(GRAPH_PIPELINE_CURSOR_PREFIX) else { + return Err(invalid_graph_pipeline_cursor( + "invalid graph pipeline cursor prefix", + )); + }; + let transport_limit = graph_pipeline_encoded_cursor_transport_limit(max_cursor_bytes); + if cursor.len() > transport_limit { + return Err(invalid_graph_pipeline_cursor(format!( + "encoded graph pipeline cursor is too large to decode within max_cursor_bytes {}", + max_cursor_bytes + ))); + } + let bytes = base64url_no_pad_decode(encoded)?; + if bytes.len() > max_cursor_bytes { + return Err(invalid_graph_pipeline_cursor(format!( + "decoded graph pipeline cursor is {} bytes, exceeding max_cursor_bytes {}", + bytes.len(), + max_cursor_bytes + ))); + } + if bytes.len() < GRAPH_PIPELINE_CURSOR_MAGIC.len() + 1 + 4 + 8 { + return Err(invalid_graph_pipeline_cursor( + "graph pipeline cursor payload is too short", + )); + } + let checksum_offset = bytes + .len() + .checked_sub(8) + .ok_or_else(|| invalid_graph_pipeline_cursor("graph pipeline cursor is missing checksum"))?; + let payload = &bytes[..checksum_offset]; + let checksum = u64::from_be_bytes( + bytes[checksum_offset..] + .try_into() + .map_err(|_| invalid_graph_pipeline_cursor("graph pipeline cursor checksum is malformed"))?, + ); + if crate::types::fnv1a(payload) != checksum { + return Err(invalid_graph_pipeline_cursor( + "graph pipeline cursor checksum mismatch", + )); + } + let mut reader = CursorPayloadReader::new(payload); + if reader.take(GRAPH_PIPELINE_CURSOR_MAGIC.len())? != GRAPH_PIPELINE_CURSOR_MAGIC { + return Err(invalid_graph_pipeline_cursor( + "graph pipeline cursor magic mismatch", + )); + } + let version = reader.read_u8()?; + if version != GRAPH_PIPELINE_CURSOR_VERSION { + return Err(invalid_graph_pipeline_cursor(format!( + "unsupported graph pipeline cursor version {version}" + ))); + } + let inner = reader.read_bytes()?; + if !reader.is_finished() { + return Err(invalid_graph_pipeline_cursor( + "graph pipeline cursor payload has trailing bytes", + )); + } + let inner = std::str::from_utf8(inner) + .map_err(|_| invalid_graph_pipeline_cursor("inner graph row cursor is not UTF-8"))? + .to_string(); + if !inner.starts_with(GRAPH_ROW_CURSOR_PREFIX) { + return Err(invalid_graph_pipeline_cursor( + "inner cursor is not a graph row cursor", + )); + } + Ok(inner) } -struct GqlExistingNodeExecution { - original: NodeRecord, - original_labels: Vec, - labels: Vec, - props: BTreeMap, - weight: f32, - dense_vector: Option, - sparse_vector: Option, +fn graph_pipeline_encoded_cursor_transport_limit(max_decoded_bytes: usize) -> usize { + let tail = match max_decoded_bytes % 3 { + 0 => 0, + 1 => 2, + _ => 3, + }; + let encoded = (max_decoded_bytes / 3) + .checked_mul(4) + .and_then(|value| value.checked_add(tail)) + .unwrap_or(usize::MAX); + GRAPH_PIPELINE_CURSOR_PREFIX.len().saturating_add(encoded) +} + +fn graph_pipeline_validate_referenced_params( + query: &GraphRowQuery, + options: &GraphPipelineOptions, +) -> Result<(), EngineError> { + let referenced_params = collect_graph_row_referenced_params(query)?; + let mut total_items = 0usize; + let mut total_bytes = 0usize; + for (name, value) in &referenced_params { + graph_pipeline_validate_param_value( + name, + value, + options, + &mut total_items, + &mut total_bytes, + )?; + } + Ok(()) +} + +fn graph_pipeline_validate_param_value( + name: &str, + value: &GraphParamValue, + options: &GraphPipelineOptions, + total_items: &mut usize, + total_bytes: &mut usize, +) -> Result<(), EngineError> { + let mut stack = vec![(value, 0usize)]; + while let Some((value, container_depth)) = stack.pop() { + match value { + GraphParamValue::Null + | GraphParamValue::Bool(_) + | GraphParamValue::Int(_) + | GraphParamValue::UInt(_) + | GraphParamValue::Float(_) => {} + GraphParamValue::String(value) => graph_pipeline_add_param_bytes( + name, + value.len(), + "string", + total_bytes, + options, + )?, + GraphParamValue::Bytes(value) => graph_pipeline_add_param_bytes( + name, + value.len(), + "bytes", + total_bytes, + options, + )?, + GraphParamValue::List(values) => { + let depth = container_depth.saturating_add(1); + graph_pipeline_check_param_depth(name, depth, options)?; + graph_pipeline_add_param_items(name, values.len(), "list", total_items, options)?; + for item in values.iter().rev() { + stack.push((item, depth)); + } + } + GraphParamValue::Map(values) => { + let depth = container_depth.saturating_add(1); + graph_pipeline_check_param_depth(name, depth, options)?; + graph_pipeline_add_param_items(name, values.len(), "map", total_items, options)?; + for (key, value) in values.iter().rev() { + graph_pipeline_add_param_bytes( + name, + key.len(), + "map key", + total_bytes, + options, + )?; + stack.push((value, depth)); + } + } + } + } + Ok(()) +} + +fn graph_pipeline_check_param_depth( + name: &str, + depth: usize, + options: &GraphPipelineOptions, +) -> Result<(), EngineError> { + if depth > options.max_ast_depth { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline parameter '{name}' nested list/map depth exceeds max_ast_depth {}", + options.max_ast_depth + ))); + } + Ok(()) +} + +fn graph_pipeline_add_param_items( + name: &str, + count: usize, + container_kind: &str, + total_items: &mut usize, + options: &GraphPipelineOptions, +) -> Result<(), EngineError> { + if count > options.max_literal_items { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline parameter '{name}' {container_kind} contains {count} items, exceeding max_literal_items {}", + options.max_literal_items + ))); + } + *total_items = total_items + .checked_add(count) + .filter(|total| *total <= options.max_literal_items) + .ok_or_else(|| { + EngineError::InvalidOperation(format!( + "referenced graph pipeline parameters contain more than max_literal_items={} total list/map items", + options.max_literal_items + )) + })?; + Ok(()) +} + +fn graph_pipeline_add_param_bytes( + name: &str, + bytes: usize, + value_kind: &str, + total_bytes: &mut usize, + options: &GraphPipelineOptions, +) -> Result<(), EngineError> { + if bytes > options.max_param_bytes { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline parameter '{name}' {value_kind} is {bytes} bytes, exceeding max_param_bytes {}", + options.max_param_bytes + ))); + } + *total_bytes = total_bytes + .checked_add(bytes) + .filter(|total| *total <= options.max_param_bytes) + .ok_or_else(|| { + EngineError::InvalidOperation(format!( + "referenced graph pipeline parameters contain more than max_param_bytes={} total string/bytes/map-key bytes", + options.max_param_bytes + )) + })?; + Ok(()) +} + +fn graph_pipeline_encode_cursor( + cursor: Option, + max_cursor_bytes: usize, +) -> Result, EngineError> { + let Some(cursor) = cursor else { + return Ok(None); + }; + let cursor_bytes = cursor.as_bytes(); + let cursor_len = u32::try_from(cursor_bytes.len()).map_err(|_| { + invalid_graph_pipeline_cursor("inner graph row cursor is too large to encode") + })?; + let mut bytes = Vec::new(); + bytes.extend_from_slice(GRAPH_PIPELINE_CURSOR_MAGIC); + push_u8(&mut bytes, GRAPH_PIPELINE_CURSOR_VERSION); + push_u32(&mut bytes, cursor_len); + bytes.extend_from_slice(cursor_bytes); + let checksum = crate::types::fnv1a(&bytes); + push_u64(&mut bytes, checksum); + if bytes.len() > max_cursor_bytes { + return Err(invalid_graph_pipeline_cursor(format!( + "emitted graph pipeline cursor payload is {} bytes, exceeding max_cursor_bytes {}", + bytes.len(), + max_cursor_bytes + ))); + } + Ok(Some(format!( + "{GRAPH_PIPELINE_CURSOR_PREFIX}{}", + base64url_no_pad_encode(&bytes) + ))) +} + +fn invalid_graph_pipeline_cursor(message: impl Into) -> EngineError { + EngineError::InvalidCursor { + message: message.into(), + } +} + +fn graph_pipeline_validate_cursor_state( + cursor_state: &GraphRowCursorState, + options: &GraphPipelineOptions, +) -> Result<(), EngineError> { + if cursor_state.original_skip > options.max_skip as u64 { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline cursor original skip {} exceeds max_skip {}", + cursor_state.original_skip, options.max_skip + ))); + } + Ok(()) +} + +fn graph_pipeline_enforce_result_caps( + query: &GraphPipelineQuery, + result: &GraphRowResult, +) -> Result<(), EngineError> { + if result.stats.rows_after_filter > query.options.max_pipeline_rows { + return Err(EngineError::InvalidOperation(format!( + "graph pipeline exceeded max_pipeline_rows {}", + query.options.max_pipeline_rows + ))); + } + Ok(()) +} + +fn graph_pipeline_result_from_graph_row_result( + query: &GraphPipelineQuery, + mut result: GraphRowResult, +) -> GraphPipelineResult { + let stats = graph_pipeline_stats_from_graph_row_stats(&result.stats); + let plan = result + .plan + .take() + .map(|explain| graph_pipeline_explain_from_graph_row_explain(query, explain, Some(stats.clone()))); + GraphPipelineResult { + columns: result.columns, + rows: result.rows, + next_cursor: result.next_cursor, + stats, + plan, + } +} + +fn graph_pipeline_stats_from_graph_row_stats(stats: &GraphRowStats) -> GraphPipelineStats { + GraphPipelineStats { + rows_returned: stats.rows_returned, + // CP34.1 starts the one supported pipeline shape from the implicit initial row. + rows_entered_pipeline: 1, + rows_after_filter: stats.rows_after_filter, + intermediate_rows: stats.intermediate_bindings_peak, + pipeline_rows_materialized: stats.rows_seen_for_page, + groups: 0, + collect_items: 0, + union_branches: 0, + union_dedup_keys: 0, + subquery_invocations: 0, + subquery_cache_hits: 0, + shortest_path_pairs: 0, + shortest_path_cache_hits: 0, + db_hits: stats.db_hits, + elapsed_us: stats.elapsed_us, + effective_at_epoch: stats.effective_at_epoch, + warnings: stats.warnings.clone(), + } +} + +fn graph_pipeline_validation_stats( + explain: &GraphRowExplain, + warnings: Vec, +) -> GraphPipelineStats { + GraphPipelineStats { + rows_returned: 0, + // Direct explain has no runtime row counts, but the CP34.1 pipeline shell still starts + // from the same implicit initial row as execution. + rows_entered_pipeline: 1, + rows_after_filter: 0, + intermediate_rows: 0, + pipeline_rows_materialized: 0, + groups: 0, + collect_items: 0, + union_branches: 0, + union_dedup_keys: 0, + subquery_invocations: 0, + subquery_cache_hits: 0, + shortest_path_pairs: 0, + shortest_path_cache_hits: 0, + db_hits: 0, + elapsed_us: None, + effective_at_epoch: explain.effective_at_epoch.unwrap_or_default(), + warnings, + } +} + +fn graph_pipeline_explain_from_graph_row_explain( + query: &GraphPipelineQuery, + graph_row: GraphRowExplain, + stats: Option, +) -> GraphPipelineExplain { + let columns = graph_row.columns.clone(); + let warnings = graph_row.warnings.clone(); + let notes = graph_row.notes.clone(); + let stats = stats.unwrap_or_else(|| graph_pipeline_validation_stats(&graph_row, warnings.clone())); + let match_stage = GraphPipelineStageExplain { + index: 0, + kind: "Match".to_string(), + detail: "graph-row-backed match stage".to_string(), + columns: Vec::new(), + graph_row: Some(Box::new(graph_row.clone())), + warnings: graph_row.warnings.clone(), + notes: graph_row.notes.clone(), + }; + let project_stage = GraphPipelineStageExplain { + index: 1, + kind: "Project(Return)".to_string(), + detail: "terminal graph-row projection stage".to_string(), + columns: columns.clone(), + graph_row: None, + warnings: Vec::new(), + notes: Vec::new(), + }; + GraphPipelineExplain { + columns, + effective_at_epoch: graph_row.effective_at_epoch, + fingerprint: format!("pipeline:{}", graph_row.fingerprint), + stages: vec![match_stage, project_stage], + row_ops: graph_row.row_ops, + order: graph_row.order, + cursor: graph_row.cursor, + projection: graph_row.projection, + caps: graph_pipeline_cap_explain(&query.options), + summaries: graph_row.summaries, + stats, + warnings, + notes, + } +} + +fn graph_pipeline_cap_explain(options: &GraphPipelineOptions) -> GraphPipelineCapExplain { + GraphPipelineCapExplain { + allow_full_scan: options.allow_full_scan, + max_rows: options.max_rows, + max_pipeline_rows: options.max_pipeline_rows, + max_groups: options.max_groups, + max_collect_items: options.max_collect_items, + max_union_branches: options.max_union_branches, + max_subquery_invocations: options.max_subquery_invocations, + max_subquery_depth: options.max_subquery_depth, + max_shortest_path_pairs: options.max_shortest_path_pairs, + max_intermediate_bindings: options.max_intermediate_bindings, + max_frontier: options.max_frontier, + max_path_hops: options.max_path_hops, + max_paths_per_start: options.max_paths_per_start, + max_order_materialization: options.max_order_materialization, + max_skip: options.max_skip, + max_cursor_bytes: options.max_cursor_bytes, + max_query_bytes: options.max_query_bytes, + max_param_bytes: options.max_param_bytes, + max_ast_depth: options.max_ast_depth, + max_literal_items: options.max_literal_items, + } +} + +fn execute_gql_mutation_unsupported_error(plan: &GqlMutationPlan) -> EngineError { + EngineError::GqlUnsupported { + feature: "GQL mutation execution".to_string(), + message: "GQL mutation execution for the supplied clause combination is not supported by the current implementation".to_string(), + span: plan.semantic.statement.span.clone(), + } +} + +impl DatabaseEngine { + fn execute_gql_mutation( + &self, + mutation: GqlMutationStatement, + params: &GqlParams, + options: &GqlExecutionOptions, + started_at: Instant, + ) -> Result { + if options.cursor.is_some() { + return Err(EngineError::InvalidCursor { + message: "GQL mutation statements do not accept cursors".into(), + }); + } + if options.mode == GqlExecutionMode::ReadOnly { + return Err(gql_read_only_mutation_error(&mutation.span)); + } + let plan = lower_mutation(mutation, params, options)?; + validate_gql_mutation_plan_for_execution(&plan)?; + if !gql_mutation_plan_is_executable(&plan) { + return Err(execute_gql_mutation_unsupported_error(&plan)); + } + self.execute_gql_create_mutation(&plan, params, options, started_at) + } +} + +fn gql_mutation_plan_is_executable(plan: &GqlMutationPlan) -> bool { + !plan.clauses.is_empty() + && plan + .clauses + .iter() + .all(|clause| { + matches!( + clause, + GqlMutationClausePlan::Create(_) + | GqlMutationClausePlan::Merge(_) + | GqlMutationClausePlan::Set(_) + | GqlMutationClausePlan::Remove(_) + | GqlMutationClausePlan::Delete { .. } + ) + }) +} + +#[derive(Clone)] +struct GqlCreateExecutionRow { + read_nodes: BTreeMap>, + read_edges: BTreeMap>, + read_paths: BTreeMap>, + read_scalars: BTreeMap, + expr_values: Vec>, + created_nodes: BTreeMap, + created_edges: BTreeMap, + created_node_writes: BTreeSet, + created_edge_writes: BTreeSet, + touched_created_nodes: BTreeSet, + touched_created_edges: BTreeSet, + produced_write: bool, +} + +struct GqlMutationInputRows { + rows: Vec, + db_hits: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct GqlPathIdentity { + node_ids: Vec, + edge_ids: Vec, +} + +struct GqlCreatedNodeExecution { + local: TxnLocalRef, + labels: Vec, + key: String, + props: BTreeMap, + weight: f32, +} + +struct GqlCreatedEdgeExecution { + alias: Option, + local: Option, + from: TxnNodeRef, + to: TxnNodeRef, + label: String, + props: BTreeMap, + weight: f32, + valid_from: Option, + valid_to: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum GqlMutationTargetKey { + CreatedNode(usize), + CreatedEdge(usize), + ExistingNode(u64), + ExistingEdge(u64), +} + +struct GqlExistingNodeExecution { + original: NodeRecord, + original_labels: Vec, + labels: Vec, + props: BTreeMap, + weight: f32, + dense_vector: Option, + sparse_vector: Option, } struct GqlExistingEdgeExecution { @@ -538,7 +1345,9 @@ impl DatabaseEngine { let mutation_timestamp = now_millis(); let mut materialized = materialize_gql_create( plan, + params, input.rows, + &txn, edge_uniqueness, options.max_mutation_ops, mutation_timestamp, @@ -648,19 +1457,22 @@ impl DatabaseEngine { let graph_params = gql_params_to_graph_params_for_mutation(params, plan, &missing_expr_ids); if let Some(read_prefix) = plan.read_prefix.as_ref() { - let outcome = execute_gql_graph_row_target(snapshot, &read_prefix.lowered)?; - for followup in outcome.followups { + let read_result = execute_gql_mutation_read_prefix( + snapshot, + &read_prefix.lowered, + options, + )?; + for followup in read_result.followups { self.runtime.enqueue_secondary_index_read_followup(followup); } - let graph_result = outcome.value; - if graph_result.rows.len() > options.max_mutation_rows { + if read_result.rows.len() > options.max_mutation_rows { return Err(gql_mutation_cap_error( "max_mutation_rows", - graph_result.rows.len(), + read_result.rows.len(), options.max_mutation_rows, )); } - if graph_result.next_cursor.is_some() { + if read_result.next_cursor.is_some() { let (cap_name, cap_value) = if options.max_intermediate_bindings <= options.max_mutation_rows { ("max_intermediate_bindings", options.max_intermediate_bindings) @@ -669,16 +1481,12 @@ impl DatabaseEngine { }; return Err(gql_mutation_cap_error( cap_name, - graph_result.rows.len().saturating_add(1), + read_result.rows.len().saturating_add(1), cap_value, )); } - let db_hits = if options.profile { - gql_profile_graph_row_db_hits(&graph_result.stats) - } else { - 0 - }; - let rows = graph_result + let db_hits = read_result.db_hits; + let rows = read_result .rows .into_iter() .map(|row| { @@ -699,9 +1507,14 @@ impl DatabaseEngine { read_nodes: BTreeMap::new(), read_edges: BTreeMap::new(), read_paths: BTreeMap::new(), + read_scalars: BTreeMap::new(), expr_values: vec![None; plan.operation_exprs.len()], created_nodes: BTreeMap::new(), created_edges: BTreeMap::new(), + created_node_writes: BTreeSet::new(), + created_edge_writes: BTreeSet::new(), + touched_created_nodes: BTreeSet::new(), + touched_created_edges: BTreeSet::new(), produced_write: false, }; fill_missing_gql_create_expr_values( @@ -718,6 +1531,52 @@ impl DatabaseEngine { } } +struct GqlMutationReadPrefixRuntimeResult { + rows: Vec, + next_cursor: Option, + db_hits: usize, + followups: Vec, +} + +fn execute_gql_mutation_read_prefix( + snapshot: &ReadView, + lowered: &GqlLoweredPlan, + options: &GqlExecutionOptions, +) -> Result { + match &lowered.native_target { + GqlNativeTarget::GraphRows { .. } => { + let outcome = execute_gql_graph_row_target(snapshot, lowered)?; + let graph_result = outcome.value; + let db_hits = if options.profile { + gql_profile_graph_row_db_hits(&graph_result.stats) + } else { + 0 + }; + Ok(GqlMutationReadPrefixRuntimeResult { + rows: graph_result.rows, + next_cursor: graph_result.next_cursor, + db_hits, + followups: outcome.followups, + }) + } + GqlNativeTarget::GraphPipeline { .. } => { + let outcome = execute_gql_graph_pipeline_target_on_view(snapshot, lowered)?; + let graph_result = outcome.value; + let db_hits = if options.profile { + gql_profile_graph_pipeline_db_hits(&graph_result.stats) + } else { + 0 + }; + Ok(GqlMutationReadPrefixRuntimeResult { + rows: graph_result.rows, + next_cursor: graph_result.next_cursor, + db_hits, + followups: outcome.followups, + }) + } + } +} + fn gql_profile_graph_row_db_hits(stats: &GraphRowStats) -> usize { stats .db_hits @@ -726,7 +1585,15 @@ fn gql_profile_graph_row_db_hits(stats: &GraphRowStats) -> usize { .max(stats.rows_returned) } -fn gql_mutation_profile_db_hits( +fn gql_profile_graph_pipeline_db_hits(stats: &GraphPipelineStats) -> usize { + stats + .db_hits + .max(stats.intermediate_rows) + .max(stats.rows_after_filter) + .max(stats.rows_returned) +} + +fn gql_mutation_profile_db_hits( options: &GqlExecutionOptions, input_db_hits: usize, materialization_db_hits: usize, @@ -754,9 +1621,14 @@ fn gql_create_input_row_from_graph_row( read_nodes: BTreeMap::new(), read_edges: BTreeMap::new(), read_paths: BTreeMap::new(), + read_scalars: BTreeMap::new(), expr_values: vec![None; plan.operation_exprs.len()], created_nodes: BTreeMap::new(), created_edges: BTreeMap::new(), + created_node_writes: BTreeSet::new(), + created_edge_writes: BTreeSet::new(), + touched_created_nodes: BTreeSet::new(), + touched_created_edges: BTreeSet::new(), produced_write: false, }; let Some(read_prefix) = plan.read_prefix.as_ref() else { @@ -786,6 +1658,11 @@ fn gql_create_input_row_from_graph_row( "path aliases are not scalar mutation targets".to_string(), )); } + GqlAliasKind::Scalar => { + return Err(EngineError::InvalidOperation( + "scalar aliases are not mutation targets".to_string(), + )); + } } value_index += 1; } @@ -806,6 +1683,16 @@ fn gql_create_input_row_from_graph_row( row.read_paths.insert(alias.clone(), identity); value_index += 2; } + GqlMutationInternalColumn::ScalarValue { alias, .. } => { + let value = values.get(value_index).ok_or_else(|| { + EngineError::InvalidOperation( + "mutation read prefix returned fewer scalar columns than planned" + .to_string(), + ) + })?; + row.read_scalars.insert(alias.clone(), value.clone()); + value_index += 1; + } GqlMutationInternalColumn::ExprValue { id, .. } => { let value = values.get(value_index).ok_or_else(|| { EngineError::InvalidOperation( @@ -836,7 +1723,7 @@ fn gql_create_missing_operation_expr_ids(plan: &GqlMutationPlan) -> Vec { plan.operation_exprs .iter() .filter_map(|expr| { - if supplied_by_read_prefix.contains(&expr.id) { + if expr.late || supplied_by_read_prefix.contains(&expr.id) { None } else { Some(expr.id) @@ -884,16 +1771,25 @@ fn fill_missing_gql_create_expr_values( Ok(()) } +#[allow(clippy::too_many_arguments)] fn materialize_gql_create( plan: &GqlMutationPlan, + params: &GqlParams, mut rows: Vec, + txn: &WriteTxn, edge_uniqueness: bool, max_mutation_ops: usize, default_valid_from: i64, snapshot: &ReadView, ) -> Result { - let (existing_node_ids, existing_edge_ids) = + let (mut existing_node_ids, mut existing_edge_ids) = collect_gql_existing_update_targets(plan, &rows); + collect_gql_late_expr_read_prefix_targets( + plan, + &rows, + &mut existing_node_ids, + &mut existing_edge_ids, + ); let mut existing_nodes = hydrate_gql_existing_node_targets(snapshot, &existing_node_ids)?; let mut existing_edges = hydrate_gql_existing_edge_targets(snapshot, &existing_edge_ids)?; @@ -911,12 +1807,16 @@ fn materialize_gql_create( let mut direct_existing_edge_deletes = BTreeSet::new(); let mut created_node_deletes = BTreeSet::new(); let mut direct_created_edge_deletes = BTreeSet::new(); + let mut merge_overlay = TxnMergeOverlay::default(); + let mut merge_node_locals: BTreeMap = BTreeMap::new(); + let mut merge_edge_locals: BTreeMap = BTreeMap::new(); + let mut merge_db_hits = 0usize; let mut op_budget = GqlMaterializationOpBudget::new(max_mutation_ops); - for (row_index, row) in rows.iter_mut().enumerate() { - for clause in &plan.clauses { - match clause { - GqlMutationClausePlan::Create(patterns) => { + for clause in &plan.clauses { + match clause { + GqlMutationClausePlan::Create(patterns) => { + for (row_index, row) in rows.iter_mut().enumerate() { for (pattern_index, pattern) in patterns.iter().enumerate() { if gql_create_pattern_has_null_read_endpoint(plan, pattern, row) { skipped_null_targets += 1; @@ -937,9 +1837,40 @@ fn materialize_gql_create( )?; } } - GqlMutationClausePlan::Set(items) => { + } + GqlMutationClausePlan::Merge(merge) => { + materialize_gql_merge_clause( + plan, + params, + merge, + &mut rows, + txn, + snapshot, + edge_uniqueness, + default_valid_from, + &mut nodes, + &mut edges, + &mut existing_nodes, + &mut existing_edges, + &mut edge_precheck_triples, + &mut merge_overlay, + &mut merge_node_locals, + &mut merge_edge_locals, + &mut merge_db_hits, + &mut target_applications, + &mut skipped_null_targets, + &mut first_existing_node_update_order, + &mut first_existing_edge_update_order, + &mut seen_existing_node_updates, + &mut seen_existing_edge_updates, + &mut op_budget, + )?; + } + GqlMutationClausePlan::Set(items) => { + for row in rows.iter_mut() { apply_gql_set_items( plan, + params, items, row, &mut nodes, @@ -954,9 +1885,12 @@ fn materialize_gql_create( &mut seen_existing_edge_updates, )?; } - GqlMutationClausePlan::Remove(items) => { + } + GqlMutationClausePlan::Remove(items) => { + for row in rows.iter_mut() { apply_gql_remove_items( plan, + params, items, row, &mut nodes, @@ -971,7 +1905,9 @@ fn materialize_gql_create( &mut seen_existing_edge_updates, )?; } - GqlMutationClausePlan::Delete { .. } => { + } + GqlMutationClausePlan::Delete { .. } => { + for row in rows.iter_mut() { apply_gql_delete_targets( clause, row, @@ -1067,7 +2003,8 @@ fn materialize_gql_create( .len() .saturating_add(existing_edge_ids.len()) .saturating_add(existing_node_deletes.len()) - .saturating_add(existing_edge_deletes.len()); + .saturating_add(existing_edge_deletes.len()) + .saturating_add(merge_db_hits); if mutation_ops > max_mutation_ops { return Err(gql_mutation_cap_error( "max_mutation_ops", @@ -1120,6 +2057,437 @@ struct GqlMutationComputedStats { duplicate_targets: usize, } +#[allow(clippy::too_many_arguments)] +fn materialize_gql_merge_clause( + plan: &GqlMutationPlan, + params: &GqlParams, + merge: &GqlMergePlan, + rows: &mut [GqlCreateExecutionRow], + txn: &WriteTxn, + snapshot: &ReadView, + edge_uniqueness: bool, + default_valid_from: i64, + nodes: &mut Vec, + edges: &mut Vec, + existing_nodes: &mut BTreeMap, + existing_edges: &mut BTreeMap, + edge_precheck_triples: &mut BTreeSet<(u64, u64, String)>, + merge_overlay: &mut TxnMergeOverlay, + merge_node_locals: &mut BTreeMap, + merge_edge_locals: &mut BTreeMap, + merge_db_hits: &mut usize, + target_applications: &mut BTreeMap, + skipped_null_targets: &mut usize, + first_existing_node_update_order: &mut Vec, + first_existing_edge_update_order: &mut Vec, + seen_existing_node_updates: &mut BTreeSet, + seen_existing_edge_updates: &mut BTreeSet, + op_budget: &mut GqlMaterializationOpBudget, +) -> Result<(), EngineError> { + match &merge.pattern { + GqlMergePatternPlan::Node { alias, label, key } => materialize_gql_node_merge( + plan, + params, + alias, + label, + key, + &merge.on_create, + &merge.on_match, + rows, + txn, + snapshot, + nodes, + edges, + existing_nodes, + existing_edges, + merge_overlay, + merge_node_locals, + merge_db_hits, + target_applications, + skipped_null_targets, + first_existing_node_update_order, + first_existing_edge_update_order, + seen_existing_node_updates, + seen_existing_edge_updates, + op_budget, + ), + GqlMergePatternPlan::Relationship { + alias, + from_alias, + to_alias, + label, + } => materialize_gql_relationship_merge( + plan, + params, + alias, + from_alias, + to_alias, + label, + &merge.on_create, + &merge.on_match, + rows, + txn, + snapshot, + edge_uniqueness, + default_valid_from, + nodes, + edges, + existing_nodes, + existing_edges, + edge_precheck_triples, + merge_overlay, + merge_edge_locals, + merge_db_hits, + target_applications, + skipped_null_targets, + first_existing_node_update_order, + first_existing_edge_update_order, + seen_existing_node_updates, + seen_existing_edge_updates, + op_budget, + ), + } +} + +#[allow(clippy::too_many_arguments, clippy::ptr_arg)] +fn materialize_gql_node_merge( + plan: &GqlMutationPlan, + params: &GqlParams, + alias: &str, + label: &str, + key_ref: &GqlMutationExprRef, + on_create: &[GqlSetItemPlan], + on_match: &[GqlSetItemPlan], + rows: &mut [GqlCreateExecutionRow], + txn: &WriteTxn, + snapshot: &ReadView, + nodes: &mut Vec, + edges: &mut Vec, + existing_nodes: &mut BTreeMap, + existing_edges: &mut BTreeMap, + merge_overlay: &mut TxnMergeOverlay, + merge_node_locals: &mut BTreeMap, + merge_db_hits: &mut usize, + target_applications: &mut BTreeMap, + skipped_null_targets: &mut usize, + first_existing_node_update_order: &mut Vec, + first_existing_edge_update_order: &mut Vec, + seen_existing_node_updates: &mut BTreeSet, + seen_existing_edge_updates: &mut BTreeSet, + op_budget: &mut GqlMaterializationOpBudget, +) -> Result<(), EngineError> { + let mut keys = Vec::with_capacity(rows.len()); + for row in rows.iter() { + let key = gql_merge_string_key(gql_create_expr_value(row, key_ref.id)?)?; + keys.push((label.to_string(), key)); + } + let batch = txn.plan_keyed_node_merge_batch(merge_overlay, &keys)?; + let missing_existing_ids = batch + .existing_ids + .iter() + .filter(|id| !existing_nodes.contains_key(id)) + .count(); + *merge_db_hits = (*merge_db_hits) + .saturating_add(batch.snapshot_lookup_count) + .saturating_add(missing_existing_ids); + ensure_gql_existing_node_targets(snapshot, existing_nodes, &batch.existing_ids)?; + op_budget.reserve( + batch + .rows + .iter() + .filter(|outcome| matches!(outcome, TxnKeyedNodeMergeRowOutcome::Create(_))) + .count(), + )?; + + for (row_index, (row, outcome)) in rows.iter_mut().zip(batch.rows).enumerate() { + match outcome { + TxnKeyedNodeMergeRowOutcome::Existing(id) => { + row.created_nodes.remove(alias); + row.read_nodes.insert(alias.to_string(), Some(id)); + apply_gql_set_items( + plan, + params, + on_match, + row, + nodes, + edges, + existing_nodes, + existing_edges, + target_applications, + skipped_null_targets, + first_existing_node_update_order, + first_existing_edge_update_order, + seen_existing_node_updates, + seen_existing_edge_updates, + )?; + } + TxnKeyedNodeMergeRowOutcome::MatchedLocal(local) => { + let node_index = *merge_node_locals.get(&local).ok_or_else(|| { + EngineError::InvalidOperation( + "GQL node MERGE local overlay target was not materialized".to_string(), + ) + })?; + row.read_nodes.remove(alias); + row.created_nodes.insert(alias.to_string(), node_index); + apply_gql_set_items( + plan, + params, + on_match, + row, + nodes, + edges, + existing_nodes, + existing_edges, + target_applications, + skipped_null_targets, + first_existing_node_update_order, + first_existing_edge_update_order, + seen_existing_node_updates, + seen_existing_edge_updates, + )?; + } + TxnKeyedNodeMergeRowOutcome::Create(local) => { + let node_index = nodes.len(); + let (_, key) = &keys[row_index]; + let local_ref = TxnLocalRef::Alias(format!("__gql_merge_node_{row_index}_{alias}")); + nodes.push(GqlCreatedNodeExecution { + local: local_ref, + labels: vec![label.to_string()], + key: key.clone(), + props: BTreeMap::new(), + weight: 1.0, + }); + merge_node_locals.insert(local, node_index); + row.read_nodes.remove(alias); + row.created_nodes.insert(alias.to_string(), node_index); + row.created_node_writes.insert(node_index); + row.produced_write = true; + apply_gql_set_items( + plan, + params, + on_create, + row, + nodes, + edges, + existing_nodes, + existing_edges, + target_applications, + skipped_null_targets, + first_existing_node_update_order, + first_existing_edge_update_order, + seen_existing_node_updates, + seen_existing_edge_updates, + )?; + } + } + } + Ok(()) +} +#[allow(clippy::too_many_arguments, clippy::ptr_arg)] +fn materialize_gql_relationship_merge( + plan: &GqlMutationPlan, + params: &GqlParams, + alias: &str, + from_alias: &str, + to_alias: &str, + label: &str, + on_create: &[GqlSetItemPlan], + on_match: &[GqlSetItemPlan], + rows: &mut [GqlCreateExecutionRow], + txn: &WriteTxn, + snapshot: &ReadView, + _edge_uniqueness: bool, + default_valid_from: i64, + nodes: &mut Vec, + edges: &mut Vec, + existing_nodes: &mut BTreeMap, + existing_edges: &mut BTreeMap, + edge_precheck_triples: &mut BTreeSet<(u64, u64, String)>, + merge_overlay: &mut TxnMergeOverlay, + merge_edge_locals: &mut BTreeMap, + merge_db_hits: &mut usize, + target_applications: &mut BTreeMap, + skipped_null_targets: &mut usize, + first_existing_node_update_order: &mut Vec, + first_existing_edge_update_order: &mut Vec, + seen_existing_node_updates: &mut BTreeSet, + seen_existing_edge_updates: &mut BTreeSet, + op_budget: &mut GqlMaterializationOpBudget, +) -> Result<(), EngineError> { + let mut inputs = Vec::with_capacity(rows.len()); + for row in rows.iter() { + let Some(from) = gql_create_node_ref_for_alias(row, from_alias, nodes)? else { + inputs.push(None); + continue; + }; + let Some(to) = gql_create_node_ref_for_alias(row, to_alias, nodes)? else { + inputs.push(None); + continue; + }; + inputs.push(Some(TxnUniqueEdgeMergeInput { + from, + to, + label: label.to_string(), + })); + } + + let batch = txn.plan_unique_edge_merge_batch(merge_overlay, &inputs)?; + let missing_existing_ids = batch + .existing_ids + .iter() + .filter(|id| !existing_edges.contains_key(id)) + .count(); + *merge_db_hits = (*merge_db_hits) + .saturating_add(batch.snapshot_lookup_count) + .saturating_add(missing_existing_ids); + ensure_gql_existing_edge_targets(snapshot, existing_edges, &batch.existing_ids)?; + edge_precheck_triples.extend(batch.missing_committed_triples.iter().cloned()); + op_budget.reserve( + batch + .rows + .iter() + .filter(|outcome| matches!(outcome, TxnUniqueEdgeMergeRowOutcome::Create { .. })) + .count(), + )?; + + for (row_index, (row, outcome)) in rows.iter_mut().zip(batch.rows).enumerate() { + match outcome { + TxnUniqueEdgeMergeRowOutcome::SkippedNull => { + *skipped_null_targets += 1; + row.created_edges.remove(alias); + row.read_edges.insert(alias.to_string(), None); + } + TxnUniqueEdgeMergeRowOutcome::Existing(id) => { + row.created_edges.remove(alias); + row.read_edges.insert(alias.to_string(), Some(id)); + apply_gql_set_items( + plan, + params, + on_match, + row, + nodes, + edges, + existing_nodes, + existing_edges, + target_applications, + skipped_null_targets, + first_existing_node_update_order, + first_existing_edge_update_order, + seen_existing_node_updates, + seen_existing_edge_updates, + )?; + } + TxnUniqueEdgeMergeRowOutcome::MatchedLocal(local) => { + let edge_index = *merge_edge_locals.get(&local).ok_or_else(|| { + EngineError::InvalidOperation( + "GQL relationship MERGE local overlay target was not materialized" + .to_string(), + ) + })?; + row.read_edges.remove(alias); + row.created_edges.insert(alias.to_string(), edge_index); + apply_gql_set_items( + plan, + params, + on_match, + row, + nodes, + edges, + existing_nodes, + existing_edges, + target_applications, + skipped_null_targets, + first_existing_node_update_order, + first_existing_edge_update_order, + seen_existing_node_updates, + seen_existing_edge_updates, + )?; + } + TxnUniqueEdgeMergeRowOutcome::Create { + local, + from, + to, + label, + } => { + let local_ref = TxnLocalRef::Alias(format!("__gql_merge_edge_{row_index}_{alias}")); + let edge_index = edges.len(); + edges.push(GqlCreatedEdgeExecution { + alias: Some(alias.to_string()), + local: Some(local_ref), + from, + to, + label, + props: BTreeMap::new(), + weight: 1.0, + valid_from: Some(default_valid_from), + valid_to: Some(i64::MAX), + }); + merge_edge_locals.insert(local, edge_index); + row.read_edges.remove(alias); + row.created_edges.insert(alias.to_string(), edge_index); + row.created_edge_writes.insert(edge_index); + row.produced_write = true; + apply_gql_set_items( + plan, + params, + on_create, + row, + nodes, + edges, + existing_nodes, + existing_edges, + target_applications, + skipped_null_targets, + first_existing_node_update_order, + first_existing_edge_update_order, + seen_existing_node_updates, + seen_existing_edge_updates, + )?; + } + } + } + Ok(()) +} + +fn ensure_gql_existing_node_targets( + snapshot: &ReadView, + existing_nodes: &mut BTreeMap, + node_ids: &BTreeSet, +) -> Result<(), EngineError> { + let missing = node_ids + .iter() + .filter(|id| !existing_nodes.contains_key(id)) + .copied() + .collect::>(); + let hydrated = hydrate_gql_existing_node_targets(snapshot, &missing)?; + existing_nodes.extend(hydrated); + Ok(()) +} + +fn ensure_gql_existing_edge_targets( + snapshot: &ReadView, + existing_edges: &mut BTreeMap, + edge_ids: &BTreeSet, +) -> Result<(), EngineError> { + let missing = edge_ids + .iter() + .filter(|id| !existing_edges.contains_key(id)) + .copied() + .collect::>(); + let hydrated = hydrate_gql_existing_edge_targets(snapshot, &missing)?; + existing_edges.extend(hydrated); + Ok(()) +} + +fn gql_merge_string_key(value: &GraphValue) -> Result { + match value { + GraphValue::String(value) if !value.is_empty() => Ok(value.clone()), + _ => Err(gql_create_invalid_value( + "GQL MERGE node key must be a non-empty string", + )), + } +} + #[allow(clippy::too_many_arguments)] fn materialize_gql_create_pattern( pattern: &GqlCreatePatternPlan, @@ -1130,7 +2498,7 @@ fn materialize_gql_create_pattern( nodes: &mut Vec, edges: &mut Vec, edge_precheck_triples: &mut BTreeSet<(u64, u64, String)>, - seen_edge_triples: &mut BTreeSet<(GqlCreateEndpointKey, GqlCreateEndpointKey, String)>, + seen_edge_triples: &mut BTreeSet<(TxnMergeEndpointKey, TxnMergeEndpointKey, String)>, edge_uniqueness: bool, op_budget: &mut GqlMaterializationOpBudget, ) -> Result<(), EngineError> { @@ -1142,7 +2510,9 @@ fn materialize_gql_create_pattern( let local_alias = format!("__gql_create_node_{row_index}_{pattern_index}_{}", node.alias); let local = TxnLocalRef::Alias(local_alias); let created = materialize_gql_create_node(node, row, local)?; - row.created_nodes.insert(node.alias.clone(), nodes.len()); + let node_index = nodes.len(); + row.created_nodes.insert(node.alias.clone(), node_index); + row.created_node_writes.insert(node_index); row.produced_write = true; nodes.push(created); } @@ -1166,7 +2536,7 @@ fn materialize_gql_create_pattern( from.clone(), to.clone(), local.clone(), - default_valid_from, + default_valid_from, )?; if edge_uniqueness { let triple = ( @@ -1184,8 +2554,10 @@ fn materialize_gql_create_pattern( edge_precheck_triples.insert((*from_id, *to_id, edge.label.clone())); } } + let edge_index = edges.len(); + row.created_edge_writes.insert(edge_index); if let (Some(alias), Some(_)) = (&created.alias, &created.local) { - row.created_edges.insert(alias.clone(), edges.len()); + row.created_edges.insert(alias.clone(), edge_index); } row.produced_write = true; edges.push(created); @@ -1263,11 +2635,163 @@ fn collect_gql_existing_update_targets( } } } - GqlMutationClausePlan::Create(_) | GqlMutationClausePlan::Delete { .. } => {} + GqlMutationClausePlan::Create(_) + | GqlMutationClausePlan::Merge(_) + | GqlMutationClausePlan::Delete { .. } => {} + } + } + } + (nodes, edges) +} + +fn collect_gql_late_expr_read_prefix_targets( + plan: &GqlMutationPlan, + rows: &[GqlCreateExecutionRow], + nodes: &mut BTreeSet, + edges: &mut BTreeSet, +) { + let mut node_aliases = BTreeSet::new(); + let mut edge_aliases = BTreeSet::new(); + for expr in plan.operation_exprs.iter().filter(|expr| expr.late) { + collect_gql_read_prefix_element_aliases_in_expr( + plan, + &expr.source, + &mut node_aliases, + &mut edge_aliases, + ); + } + for row in rows { + for alias in &node_aliases { + if let Some(Some(id)) = row.read_nodes.get(alias) { + nodes.insert(*id); + } + } + for alias in &edge_aliases { + if let Some(Some(id)) = row.read_edges.get(alias) { + edges.insert(*id); + } + } + } +} + +fn collect_gql_read_prefix_element_aliases_in_expr( + plan: &GqlMutationPlan, + expr: &Expr, + node_aliases: &mut BTreeSet, + edge_aliases: &mut BTreeSet, +) { + match &expr.kind { + ExprKind::Variable(name) => { + if let Some(binding) = plan.semantic.aliases.get(name) { + if binding.origin == GqlAliasOrigin::ReadPrefix + && matches!(binding.kind, GqlAliasKind::Node | GqlAliasKind::Edge) + { + match binding.kind { + GqlAliasKind::Node => { + node_aliases.insert(name.clone()); + } + GqlAliasKind::Edge => { + edge_aliases.insert(name.clone()); + } + GqlAliasKind::Path | GqlAliasKind::Scalar => {} + } + } + } + } + ExprKind::PropertyAccess { object, .. } + | ExprKind::Unary { expr: object, .. } + | ExprKind::IsNull { expr: object, .. } => { + collect_gql_read_prefix_element_aliases_in_expr( + plan, + object, + node_aliases, + edge_aliases, + ); + } + ExprKind::Binary { left, right, .. } => { + collect_gql_read_prefix_element_aliases_in_expr( + plan, + left, + node_aliases, + edge_aliases, + ); + collect_gql_read_prefix_element_aliases_in_expr( + plan, + right, + node_aliases, + edge_aliases, + ); + } + ExprKind::FunctionCall { args, .. } | ExprKind::List(args) => { + for arg in args { + collect_gql_read_prefix_element_aliases_in_expr( + plan, + arg, + node_aliases, + edge_aliases, + ); + } + } + ExprKind::AggregateCall { arg, .. } => { + if let Some(arg) = arg.as_ref() { + collect_gql_read_prefix_element_aliases_in_expr( + plan, + arg, + node_aliases, + edge_aliases, + ); + } + } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand.as_ref() { + collect_gql_read_prefix_element_aliases_in_expr( + plan, + operand, + node_aliases, + edge_aliases, + ); + } + for branch in branches { + collect_gql_read_prefix_element_aliases_in_expr( + plan, + &branch.when, + node_aliases, + edge_aliases, + ); + collect_gql_read_prefix_element_aliases_in_expr( + plan, + &branch.then, + node_aliases, + edge_aliases, + ); + } + if let Some(else_expr) = else_expr.as_ref() { + collect_gql_read_prefix_element_aliases_in_expr( + plan, + else_expr, + node_aliases, + edge_aliases, + ); + } + } + ExprKind::Map(map) => { + for entry in &map.entries { + collect_gql_read_prefix_element_aliases_in_expr( + plan, + &entry.value, + node_aliases, + edge_aliases, + ); } } + ExprKind::ExistsSubquery(_) + | ExprKind::Literal(_) + | ExprKind::Parameter(_) => {} } - (nodes, edges) } fn collect_gql_existing_update_target( @@ -1297,7 +2821,16 @@ fn collect_gql_existing_update_target( edges.insert(*id); } } - GqlAliasKind::Path => {} + GqlAliasKind::Path | GqlAliasKind::Scalar => {} + } +} + +fn gql_alias_kind_name(kind: GqlAliasKind) -> &'static str { + match kind { + GqlAliasKind::Node => "node", + GqlAliasKind::Edge => "edge", + GqlAliasKind::Path => "path", + GqlAliasKind::Scalar => "scalar", } } @@ -1478,8 +3011,8 @@ fn gql_delete_target_for_alias( }; Ok(Some(GqlMutationTargetKey::ExistingEdge(*id))) } - GqlAliasKind::Path => Err(EngineError::InvalidOperation( - "path aliases are not scalar mutation targets".to_string(), + GqlAliasKind::Path | GqlAliasKind::Scalar => Err(EngineError::InvalidOperation( + format!("{} aliases are not mutation targets", gql_alias_kind_name(kind)), )), } } @@ -1604,6 +3137,7 @@ fn gql_node_ref_matches_deleted_node( #[allow(clippy::too_many_arguments)] fn apply_gql_set_items( plan: &GqlMutationPlan, + params: &GqlParams, items: &[GqlSetItemPlan], row: &mut GqlCreateExecutionRow, nodes: &mut [GqlCreatedNodeExecution], @@ -1625,7 +3159,16 @@ fn apply_gql_set_items( property, value, } => { - let value = gql_create_expr_value(row, value.id)?.clone(); + let value = gql_mutation_set_expr_value( + plan, + params, + row, + value, + nodes, + edges, + existing_nodes, + existing_edges, + )?; let Some(target) = gql_mutation_target_for_alias( row, alias, @@ -1641,7 +3184,7 @@ fn apply_gql_set_items( continue; }; if apply_gql_set_property( - target, + target.clone(), property, &value, nodes, @@ -1649,11 +3192,21 @@ fn apply_gql_set_items( existing_nodes, existing_edges, )? { + mark_gql_touched_created_target(row, &target); row.produced_write = true; } } GqlSetItemPlan::MapMerge { alias, kind, value } => { - let value = gql_create_expr_value(row, value.id)?.clone(); + let value = gql_mutation_set_expr_value( + plan, + params, + row, + value, + nodes, + edges, + existing_nodes, + existing_edges, + )?; let Some(target) = gql_mutation_target_for_alias( row, alias, @@ -1669,13 +3222,14 @@ fn apply_gql_set_items( continue; }; if apply_gql_map_merge( - target, + target.clone(), &value, nodes, edges, existing_nodes, existing_edges, )? { + mark_gql_touched_created_target(row, &target); row.produced_write = true; } } @@ -1694,19 +3248,20 @@ fn apply_gql_set_items( else { continue; }; - if apply_gql_add_node_label(target, label, nodes, existing_nodes)? { + if apply_gql_add_node_label(target.clone(), label, nodes, existing_nodes)? { + mark_gql_touched_created_target(row, &target); row.produced_write = true; } } } } - let _ = plan; Ok(()) } #[allow(clippy::too_many_arguments)] fn apply_gql_remove_items( - plan: &GqlMutationPlan, + _plan: &GqlMutationPlan, + _params: &GqlParams, items: &[GqlRemoveItemPlan], row: &mut GqlCreateExecutionRow, nodes: &mut [GqlCreatedNodeExecution], @@ -1742,13 +3297,14 @@ fn apply_gql_remove_items( continue; }; if apply_gql_remove_property( - target, + target.clone(), property, nodes, edges, existing_nodes, existing_edges, )? { + mark_gql_touched_created_target(row, &target); row.produced_write = true; } } @@ -1767,13 +3323,13 @@ fn apply_gql_remove_items( else { continue; }; - if apply_gql_remove_node_label(target, label, nodes, existing_nodes)? { + if apply_gql_remove_node_label(target.clone(), label, nodes, existing_nodes)? { + mark_gql_touched_created_target(row, &target); row.produced_write = true; } } } } - let _ = plan; Ok(()) } @@ -1833,11 +3389,76 @@ fn gql_mutation_target_for_alias( "path aliases are not scalar mutation targets".to_string(), )); } + GqlAliasKind::Scalar => { + return Err(EngineError::InvalidOperation( + "scalar aliases are not mutation targets".to_string(), + )); + } }; *target_applications.entry(target.clone()).or_default() += 1; Ok(Some(target)) } +fn mark_gql_touched_created_target( + row: &mut GqlCreateExecutionRow, + target: &GqlMutationTargetKey, +) { + match target { + GqlMutationTargetKey::CreatedNode(index) => { + row.touched_created_nodes.insert(*index); + } + GqlMutationTargetKey::CreatedEdge(index) => { + row.touched_created_edges.insert(*index); + } + GqlMutationTargetKey::ExistingNode(_) | GqlMutationTargetKey::ExistingEdge(_) => {} + } +} + +#[allow(clippy::too_many_arguments)] +fn gql_mutation_set_expr_value( + plan: &GqlMutationPlan, + params: &GqlParams, + row: &GqlCreateExecutionRow, + expr_ref: &GqlMutationExprRef, + nodes: &[GqlCreatedNodeExecution], + edges: &[GqlCreatedEdgeExecution], + existing_nodes: &BTreeMap, + existing_edges: &BTreeMap, +) -> Result { + let expr_plan = plan.operation_exprs.get(expr_ref.id).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "GQL mutation expression ref #{} is missing from the execution plan", + expr_ref.id + )) + })?; + if !expr_plan.late { + return gql_create_expr_value(row, expr_ref.id).cloned(); + } + let hydrated = GqlMutationHydratedRecords::default(); + let context = GqlMutationReturnEvalContext { + plan, + row, + nodes, + edges, + existing_nodes, + existing_edges, + commit: None, + hydrated: &hydrated, + include_vectors: false, + path_id_only: false, + }; + let value = gql_mutation_return_expr_value(&expr_plan.source, params, &context)?; + let graph_value = gql_value_to_graph_eval_scalar(value)?.ok_or_else(|| { + gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "GQL MERGE action expression must produce a scalar, list, map, or null value" + .to_string(), + expr_plan.source.span.clone(), + ) + })?; + graph_eval_value_to_graph_value(graph_value) +} + fn apply_gql_set_property( target: GqlMutationTargetKey, property: &str, @@ -1996,7 +3617,7 @@ fn reject_reserved_gql_map_merge_keys( key.as_str(), "id" | "from" | "to" | "label" | "type" | "created_at" | "updated_at" ), - GqlAliasKind::Path => true, + GqlAliasKind::Path | GqlAliasKind::Scalar => true, }; if reserved { return Err(EngineError::InvalidOperation(format!( @@ -2262,8 +3883,21 @@ fn gql_row_produced_effective_write( created_node_deletes: &BTreeSet, created_edge_deletes: &BTreeSet, ) -> bool { - row.created_nodes.values().any(|&index| nodes.get(index).is_some()) - || row.created_edges.values().any(|&index| edges.get(index).is_some()) + row.created_node_writes + .iter() + .any(|&index| nodes.get(index).is_some() && !created_node_deletes.contains(&index)) + || row + .created_edge_writes + .iter() + .any(|&index| edges.get(index).is_some() && !created_edge_deletes.contains(&index)) + || row + .touched_created_nodes + .iter() + .any(|&index| nodes.get(index).is_some() && !created_node_deletes.contains(&index)) + || row + .touched_created_edges + .iter() + .any(|&index| edges.get(index).is_some() && !created_edge_deletes.contains(&index)) || row .read_nodes .values() @@ -2557,6 +4191,7 @@ fn precheck_gql_create_conflicts( #[derive(Clone)] struct GqlMutationReturnStaticPlan { exprs: Vec, + distinct: bool, order_by: Vec, skip: usize, limit: Option, @@ -2571,7 +4206,7 @@ struct GqlMutationResolvedOrderItem { struct GqlMutationReturnExecutionPlan { static_plan: GqlMutationReturnStaticPlan, - ordered_rows: Vec, + selected_rows: Vec, output_hydration_needs: GqlMutationReturnHydrationNeeds, read_set: TxnReturnReadSet, } @@ -2670,6 +4305,7 @@ fn build_gql_mutation_return_static_plan( .transpose()?; Ok(Some(GqlMutationReturnStaticPlan { exprs, + distinct: return_plan.distinct, order_by, skip, limit, @@ -2696,16 +4332,24 @@ fn build_gql_mutation_return_execution_plan( options.max_order_materialization, )); } - let returned_count = + let candidate_count = gql_mutation_return_count_after_row_ops(materialized.rows.len(), &static_plan); - if returned_count > options.max_rows { + if !static_plan.distinct && candidate_count > options.max_rows { return Err(gql_mutation_cap_error( "max_rows", - returned_count, + candidate_count, options.max_rows, )); } validate_gql_mutation_order_exprs_materialized(plan, &static_plan.order_by, materialized)?; + if static_plan.distinct { + validate_gql_mutation_return_distinct_exprs_static(plan, &static_plan.exprs)?; + validate_gql_mutation_return_distinct_exprs_materialized( + plan, + &static_plan.exprs, + materialized, + )?; + } let ordered_precommit = build_gql_mutation_ordered_rows_precommit( plan, @@ -2715,35 +4359,76 @@ fn build_gql_mutation_return_execution_plan( snapshot, options, )?; - let selected_rows = + let candidate_rows = selected_gql_mutation_return_rows(&ordered_precommit.rows, static_plan.skip, static_plan.limit); - let mut output_hydration_needs = GqlMutationReturnHydrationNeeds::default(); + let mut candidate_hydration_needs = GqlMutationReturnHydrationNeeds::default(); let mut read_set = ordered_precommit.read_set; for item in &static_plan.exprs { collect_gql_mutation_return_expr_ids( plan, materialized, &item.expr, - &selected_rows, + &candidate_rows, GqlMutationReturnUse::Output, None, - &mut output_hydration_needs, + &mut candidate_hydration_needs, &mut read_set, ); } - let hydrated = hydrate_gql_mutation_return_records(snapshot, &output_hydration_needs)?; + let hydrated = hydrate_gql_mutation_return_records(snapshot, &candidate_hydration_needs)?; validate_gql_mutation_return_output_values_precommit( plan, &static_plan, params, materialized, - &selected_rows, + &candidate_rows, &hydrated, options, )?; + let selected_rows = if static_plan.distinct { + select_distinct_gql_mutation_return_rows_precommit( + plan, + &static_plan, + params, + materialized, + &candidate_rows, + &hydrated, + options, + )? + } else { + candidate_rows + }; + if selected_rows.len() > options.max_rows { + return Err(gql_mutation_cap_error( + "max_rows", + selected_rows.len(), + options.max_rows, + )); + } + let output_hydration_needs = if static_plan.distinct { + let mut needs = GqlMutationReturnHydrationNeeds::default(); + let mut selected_read_set = TxnReturnReadSet::default(); + for item in &static_plan.exprs { + collect_gql_mutation_return_expr_ids( + plan, + materialized, + &item.expr, + &selected_rows, + GqlMutationReturnUse::Output, + None, + &mut needs, + &mut selected_read_set, + ); + } + read_set.node_ids.extend(selected_read_set.node_ids); + read_set.edge_ids.extend(selected_read_set.edge_ids); + needs + } else { + candidate_hydration_needs + }; Ok(Some(GqlMutationReturnExecutionPlan { static_plan, - ordered_rows: ordered_precommit.rows, + selected_rows, output_hydration_needs, read_set, })) @@ -2818,7 +4503,10 @@ fn build_gql_mutation_ordered_rows_precommit( let context = GqlMutationReturnEvalContext { plan, row, - materialized, + nodes: &materialized.nodes, + edges: &materialized.edges, + existing_nodes: &materialized.existing_nodes, + existing_edges: &materialized.existing_edges, commit: None, hydrated: &hydrated, include_vectors: options.include_vectors, @@ -2859,69 +4547,481 @@ fn build_gql_mutation_ordered_rows_precommit( left.row_index.cmp(&right.row_index) }); } - Ok(GqlMutationOrderedRowsPrecommit { rows, read_set }) + Ok(GqlMutationOrderedRowsPrecommit { rows, read_set }) +} + +fn selected_gql_mutation_return_rows( + ordered_rows: &[GqlMutationReturnOrderedRow], + skip: usize, + limit: Option, +) -> Vec { + let iter = ordered_rows.iter().skip(skip).map(|row| row.row_index); + match limit { + Some(limit) => iter.take(limit).collect(), + None => iter.collect(), + } +} + +fn gql_mutation_return_needs_committed_view( + return_execution: &GqlMutationReturnExecutionPlan, +) -> bool { + !return_execution.selected_rows.is_empty() +} + +fn validate_gql_mutation_return_output_values_precommit( + plan: &GqlMutationPlan, + static_plan: &GqlMutationReturnStaticPlan, + params: &GqlParams, + materialized: &GqlCreateMaterialization, + selected_rows: &[usize], + hydrated: &GqlMutationHydratedRecords, + options: &GqlExecutionOptions, +) -> Result<(), EngineError> { + for &row_index in selected_rows { + let row = materialized.rows.get(row_index).ok_or_else(|| { + EngineError::InvalidOperation( + "GQL mutation RETURN selected row index is out of bounds".to_string(), + ) + })?; + let context = GqlMutationReturnEvalContext { + plan, + row, + nodes: &materialized.nodes, + edges: &materialized.edges, + existing_nodes: &materialized.existing_nodes, + existing_edges: &materialized.existing_edges, + commit: None, + hydrated, + include_vectors: options.include_vectors, + path_id_only: false, + }; + for item in &static_plan.exprs { + let _ = gql_mutation_return_expr_value(&item.expr, params, &context)?; + } + } + Ok(()) +} + +fn select_distinct_gql_mutation_return_rows_precommit( + plan: &GqlMutationPlan, + static_plan: &GqlMutationReturnStaticPlan, + params: &GqlParams, + materialized: &GqlCreateMaterialization, + candidate_rows: &[usize], + hydrated: &GqlMutationHydratedRecords, + options: &GqlExecutionOptions, +) -> Result, EngineError> { + let mut seen = BTreeSet::new(); + let mut selected = Vec::with_capacity(candidate_rows.len()); + for &row_index in candidate_rows { + let row = materialized.rows.get(row_index).ok_or_else(|| { + EngineError::InvalidOperation( + "GQL mutation RETURN selected row index is out of bounds".to_string(), + ) + })?; + let context = GqlMutationReturnEvalContext { + plan, + row, + nodes: &materialized.nodes, + edges: &materialized.edges, + existing_nodes: &materialized.existing_nodes, + existing_edges: &materialized.existing_edges, + commit: None, + hydrated, + include_vectors: options.include_vectors, + path_id_only: false, + }; + let key = static_plan + .exprs + .iter() + .map(|item| gql_mutation_return_distinct_key_for_expr(&item.expr, params, &context)) + .collect::, _>>()?; + if !seen.contains(&key) && seen.len() >= options.max_groups { + return Err(gql_mutation_cap_error( + "max_groups", + seen.len().saturating_add(1), + options.max_groups, + )); + } + if seen.insert(key) { + selected.push(row_index); + } + } + Ok(selected) +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum GqlMutationReturnEntityDistinctKey { + Existing(u64), + Created(usize), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum GqlMutationReturnDistinctKey { + Scalar(GraphCanonicalKey), + Node(GqlMutationReturnEntityDistinctKey), + Edge(GqlMutationReturnEntityDistinctKey), + Path { + nodes: Vec, + edges: Vec, + }, + List(Vec), + Map(Vec<(String, GqlMutationReturnDistinctKey)>), +} + +fn gql_mutation_return_distinct_key_for_expr( + expr: &Expr, + params: &GqlParams, + context: &GqlMutationReturnEvalContext<'_>, +) -> Result { + match &expr.kind { + ExprKind::Literal(literal) => { + gql_value_to_mutation_return_distinct_key(&gql_literal_to_value(literal), &expr.span) + } + ExprKind::Parameter(name) => { + let value = params + .get(name) + .map(gql_param_to_value) + .ok_or_else(|| EngineError::GqlParameter { + name: name.clone(), + expected: "GqlParamValue".to_string(), + message: format!("missing parameter '${name}'"), + span: expr.span.clone(), + })?; + gql_value_to_mutation_return_distinct_key(&value, &expr.span) + } + ExprKind::Variable(alias) => { + gql_mutation_alias_distinct_key(alias, context, &expr.span) + } + ExprKind::List(items) => Ok(GqlMutationReturnDistinctKey::List( + items + .iter() + .map(|item| gql_mutation_return_distinct_key_for_expr(item, params, context)) + .collect::, _>>()?, + )), + ExprKind::Map(map) => Ok(GqlMutationReturnDistinctKey::Map( + map.entries + .iter() + .map(|entry| { + Ok(( + entry.key.name.clone(), + gql_mutation_return_distinct_key_for_expr(&entry.value, params, context)?, + )) + }) + .collect::, EngineError>>()? + .into_iter() + .collect(), + )), + ExprKind::PropertyAccess { object, property } => { + if let ExprKind::Map(map) = &object.kind { + if let Some(entry) = map + .entries + .iter() + .find(|entry| entry.key.name == property.name) + { + return gql_mutation_return_distinct_key_for_expr( + &entry.value, + params, + context, + ); + } + return Ok(GqlMutationReturnDistinctKey::Scalar(GraphCanonicalKey::Null)); + } + let value = gql_mutation_return_expr_value(expr, params, context)?; + gql_value_to_mutation_return_distinct_key(&value, &expr.span) + } + ExprKind::FunctionCall { name, args } => { + if let Some(key) = + gql_mutation_graph_function_distinct_key(&name.name, args, context, &expr.span)? + { + return Ok(key); + } + let value = gql_mutation_return_expr_value(expr, params, context)?; + gql_value_to_mutation_return_distinct_key(&value, &expr.span) + } + ExprKind::Case { + operand, + branches, + else_expr, + } => gql_mutation_case_distinct_key( + operand.as_deref(), + branches, + else_expr.as_deref(), + params, + context, + &expr.span, + ), + ExprKind::Unary { .. } | ExprKind::Binary { .. } | ExprKind::IsNull { .. } => { + let value = gql_mutation_return_expr_value(expr, params, context)?; + gql_value_to_mutation_return_distinct_key(&value, &expr.span) + } + ExprKind::AggregateCall { name_span, .. } => Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "aggregate functions are not supported in mutation RETURN".to_string(), + name_span.clone(), + )), + ExprKind::ExistsSubquery(_) => Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "GQL mutation RETURN does not support subquery expressions".to_string(), + expr.span.clone(), + )), + } +} + +fn gql_mutation_alias_distinct_key( + alias: &str, + context: &GqlMutationReturnEvalContext<'_>, + span: &SourceSpan, +) -> Result { + let Some(binding) = context.plan.semantic.aliases.get(alias) else { + return Ok(GqlMutationReturnDistinctKey::Scalar(GraphCanonicalKey::Null)); + }; + match binding.kind { + GqlAliasKind::Node => { + if let Some(&index) = context.row.created_nodes.get(alias) { + return Ok(GqlMutationReturnDistinctKey::Node( + GqlMutationReturnEntityDistinctKey::Created(index), + )); + } + Ok(context + .node_id(alias) + .map(|id| { + GqlMutationReturnDistinctKey::Node( + GqlMutationReturnEntityDistinctKey::Existing(id), + ) + }) + .unwrap_or(GqlMutationReturnDistinctKey::Scalar( + GraphCanonicalKey::Null, + ))) + } + GqlAliasKind::Edge => { + if let Some(&index) = context.row.created_edges.get(alias) { + return Ok(GqlMutationReturnDistinctKey::Edge( + GqlMutationReturnEntityDistinctKey::Created(index), + )); + } + Ok(context + .edge_id(alias) + .map(|id| { + GqlMutationReturnDistinctKey::Edge( + GqlMutationReturnEntityDistinctKey::Existing(id), + ) + }) + .unwrap_or(GqlMutationReturnDistinctKey::Scalar( + GraphCanonicalKey::Null, + ))) + } + GqlAliasKind::Path => Ok(context + .path(alias) + .map(gql_path_identity_distinct_key) + .unwrap_or(GqlMutationReturnDistinctKey::Scalar( + GraphCanonicalKey::Null, + ))), + GqlAliasKind::Scalar => { + let value = row_scalar_value(alias, context)?; + gql_value_to_mutation_return_distinct_key(&value, span) + } + } } -fn selected_gql_mutation_return_rows( - ordered_rows: &[GqlMutationReturnOrderedRow], - skip: usize, - limit: Option, -) -> Vec { - let iter = ordered_rows.iter().skip(skip).map(|row| row.row_index); - match limit { - Some(limit) => iter.take(limit).collect(), - None => iter.collect(), +fn gql_path_identity_distinct_key(path: &GqlPathIdentity) -> GqlMutationReturnDistinctKey { + GqlMutationReturnDistinctKey::Path { + nodes: path + .node_ids + .iter() + .copied() + .map(GqlMutationReturnEntityDistinctKey::Existing) + .collect(), + edges: path + .edge_ids + .iter() + .copied() + .map(GqlMutationReturnEntityDistinctKey::Existing) + .collect(), } } -fn gql_mutation_return_selected_row_count( - ordered_rows: &[GqlMutationReturnOrderedRow], - skip: usize, - limit: Option, -) -> usize { - let after_skip = ordered_rows.len().saturating_sub(skip); - limit.map_or(after_skip, |limit| after_skip.min(limit)) -} - -fn gql_mutation_return_needs_committed_view( - return_execution: &GqlMutationReturnExecutionPlan, -) -> bool { - gql_mutation_return_selected_row_count( - &return_execution.ordered_rows, - return_execution.static_plan.skip, - return_execution.static_plan.limit, - ) > 0 +fn gql_mutation_graph_function_distinct_key( + function: &str, + args: &[Expr], + context: &GqlMutationReturnEvalContext<'_>, + _span: &SourceSpan, +) -> Result, EngineError> { + let Some(Expr { + kind: ExprKind::Variable(alias), + .. + }) = args.first() + else { + return Ok(None); + }; + let lower = function.to_ascii_lowercase(); + let Some(binding) = context.plan.semantic.aliases.get(alias) else { + return Ok(Some(GqlMutationReturnDistinctKey::Scalar( + GraphCanonicalKey::Null, + ))); + }; + if binding.kind != GqlAliasKind::Path { + return Ok(None); + } + let Some(path) = context.path(alias) else { + return Ok(Some(GqlMutationReturnDistinctKey::Scalar( + GraphCanonicalKey::Null, + ))); + }; + let key = match lower.as_str() { + "start_node" => path.node_ids.first().copied().map(|id| { + GqlMutationReturnDistinctKey::Node(GqlMutationReturnEntityDistinctKey::Existing(id)) + }), + "end_node" => path.node_ids.last().copied().map(|id| { + GqlMutationReturnDistinctKey::Node(GqlMutationReturnEntityDistinctKey::Existing(id)) + }), + "nodes" => Some(GqlMutationReturnDistinctKey::List( + path.node_ids + .iter() + .copied() + .map(|id| { + GqlMutationReturnDistinctKey::Node( + GqlMutationReturnEntityDistinctKey::Existing(id), + ) + }) + .collect(), + )), + "relationships" => Some(GqlMutationReturnDistinctKey::List( + path.edge_ids + .iter() + .copied() + .map(|id| { + GqlMutationReturnDistinctKey::Edge( + GqlMutationReturnEntityDistinctKey::Existing(id), + ) + }) + .collect(), + )), + _ => return Ok(None), + }; + Ok(Some(key.unwrap_or({ + GqlMutationReturnDistinctKey::Scalar(GraphCanonicalKey::Null) + }))) } -fn validate_gql_mutation_return_output_values_precommit( - plan: &GqlMutationPlan, - static_plan: &GqlMutationReturnStaticPlan, +fn gql_mutation_case_distinct_key( + operand: Option<&Expr>, + branches: &[crate::gql::ast::CaseBranch], + else_expr: Option<&Expr>, params: &GqlParams, - materialized: &GqlCreateMaterialization, - selected_rows: &[usize], - hydrated: &GqlMutationHydratedRecords, - options: &GqlExecutionOptions, -) -> Result<(), EngineError> { - for &row_index in selected_rows { - let row = materialized.rows.get(row_index).ok_or_else(|| { - EngineError::InvalidOperation( - "GQL mutation RETURN selected row index is out of bounds".to_string(), - ) - })?; - let context = GqlMutationReturnEvalContext { - plan, - row, - materialized, - commit: None, - hydrated, - include_vectors: options.include_vectors, - path_id_only: false, - }; - for item in &static_plan.exprs { - let _ = gql_mutation_return_expr_value(&item.expr, params, &context)?; + context: &GqlMutationReturnEvalContext<'_>, + span: &SourceSpan, +) -> Result { + if let Some(operand) = operand { + let operand_value = gql_mutation_return_expr_value(operand, params, context)?; + for branch in branches { + let when_value = gql_mutation_return_expr_value(&branch.when, params, context)?; + if let Some(value) = + gql_mutation_try_eval_shared_binary(BinaryOp::Eq, &operand_value, &when_value, span)? + { + match value { + GqlValue::Bool(true) => { + return gql_mutation_return_distinct_key_for_expr( + &branch.then, + params, + context, + ); + } + GqlValue::Bool(false) | GqlValue::Null => {} + _ => unreachable!("equality returns bool or null"), + } + } else if matches!( + gql_mutation_compare_values(BinaryOp::Eq, operand_value.clone(), when_value), + GqlValue::Bool(true) + ) { + return gql_mutation_return_distinct_key_for_expr( + &branch.then, + params, + context, + ); + } + } + } else { + for branch in branches { + if let Some(true) = gql_mutation_bool_or_null(&branch.when, params, context)? { + return gql_mutation_return_distinct_key_for_expr( + &branch.then, + params, + context, + ); + } } } - Ok(()) + else_expr + .map(|expr| gql_mutation_return_distinct_key_for_expr(expr, params, context)) + .unwrap_or_else(|| Ok(GqlMutationReturnDistinctKey::Scalar(GraphCanonicalKey::Null))) +} + +fn gql_value_to_mutation_return_distinct_key( + value: &GqlValue, + span: &SourceSpan, +) -> Result { + Ok(match value { + GqlValue::Null + | GqlValue::Bool(_) + | GqlValue::Int(_) + | GqlValue::UInt(_) + | GqlValue::Float(_) + | GqlValue::String(_) + | GqlValue::Bytes(_) => { + let graph_value = gql_value_ref_to_graph_eval_scalar(value)?.ok_or_else(|| { + gql_distinct_key_error("GQL mutation RETURN DISTINCT scalar key is invalid", span) + })?; + GqlMutationReturnDistinctKey::Scalar(graph_canonical_key_for_value(&graph_value)?) + } + GqlValue::List(values) => GqlMutationReturnDistinctKey::List( + values + .iter() + .map(|value| gql_value_to_mutation_return_distinct_key(value, span)) + .collect::, _>>()?, + ), + GqlValue::Map(values) => GqlMutationReturnDistinctKey::Map( + values + .iter() + .map(|(key, value)| { + Ok(( + key.clone(), + gql_value_to_mutation_return_distinct_key(value, span)?, + )) + }) + .collect::, EngineError>>()?, + ), + GqlValue::Node(node) => GqlMutationReturnDistinctKey::Node( + GqlMutationReturnEntityDistinctKey::Existing(node.id.ok_or_else(|| { + gql_distinct_key_error( + "GQL mutation RETURN DISTINCT requires a precommit node identity", + span, + ) + })?), + ), + GqlValue::Edge(edge) => GqlMutationReturnDistinctKey::Edge( + GqlMutationReturnEntityDistinctKey::Existing(edge.id.ok_or_else(|| { + gql_distinct_key_error( + "GQL mutation RETURN DISTINCT requires a precommit edge identity", + span, + ) + })?), + ), + GqlValue::Path(path) => GqlMutationReturnDistinctKey::Path { + nodes: path + .node_ids + .iter() + .copied() + .map(GqlMutationReturnEntityDistinctKey::Existing) + .collect(), + edges: path + .edge_ids + .iter() + .copied() + .map(GqlMutationReturnEntityDistinctKey::Existing) + .collect(), + }, + }) } fn build_gql_mutation_return_rows( @@ -2936,11 +5036,7 @@ fn build_gql_mutation_return_rows( let Some(return_execution) = return_execution else { return Ok(Vec::new()); }; - let selected = selected_gql_mutation_return_rows( - &return_execution.ordered_rows, - return_execution.static_plan.skip, - return_execution.static_plan.limit, - ); + let selected = return_execution.selected_rows.clone(); if selected.is_empty() { return Ok(Vec::new()); } @@ -2962,7 +5058,10 @@ fn build_gql_mutation_return_rows( let context = GqlMutationReturnEvalContext { plan, row, - materialized, + nodes: &materialized.nodes, + edges: &materialized.edges, + existing_nodes: &materialized.existing_nodes, + existing_edges: &materialized.existing_edges, commit: Some(commit), hydrated: &hydrated, include_vectors: options.include_vectors, @@ -2976,7 +5075,7 @@ fn build_gql_mutation_return_rows( .collect::, _>>()?; Ok(GqlRow { values }) }) - .collect() + .collect::, EngineError>>() } #[derive(Clone, Copy, PartialEq, Eq)] @@ -2988,7 +5087,10 @@ enum GqlMutationReturnUse { struct GqlMutationReturnEvalContext<'a> { plan: &'a GqlMutationPlan, row: &'a GqlCreateExecutionRow, - materialized: &'a GqlCreateMaterialization, + nodes: &'a [GqlCreatedNodeExecution], + edges: &'a [GqlCreatedEdgeExecution], + existing_nodes: &'a BTreeMap, + existing_edges: &'a BTreeMap, commit: Option<&'a TxnCommitResult>, hydrated: &'a GqlMutationHydratedRecords, include_vectors: bool, @@ -3096,6 +5198,50 @@ fn resolve_mutation_return_aliases_in_expr( .map(|arg| resolve_mutation_return_aliases_in_expr(arg, return_aliases, plan)) .collect::, _>>()?, }, + ExprKind::AggregateCall { name_span, .. } => { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "aggregate functions are not supported in mutation RETURN".to_string(), + name_span.clone(), + )); + } + ExprKind::Case { + operand, + branches, + else_expr, + } => ExprKind::Case { + operand: operand + .as_ref() + .map(|operand| { + resolve_mutation_return_aliases_in_expr(operand, return_aliases, plan) + .map(Box::new) + }) + .transpose()?, + branches: branches + .iter() + .map(|branch| { + Ok(crate::gql::ast::CaseBranch { + when: resolve_mutation_return_aliases_in_expr( + &branch.when, + return_aliases, + plan, + )?, + then: resolve_mutation_return_aliases_in_expr( + &branch.then, + return_aliases, + plan, + )?, + }) + }) + .collect::, EngineError>>()?, + else_expr: else_expr + .as_ref() + .map(|else_expr| { + resolve_mutation_return_aliases_in_expr(else_expr, return_aliases, plan) + .map(Box::new) + }) + .transpose()?, + }, ExprKind::List(items) => ExprKind::List( items .iter() @@ -3110,6 +5256,13 @@ fn resolve_mutation_return_aliases_in_expr( } ExprKind::Map(resolved) } + ExprKind::ExistsSubquery(_) => { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "GQL mutation RETURN does not support subquery expressions".to_string(), + expr.span.clone(), + )) + } ExprKind::Literal(_) | ExprKind::Parameter(_) | ExprKind::Variable(_) => { return Ok(expr.clone()) } @@ -3126,6 +5279,19 @@ fn validate_gql_mutation_return_exprs_static( ) -> Result<(), EngineError> { for expr in exprs { validate_gql_mutation_return_expr_static(plan, &expr.expr)?; + validate_gql_mutation_return_commit_dependent_metadata_static(plan, &expr.expr, true)?; + } + Ok(()) +} + +fn validate_gql_mutation_return_distinct_exprs_static( + plan: &GqlMutationPlan, + exprs: &[GqlReturnExpr], +) -> Result<(), EngineError> { + for expr in exprs { + validate_gql_mutation_return_commit_dependent_metadata_static( + plan, &expr.expr, false, + )?; } Ok(()) } @@ -3153,9 +5319,32 @@ fn validate_gql_mutation_return_expr_static( validate_gql_mutation_return_expr_static(plan, left)?; validate_gql_mutation_return_expr_static(plan, right)?; } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand { + validate_gql_mutation_return_expr_static(plan, operand)?; + } + for branch in branches { + validate_gql_mutation_return_expr_static(plan, &branch.when)?; + validate_gql_mutation_return_expr_static(plan, &branch.then)?; + } + if let Some(else_expr) = else_expr { + validate_gql_mutation_return_expr_static(plan, else_expr)?; + } + } ExprKind::FunctionCall { name, args } => { validate_gql_mutation_return_function_static(plan, &name.name, args, &expr.span)? } + ExprKind::AggregateCall { name_span, .. } => { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "aggregate functions are not supported in mutation RETURN".to_string(), + name_span.clone(), + )); + } ExprKind::List(args) => { for arg in args { validate_gql_mutation_return_expr_static(plan, arg)?; @@ -3166,6 +5355,13 @@ fn validate_gql_mutation_return_expr_static( validate_gql_mutation_return_expr_static(plan, &entry.value)?; } } + ExprKind::ExistsSubquery(_) => { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "GQL mutation RETURN does not support subquery expressions".to_string(), + expr.span.clone(), + )); + } ExprKind::Literal(_) | ExprKind::Parameter(_) => {} } Ok(()) @@ -3206,6 +5402,14 @@ fn validate_gql_mutation_return_function_static( args: &[Expr], span: &SourceSpan, ) -> Result<(), EngineError> { + let lower = function.to_ascii_lowercase(); + if gql_scalar_function_name(&lower).is_some() { + validate_gql_scalar_function_arity(&lower, function, args.len(), span)?; + for arg in args { + validate_gql_mutation_return_expr_static(plan, arg)?; + } + return Ok(()); + } let [arg] = args else { return Err(gql_semantic_error( GqlSemanticErrorCode::InvalidReturnExpression, @@ -3226,8 +5430,7 @@ fn validate_gql_mutation_return_function_static( &arg.span, )); }; - let function = function.to_ascii_lowercase(); - let valid = match function.as_str() { + let valid = match lower.as_str() { "id" => matches!(binding.kind, GqlAliasKind::Node | GqlAliasKind::Edge), "labels" => binding.kind == GqlAliasKind::Node, "type" => binding.kind == GqlAliasKind::Edge, @@ -3246,18 +5449,211 @@ fn validate_gql_mutation_return_function_static( } else { Err(gql_semantic_error( GqlSemanticErrorCode::InvalidReturnExpression, - format!("function '{}' received an unsupported alias kind", function), + format!("function '{}' received an unsupported alias kind", lower), span.clone(), )) } } +fn validate_gql_mutation_return_commit_dependent_metadata_static( + plan: &GqlMutationPlan, + expr: &Expr, + allow_direct_output: bool, +) -> Result<(), EngineError> { + if gql_mutation_return_expr_is_commit_dependent_created_metadata(plan, expr) { + if allow_direct_output { + return Ok(()); + } + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "commit-assigned created alias metadata cannot be used inside rich mutation RETURN expressions".to_string(), + expr.span.clone(), + )); + } + + match &expr.kind { + ExprKind::PropertyAccess { object, .. } => { + validate_gql_mutation_return_commit_dependent_metadata_static(plan, object, false) + } + ExprKind::Unary { expr, .. } | ExprKind::IsNull { expr, .. } => { + validate_gql_mutation_return_commit_dependent_metadata_static(plan, expr, false) + } + ExprKind::Binary { left, right, .. } => { + validate_gql_mutation_return_commit_dependent_metadata_static(plan, left, false)?; + validate_gql_mutation_return_commit_dependent_metadata_static(plan, right, false) + } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand { + validate_gql_mutation_return_commit_dependent_metadata_static(plan, operand, false)?; + } + for branch in branches { + validate_gql_mutation_return_commit_dependent_metadata_static( + plan, + &branch.when, + false, + )?; + validate_gql_mutation_return_commit_dependent_metadata_static( + plan, + &branch.then, + false, + )?; + } + if let Some(else_expr) = else_expr { + validate_gql_mutation_return_commit_dependent_metadata_static( + plan, else_expr, false, + )?; + } + Ok(()) + } + ExprKind::FunctionCall { args, .. } => { + for arg in args { + validate_gql_mutation_return_commit_dependent_metadata_static(plan, arg, false)?; + } + Ok(()) + } + ExprKind::AggregateCall { name_span, .. } => Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "aggregate functions are not supported in mutation RETURN".to_string(), + name_span.clone(), + )), + ExprKind::List(items) => { + for item in items { + validate_gql_mutation_return_commit_dependent_metadata_static(plan, item, false)?; + } + Ok(()) + } + ExprKind::Map(map) => { + for entry in &map.entries { + validate_gql_mutation_return_commit_dependent_metadata_static( + plan, + &entry.value, + false, + )?; + } + Ok(()) + } + ExprKind::ExistsSubquery(_) => Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "GQL mutation RETURN does not support subquery expressions".to_string(), + expr.span.clone(), + )), + ExprKind::Literal(_) | ExprKind::Parameter(_) | ExprKind::Variable(_) => Ok(()), + } +} + +fn gql_mutation_return_expr_is_commit_dependent_created_metadata( + plan: &GqlMutationPlan, + expr: &Expr, +) -> bool { + match &expr.kind { + ExprKind::FunctionCall { name, args } if name.name.eq_ignore_ascii_case("id") => { + matches!( + args.as_slice(), + [Expr { + kind: ExprKind::Variable(alias), + .. + }] if gql_mutation_created_alias_kind(plan, alias) + .is_some_and(|kind| matches!(kind, GqlAliasKind::Node | GqlAliasKind::Edge)) + ) + } + ExprKind::PropertyAccess { object, property } => { + let ExprKind::Variable(alias) = &object.kind else { + return false; + }; + match gql_mutation_created_alias_kind(plan, alias) { + Some(GqlAliasKind::Node) => matches!( + property.name.as_str(), + "id" | "created_at" | "updated_at" + ), + Some(GqlAliasKind::Edge) => matches!( + property.name.as_str(), + "id" | "from" | "to" | "created_at" | "updated_at" + ), + Some(GqlAliasKind::Path | GqlAliasKind::Scalar) | None => false, + } + } + _ => false, + } +} + +fn gql_mutation_created_alias_kind(plan: &GqlMutationPlan, alias: &str) -> Option { + plan.semantic + .aliases + .get(alias) + .filter(|binding| matches!(binding.origin, GqlAliasOrigin::Created | GqlAliasOrigin::Merged)) + .map(|binding| binding.kind) +} + +fn validate_gql_scalar_function_arity( + lower: &str, + display: &str, + arg_count: usize, + span: &SourceSpan, +) -> Result<(), EngineError> { + let valid = match lower { + "coalesce" => arg_count >= 1, + "substring" => matches!(arg_count, 2 | 3), + "to_string" | "to_integer" | "to_float" | "abs" | "floor" | "ceil" | "round" + | "lower" | "upper" | "trim" | "size" | "head" | "last" => arg_count == 1, + _ => false, + }; + if valid { + return Ok(()); + } + let expected = match lower { + "coalesce" => "at least one argument", + "substring" => "two or three arguments", + _ => "exactly one argument", + }; + Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + format!("function '{display}' expects {expected}"), + span.clone(), + )) +} + +fn gql_scalar_function_name(lower: &str) -> Option { + match lower { + "coalesce" => Some(GraphFunction::Coalesce), + "to_string" => Some(GraphFunction::ToString), + "to_integer" => Some(GraphFunction::ToInteger), + "to_float" => Some(GraphFunction::ToFloat), + "abs" => Some(GraphFunction::Abs), + "floor" => Some(GraphFunction::Floor), + "ceil" => Some(GraphFunction::Ceil), + "round" => Some(GraphFunction::Round), + "lower" => Some(GraphFunction::Lower), + "upper" => Some(GraphFunction::Upper), + "trim" => Some(GraphFunction::Trim), + "substring" => Some(GraphFunction::Substring), + "size" => Some(GraphFunction::Size), + "head" => Some(GraphFunction::Head), + "last" => Some(GraphFunction::Last), + _ => None, + } +} + fn validate_gql_mutation_order_exprs_static( plan: &GqlMutationPlan, order_by: &[GqlMutationResolvedOrderItem], ) -> Result<(), EngineError> { for item in order_by { validate_gql_mutation_order_expr_static(plan, &item.expr, &item.span)?; + validate_gql_mutation_return_commit_dependent_metadata_static(plan, &item.expr, false) + .map_err(|err| match err { + EngineError::GqlSemantic { .. } + if gql_mutation_return_expr_is_commit_dependent_created_metadata( + plan, &item.expr, + ) => + { + gql_order_key_error(&item.span) + } + other => other, + })?; } Ok(()) } @@ -3270,6 +5666,7 @@ fn validate_gql_mutation_order_expr_static( match &expr.kind { ExprKind::Variable(alias) => match plan.semantic.aliases.get(alias).map(|binding| binding.kind) { Some(GqlAliasKind::Path) => Ok(()), + Some(GqlAliasKind::Scalar) => Ok(()), Some(GqlAliasKind::Node | GqlAliasKind::Edge) => Err(gql_order_key_error(span)), None => Ok(()), }, @@ -3292,6 +5689,8 @@ fn validate_gql_mutation_order_expr_static( } Ok(()) } + ExprKind::AggregateCall { name_span, .. } => Err(gql_order_key_error(name_span)), + ExprKind::ExistsSubquery(_) => Err(gql_order_key_error(span)), ExprKind::PropertyAccess { object, property } => { if matches!(property.name.as_str(), "labels" | "node_ids" | "edge_ids") { return Err(gql_order_key_error(span)); @@ -3310,6 +5709,23 @@ fn validate_gql_mutation_order_expr_static( validate_gql_mutation_order_expr_static(plan, left, span)?; validate_gql_mutation_order_expr_static(plan, right, span) } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand { + validate_gql_mutation_order_expr_static(plan, operand, span)?; + } + for branch in branches { + validate_gql_mutation_order_expr_static(plan, &branch.when, span)?; + validate_gql_mutation_order_expr_static(plan, &branch.then, span)?; + } + if let Some(else_expr) = else_expr { + validate_gql_mutation_order_expr_static(plan, else_expr, span)?; + } + Ok(()) + } ExprKind::Literal(_) | ExprKind::Parameter(_) => Ok(()), } } @@ -3330,6 +5746,141 @@ fn validate_gql_mutation_order_exprs_materialized( Ok(()) } +fn validate_gql_mutation_return_distinct_exprs_materialized( + plan: &GqlMutationPlan, + exprs: &[GqlReturnExpr], + materialized: &GqlCreateMaterialization, +) -> Result<(), EngineError> { + for expr in exprs { + validate_gql_mutation_return_distinct_expr_materialized( + plan, + materialized, + &expr.expr, + )?; + } + Ok(()) +} + +fn validate_gql_mutation_return_distinct_expr_materialized( + plan: &GqlMutationPlan, + materialized: &GqlCreateMaterialization, + expr: &Expr, +) -> Result<(), EngineError> { + match &expr.kind { + ExprKind::PropertyAccess { object, property } => { + if let ExprKind::Variable(alias) = &object.kind { + validate_gql_mutation_return_distinct_alias_property_materialized( + plan, + materialized, + alias, + &property.name, + &expr.span, + )?; + } else { + validate_gql_mutation_return_distinct_expr_materialized( + plan, + materialized, + object, + )?; + } + Ok(()) + } + ExprKind::FunctionCall { args, .. } | ExprKind::List(args) => { + for arg in args { + validate_gql_mutation_return_distinct_expr_materialized( + plan, + materialized, + arg, + )?; + } + Ok(()) + } + ExprKind::AggregateCall { name_span, .. } => Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "aggregate functions are not supported in mutation RETURN".to_string(), + name_span.clone(), + )), + ExprKind::ExistsSubquery(_) => Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "GQL mutation RETURN does not support subquery expressions".to_string(), + expr.span.clone(), + )), + ExprKind::Unary { expr, .. } | ExprKind::IsNull { expr, .. } => { + validate_gql_mutation_return_distinct_expr_materialized(plan, materialized, expr) + } + ExprKind::Binary { left, right, .. } => { + validate_gql_mutation_return_distinct_expr_materialized(plan, materialized, left)?; + validate_gql_mutation_return_distinct_expr_materialized(plan, materialized, right) + } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand { + validate_gql_mutation_return_distinct_expr_materialized( + plan, + materialized, + operand, + )?; + } + for branch in branches { + validate_gql_mutation_return_distinct_expr_materialized( + plan, + materialized, + &branch.when, + )?; + validate_gql_mutation_return_distinct_expr_materialized( + plan, + materialized, + &branch.then, + )?; + } + if let Some(else_expr) = else_expr { + validate_gql_mutation_return_distinct_expr_materialized( + plan, + materialized, + else_expr, + )?; + } + Ok(()) + } + ExprKind::Map(map) => { + for entry in &map.entries { + validate_gql_mutation_return_distinct_expr_materialized( + plan, + materialized, + &entry.value, + )?; + } + Ok(()) + } + ExprKind::Literal(_) | ExprKind::Parameter(_) | ExprKind::Variable(_) => Ok(()), + } +} + +fn validate_gql_mutation_return_distinct_alias_property_materialized( + plan: &GqlMutationPlan, + materialized: &GqlCreateMaterialization, + alias: &str, + property: &str, + span: &SourceSpan, +) -> Result<(), EngineError> { + let Some(binding) = plan.semantic.aliases.get(alias) else { + return Ok(()); + }; + if property == "updated_at" + && matches!(binding.origin, GqlAliasOrigin::ReadPrefix | GqlAliasOrigin::Merged) + && gql_mutation_order_alias_has_changed_target(materialized, alias, binding.kind) + { + return Err(gql_distinct_key_error( + "GQL mutation RETURN DISTINCT cannot use same-mutation updated_at metadata", + span, + )); + } + Ok(()) +} + fn validate_gql_mutation_order_expr_materialized( plan: &GqlMutationPlan, materialized: &GqlCreateMaterialization, @@ -3359,7 +5910,7 @@ fn validate_gql_mutation_order_expr_materialized( }) = args.first() { if plan.semantic.aliases.get(alias).is_some_and(|binding| { - binding.origin == GqlAliasOrigin::Created + matches!(binding.origin, GqlAliasOrigin::Created | GqlAliasOrigin::Merged) && matches!(binding.kind, GqlAliasKind::Node | GqlAliasKind::Edge) }) { return Err(gql_order_key_error(span)); @@ -3371,6 +5922,8 @@ fn validate_gql_mutation_order_expr_materialized( } Ok(()) } + ExprKind::AggregateCall { name_span, .. } => Err(gql_order_key_error(name_span)), + ExprKind::ExistsSubquery(_) => Err(gql_order_key_error(span)), ExprKind::Unary { expr, .. } | ExprKind::IsNull { expr, .. } => { validate_gql_mutation_order_expr_materialized(plan, materialized, expr, span) } @@ -3378,6 +5931,38 @@ fn validate_gql_mutation_order_expr_materialized( validate_gql_mutation_order_expr_materialized(plan, materialized, left, span)?; validate_gql_mutation_order_expr_materialized(plan, materialized, right, span) } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand { + validate_gql_mutation_order_expr_materialized(plan, materialized, operand, span)?; + } + for branch in branches { + validate_gql_mutation_order_expr_materialized( + plan, + materialized, + &branch.when, + span, + )?; + validate_gql_mutation_order_expr_materialized( + plan, + materialized, + &branch.then, + span, + )?; + } + if let Some(else_expr) = else_expr { + validate_gql_mutation_order_expr_materialized( + plan, + materialized, + else_expr, + span, + )?; + } + Ok(()) + } ExprKind::List(items) => { for item in items { validate_gql_mutation_order_expr_materialized(plan, materialized, item, span)?; @@ -3409,20 +5994,21 @@ fn validate_gql_mutation_order_alias_property_materialized( let Some(binding) = plan.semantic.aliases.get(alias) else { return Ok(()); }; - if binding.origin == GqlAliasOrigin::Created { + if matches!(binding.origin, GqlAliasOrigin::Created | GqlAliasOrigin::Merged) { let volatile = match binding.kind { GqlAliasKind::Node => matches!(property, "id" | "created_at" | "updated_at"), GqlAliasKind::Edge => { matches!(property, "id" | "from" | "to" | "created_at" | "updated_at") } GqlAliasKind::Path => false, + GqlAliasKind::Scalar => false, }; if volatile { return Err(gql_order_key_error(span)); } } if property == "updated_at" - && binding.origin == GqlAliasOrigin::ReadPrefix + && matches!(binding.origin, GqlAliasOrigin::ReadPrefix | GqlAliasOrigin::Merged) && gql_mutation_order_alias_has_changed_target(materialized, alias, binding.kind) { return Err(gql_order_key_error(span)); @@ -3448,7 +6034,7 @@ fn gql_mutation_order_alias_has_changed_target( .and_then(|id| *id) .and_then(|id| materialized.existing_edges.get(&id)) .is_some_and(gql_existing_edge_changed), - GqlAliasKind::Path => false, + GqlAliasKind::Path | GqlAliasKind::Scalar => false, }) } @@ -3475,9 +6061,14 @@ fn evaluate_gql_mutation_count_expr( read_nodes: BTreeMap::new(), read_edges: BTreeMap::new(), read_paths: BTreeMap::new(), + read_scalars: BTreeMap::new(), expr_values: Vec::new(), created_nodes: BTreeMap::new(), created_edges: BTreeMap::new(), + created_node_writes: BTreeSet::new(), + created_edge_writes: BTreeSet::new(), + touched_created_nodes: BTreeSet::new(), + touched_created_edges: BTreeSet::new(), produced_write: false, }; let empty_materialization = GqlCreateMaterialization { @@ -3509,7 +6100,10 @@ fn evaluate_gql_mutation_count_expr( let context = GqlMutationReturnEvalContext { plan, row: &empty_row, - materialized: &empty_materialization, + nodes: &empty_materialization.nodes, + edges: &empty_materialization.edges, + existing_nodes: &empty_materialization.existing_nodes, + existing_edges: &empty_materialization.existing_edges, commit: None, hydrated: &empty_materialized, include_vectors: options.include_vectors, @@ -3543,13 +6137,33 @@ fn gql_mutation_expr_depends_on_alias(expr: &Expr, plan: &GqlMutationPlan) -> bo gql_mutation_expr_depends_on_alias(left, plan) || gql_mutation_expr_depends_on_alias(right, plan) } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + operand + .as_ref() + .is_some_and(|operand| gql_mutation_expr_depends_on_alias(operand, plan)) + || branches.iter().any(|branch| { + gql_mutation_expr_depends_on_alias(&branch.when, plan) + || gql_mutation_expr_depends_on_alias(&branch.then, plan) + }) + || else_expr + .as_ref() + .is_some_and(|else_expr| gql_mutation_expr_depends_on_alias(else_expr, plan)) + } ExprKind::FunctionCall { args, .. } | ExprKind::List(args) => args .iter() .any(|arg| gql_mutation_expr_depends_on_alias(arg, plan)), + ExprKind::AggregateCall { arg, .. } => arg + .as_ref() + .is_some_and(|arg| gql_mutation_expr_depends_on_alias(arg, plan)), ExprKind::Map(map) => map .entries .iter() .any(|entry| gql_mutation_expr_depends_on_alias(&entry.value, plan)), + ExprKind::ExistsSubquery(_) => true, ExprKind::Literal(_) | ExprKind::Parameter(_) => false, } } @@ -3723,6 +6337,13 @@ fn collect_gql_mutation_return_expr_ids( ); } } + ExprKind::AggregateCall { arg, .. } => { + if let Some(arg) = arg.as_ref() { + collect_gql_mutation_return_expr_ids( + plan, materialized, arg, row_indices, use_, commit, ids, read_set, + ); + } + } ExprKind::Unary { expr, .. } | ExprKind::IsNull { expr, .. } => { collect_gql_mutation_return_expr_ids( plan, materialized, expr, row_indices, use_, commit, ids, read_set, @@ -3736,6 +6357,51 @@ fn collect_gql_mutation_return_expr_ids( plan, materialized, right, row_indices, use_, commit, ids, read_set, ); } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand { + collect_gql_mutation_return_expr_ids( + plan, materialized, operand, row_indices, use_, commit, ids, read_set, + ); + } + for branch in branches { + collect_gql_mutation_return_expr_ids( + plan, + materialized, + &branch.when, + row_indices, + use_, + commit, + ids, + read_set, + ); + collect_gql_mutation_return_expr_ids( + plan, + materialized, + &branch.then, + row_indices, + use_, + commit, + ids, + read_set, + ); + } + if let Some(else_expr) = else_expr { + collect_gql_mutation_return_expr_ids( + plan, + materialized, + else_expr, + row_indices, + use_, + commit, + ids, + read_set, + ); + } + } ExprKind::List(items) => { for item in items { collect_gql_mutation_return_expr_ids( @@ -3750,6 +6416,7 @@ fn collect_gql_mutation_return_expr_ids( ); } } + ExprKind::ExistsSubquery(_) => {} ExprKind::Literal(_) | ExprKind::Parameter(_) => {} } } @@ -3824,18 +6491,25 @@ fn collect_gql_mutation_alias_ids( }; match binding.kind { GqlAliasKind::Node => { - if binding.origin == GqlAliasOrigin::Created && commit.is_none() { + let local_created = row.created_nodes.get(alias).copied(); + if commit.is_none() + && matches!(binding.origin, GqlAliasOrigin::Created | GqlAliasOrigin::Merged) + && local_created.is_some() + { if use_ == GqlMutationReturnUse::Output { - if let Some(&index) = row.created_nodes.get(alias) { + if let Some(index) = local_created { ids.created_node_indices.insert(index); } } continue; } if let Some(id) = - gql_mutation_node_id_for_alias(alias, row, materialized, commit) + gql_mutation_node_id_for_alias(alias, row, &materialized.nodes, commit) { - if binding.origin == GqlAliasOrigin::ReadPrefix + if matches!( + binding.origin, + GqlAliasOrigin::ReadPrefix | GqlAliasOrigin::Merged + ) && matches!(use_, GqlMutationReturnUse::Output | GqlMutationReturnUse::Order) { read_set.node_ids.insert(id); @@ -3846,18 +6520,25 @@ fn collect_gql_mutation_alias_ids( } } GqlAliasKind::Edge => { - if binding.origin == GqlAliasOrigin::Created && commit.is_none() { + let local_created = row.created_edges.get(alias).copied(); + if commit.is_none() + && matches!(binding.origin, GqlAliasOrigin::Created | GqlAliasOrigin::Merged) + && local_created.is_some() + { if use_ == GqlMutationReturnUse::Output { - if let Some(&index) = row.created_edges.get(alias) { + if let Some(index) = local_created { ids.created_edge_indices.insert(index); } } continue; } if let Some(id) = - gql_mutation_edge_id_for_alias(alias, row, materialized, commit) + gql_mutation_edge_id_for_alias(alias, row, &materialized.edges, commit) { - if binding.origin == GqlAliasOrigin::ReadPrefix + if matches!( + binding.origin, + GqlAliasOrigin::ReadPrefix | GqlAliasOrigin::Merged + ) && matches!(use_, GqlMutationReturnUse::Output | GqlMutationReturnUse::Order) { read_set.edge_ids.insert(id); @@ -3878,6 +6559,7 @@ fn collect_gql_mutation_alias_ids( read_set.edge_ids.extend(path.edge_ids.iter().copied()); } } + GqlAliasKind::Scalar => {} } } } @@ -3914,18 +6596,21 @@ fn gql_mutation_return_expr_value( )), } } - ExprKind::Unary { - op: UnaryOp::Not, - expr, - } => match gql_mutation_return_expr_value(expr, params, context)? { - GqlValue::Bool(value) => Ok(GqlValue::Bool(!value)), - GqlValue::Null => Ok(GqlValue::Null), - _ => Err(gql_semantic_error( - GqlSemanticErrorCode::InvalidReturnExpression, - "NOT requires a boolean or null operand".to_string(), - expr.span.clone(), - )), - }, + ExprKind::Unary { op, expr } => { + let value = gql_mutation_return_expr_value(expr, params, context)?; + let graph_value = gql_value_to_graph_eval_scalar(value)?.ok_or_else(|| { + gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "unary scalar expression requires scalar or null input".to_string(), + expr.span.clone(), + ) + })?; + let graph_op = match op { + UnaryOp::Not => GraphUnaryOp::Not, + UnaryOp::Neg => GraphUnaryOp::Neg, + }; + graph_eval_to_gql_scalar(eval_graph_unary_value(graph_op, &graph_value)?, &expr.span) + } ExprKind::Binary { op, left, right } => { gql_mutation_eval_binary(*op, left, right, params, context) } @@ -3937,6 +6622,17 @@ fn gql_mutation_return_expr_value( Ok(GqlValue::Bool(if *negated { !is_null } else { is_null })) } ExprKind::FunctionCall { name, args } => { + let lower = name.name.to_ascii_lowercase(); + if let Some(function) = gql_scalar_function_name(&lower) { + return gql_mutation_scalar_function_value( + function, + &name.name, + args, + params, + context, + &expr.span, + ); + } let Some(Expr { kind: ExprKind::Variable(alias), .. @@ -3950,6 +6646,28 @@ fn gql_mutation_return_expr_value( }; gql_mutation_function_value(&name.name, alias, context, &expr.span) } + ExprKind::AggregateCall { name_span, .. } => Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "aggregate functions are not supported in mutation RETURN".to_string(), + name_span.clone(), + )), + ExprKind::ExistsSubquery(_) => Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "GQL mutation RETURN does not support subquery expressions".to_string(), + expr.span.clone(), + )), + ExprKind::Case { + operand, + branches, + else_expr, + } => gql_mutation_case_value( + operand.as_deref(), + branches, + else_expr.as_deref(), + params, + context, + &expr.span, + ), ExprKind::List(items) => Ok(GqlValue::List( items .iter() @@ -4008,28 +6726,281 @@ fn gql_mutation_eval_binary( | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge + | BinaryOp::Add + | BinaryOp::Sub + | BinaryOp::Mul + | BinaryOp::Div + | BinaryOp::StartsWith + | BinaryOp::EndsWith + | BinaryOp::Contains | BinaryOp::In => { let left_value = gql_mutation_return_expr_value(left, params, context)?; let right_value = gql_mutation_return_expr_value(right, params, context)?; + if matches!( + op, + BinaryOp::Add + | BinaryOp::Sub + | BinaryOp::Mul + | BinaryOp::Div + | BinaryOp::StartsWith + | BinaryOp::EndsWith + | BinaryOp::Contains + ) { + return gql_mutation_eval_shared_binary(op, left_value, right_value, &left.span); + } + if let Some(value) = + gql_mutation_try_eval_shared_binary(op, &left_value, &right_value, &left.span)? + { + return Ok(value); + } Ok(gql_mutation_compare_values(op, left_value, right_value)) } } } -fn gql_mutation_bool_or_null( - expr: &Expr, +fn gql_mutation_eval_shared_binary( + op: BinaryOp, + left: GqlValue, + right: GqlValue, + span: &SourceSpan, +) -> Result { + let left = gql_value_to_graph_eval_scalar(left)?.ok_or_else(|| { + gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "scalar operator requires scalar, list, map, or null operands".to_string(), + span.clone(), + ) + })?; + let right = gql_value_to_graph_eval_scalar(right)?.ok_or_else(|| { + gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "scalar operator requires scalar, list, map, or null operands".to_string(), + span.clone(), + ) + })?; + graph_eval_to_gql_scalar( + eval_graph_binary_values(gql_binary_op_to_graph_op(op), &left, &right)?, + span, + ) +} + +fn gql_mutation_try_eval_shared_binary( + op: BinaryOp, + left: &GqlValue, + right: &GqlValue, + span: &SourceSpan, +) -> Result, EngineError> { + let Some(left) = gql_value_ref_to_graph_eval_scalar(left)? else { + return Ok(None); + }; + let Some(right) = gql_value_ref_to_graph_eval_scalar(right)? else { + return Ok(None); + }; + graph_eval_to_gql_scalar( + eval_graph_binary_values(gql_binary_op_to_graph_op(op), &left, &right)?, + span, + ) + .map(Some) +} + +fn gql_mutation_bool_or_null( + expr: &Expr, + params: &GqlParams, + context: &GqlMutationReturnEvalContext<'_>, +) -> Result, EngineError> { + match gql_mutation_return_expr_value(expr, params, context)? { + GqlValue::Bool(value) => Ok(Some(value)), + GqlValue::Null => Ok(None), + _ => Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "boolean operators require boolean or null operands".to_string(), + expr.span.clone(), + )), + } +} + +fn gql_mutation_scalar_function_value( + function: GraphFunction, + display: &str, + args: &[Expr], + params: &GqlParams, + context: &GqlMutationReturnEvalContext<'_>, + span: &SourceSpan, +) -> Result { + validate_gql_scalar_function_arity( + &display.to_ascii_lowercase(), + display, + args.len(), + span, + )?; + if function == GraphFunction::Coalesce { + for arg in args { + let value = gql_mutation_return_expr_value(arg, params, context)?; + let graph_value = gql_value_to_graph_eval_scalar(value)?.ok_or_else(|| { + gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + format!("function '{display}' expects scalar, list, map, or null input"), + arg.span.clone(), + ) + })?; + if !graph_value.is_null() { + let checked = eval_graph_scalar_function_values( + GraphFunction::Coalesce, + std::slice::from_ref(&graph_value), + )?; + return graph_eval_to_gql_scalar(checked, &arg.span); + } + } + return Ok(GqlValue::Null); + } + let values = args + .iter() + .map(|arg| { + let value = gql_mutation_return_expr_value(arg, params, context)?; + gql_value_to_graph_eval_scalar(value)?.ok_or_else(|| { + gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + format!("function '{display}' expects scalar, list, map, or null input"), + arg.span.clone(), + ) + }) + }) + .collect::, EngineError>>()?; + graph_eval_to_gql_scalar(eval_graph_scalar_function_values(function, &values)?, span) +} + +fn gql_mutation_case_value( + operand: Option<&Expr>, + branches: &[crate::gql::ast::CaseBranch], + else_expr: Option<&Expr>, params: &GqlParams, context: &GqlMutationReturnEvalContext<'_>, -) -> Result, EngineError> { - match gql_mutation_return_expr_value(expr, params, context)? { - GqlValue::Bool(value) => Ok(Some(value)), - GqlValue::Null => Ok(None), - _ => Err(gql_semantic_error( - GqlSemanticErrorCode::InvalidReturnExpression, - "boolean operators require boolean or null operands".to_string(), - expr.span.clone(), - )), + span: &SourceSpan, +) -> Result { + if let Some(operand) = operand { + let operand_value = gql_mutation_return_expr_value(operand, params, context)?; + for branch in branches { + let when_value = gql_mutation_return_expr_value(&branch.when, params, context)?; + if let Some(value) = gql_mutation_try_eval_shared_binary( + BinaryOp::Eq, + &operand_value, + &when_value, + span, + )? { + match value { + GqlValue::Bool(true) => { + return gql_mutation_return_expr_value(&branch.then, params, context); + } + GqlValue::Bool(false) | GqlValue::Null => {} + _ => unreachable!("equality returns bool or null"), + } + } else if matches!( + gql_mutation_compare_values(BinaryOp::Eq, operand_value.clone(), when_value), + GqlValue::Bool(true) + ) { + return gql_mutation_return_expr_value(&branch.then, params, context); + } + } + } else { + for branch in branches { + if let Some(true) = gql_mutation_bool_or_null(&branch.when, params, context)? { return gql_mutation_return_expr_value(&branch.then, params, context) } + } } + else_expr + .map(|expr| gql_mutation_return_expr_value(expr, params, context)) + .unwrap_or(Ok(GqlValue::Null)) +} + +fn gql_binary_op_to_graph_op(op: BinaryOp) -> GraphBinaryOp { + match op { + BinaryOp::Or => GraphBinaryOp::Or, + BinaryOp::And => GraphBinaryOp::And, + BinaryOp::Add => GraphBinaryOp::Add, + BinaryOp::Sub => GraphBinaryOp::Sub, + BinaryOp::Mul => GraphBinaryOp::Mul, + BinaryOp::Div => GraphBinaryOp::Div, + BinaryOp::Eq => GraphBinaryOp::Eq, + BinaryOp::Neq => GraphBinaryOp::Neq, + BinaryOp::Lt => GraphBinaryOp::Lt, + BinaryOp::Le => GraphBinaryOp::Le, + BinaryOp::Gt => GraphBinaryOp::Gt, + BinaryOp::Ge => GraphBinaryOp::Ge, + BinaryOp::In => GraphBinaryOp::In, + BinaryOp::StartsWith => GraphBinaryOp::StartsWith, + BinaryOp::EndsWith => GraphBinaryOp::EndsWith, + BinaryOp::Contains => GraphBinaryOp::Contains, + } +} + +fn gql_value_to_graph_eval_scalar(value: GqlValue) -> Result, EngineError> { + gql_value_ref_to_graph_eval_scalar(&value) +} + +fn gql_value_ref_to_graph_eval_scalar(value: &GqlValue) -> Result, EngineError> { + Ok(match value { + GqlValue::Null => Some(GraphEvalValue::Null), + GqlValue::Bool(value) => Some(GraphEvalValue::Bool(*value)), + GqlValue::Int(value) => Some(GraphEvalValue::Int(*value)), + GqlValue::UInt(value) => Some(GraphEvalValue::UInt(*value)), + GqlValue::Float(value) => Some(GraphEvalValue::Float(*value)), + GqlValue::String(value) => Some(GraphEvalValue::String(value.clone())), + GqlValue::Bytes(value) => Some(GraphEvalValue::Bytes(value.clone())), + GqlValue::List(values) => { + let mut out = Vec::with_capacity(values.len()); + for value in values { + let Some(value) = gql_value_ref_to_graph_eval_scalar(value)? else { + return Ok(None); + }; + out.push(value); + } + Some(GraphEvalValue::List(out)) + } + GqlValue::Map(values) => { + let mut out = BTreeMap::new(); + for (key, value) in values { + let Some(value) = gql_value_ref_to_graph_eval_scalar(value)? else { + return Ok(None); + }; + out.insert(key.clone(), value); + } + Some(GraphEvalValue::Map(out)) + } + GqlValue::Node(_) | GqlValue::Edge(_) | GqlValue::Path(_) => None, + }) +} + +fn graph_eval_to_gql_scalar( + value: GraphEvalValue, + span: &SourceSpan, +) -> Result { + Ok(match value { + GraphEvalValue::Null => GqlValue::Null, + GraphEvalValue::Bool(value) => GqlValue::Bool(value), + GraphEvalValue::Int(value) => GqlValue::Int(value), + GraphEvalValue::UInt(value) => GqlValue::UInt(value), + GraphEvalValue::Float(value) => GqlValue::Float(value), + GraphEvalValue::String(value) => GqlValue::String(value), + GraphEvalValue::Bytes(value) => GqlValue::Bytes(value), + GraphEvalValue::List(values) => GqlValue::List( + values + .into_iter() + .map(|value| graph_eval_to_gql_scalar(value, span)) + .collect::, _>>()?, + ), + GraphEvalValue::Map(values) => GqlValue::Map( + values + .into_iter() + .map(|(key, value)| Ok((key, graph_eval_to_gql_scalar(value, span)?))) + .collect::, EngineError>>()?, + ), + GraphEvalValue::Node(_) | GraphEvalValue::Edge(_) | GraphEvalValue::Path(_) => { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "scalar expression produced a graph element value".to_string(), + span.clone(), + )); + } + }) } fn gql_mutation_compare_values(op: BinaryOp, left: GqlValue, right: GqlValue) -> GqlValue { @@ -4075,7 +7046,15 @@ fn gql_mutation_compare_values(op: BinaryOp, left: GqlValue, right: GqlValue) -> } _ => GqlValue::Null, }, - BinaryOp::And | BinaryOp::Or => unreachable!(), + BinaryOp::And + | BinaryOp::Or + | BinaryOp::Add + | BinaryOp::Sub + | BinaryOp::Mul + | BinaryOp::Div + | BinaryOp::StartsWith + | BinaryOp::EndsWith + | BinaryOp::Contains => unreachable!(), } } @@ -4091,7 +7070,7 @@ fn gql_mutation_alias_value( if context.commit.is_none() { if let Some(&index) = context.row.created_nodes.get(alias) { GqlValue::Node(gql_node_from_created_execution( - &context.materialized.nodes[index], + &context.nodes[index], context.commit, context.include_vectors, )) @@ -4114,7 +7093,7 @@ fn gql_mutation_alias_value( if context.commit.is_none() { if let Some(&index) = context.row.created_edges.get(alias) { GqlValue::Edge(gql_edge_from_created_execution( - &context.materialized.edges[index], + &context.edges[index], context.commit, )) } else { @@ -4143,6 +7122,7 @@ fn gql_mutation_alias_value( }) .transpose()? .unwrap_or(GqlValue::Null), + GqlAliasKind::Scalar => row_scalar_value(alias, context)?, }) } @@ -4159,7 +7139,7 @@ fn gql_mutation_alias_property_value( if context.commit.is_none() { if let Some(&index) = context.row.created_nodes.get(alias) { let node = gql_node_from_created_execution( - &context.materialized.nodes[index], + &context.nodes[index], context.commit, context.include_vectors, ); @@ -4183,7 +7163,7 @@ fn gql_mutation_alias_property_value( if context.commit.is_none() { if let Some(&index) = context.row.created_edges.get(alias) { let edge = gql_edge_from_created_execution( - &context.materialized.edges[index], + &context.edges[index], context.commit, ); Ok(gql_edge_property_from_value(edge, property)) @@ -4211,9 +7191,24 @@ fn gql_mutation_alias_property_value( _ => GqlValue::Null, }) .unwrap_or(GqlValue::Null)), + GqlAliasKind::Scalar => Ok(GqlValue::Null), } } +fn row_scalar_value( + alias: &str, + context: &GqlMutationReturnEvalContext<'_>, +) -> Result { + context + .row + .read_scalars + .get(alias) + .cloned() + .map(graph_value_to_gql_value) + .transpose() + .map(|value| value.unwrap_or(GqlValue::Null)) +} + fn gql_mutation_function_value( function: &str, alias: &str, @@ -4236,7 +7231,7 @@ fn gql_mutation_function_value( .get(alias) .map(|&index| { GqlValue::List( - context.materialized.nodes[index] + context.nodes[index] .labels .iter() .cloned() @@ -4256,7 +7251,7 @@ fn gql_mutation_function_value( .row .created_edges .get(alias) - .map(|&index| GqlValue::String(context.materialized.edges[index].label.clone())) + .map(|&index| GqlValue::String(context.edges[index].label.clone())) .map(Ok) .unwrap_or_else(|| { context @@ -4333,11 +7328,11 @@ fn gql_mutation_function_value( impl<'a> GqlMutationReturnEvalContext<'a> { fn node_id(&self, alias: &str) -> Option { - gql_mutation_node_id_for_alias(alias, self.row, self.materialized, self.commit) + gql_mutation_node_id_for_alias(alias, self.row, self.nodes, self.commit) } fn edge_id(&self, alias: &str) -> Option { - gql_mutation_edge_id_for_alias(alias, self.row, self.materialized, self.commit) + gql_mutation_edge_id_for_alias(alias, self.row, self.edges, self.commit) } fn path(&self, alias: &str) -> Option<&GqlPathIdentity> { @@ -4404,17 +7399,17 @@ impl<'a> GqlMutationReturnEvalContext<'a> { fn gql_node(&self, id: u64) -> Result { if self.commit.is_none() { - if let Some(node) = self.materialized.existing_nodes.get(&id) { + if let Some(node) = self.existing_nodes.get(&id) { return Ok(gql_node_from_existing_execution(id, node, self.include_vectors)); } } if let Some(node) = self.hydrated.nodes.get(&id) { return Ok(gql_node_from_record(&node.record, &node.labels, self.include_vectors)); } - if let Some(node) = self.materialized.existing_nodes.get(&id) { + if let Some(node) = self.existing_nodes.get(&id) { return Ok(gql_node_from_existing_execution(id, node, self.include_vectors)); } - if let Some(node) = self.materialized.nodes.iter().find(|node| { + if let Some(node) = self.nodes.iter().find(|node| { self.commit .and_then(|commit| commit.local_node_ids.get(&node.local).copied()) .is_some_and(|committed_id| committed_id == id) @@ -4433,17 +7428,17 @@ impl<'a> GqlMutationReturnEvalContext<'a> { fn gql_edge(&self, id: u64) -> Result { if self.commit.is_none() { - if let Some(edge) = self.materialized.existing_edges.get(&id) { + if let Some(edge) = self.existing_edges.get(&id) { return Ok(gql_edge_from_existing_execution(id, edge)); } } if let Some(edge) = self.hydrated.edges.get(&id) { return Ok(gql_edge_from_record(&edge.record, &edge.label)); } - if let Some(edge) = self.materialized.existing_edges.get(&id) { + if let Some(edge) = self.existing_edges.get(&id) { return Ok(gql_edge_from_existing_execution(id, edge)); } - if let Some(edge) = self.materialized.edges.iter().find(|edge| { + if let Some(edge) = self.edges.iter().find(|edge| { edge.local .as_ref() .and_then(|local| { @@ -4506,11 +7501,11 @@ fn gql_edge_property_from_value(edge: GqlEdge, property: &str) -> GqlValue { fn gql_mutation_node_id_for_alias( alias: &str, row: &GqlCreateExecutionRow, - materialized: &GqlCreateMaterialization, + nodes: &[GqlCreatedNodeExecution], commit: Option<&TxnCommitResult>, ) -> Option { if let Some(&node_index) = row.created_nodes.get(alias) { - let node = &materialized.nodes[node_index]; + let node = &nodes[node_index]; return commit.and_then(|commit| commit.local_node_ids.get(&node.local).copied()); } row.read_nodes.get(alias).copied().flatten() @@ -4519,11 +7514,11 @@ fn gql_mutation_node_id_for_alias( fn gql_mutation_edge_id_for_alias( alias: &str, row: &GqlCreateExecutionRow, - materialized: &GqlCreateMaterialization, + edges: &[GqlCreatedEdgeExecution], commit: Option<&TxnCommitResult>, ) -> Option { if let Some(&edge_index) = row.created_edges.get(alias) { - let edge = &materialized.edges[edge_index]; + let edge = &edges[edge_index]; return edge .local .as_ref() @@ -4817,13 +7812,11 @@ fn gql_create_node_ref_for_alias( ))) } -fn gql_create_endpoint_key(target: &TxnNodeRef) -> GqlCreateEndpointKey { +fn gql_create_endpoint_key(target: &TxnNodeRef) -> TxnMergeEndpointKey { match target { - TxnNodeRef::Id(id) => GqlCreateEndpointKey::Id(*id), - TxnNodeRef::Local(local) => GqlCreateEndpointKey::Local(local.clone()), - TxnNodeRef::Key { label, key } => { - GqlCreateEndpointKey::Local(TxnLocalRef::Alias(format!("{label}:{key}"))) - } + TxnNodeRef::Id(id) => TxnMergeEndpointKey::Id(*id), + TxnNodeRef::Local(local) => TxnMergeEndpointKey::Local(local.clone()), + TxnNodeRef::Key { label, key } => TxnMergeEndpointKey::Key(label.clone(), key.clone()), } } @@ -5204,7 +8197,15 @@ fn wrap_read_gql_explain( fn validate_gql_mutation_plan_for_execution(plan: &GqlMutationPlan) -> Result<(), EngineError> { if let Some(read_prefix) = plan.read_prefix.as_ref() { - normalize_gql_graph_row_target(&read_prefix.lowered)?; + match &read_prefix.lowered.native_target { + GqlNativeTarget::GraphRows { .. } => { + normalize_gql_graph_row_target(&read_prefix.lowered)?; + } + GqlNativeTarget::GraphPipeline { query } => { + normalize_graph_pipeline_query(query) + .map_err(|err| graph_pipeline_execution_error_to_gql(err, &read_prefix.lowered))?; + } + } } Ok(()) } @@ -5242,13 +8243,20 @@ fn build_gql_mutation_explain_with_snapshot( .filter_map(|column| match column { GqlMutationInternalColumn::TargetId { alias, .. } | GqlMutationInternalColumn::TargetPath { alias } => Some(alias.clone()), - GqlMutationInternalColumn::ExprValue { .. } => None, + GqlMutationInternalColumn::ScalarValue { .. } + | GqlMutationInternalColumn::ExprValue { .. } => None, }) .collect(), expression_columns: read_prefix .internal_columns .iter() - .filter(|column| matches!(column, GqlMutationInternalColumn::ExprValue { .. })) + .filter(|column| { + matches!( + column, + GqlMutationInternalColumn::ScalarValue { .. } + | GqlMutationInternalColumn::ExprValue { .. } + ) + }) .count(), }) } else { @@ -5265,7 +8273,7 @@ fn build_gql_mutation_explain_with_snapshot( warnings.dedup(); let notes = vec![ "Mutation explain is side-effect-free and does not open write transactions, allocate label tokens, append WAL records, mutate memtables, or enqueue index followups".to_string(), - "CREATE, SET, REMOVE, DELETE, and DETACH DELETE execution are supported through one WriteTxn; SET/REMOVE use crate-private by-ID record replacement adapters and DETACH DELETE reuses transaction cascade planning".to_string(), + "CREATE, MERGE, SET, REMOVE, DELETE, and DETACH DELETE execution are supported through one WriteTxn; MERGE uses batch transaction snapshot lookups plus statement-local overlays, SET/REMOVE use crate-private by-ID record replacement adapters, and DETACH DELETE reuses transaction cascade planning".to_string(), "Mutation RETURN supports row operations, compact-row-compatible Rust rows, include-vectors projection, post-commit batch hydration, and crate-private returned-alias read-set validation for CREATE/SET/REMOVE; DELETE/DETACH RETURN remains rejected".to_string(), ]; let return_explain = plan @@ -5308,7 +8316,7 @@ fn build_gql_mutation_explain_with_snapshot( return_plan: return_explain, would_create_node_labels: mutation_create_node_labels(plan), would_create_edge_labels: mutation_create_edge_labels(plan), - uses_transaction_snapshot: plan.read_prefix.is_some(), + uses_transaction_snapshot: gql_mutation_uses_transaction_snapshot(plan), uses_write_txn: true, replacement_adapters: mutation_uses_replacement_adapters(plan), atomic_commit: true, @@ -5326,6 +8334,13 @@ fn gql_execution_cap_summary(options: &GqlExecutionOptions) -> GqlExecutionCapSu max_cursor_bytes: options.max_cursor_bytes, max_mutation_rows: options.max_mutation_rows, max_mutation_ops: options.max_mutation_ops, + max_pipeline_rows: options.max_pipeline_rows, + max_groups: options.max_groups, + max_collect_items: options.max_collect_items, + max_union_branches: options.max_union_branches, + max_subquery_invocations: options.max_subquery_invocations, + max_subquery_depth: options.max_subquery_depth, + max_shortest_path_pairs: options.max_shortest_path_pairs, max_query_bytes: options.max_query_bytes, max_param_bytes: options.max_param_bytes, max_ast_depth: options.max_ast_depth, @@ -5339,6 +8354,14 @@ fn gql_execution_cap_summary(options: &GqlExecutionOptions) -> GqlExecutionCapSu } } +fn gql_mutation_uses_transaction_snapshot(plan: &GqlMutationPlan) -> bool { + plan.read_prefix.is_some() + || plan + .clauses + .iter() + .any(|clause| matches!(clause, GqlMutationClausePlan::Merge(_))) +} + fn mutation_operation_explains(plan: &GqlMutationPlan) -> Vec { plan .clauses @@ -5352,6 +8375,7 @@ fn mutation_operation_explains(plan: &GqlMutationPlan) -> Vec>() }) .collect::>(), + GqlMutationClausePlan::Merge(merge) => vec![merge_operation_explain(merge)], GqlMutationClausePlan::Set(items) => items.iter().map(set_operation_explain).collect(), GqlMutationClausePlan::Remove(items) => { items.iter().map(remove_operation_explain).collect() @@ -5364,6 +8388,37 @@ fn mutation_operation_explains(plan: &GqlMutationPlan) -> Vec GqlMutationOperationExplain { + match &merge.pattern { + GqlMergePatternPlan::Node { alias, label, key } => GqlMutationOperationExplain { + op: "MERGE NODE".to_string(), + target_alias: Some(alias.clone()), + row_multiplicity: "per mutation input row with statement-local key overlay".to_string(), + detail: format!( + "label={label:?}; key expr #{}; ON CREATE items={}; ON MATCH items={}; staged through WriteTxn during execution", + key.id, + merge.on_create.len(), + merge.on_match.len() + ), + }, + GqlMergePatternPlan::Relationship { + alias, + from_alias, + to_alias, + label, + } => GqlMutationOperationExplain { + op: "MERGE EDGE".to_string(), + target_alias: Some(alias.clone()), + row_multiplicity: "per mutation input row with statement-local triple overlay".to_string(), + detail: format!( + "{from_alias} -[:{label}]-> {to_alias}; requires edge_uniqueness=true; ON CREATE items={}; ON MATCH items={}; staged through WriteTxn during execution", + merge.on_create.len(), + merge.on_match.len() + ), + }, + } +} + fn create_node_operation_explain(node: &GqlCreateNodePlan) -> GqlMutationOperationExplain { GqlMutationOperationExplain { op: if node.created { @@ -5469,14 +8524,24 @@ fn delete_operation_explain( fn mutation_create_node_labels(plan: &GqlMutationPlan) -> Vec { let mut labels = BTreeSet::new(); for clause in &plan.clauses { - if let GqlMutationClausePlan::Create(patterns) = clause { - for pattern in patterns { - for node in &pattern.nodes { - if node.created { - labels.extend(node.labels.iter().cloned()); + match clause { + GqlMutationClausePlan::Create(patterns) => { + for pattern in patterns { + for node in &pattern.nodes { + if node.created { + labels.extend(node.labels.iter().cloned()); + } } } } + GqlMutationClausePlan::Merge(merge) => { + if let GqlMergePatternPlan::Node { label, .. } = &merge.pattern { + labels.insert(label.clone()); + } + } + GqlMutationClausePlan::Set(_) + | GqlMutationClausePlan::Remove(_) + | GqlMutationClausePlan::Delete { .. } => {} } } labels.into_iter().collect() @@ -5485,10 +8550,20 @@ fn mutation_create_node_labels(plan: &GqlMutationPlan) -> Vec { fn mutation_create_edge_labels(plan: &GqlMutationPlan) -> Vec { let mut labels = BTreeSet::new(); for clause in &plan.clauses { - if let GqlMutationClausePlan::Create(patterns) = clause { - for pattern in patterns { - labels.extend(pattern.edges.iter().map(|edge| edge.label.clone())); + match clause { + GqlMutationClausePlan::Create(patterns) => { + for pattern in patterns { + labels.extend(pattern.edges.iter().map(|edge| edge.label.clone())); + } + } + GqlMutationClausePlan::Merge(merge) => { + if let GqlMergePatternPlan::Relationship { label, .. } = &merge.pattern { + labels.insert(label.clone()); + } } + GqlMutationClausePlan::Set(_) + | GqlMutationClausePlan::Remove(_) + | GqlMutationClausePlan::Delete { .. } => {} } } labels.into_iter().collect() @@ -5497,7 +8572,14 @@ fn mutation_create_edge_labels(plan: &GqlMutationPlan) -> Vec { fn mutation_uses_replacement_adapters(plan: &GqlMutationPlan) -> bool { plan.clauses .iter() - .any(|clause| matches!(clause, GqlMutationClausePlan::Set(_) | GqlMutationClausePlan::Remove(_))) + .any(|clause| { + matches!( + clause, + GqlMutationClausePlan::Set(_) + | GqlMutationClausePlan::Remove(_) + | GqlMutationClausePlan::Merge(_) + ) + }) } fn mutation_internal_column_summary(column: &GqlMutationInternalColumn) -> String { @@ -5508,6 +8590,9 @@ fn mutation_internal_column_summary(column: &GqlMutationInternalColumn) -> Strin GqlMutationInternalColumn::TargetPath { alias } => { format!("target path identity: {alias}") } + GqlMutationInternalColumn::ScalarValue { alias, expr } => { + format!("scalar value: {alias} = {expr:?}") + } GqlMutationInternalColumn::ExprValue { id, expr } => { format!("expr value #{id}: {expr:?}") } @@ -6808,7 +9893,11 @@ fn configure_gql_graph_row_target( row_counts: &GqlRowCounts, options: &GqlExecutionOptions, ) -> Result<(), EngineError> { - let GqlNativeTarget::GraphRows { query } = &mut lowered.native_target; + let GqlNativeTarget::GraphRows { query } = &mut lowered.native_target else { + return Err(EngineError::InvalidOperation( + "GQL graph-row target configuration received a non-graph-row target".to_string(), + )); + }; query.query.order_by = order_by .iter() .map(|item| { @@ -6876,10 +9965,36 @@ fn execute_gql_graph_row_target( .map_err(|err| graph_row_execution_error_to_gql(err, lowered)) } +fn execute_gql_graph_pipeline_target_on_view( + view: &ReadView, + lowered: &GqlLoweredPlan, +) -> Result, EngineError> { + let GqlNativeTarget::GraphPipeline { query } = &lowered.native_target else { + return Err(EngineError::InvalidOperation( + "GQL graph-pipeline normalization received a non-graph-pipeline target".to_string(), + )); + }; + let normalized = normalize_graph_pipeline_query(query) + .map_err(|err| graph_pipeline_execution_error_to_gql(err, lowered))?; + let cursor_state = graph_pipeline_cursor_state_from_decoded( + None, + &query.page, + query.at_epoch, + query.options.max_skip, + ) + .map_err(|err| graph_pipeline_execution_error_to_gql(err, lowered))?; + view.query_graph_pipeline_normalized(&normalized, cursor_state) + .map_err(|err| graph_pipeline_execution_error_to_gql(err, lowered)) +} + fn normalize_gql_graph_row_target( lowered: &GqlLoweredPlan, ) -> Result { - let GqlNativeTarget::GraphRows { query } = &lowered.native_target; + let GqlNativeTarget::GraphRows { query } = &lowered.native_target else { + return Err(EngineError::InvalidOperation( + "GQL graph-row normalization received a non-graph-row target".to_string(), + )); + }; let fallback_span = lowered .semantic .query @@ -7053,9 +10168,29 @@ fn gql_expr_depends_on_alias(expr: &Expr, plan: &GqlSemanticPlan) -> bool { ExprKind::Binary { left, right, .. } => { gql_expr_depends_on_alias(left, plan) || gql_expr_depends_on_alias(right, plan) } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + operand + .as_ref() + .is_some_and(|operand| gql_expr_depends_on_alias(operand, plan)) + || branches.iter().any(|branch| { + gql_expr_depends_on_alias(&branch.when, plan) + || gql_expr_depends_on_alias(&branch.then, plan) + }) + || else_expr + .as_ref() + .is_some_and(|else_expr| gql_expr_depends_on_alias(else_expr, plan)) + } ExprKind::FunctionCall { args, .. } | ExprKind::List(args) => { args.iter().any(|arg| gql_expr_depends_on_alias(arg, plan)) } + ExprKind::AggregateCall { arg, .. } => arg + .as_ref() + .is_some_and(|arg| gql_expr_depends_on_alias(arg, plan)), + ExprKind::ExistsSubquery(_) => true, ExprKind::Map(map) => map .entries .iter() @@ -7147,6 +10282,47 @@ fn resolve_return_aliases_in_expr( .map(|arg| resolve_return_aliases_in_expr(arg, return_aliases, plan)) .collect::, _>>()?, }, + ExprKind::AggregateCall { + function, + distinct, + arg, + name_span, + } => ExprKind::AggregateCall { + function: *function, + distinct: *distinct, + arg: arg + .as_ref() + .map(|arg| resolve_return_aliases_in_expr(arg, return_aliases, plan).map(Box::new)) + .transpose()?, + name_span: name_span.clone(), + }, + ExprKind::Case { + operand, + branches, + else_expr, + } => ExprKind::Case { + operand: operand + .as_ref() + .map(|operand| { + resolve_return_aliases_in_expr(operand, return_aliases, plan).map(Box::new) + }) + .transpose()?, + branches: branches + .iter() + .map(|branch| { + Ok(crate::gql::ast::CaseBranch { + when: resolve_return_aliases_in_expr(&branch.when, return_aliases, plan)?, + then: resolve_return_aliases_in_expr(&branch.then, return_aliases, plan)?, + }) + }) + .collect::, EngineError>>()?, + else_expr: else_expr + .as_ref() + .map(|else_expr| { + resolve_return_aliases_in_expr(else_expr, return_aliases, plan).map(Box::new) + }) + .transpose()?, + }, ExprKind::List(items) => ExprKind::List( items .iter() @@ -7164,6 +10340,7 @@ fn resolve_return_aliases_in_expr( } ExprKind::Map(resolved) } + ExprKind::ExistsSubquery(_) => return Ok(expr.clone()), ExprKind::Literal(_) | ExprKind::Parameter(_) | ExprKind::Variable(_) => { return Ok(expr.clone()) } @@ -7288,6 +10465,164 @@ fn gql_order_key_error(span: &SourceSpan) -> EngineError { } } +fn gql_distinct_key_error(message: &str, span: &SourceSpan) -> EngineError { + EngineError::GqlSemantic { + code: GqlSemanticErrorCode::InvalidReturnExpression, + message: message.to_string(), + span: span.clone(), + } +} + +fn graph_pipeline_execution_error_to_gql( + err: EngineError, + lowered: &GqlLoweredPlan, +) -> EngineError { + match err { + EngineError::InvalidOperation(message) if graph_row_full_scan_error_message(&message) => { + gql_semantic_error( + GqlSemanticErrorCode::FullScanNotAllowed, + message, + lowered.semantic.query.pipeline.span.clone(), + ) + } + EngineError::InvalidOperation(message) + if message.contains("ORDER BY") + || message.contains("order contexts") + || message.contains("orderable") => + { + gql_order_key_error(&lowered.semantic.query.return_clause.span) + } + other => other, + } +} + +fn build_gql_pipeline_execution_explain( + lowered: &GqlLoweredPlan, + pipeline: &GraphPipelineExplain, + options: &GqlExecutionOptions, +) -> GqlExecutionExplain { + let read = build_gql_pipeline_read_explain(lowered, pipeline, options); + let mut notes = lowered.notes.clone(); + notes.extend(pipeline.notes.iter().cloned()); + for stage in &pipeline.stages { + notes.extend(stage.notes.iter().cloned()); + } + notes.sort(); + notes.dedup(); + + GqlExecutionExplain { + kind: GqlStatementKind::Query, + columns: read.columns.clone(), + warnings: read.warnings.clone(), + read: Some(read), + mutation: None, + caps: gql_execution_cap_summary(options), + notes, + } +} + +fn build_gql_pipeline_read_explain( + lowered: &GqlLoweredPlan, + pipeline: &GraphPipelineExplain, + options: &GqlExecutionOptions, +) -> GqlExplain { + let mut warnings = lowered.warnings.clone(); + warnings.extend(pipeline.warnings.iter().cloned()); + warnings.sort(); + warnings.dedup(); + + let mut projection = Vec::new(); + for stage in &pipeline.stages { + projection.push(format!( + "graph pipeline stage {}: {}: {}", + stage.index, stage.kind, stage.detail + )); + projection.extend( + stage + .notes + .iter() + .map(|note| format!("graph pipeline stage note: {note}")), + ); + if let Some(graph_row) = stage.graph_row.as_ref() { + projection.extend(graph_row.plan.iter().map(|node| { + format!( + "nested graph row plan: stage {}: {}: {}", + stage.index, node.kind, node.detail + ) + })); + projection.extend(graph_row.row_ops.iter().map(|op| { + format!( + "nested graph row row op: stage {}: {}: {}", + stage.index, op.kind, op.detail + ) + })); + } + } + projection.extend( + pipeline + .row_ops + .iter() + .map(|op| format!("graph pipeline row op: {}: {}", op.kind, op.detail)), + ); + projection.push(format!( + "graph pipeline cursor: supplied={}, codec_implemented={}, message={}", + pipeline.cursor.supplied, + pipeline.cursor.codec_implemented, + pipeline.cursor.message.as_deref().unwrap_or("none") + )); + + GqlExplain { + columns: pipeline.columns.clone(), + target: GqlLoweringTarget::GraphPipelineQuery, + native_plan: None, + pushed_down: lowered + .pushed_down + .iter() + .map(|predicate| predicate.summary.clone()) + .collect(), + residual: lowered + .residual_predicates + .iter() + .map(|expr| format!("residual filter: {}", gql_expr_summary(expr))) + .collect(), + projection, + row_ops: gql_pipeline_row_ops(pipeline), + caps: GqlCapSummary { + allow_full_scan: options.allow_full_scan, + max_rows: options.max_rows, + max_intermediate_bindings: options.max_intermediate_bindings, + max_skip: options.max_skip, + max_query_bytes: options.max_query_bytes, + max_param_bytes: options.max_param_bytes, + max_ast_depth: options.max_ast_depth, + max_literal_items: options.max_literal_items, + }, + warnings, + } +} + +fn gql_pipeline_row_ops(pipeline: &GraphPipelineExplain) -> Vec { + let mut ops = Vec::new(); + if pipeline + .row_ops + .iter() + .any(|op| op.kind.contains("Filter")) + { + ops.push(GqlRowOperation::ResidualFilter); + } + if pipeline.row_ops.iter().any(|op| op.kind == "Sort") { + ops.push(GqlRowOperation::Sort); + } + if pipeline.row_ops.iter().any(|op| op.kind == "Skip") { + ops.push(GqlRowOperation::Skip); + } + if pipeline.row_ops.iter().any(|op| op.kind == "Limit") { + ops.push(GqlRowOperation::Limit); + } + ops.push(GqlRowOperation::Projection); + ops +} + fn build_gql_explain( view: &ReadView, lowered: &GqlLoweredPlan, @@ -7295,6 +10630,38 @@ fn build_gql_explain( order_by: &[GqlResolvedOrderItem], options: &GqlExecutionOptions, ) -> Result { + if let GqlNativeTarget::GraphPipeline { query } = &lowered.native_target { + let mut normalized = normalize_graph_pipeline_query(query) + .map_err(|err| graph_pipeline_execution_error_to_gql(err, lowered))?; + normalized.options.include_plan = true; + let cursor_state = graph_pipeline_cursor_state_from_decoded( + None, + &query.page, + query.at_epoch, + query.options.max_skip, + ) + .map_err(|err| graph_pipeline_execution_error_to_gql(err, lowered))?; + let pipeline_explain = if normalized.options.profile { + view.query_graph_pipeline_normalized(&normalized, cursor_state) + .map_err(|err| graph_pipeline_execution_error_to_gql(err, lowered))? + .value + .plan + .ok_or_else(|| { + EngineError::InvalidOperation( + "graph pipeline explain did not produce a plan".to_string(), + ) + })? + } else { + view.explain_graph_pipeline_normalized(&normalized, cursor_state) + .map_err(|err| graph_pipeline_execution_error_to_gql(err, lowered))? + }; + return Ok(build_gql_pipeline_read_explain( + lowered, + &pipeline_explain, + options, + )); + } + let mut warnings = lowered.warnings.clone(); let normalized = normalize_gql_graph_row_target(lowered)?; let cursor_state = graph_row_prepare_cursor_state( @@ -7440,6 +10807,7 @@ fn gql_limit_zero_projection_summaries( fn gql_explain_target(kind: GqlNativeTargetKind) -> GqlLoweringTarget { match kind { GqlNativeTargetKind::GraphRows => GqlLoweringTarget::GraphRowQuery, + GqlNativeTargetKind::GraphPipeline => GqlLoweringTarget::GraphPipelineQuery, } } @@ -7667,9 +11035,15 @@ fn gql_append_graph_expr_output_summaries(summaries: &mut Vec, expr: &Gr GraphFunction::Relationships => { summaries.push(format!("output selected field: {alias}.relationships")) } + _ => {} } } } + GraphExpr::AggregateCall { arg, .. } => { + if let Some(arg) = arg.as_ref() { + gql_append_graph_expr_output_summaries(summaries, arg); + } + } GraphExpr::List(items) => { for item in items { gql_append_graph_expr_output_summaries(summaries, item); @@ -7687,6 +11061,22 @@ fn gql_append_graph_expr_output_summaries(summaries: &mut Vec, expr: &Gr gql_append_graph_expr_output_summaries(summaries, left); gql_append_graph_expr_output_summaries(summaries, right); } + GraphExpr::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand { + gql_append_graph_expr_output_summaries(summaries, operand); + } + for branch in branches { + gql_append_graph_expr_output_summaries(summaries, &branch.when); + gql_append_graph_expr_output_summaries(summaries, &branch.then); + } + if let Some(else_expr) = else_expr { + gql_append_graph_expr_output_summaries(summaries, else_expr); + } + } GraphExpr::Null | GraphExpr::Bool(_) | GraphExpr::Int(_) @@ -7696,6 +11086,7 @@ fn gql_append_graph_expr_output_summaries(summaries: &mut Vec, expr: &Gr | GraphExpr::Bytes(_) | GraphExpr::Param(_) | GraphExpr::Binding(_) + | GraphExpr::ExistsSubquery(_) | GraphExpr::Property { .. } => {} } } @@ -7861,10 +11252,10 @@ fn gql_expr_summary(expr: &Expr) -> String { ExprKind::PropertyAccess { object, property } => { format!("{}.{}", gql_expr_summary(object), property.name) } - ExprKind::Unary { - op: UnaryOp::Not, - expr, - } => format!("NOT {}", gql_expr_summary(expr)), + ExprKind::Unary { op, expr } => match op { + UnaryOp::Not => format!("NOT {}", gql_expr_summary(expr)), + UnaryOp::Neg => format!("-{}", gql_expr_summary(expr)), + }, ExprKind::Binary { op, left, right } => format!( "{} {} {}", gql_expr_summary(left), @@ -7886,8 +11277,51 @@ fn gql_expr_summary(expr: &Expr) -> String { .collect::>() .join(", ") ), + ExprKind::AggregateCall { + function, + distinct, + arg, + .. + } => { + let function = match function { + crate::gql::ast::AggregateFunction::Count => "count", + crate::gql::ast::AggregateFunction::Sum => "sum", + crate::gql::ast::AggregateFunction::Avg => "avg", + crate::gql::ast::AggregateFunction::Min => "min", + crate::gql::ast::AggregateFunction::Max => "max", + crate::gql::ast::AggregateFunction::Collect => "collect", + }; + let arg = match arg { + Some(arg) if *distinct => format!("DISTINCT {}", gql_expr_summary(arg)), + Some(arg) => gql_expr_summary(arg), + None => "*".to_string(), + }; + format!("{function}({arg})") + } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + let mut parts = Vec::new(); + if let Some(operand) = operand { + parts.push(gql_expr_summary(operand)); + } + for branch in branches { + parts.push(format!( + "WHEN {} THEN {}", + gql_expr_summary(&branch.when), + gql_expr_summary(&branch.then) + )); + } + if let Some(else_expr) = else_expr { + parts.push(format!("ELSE {}", gql_expr_summary(else_expr))); + } + format!("CASE {} END", parts.join(" ")) + } ExprKind::List(_) => "list".to_string(), ExprKind::Map(_) => "map".to_string(), + ExprKind::ExistsSubquery(_) => "EXISTS subquery".to_string(), } } @@ -7905,6 +11339,10 @@ fn gql_binary_op_summary(op: BinaryOp) -> &'static str { match op { BinaryOp::Or => "OR", BinaryOp::And => "AND", + BinaryOp::Add => "+", + BinaryOp::Sub => "-", + BinaryOp::Mul => "*", + BinaryOp::Div => "/", BinaryOp::Eq => "=", BinaryOp::Neq => "<>", BinaryOp::Lt => "<", @@ -7912,5 +11350,8 @@ fn gql_binary_op_summary(op: BinaryOp) -> &'static str { BinaryOp::Gt => ">", BinaryOp::Ge => ">=", BinaryOp::In => "IN", + BinaryOp::StartsWith => "STARTS WITH", + BinaryOp::EndsWith => "ENDS WITH", + BinaryOp::Contains => "CONTAINS", } } diff --git a/src/engine/query_exec.rs b/src/engine/query_exec.rs index 1867baf..e783643 100644 --- a/src/engine/query_exec.rs +++ b/src/engine/query_exec.rs @@ -29,6 +29,22 @@ enum CandidateMaterializationResult { }, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum GraphRowRuntimeGoal { + AllRows, + ExistsOne, +} + +impl GraphRowRuntimeGoal { + fn is_exists_one(self) -> bool { + matches!(self, Self::ExistsOne) + } + + fn reached(self, rows: usize) -> bool { + self.is_exists_one() && rows > 0 + } +} + fn materialization_followups( followup: Option, ) -> Vec { @@ -1694,6 +1710,12 @@ fn graph_row_collect_expr_dependency_slots( graph_row_collect_expr_dependency_slots(arg, schema, prior_slots, slots)?; } } + GraphExpr::AggregateCall { arg, .. } => { + if let Some(arg) = arg { + graph_row_collect_expr_dependency_slots(arg, schema, prior_slots, slots)?; + } + } + GraphExpr::ExistsSubquery(_) => {} GraphExpr::Unary { expr, .. } | GraphExpr::IsNull(expr) | GraphExpr::IsNotNull(expr) => { @@ -1703,6 +1725,32 @@ fn graph_row_collect_expr_dependency_slots( graph_row_collect_expr_dependency_slots(left, schema, prior_slots, slots)?; graph_row_collect_expr_dependency_slots(right, schema, prior_slots, slots)?; } + GraphExpr::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand { + graph_row_collect_expr_dependency_slots(operand, schema, prior_slots, slots)?; + } + for branch in branches { + graph_row_collect_expr_dependency_slots( + &branch.when, + schema, + prior_slots, + slots, + )?; + graph_row_collect_expr_dependency_slots( + &branch.then, + schema, + prior_slots, + slots, + )?; + } + if let Some(else_expr) = else_expr { + graph_row_collect_expr_dependency_slots(else_expr, schema, prior_slots, slots)?; + } + } GraphExpr::Null | GraphExpr::Bool(_) | GraphExpr::Int(_) @@ -2210,11 +2258,45 @@ fn graph_row_cursor_order_atom_expectation( GraphFunction::Labels | GraphFunction::Nodes | GraphFunction::Relationships => { GraphRowCursorAtomExpectation::Unsupported } + GraphFunction::ToString + | GraphFunction::Lower + | GraphFunction::Upper + | GraphFunction::Trim + | GraphFunction::Substring => GraphRowCursorAtomExpectation::String, + GraphFunction::ToInteger + | GraphFunction::ToFloat + | GraphFunction::Abs + | GraphFunction::Floor + | GraphFunction::Ceil + | GraphFunction::Round + | GraphFunction::Size => GraphRowCursorAtomExpectation::Number, + GraphFunction::Coalesce | GraphFunction::Head | GraphFunction::Last => { + GraphRowCursorAtomExpectation::AnyOrderable + } }, - BoundGraphExpr::Unary { .. } - | BoundGraphExpr::Binary { .. } - | BoundGraphExpr::IsNull(_) - | BoundGraphExpr::IsNotNull(_) => GraphRowCursorAtomExpectation::Bool, + BoundGraphExpr::Unary { op, .. } => match op { + GraphUnaryOp::Not => GraphRowCursorAtomExpectation::Bool, + GraphUnaryOp::Neg => GraphRowCursorAtomExpectation::Number, + }, + BoundGraphExpr::Binary { op, .. } => match op { + GraphBinaryOp::Add | GraphBinaryOp::Sub | GraphBinaryOp::Mul | GraphBinaryOp::Div => { + GraphRowCursorAtomExpectation::Number + } + GraphBinaryOp::And + | GraphBinaryOp::Or + | GraphBinaryOp::Eq + | GraphBinaryOp::Neq + | GraphBinaryOp::Lt + | GraphBinaryOp::Le + | GraphBinaryOp::Gt + | GraphBinaryOp::Ge + | GraphBinaryOp::In + | GraphBinaryOp::StartsWith + | GraphBinaryOp::EndsWith + | GraphBinaryOp::Contains => GraphRowCursorAtomExpectation::Bool, + }, + BoundGraphExpr::Case { .. } => GraphRowCursorAtomExpectation::AnyOrderable, + BoundGraphExpr::IsNull(_) | BoundGraphExpr::IsNotNull(_) => GraphRowCursorAtomExpectation::Bool, }) } @@ -2303,7 +2385,9 @@ fn graph_row_cursor_atom_matches_slot_kind( crate::graph_row::GraphSortAtom::Bool(_) | crate::graph_row::GraphSortAtom::Number(_) | crate::graph_row::GraphSortAtom::String(_) - | crate::graph_row::GraphSortAtom::Bytes(_) => { + | crate::graph_row::GraphSortAtom::Bytes(_) + | crate::graph_row::GraphSortAtom::List(_) + | crate::graph_row::GraphSortAtom::Map(_) => { slot_kind == crate::graph_row::GraphBindingSlotKind::Scalar } } @@ -2671,6 +2755,25 @@ fn graph_row_fingerprint_expr(writer: &mut GraphRowFingerprintWriter, expr: &Gra graph_row_fingerprint_expr(writer, arg); } } + GraphExpr::AggregateCall { + function, + distinct, + arg, + } => { + writer.tag(21); + writer.tag(*function as u8); + writer.bool(*distinct); + graph_row_fingerprint_option_expr(writer, arg.as_deref()); + } + GraphExpr::ExistsSubquery(stage) => { + writer.tag(22); + graph_row_fingerprint_string_vec(writer, &stage.import_aliases); + graph_pipeline_fingerprint_stages(writer, &stage.query.stages); + graph_row_fingerprint_string_vec( + writer, + &graph_pipeline_declared_branch_columns(&stage.query), + ); + } GraphExpr::Unary { op, expr } => { writer.tag(16); writer.tag(*op as u8); @@ -2690,6 +2793,20 @@ fn graph_row_fingerprint_expr(writer: &mut GraphRowFingerprintWriter, expr: &Gra writer.tag(19); graph_row_fingerprint_expr(writer, expr); } + GraphExpr::Case { + operand, + branches, + else_expr, + } => { + writer.tag(20); + graph_row_fingerprint_option_expr(writer, operand.as_deref()); + writer.len(branches.len()); + for branch in branches { + graph_row_fingerprint_expr(writer, &branch.when); + graph_row_fingerprint_expr(writer, &branch.then); + } + graph_row_fingerprint_option_expr(writer, else_expr.as_deref()); + } } } @@ -3320,6 +3437,22 @@ fn encode_graph_sort_atoms( push_u64_vec(bytes, nodes)?; push_u64_vec(bytes, edges)?; } + crate::graph_row::GraphSortAtom::List(values) => { + push_u8(bytes, 8); + encode_graph_sort_atoms(bytes, values)?; + } + crate::graph_row::GraphSortAtom::Map(values) => { + push_u8(bytes, 9); + push_u32(bytes, values.len().try_into().map_err(|_| { + EngineError::InvalidOperation( + "graph row cursor map sort atom is too large".to_string(), + ) + })?); + for (key, value) in values { + push_bytes(bytes, key.as_bytes())?; + encode_graph_sort_atoms(bytes, std::slice::from_ref(value))?; + } + } } } Ok(()) @@ -3375,6 +3508,29 @@ fn decode_graph_sort_atoms( edges, } } + 8 => crate::graph_row::GraphSortAtom::List(decode_graph_sort_atoms(reader)?), + 9 => { + let len = reader.read_u32()? as usize; + if len > reader.remaining() { + return Err(invalid_graph_row_cursor( + "cursor map sort atom count exceeds remaining payload", + )); + } + let mut values = Vec::with_capacity(len); + for _ in 0..len { + let key = std::str::from_utf8(reader.read_bytes()?) + .map_err(|_| invalid_graph_row_cursor("cursor map key is not UTF-8"))? + .to_string(); + let mut value = decode_graph_sort_atoms(reader)?; + if value.len() != 1 { + return Err(invalid_graph_row_cursor( + "cursor map value did not contain exactly one sort atom", + )); + } + values.push((key, value.remove(0))); + } + crate::graph_row::GraphSortAtom::Map(values) + } value => { return Err(invalid_graph_row_cursor(format!( "invalid sort atom tag {value}" @@ -5264,7 +5420,7 @@ impl ReadView { if !missing_ids.is_empty() { let records = self.get_edges(&missing_ids)?; - for (index, record) in missing_positions.into_iter().zip(records.into_iter()) { + for (index, record) in missing_positions.into_iter().zip(records) { slots[index] = record; } } @@ -5331,7 +5487,7 @@ impl ReadView { let projected = self .sources() .find_edge_properties(&property_candidate_ids, &property_keys)?; - for (&edge_id, props) in property_candidate_ids.iter().zip(projected.into_iter()) { + for (&edge_id, props) in property_candidate_ids.iter().zip(projected) { let Some(props) = props else { continue; }; @@ -5752,7 +5908,11 @@ impl ReadView { nodes.push(runtime); } - let mut bound_slots = BTreeSet::new(); + let mut bound_slots = query + .initial_bound_slots + .iter() + .copied() + .collect::>(); let mut next_hidden_id = 0usize; let mut runtime = self.normalize_graph_row_runtime_piece_plan( query, @@ -5978,6 +6138,18 @@ impl ReadView { node.alias )) })?; + let slot_info = query.binding_schema.slot(slot).ok_or_else(|| { + EngineError::InvalidOperation(format!( + "graph row node alias '{}' is missing from binding schema", + node.alias + )) + })?; + if slot_info.kind != crate::graph_row::GraphBindingSlotKind::Node { + return Err(EngineError::InvalidOperation(format!( + "graph row node alias '{}' resolved to a non-node binding slot", + node.alias + ))); + } let (label_filter, single_label_id, warnings) = self.resolve_node_query_label_filter(node.label_filter.as_ref())?; let mut filter = normalize_optional_node_filter(node.filter.as_ref())?; @@ -6276,6 +6448,7 @@ impl ReadView { &runtime, &physical_plan, None, + GraphRowRuntimeGoal::AllRows, effective_at_epoch, policy_cutoffs.as_ref(), &mut followups, @@ -6653,6 +6826,7 @@ impl ReadView { runtime: &GraphRowRuntimePlan, physical_plan: &GraphRowPhysicalPlan, initial_rows: Option>, + goal: GraphRowRuntimeGoal, effective_at_epoch: i64, policy_cutoffs: Option<&PrecomputedPruneCutoffs>, followups: &mut Vec, @@ -6662,12 +6836,32 @@ impl ReadView { mut explain_trace: Option<&mut GraphRowExplainTrace>, ) -> Result, EngineError> { if runtime.steps.is_empty() { + let fallback_node_driver; + let initial_driver = match &physical_plan.initial_driver { + GraphRowInitialDriver::Empty { .. } if !runtime.nodes.is_empty() => { + fallback_node_driver = GraphRowInitialDriver::Node { + node_index: 0, + alias: runtime.nodes[0].alias.clone(), + }; + &fallback_node_driver + } + driver => driver, + }; let rows = match initial_rows { - Some(rows) => rows, + Some(rows) => self.graph_row_seed_required_segment_rows( + query, + runtime, + initial_driver, + rows, + GraphRowRuntimeGoal::AllRows, + policy_cutoffs, + followups, + )?, None => self.graph_row_initial_rows( query, runtime, - &physical_plan.initial_driver, + initial_driver, + goal, policy_cutoffs, followups, )?, @@ -6682,7 +6876,13 @@ impl ReadView { } let mut rows = initial_rows; - for step in &runtime.steps { + let step_count = runtime.steps.len(); + for (step_index, step) in runtime.steps.iter().enumerate() { + let step_goal = if step_index + 1 == step_count { + goal + } else { + GraphRowRuntimeGoal::AllRows + }; let next_rows = match step { GraphRowRuntimeStep::RequiredSegment(segment_index) => { let segment_plan = physical_plan @@ -6700,6 +6900,7 @@ impl ReadView { physical_plan, segment_plan, rows.take(), + step_goal, effective_at_epoch, policy_cutoffs, followups, @@ -6714,6 +6915,7 @@ impl ReadView { self.graph_row_compose_fixed_path_rows( path, left_rows, + step_goal, explain_trace.as_deref_mut(), )? } @@ -6748,6 +6950,7 @@ impl ReadView { followups, frontier_peak, paths_enumerated, + step_goal, explain_trace.as_deref_mut(), )? } @@ -6768,6 +6971,7 @@ impl ReadView { &self, fixed_path: &GraphRowRuntimeFixedPath, mut rows: Vec, + goal: GraphRowRuntimeGoal, explain_trace: Option<&mut GraphRowExplainTrace>, ) -> Result, EngineError> { for row in &mut rows { @@ -6818,6 +7022,9 @@ impl ReadView { ), ); } + if goal.reached(rows.len()) { + rows.truncate(1); + } Ok(rows) } @@ -6829,6 +7036,7 @@ impl ReadView { physical_plan: &GraphRowPhysicalPlan, segment_plan: &GraphRowPhysicalSegment, current_rows: Option>, + goal: GraphRowRuntimeGoal, effective_at_epoch: i64, policy_cutoffs: Option<&PrecomputedPruneCutoffs>, followups: &mut Vec, @@ -6841,6 +7049,7 @@ impl ReadView { runtime, &segment_plan.initial_driver, rows, + GraphRowRuntimeGoal::AllRows, policy_cutoffs, followups, )?, @@ -6848,23 +7057,31 @@ impl ReadView { query, runtime, &segment_plan.initial_driver, + GraphRowRuntimeGoal::AllRows, policy_cutoffs, followups, )?, }; - for &edge_index in &segment_plan.edge_order { + let edge_count = segment_plan.edge_order.len(); + for (position, &edge_index) in segment_plan.edge_order.iter().enumerate() { let edge = &runtime.edges[edge_index]; let planned_source_choice = physical_plan .edge_source_choices .get(edge_index) .and_then(|choice| *choice); + let edge_goal = if position + 1 == edge_count { + goal + } else { + GraphRowRuntimeGoal::AllRows + }; rows = self.graph_row_expand_fixed_edge( query, runtime, edge, planned_source_choice, rows, + edge_goal, effective_at_epoch, policy_cutoffs, followups, @@ -6875,12 +7092,14 @@ impl ReadView { Ok(rows) } + #[allow(clippy::too_many_arguments)] fn graph_row_seed_required_segment_rows( &self, query: &NormalizedGraphRowQuery, runtime: &GraphRowRuntimePlan, initial_driver: &GraphRowInitialDriver, rows: Vec, + goal: GraphRowRuntimeGoal, policy_cutoffs: Option<&PrecomputedPruneCutoffs>, followups: &mut Vec, ) -> Result, EngineError> { @@ -6902,7 +7121,7 @@ impl ReadView { if row.slot_is_null(anchor.slot)? { continue; } - if row.slot_is_bound(anchor.slot)? { + if row.node_id_for_slot_if_bound(anchor.slot)?.is_some() { output.push(row); continue; } @@ -6910,6 +7129,7 @@ impl ReadView { anchor_ids = Some(self.graph_row_initial_node_ids( query, anchor, + GraphRowRuntimeGoal::AllRows, policy_cutoffs, followups, )?); @@ -6927,11 +7147,127 @@ impl ReadView { query.options.max_intermediate_bindings, )); } + if goal.reached(output.len()) { + return Ok(output); + } } } Ok(output) } + fn graph_row_partition_initial_bound_node_constraint_rows( + &self, + query: &NormalizedGraphRowQuery, + runtime: &GraphRowRuntimePlan, + rows: Vec, + policy_cutoffs: Option<&PrecomputedPruneCutoffs>, + ) -> Result< + ( + Vec, + Vec, + ), + EngineError, + > { + if rows.is_empty() || query.initial_bound_slots.is_empty() { + return Ok((rows, Vec::new())); + } + + let initial_slots = query + .initial_bound_slots + .iter() + .copied() + .collect::>(); + let constrained_nodes = runtime + .nodes + .iter() + .filter(|node| { + initial_slots.contains(&node.slot) && graph_row_node_query_has_anchor(&node.query) + }) + .collect::>(); + if constrained_nodes.is_empty() { + return Ok((rows, Vec::new())); + } + + let mut verified_by_slot = BTreeMap::new(); + for node in constrained_nodes { + let candidate_ids = graph_row_collect_node_ids(&rows, node.slot)?; + let verified = + self.graph_row_verified_bound_anchor_ids(&candidate_ids, node, policy_cutoffs)?; + verified_by_slot.insert(node.slot, verified); + } + + let mut valid = Vec::with_capacity(rows.len()); + let mut invalid = Vec::new(); + 'rows: for row in rows { + for (slot, verified_ids) in &verified_by_slot { + let Some(node_id) = row.node_id_for_slot_if_bound(*slot)? else { + invalid.push(row); + continue 'rows; + }; + if !verified_ids.contains(&node_id) { + invalid.push(row); + continue 'rows; + } + } + valid.push(row); + } + Ok((valid, invalid)) + } + + fn graph_row_null_extend_initial_optional_miss_row( + &self, + query: &NormalizedGraphRowQuery, + mut row: crate::graph_row::GraphBindingRow, + ) -> Result { + let initial_slots = query + .initial_bound_slots + .iter() + .copied() + .collect::>(); + for slot in query.binding_schema.slots() { + let slot_ref = crate::graph_row::GraphBindingSlotRef { + kind: slot.kind, + index: slot.index, + }; + if slot.user_alias.is_some() && !initial_slots.contains(&slot_ref) { + row.set_null(&query.binding_schema, slot_ref)?; + } + } + Ok(row) + } + + fn graph_row_verified_bound_anchor_ids( + &self, + candidate_ids: &[u64], + anchor: &GraphRowRuntimeNode, + policy_cutoffs: Option<&PrecomputedPruneCutoffs>, + ) -> Result { + let mut unique = candidate_ids.to_vec(); + unique.sort_unstable(); + unique.dedup(); + if unique.is_empty() { + return Ok(NodeIdSet::default()); + } + let include_key = !anchor.query.keys.is_empty(); + let mut property_keys = Vec::new(); + collect_node_filter_property_keys(&anchor.query.filter, &mut property_keys); + property_keys.sort(); + property_keys.dedup(); + let mut verified = Vec::with_capacity(unique.len()); + for chunk in unique.chunks(QUERY_VERIFY_CHUNK) { + let _ = self.verify_node_candidate_chunk( + chunk, + &anchor.query, + policy_cutoffs, + include_key, + &property_keys, + &mut verified, + usize::MAX, + )?; + } + Ok(verified.into_iter().collect()) + } + #[allow(clippy::too_many_arguments)] fn graph_row_execute_optional_group( &self, @@ -7012,6 +7348,7 @@ impl ReadView { &group.runtime, group_physical_plan, Some(vec![query.binding_schema.empty_row()]), + GraphRowRuntimeGoal::AllRows, effective_at_epoch, policy_cutoffs, followups, @@ -7119,6 +7456,7 @@ impl ReadView { &group.runtime, group_physical_plan, Some(representatives), + GraphRowRuntimeGoal::AllRows, effective_at_epoch, policy_cutoffs, followups, @@ -7205,6 +7543,7 @@ impl ReadView { followups: &mut Vec, frontier_peak: &mut usize, paths_enumerated: &mut usize, + goal: GraphRowRuntimeGoal, mut explain_trace: Option<&mut GraphRowExplainTrace>, ) -> Result, EngineError> { if left_rows.is_empty() { @@ -7260,6 +7599,7 @@ impl ReadView { followups, frontier_peak, paths_enumerated, + goal, explain_trace.as_deref_mut(), ); } @@ -7337,6 +7677,12 @@ impl ReadView { graph_path.clone(), &mut output, )?; + if goal.reached(output.len()) { + break; + } + } + if goal.reached(output.len()) { + break; } } @@ -7375,6 +7721,7 @@ impl ReadView { followups: &mut Vec, frontier_peak: &mut usize, paths_enumerated: &mut usize, + goal: GraphRowRuntimeGoal, mut explain_trace: Option<&mut GraphRowExplainTrace>, ) -> Result, EngineError> { let temp_edge = GraphRowRuntimeEdge { @@ -7475,6 +7822,12 @@ impl ReadView { edges: vec![candidate.meta.id], }; self.graph_row_push_vlp_path_row(query, path, &row, graph_path, &mut output)?; + if goal.reached(output.len()) { + break; + } + } + if goal.reached(output.len()) { + break; } } *paths_enumerated = paths_enumerated.saturating_add(output.len()); @@ -8003,6 +8356,7 @@ impl ReadView { query: &NormalizedGraphRowQuery, runtime: &GraphRowRuntimePlan, initial_driver: &GraphRowInitialDriver, + goal: GraphRowRuntimeGoal, policy_cutoffs: Option<&PrecomputedPruneCutoffs>, followups: &mut Vec, ) -> Result, EngineError> { @@ -8014,13 +8368,16 @@ impl ReadView { "graph row physical plan references missing node index {node_index}" )) })?; - let ids = self.graph_row_initial_node_ids(query, anchor, policy_cutoffs, followups)?; + let ids = self.graph_row_initial_node_ids(query, anchor, goal, policy_cutoffs, followups)?; let mut rows = Vec::with_capacity(ids.len()); for node_id in ids { let mut row = query.binding_schema.empty_row(); row.bind_node(anchor.slot, crate::graph_row::GraphBoundNode::id_only(node_id))?; rows.push(row); + if goal.reached(rows.len()) { + break; + } } Ok(rows) } @@ -8029,16 +8386,23 @@ impl ReadView { &self, query: &NormalizedGraphRowQuery, anchor: &GraphRowRuntimeNode, + goal: GraphRowRuntimeGoal, policy_cutoffs: Option<&PrecomputedPruneCutoffs>, followups: &mut Vec, ) -> Result, EngineError> { let mut anchor_query = anchor.query.clone(); - anchor_query.page.limit = Some(query.options.max_intermediate_bindings.saturating_add(1)); + anchor_query.page.limit = Some(if goal.is_exists_one() { + 1 + } else { + query.options.max_intermediate_bindings.saturating_add(1) + }); let planned = self.plan_normalized_node_query(&anchor_query)?; let (page, mut node_followups) = self.query_node_page_planned(&anchor_query, planned, false, policy_cutoffs)?; followups.append(&mut node_followups); - if page.next_cursor.is_some() || page.ids.len() > query.options.max_intermediate_bindings { + if !goal.is_exists_one() + && (page.next_cursor.is_some() || page.ids.len() > query.options.max_intermediate_bindings) + { return Err(graph_row_cap_error( "max_intermediate_bindings", query.options.max_intermediate_bindings, @@ -8055,6 +8419,7 @@ impl ReadView { edge: &GraphRowRuntimeEdge, planned_source_choice: Option, rows: Vec, + goal: GraphRowRuntimeGoal, effective_at_epoch: i64, policy_cutoffs: Option<&PrecomputedPruneCutoffs>, followups: &mut Vec, @@ -8174,6 +8539,9 @@ impl ReadView { candidate, &mut next_rows, )?; + if goal.reached(next_rows.len()) { + return Ok(next_rows); + } } } else { for candidate in &candidates { @@ -8188,6 +8556,9 @@ impl ReadView { candidate, &mut next_rows, )?; + if goal.reached(next_rows.len()) { + return Ok(next_rows); + } } } } @@ -8954,7 +9325,7 @@ impl ReadView { let projected = self .sources() .find_edge_properties(&property_candidate_ids, &property_keys)?; - for (&edge_id, props) in property_candidate_ids.iter().zip(projected.into_iter()) { + for (&edge_id, props) in property_candidate_ids.iter().zip(projected) { let Some(props) = props else { continue; }; @@ -9023,7 +9394,7 @@ impl ReadView { } let selected = self.sources().find_node_projected_fields(&ids, node_needs)?; let mut by_id = NodeIdMap::with_capacity_and_hasher(ids.len(), Default::default()); - for (node_id, fields) in ids.into_iter().zip(selected.into_iter()) { + for (node_id, fields) in ids.into_iter().zip(selected) { if let Some(fields) = fields { by_id.insert(node_id, fields); } @@ -9055,7 +9426,7 @@ impl ReadView { } let selected = self.sources().find_edge_projected_fields(&ids, edge_needs)?; let mut by_id = NodeIdMap::with_capacity_and_hasher(ids.len(), Default::default()); - for (edge_id, fields) in ids.into_iter().zip(selected.into_iter()) { + for (edge_id, fields) in ids.into_iter().zip(selected) { if let Some(fields) = fields { by_id.insert(edge_id, fields); } @@ -9104,7 +9475,7 @@ impl ReadView { if !ids.is_empty() { let selected = self.sources().find_node_projected_fields(&ids, node_needs)?; nodes_by_id = NodeIdMap::with_capacity_and_hasher(ids.len(), Default::default()); - for (node_id, fields) in ids.into_iter().zip(selected.into_iter()) { + for (node_id, fields) in ids.into_iter().zip(selected) { if let Some(fields) = fields { nodes_by_id.insert( node_id, @@ -9121,7 +9492,7 @@ impl ReadView { if !ids.is_empty() { let selected = self.sources().find_edge_projected_fields(&ids, edge_needs)?; edges_by_id = NodeIdMap::with_capacity_and_hasher(ids.len(), Default::default()); - for (edge_id, fields) in ids.into_iter().zip(selected.into_iter()) { + for (edge_id, fields) in ids.into_iter().zip(selected) { if let Some(fields) = fields { edges_by_id.insert( edge_id, diff --git a/src/engine/query_ir.rs b/src/engine/query_ir.rs index 1c48a16..c82bd5a 100644 --- a/src/engine/query_ir.rs +++ b/src/engine/query_ir.rs @@ -119,6 +119,7 @@ struct NormalizedEdgeQuery { #[derive(Clone, Debug)] pub(crate) struct NormalizedGraphRowQuery { pub(crate) binding_schema: crate::graph_row::GraphBindingSchema, + pub(crate) initial_bound_slots: Vec, pub(crate) nodes: Vec, pub(crate) pieces: Vec, pub(crate) fixed_paths: Vec, @@ -161,6 +162,8 @@ struct GraphRowAliasState { node_aliases: HashSet, edge_aliases: HashSet, path_aliases: HashSet, + scalar_aliases: HashSet, + external_node_aliases: HashSet, node_first_scope: HashMap, edge_first_scope: HashMap, path_first_scope: HashMap, @@ -168,6 +171,7 @@ struct GraphRowAliasState { required_edge_order: Vec, optional_alias_order: Vec, path_order: Vec, + scalar_order: Vec, } #[derive(Clone, Default)] @@ -218,16 +222,51 @@ pub(crate) fn normalize_graph_row_query_with_gql_fixed_paths( ) } +pub(crate) fn normalize_graph_row_query_with_pipeline_input( + query: &GraphRowQuery, + edge_id_constraints: &BTreeMap>, + logical_limit: Option, + fixed_paths: &[GraphFixedPathBinding], + input_schema: &crate::graph_row::GraphBindingSchema, +) -> Result { + normalize_graph_row_query_with_internal_limits_and_input( + query, + edge_id_constraints, + logical_limit, + fixed_paths, + Some(input_schema), + ) +} + fn normalize_graph_row_query_with_internal_limits( query: &GraphRowQuery, edge_id_constraints: &BTreeMap>, logical_limit: Option, fixed_paths: &[GraphFixedPathBinding], +) -> Result { + normalize_graph_row_query_with_internal_limits_and_input( + query, + edge_id_constraints, + logical_limit, + fixed_paths, + None, + ) +} + +fn normalize_graph_row_query_with_internal_limits_and_input( + query: &GraphRowQuery, + edge_id_constraints: &BTreeMap>, + logical_limit: Option, + fixed_paths: &[GraphFixedPathBinding], + input_schema: Option<&crate::graph_row::GraphBindingSchema>, ) -> Result { validate_graph_row_page(&query.page, &query.options)?; let referenced_params = collect_graph_row_referenced_params(query)?; let mut aliases = GraphRowAliasState::default(); + if let Some(input_schema) = input_schema { + seed_graph_row_aliases_from_input_schema(input_schema, &mut aliases)?; + } collect_graph_row_node_aliases(query, &mut aliases)?; for piece in &query.pieces { collect_graph_row_piece_aliases(piece, GraphAliasScope::Required, &mut aliases)?; @@ -241,7 +280,7 @@ fn normalize_graph_row_query_with_internal_limits( .or_insert(GraphAliasScope::Required); } - validate_graph_row_anchors(query, edge_id_constraints)?; + validate_graph_row_anchors(query, edge_id_constraints, &aliases.external_node_aliases)?; validate_graph_row_optional_filters(&query.pieces, fixed_paths, &aliases, &query.params)?; if let Some(expr) = query.where_.as_ref() { validate_graph_expr_aliases(expr, &aliases, &query.params)?; @@ -249,7 +288,7 @@ fn normalize_graph_row_query_with_internal_limits( for order in &query.order_by { validate_graph_order_expr(order, &aliases, &query.params)?; } - validate_graph_row_required_connectivity(query)?; + validate_graph_row_required_connectivity(query, &aliases.external_node_aliases)?; let mut return_items = match query.return_items.as_ref() { Some(items) => { @@ -298,7 +337,10 @@ fn normalize_graph_row_query_with_internal_limits( validate_graph_return_projection(&item.projection, &query.output, expr_kind)?; columns.push(graph_return_column_name(item)?); } - let binding_schema = build_graph_row_binding_schema(&aliases, &query.pieces)?; + let binding_schema = + build_graph_row_binding_schema(&aliases, &query.pieces, input_schema)?; + let initial_bound_slots = + graph_row_initial_bound_slots(input_schema, &binding_schema); let bound_return_items = crate::graph_row::bind_graph_return_items(&binding_schema, &return_items)?; let bound_order_by = crate::graph_row::bind_graph_order_items(&binding_schema, &resolved_order_by)?; let bound_where = resolved_where @@ -322,6 +364,7 @@ fn normalize_graph_row_query_with_internal_limits( Ok(NormalizedGraphRowQuery { binding_schema, + initial_bound_slots, nodes: query.nodes.clone(), pieces: resolved_pieces, fixed_paths: fixed_paths.to_vec(), @@ -345,6 +388,51 @@ fn normalize_graph_row_query_with_internal_limits( }) } +fn graph_row_initial_bound_slots( + input_schema: Option<&crate::graph_row::GraphBindingSchema>, + binding_schema: &crate::graph_row::GraphBindingSchema, +) -> Vec { + let Some(input_schema) = input_schema else { + return Vec::new(); + }; + let mut slots = input_schema + .slots() + .iter() + .filter_map(|slot| { + if let Some(alias) = slot.user_alias.as_ref() { + binding_schema.slot_for_alias(alias) + } else { + graph_row_internal_scalar_slot(binding_schema, slot) + } + }) + .collect::>(); + slots.sort_unstable(); + slots.dedup(); + slots +} + +fn graph_row_internal_scalar_slot( + schema: &crate::graph_row::GraphBindingSchema, + source: &crate::graph_row::GraphBindingSlot, +) -> Option { + if source.kind != crate::graph_row::GraphBindingSlotKind::Scalar || source.user_alias.is_some() { + return None; + } + schema.slots().iter().find_map(|slot| { + if slot.kind == crate::graph_row::GraphBindingSlotKind::Scalar + && slot.user_alias.is_none() + && slot.name == source.name + { + Some(crate::graph_row::GraphBindingSlotRef { + kind: slot.kind, + index: slot.index, + }) + } else { + None + } + }) +} + fn collect_graph_row_referenced_params( query: &GraphRowQuery, ) -> Result, EngineError> { @@ -409,6 +497,16 @@ fn collect_graph_expr_param_names(expr: &GraphExpr, names: &mut BTreeSet collect_graph_expr_param_names(arg, names); } } + GraphExpr::AggregateCall { arg, .. } => { + if let Some(arg) = arg { + collect_graph_expr_param_names(arg, names); + } + } + GraphExpr::ExistsSubquery(stage) => { + for stage in &stage.query.stages { + collect_graph_pipeline_stage_param_names(stage, names); + } + } GraphExpr::Unary { expr, .. } | GraphExpr::IsNull(expr) | GraphExpr::IsNotNull(expr) => { collect_graph_expr_param_names(expr, names); } @@ -416,6 +514,22 @@ fn collect_graph_expr_param_names(expr: &GraphExpr, names: &mut BTreeSet collect_graph_expr_param_names(left, names); collect_graph_expr_param_names(right, names); } + GraphExpr::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand { + collect_graph_expr_param_names(operand, names); + } + for branch in branches { + collect_graph_expr_param_names(&branch.when, names); + collect_graph_expr_param_names(&branch.then, names); + } + if let Some(else_expr) = else_expr { + collect_graph_expr_param_names(else_expr, names); + } + } GraphExpr::Null | GraphExpr::Bool(_) | GraphExpr::Int(_) @@ -431,7 +545,10 @@ fn collect_graph_expr_param_names(expr: &GraphExpr, names: &mut BTreeSet } } -fn validate_graph_row_required_connectivity(query: &GraphRowQuery) -> Result<(), EngineError> { +fn validate_graph_row_required_connectivity( + query: &GraphRowQuery, + external_bound_nodes: &HashSet, +) -> Result<(), EngineError> { let required_edges = query .pieces .iter() @@ -483,6 +600,9 @@ fn validate_graph_row_required_connectivity(query: &GraphRowQuery) -> Result<(), } for node in &query.nodes { + if external_bound_nodes.contains(&node.alias) { + continue; + } if !visited.contains(node.alias.as_str()) { return Err(EngineError::InvalidOperation( "graph row required fixed patterns must be connected".to_string(), @@ -550,6 +670,26 @@ fn resolve_graph_expr_params( .map(|arg| resolve_graph_expr_params(arg, params)) .collect::, _>>()?, }, + GraphExpr::AggregateCall { + function, + distinct, + arg, + } => GraphExpr::AggregateCall { + function: *function, + distinct: *distinct, + arg: arg + .as_ref() + .map(|arg| resolve_graph_expr_params(arg, params).map(Box::new)) + .transpose()?, + }, + GraphExpr::ExistsSubquery(stage) => { + let mut query = (*stage.query).clone(); + query.params = params.clone(); + GraphExpr::ExistsSubquery(GraphSubqueryStage { + query: Box::new(query), + import_aliases: stage.import_aliases.clone(), + }) + } GraphExpr::Unary { op, expr } => GraphExpr::Unary { op: *op, expr: Box::new(resolve_graph_expr_params(expr, params)?), @@ -559,6 +699,29 @@ fn resolve_graph_expr_params( op: *op, right: Box::new(resolve_graph_expr_params(right, params)?), }, + GraphExpr::Case { + operand, + branches, + else_expr, + } => GraphExpr::Case { + operand: operand + .as_ref() + .map(|operand| resolve_graph_expr_params(operand, params).map(Box::new)) + .transpose()?, + branches: branches + .iter() + .map(|branch| { + Ok(GraphCaseBranch { + when: resolve_graph_expr_params(&branch.when, params)?, + then: resolve_graph_expr_params(&branch.then, params)?, + }) + }) + .collect::, EngineError>>()?, + else_expr: else_expr + .as_ref() + .map(|else_expr| resolve_graph_expr_params(else_expr, params).map(Box::new)) + .transpose()?, + }, GraphExpr::IsNull(inner) => { GraphExpr::IsNull(Box::new(resolve_graph_expr_params(inner, params)?)) } @@ -637,9 +800,10 @@ pub(crate) fn validate_graph_row_page( fn validate_graph_row_anchors( query: &GraphRowQuery, edge_id_constraints: &BTreeMap>, + external_bound_nodes: &HashSet, ) -> Result<(), EngineError> { if query.pieces.is_empty() { - return validate_graph_row_no_piece_anchors(query); + return validate_graph_row_no_piece_anchors(query, external_bound_nodes); } if query.options.allow_full_scan { @@ -651,7 +815,9 @@ fn validate_graph_row_anchors( .iter() .map(|node| (node.alias.as_str(), node)) .collect(); - let mut anchors = GraphRowAnchorState::default(); + let mut anchors = GraphRowAnchorState { + bound_nodes: external_bound_nodes.clone(), + }; for node in &query.nodes { if graph_node_pattern_has_structural_anchor(node) { anchors.bound_nodes.insert(node.alias.clone()); @@ -664,17 +830,25 @@ fn validate_graph_row_anchors( Ok(()) } -fn validate_graph_row_no_piece_anchors(query: &GraphRowQuery) -> Result<(), EngineError> { - if query.nodes.len() > 1 { +fn validate_graph_row_no_piece_anchors( + query: &GraphRowQuery, + external_bound_nodes: &HashSet, +) -> Result<(), EngineError> { + let unbound_nodes = query + .nodes + .iter() + .filter(|node| !external_bound_nodes.contains(&node.alias)) + .collect::>(); + if unbound_nodes.len() > 1 { return Err(EngineError::InvalidOperation( "graph row queries with multiple unconnected node aliases are out of scope" .to_string(), )); } - if query.options.allow_full_scan || query.nodes.is_empty() { + if query.options.allow_full_scan || unbound_nodes.is_empty() { return Ok(()); } - let Some(node) = query.nodes.first() else { + let Some(node) = unbound_nodes.first() else { return Ok(()); }; if graph_node_pattern_has_structural_anchor(node) { @@ -874,8 +1048,27 @@ fn collect_graph_row_node_aliases( query: &GraphRowQuery, aliases: &mut GraphRowAliasState, ) -> Result<(), EngineError> { + let mut query_seen = HashSet::new(); for node in &query.nodes { validate_graph_alias("node", &node.alias)?; + if !query_seen.insert(node.alias.clone()) { + return Err(EngineError::InvalidOperation(format!( + "graph row node alias '{}' is introduced more than once", + node.alias + ))); + } + if aliases.edge_aliases.contains(&node.alias) + || aliases.path_aliases.contains(&node.alias) + || aliases.scalar_aliases.contains(&node.alias) + { + return Err(EngineError::InvalidOperation(format!( + "graph row node alias '{}' collides with an existing non-node alias", + node.alias + ))); + } + if aliases.external_node_aliases.contains(&node.alias) { + continue; + } if !aliases.node_aliases.insert(node.alias.clone()) { return Err(EngineError::InvalidOperation(format!( "graph row node alias '{}' is introduced more than once", @@ -887,6 +1080,70 @@ fn collect_graph_row_node_aliases( Ok(()) } +fn seed_graph_row_aliases_from_input_schema( + input_schema: &crate::graph_row::GraphBindingSchema, + aliases: &mut GraphRowAliasState, +) -> Result<(), EngineError> { + for slot in input_schema.slots() { + let Some(alias) = slot.user_alias.as_ref() else { + continue; + }; + match slot.kind { + crate::graph_row::GraphBindingSlotKind::Node => { + validate_graph_alias("node", alias)?; + aliases.node_aliases.insert(alias.clone()); + aliases.external_node_aliases.insert(alias.clone()); + aliases.node_first_scope.insert( + alias.clone(), + if slot.nullable { + GraphAliasScope::Optional + } else { + GraphAliasScope::Required + }, + ); + aliases.node_order.push(alias.clone()); + } + crate::graph_row::GraphBindingSlotKind::Edge => { + validate_graph_alias("edge", alias)?; + aliases.edge_aliases.insert(alias.clone()); + aliases.edge_first_scope.insert( + alias.clone(), + if slot.nullable { + GraphAliasScope::Optional + } else { + GraphAliasScope::Required + }, + ); + if slot.nullable { + aliases.optional_alias_order.push(alias.clone()); + } else { + aliases.required_edge_order.push(alias.clone()); + } + } + crate::graph_row::GraphBindingSlotKind::Path => { + validate_graph_alias("path", alias)?; + aliases.path_aliases.insert(alias.clone()); + aliases.path_first_scope.insert( + alias.clone(), + if slot.nullable { + GraphAliasScope::Optional + } else { + GraphAliasScope::Required + }, + ); + aliases.path_order.push(alias.clone()); + } + crate::graph_row::GraphBindingSlotKind::Scalar => { + validate_graph_alias("scalar", alias)?; + aliases.scalar_aliases.insert(alias.clone()); + aliases.scalar_order.push(alias.clone()); + } + crate::graph_row::GraphBindingSlotKind::HiddenOccurrence => {} + } + } + Ok(()) +} + fn collect_graph_row_piece_aliases( piece: &GraphPatternPiece, scope: GraphAliasScope, @@ -984,6 +1241,11 @@ fn collect_graph_row_edge_alias( "graph row edge alias '{alias}' collides with a node alias" ))); } + if aliases.scalar_aliases.contains(alias) { + return Err(EngineError::InvalidOperation(format!( + "graph row edge alias '{alias}' collides with a scalar alias" + ))); + } if aliases.path_aliases.contains(alias) { return Err(EngineError::InvalidOperation(format!( "graph row edge alias '{alias}' collides with a path alias" @@ -1010,9 +1272,12 @@ fn collect_graph_row_path_alias( aliases: &mut GraphRowAliasState, ) -> Result<(), EngineError> { validate_graph_alias("path", alias)?; - if aliases.node_aliases.contains(alias) || aliases.edge_aliases.contains(alias) { + if aliases.node_aliases.contains(alias) + || aliases.edge_aliases.contains(alias) + || aliases.scalar_aliases.contains(alias) + { return Err(EngineError::InvalidOperation(format!( - "graph row path alias '{alias}' collides with a node or edge alias" + "graph row path alias '{alias}' collides with a node, edge, or scalar alias" ))); } if !aliases.path_aliases.insert(alias.to_string()) { @@ -1228,6 +1493,8 @@ impl GraphRowVisibleAliases { node_aliases: self.node_aliases.clone(), edge_aliases: self.edge_aliases.clone(), path_aliases: self.path_aliases.clone(), + scalar_aliases: HashSet::new(), + external_node_aliases: HashSet::new(), node_first_scope: HashMap::new(), edge_first_scope: HashMap::new(), path_first_scope: HashMap::new(), @@ -1235,6 +1502,7 @@ impl GraphRowVisibleAliases { required_edge_order: Vec::new(), optional_alias_order: Vec::new(), path_order: Vec::new(), + scalar_order: Vec::new(), } } } @@ -1301,6 +1569,10 @@ fn graph_expr_kind( Err(EngineError::InvalidOperation(format!( "graph row property expression cannot reference path alias '{alias}'" ))) + } else if aliases.scalar_aliases.contains(alias) { + Err(EngineError::InvalidOperation(format!( + "graph row property expression requires a node or edge alias, got scalar alias '{alias}'" + ))) } else { Err(unknown_graph_alias(alias)) } @@ -1315,7 +1587,10 @@ fn graph_expr_kind( | GraphNodeField::CreatedAt | GraphNodeField::UpdatedAt => GraphExprKind::Scalar, }) - } else if aliases.edge_aliases.contains(alias) || aliases.path_aliases.contains(alias) { + } else if aliases.edge_aliases.contains(alias) + || aliases.path_aliases.contains(alias) + || aliases.scalar_aliases.contains(alias) + { Err(EngineError::InvalidOperation(format!( "graph row node field references non-node alias '{alias}'" ))) @@ -1326,7 +1601,10 @@ fn graph_expr_kind( GraphExpr::EdgeField { alias, .. } => { if aliases.edge_aliases.contains(alias) { Ok(GraphExprKind::Scalar) - } else if aliases.node_aliases.contains(alias) || aliases.path_aliases.contains(alias) { + } else if aliases.node_aliases.contains(alias) + || aliases.path_aliases.contains(alias) + || aliases.scalar_aliases.contains(alias) + { Err(EngineError::InvalidOperation(format!( "graph row edge field references non-edge alias '{alias}'" ))) @@ -1340,7 +1618,10 @@ fn graph_expr_kind( GraphPathField::NodeIds | GraphPathField::EdgeIds => GraphExprKind::List, GraphPathField::Length => GraphExprKind::Scalar, }) - } else if aliases.node_aliases.contains(alias) || aliases.edge_aliases.contains(alias) { + } else if aliases.node_aliases.contains(alias) + || aliases.edge_aliases.contains(alias) + || aliases.scalar_aliases.contains(alias) + { Err(EngineError::InvalidOperation(format!( "graph row path field references non-path alias '{alias}'" ))) @@ -1349,19 +1630,59 @@ fn graph_expr_kind( } } GraphExpr::Function { name, args } => graph_function_expr_kind(*name, args, aliases, params), - GraphExpr::Unary { expr, .. } => { - graph_expr_kind(expr, aliases, params)?; + GraphExpr::AggregateCall { .. } => Err(EngineError::InvalidOperation( + "aggregate expressions require graph pipeline projection execution".to_string(), + )), + GraphExpr::ExistsSubquery(_) => Err(EngineError::InvalidOperation( + "EXISTS subqueries require graph pipeline predicate execution".to_string(), + )), + GraphExpr::Unary { op, expr } => { + let kind = graph_expr_kind(expr, aliases, params)?; + graph_require_scalar_operand(graph_unary_operator_name(*op), kind)?; Ok(GraphExprKind::Scalar) } GraphExpr::IsNull(expr) | GraphExpr::IsNotNull(expr) => { graph_expr_kind(expr, aliases, params)?; Ok(GraphExprKind::Scalar) } - GraphExpr::Binary { left, right, .. } => { - graph_expr_kind(left, aliases, params)?; - graph_expr_kind(right, aliases, params)?; + GraphExpr::Binary { left, op, right } => { + let left_kind = graph_expr_kind(left, aliases, params)?; + let right_kind = graph_expr_kind(right, aliases, params)?; + validate_graph_binary_operand_kinds(*op, left_kind, right_kind)?; Ok(GraphExprKind::Scalar) } + GraphExpr::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand { + graph_expr_kind(operand, aliases, params)?; + } + let mut result_kind = None; + for branch in branches { + let when_kind = graph_expr_kind(&branch.when, aliases, params)?; + if operand.is_none() { + graph_require_scalar_operand("CASE WHEN", when_kind)?; + } + result_kind = Some(graph_merge_case_result_kind( + result_kind, + graph_expr_kind(&branch.then, aliases, params)?, + )); + } + if let Some(else_expr) = else_expr { + result_kind = Some(graph_merge_case_result_kind( + result_kind, + graph_expr_kind(else_expr, aliases, params)?, + )); + } else { + result_kind = Some(graph_merge_case_result_kind( + result_kind, + GraphExprKind::Scalar, + )); + } + Ok(result_kind.unwrap_or(GraphExprKind::Scalar)) + } } } @@ -1385,6 +1706,34 @@ fn graph_function_expr_kind( aliases: &GraphRowAliasState, params: &BTreeMap, ) -> Result { + if is_scalar_graph_function(name) { + validate_graph_scalar_function_arity(name, args.len())?; + for arg in args { + let arg_kind = graph_expr_kind(arg, aliases, params)?; + let invalid_arg = match name { + GraphFunction::Size => matches!( + arg_kind, + GraphExprKind::Node | GraphExprKind::Edge | GraphExprKind::Path + ), + _ => matches!( + arg_kind, + GraphExprKind::Node + | GraphExprKind::Edge + | GraphExprKind::Path + | GraphExprKind::NodeList + | GraphExprKind::EdgeList + ), + }; + if invalid_arg { + return Err(graph_function_kind_error( + name, + "scalar, list, map, or null input", + arg_kind, + )); + } + } + return Ok(GraphExprKind::Scalar); + } if args.len() != 1 { return Err(EngineError::InvalidOperation(format!( "graph row function {} expects exactly one argument", @@ -1416,7 +1765,68 @@ fn graph_function_expr_kind( | GraphFunction::Relationships => { Err(graph_function_kind_error(name, "a path", arg_kind)) } + _ => Err(EngineError::InvalidOperation(format!( + "graph row function {} is not supported in graph-row single-block expressions", + graph_function_name(name) + ))), + } +} + +fn validate_graph_scalar_function_arity( + name: GraphFunction, + arg_count: usize, +) -> Result<(), EngineError> { + let valid = match name { + GraphFunction::Coalesce => arg_count >= 1, + GraphFunction::Substring => matches!(arg_count, 2 | 3), + GraphFunction::ToString + | GraphFunction::ToInteger + | GraphFunction::ToFloat + | GraphFunction::Abs + | GraphFunction::Floor + | GraphFunction::Ceil + | GraphFunction::Round + | GraphFunction::Lower + | GraphFunction::Upper + | GraphFunction::Trim + | GraphFunction::Size + | GraphFunction::Head + | GraphFunction::Last => arg_count == 1, + _ => false, + }; + if valid { + return Ok(()); } + let expected = match name { + GraphFunction::Coalesce => "at least one argument", + GraphFunction::Substring => "two or three arguments", + _ => "exactly one argument", + }; + Err(EngineError::InvalidOperation(format!( + "graph row function {} expects {expected}", + graph_function_name(name) + ))) +} + +fn is_scalar_graph_function(name: GraphFunction) -> bool { + matches!( + name, + GraphFunction::Coalesce + | GraphFunction::ToString + | GraphFunction::ToInteger + | GraphFunction::ToFloat + | GraphFunction::Abs + | GraphFunction::Floor + | GraphFunction::Ceil + | GraphFunction::Round + | GraphFunction::Lower + | GraphFunction::Upper + | GraphFunction::Trim + | GraphFunction::Substring + | GraphFunction::Size + | GraphFunction::Head + | GraphFunction::Last + ) } fn graph_list_expr_kind(item_kinds: Vec) -> Result { @@ -1445,6 +1855,93 @@ fn graph_expr_kind_is_list_or_map(kind: GraphExprKind) -> bool { ) } +fn validate_graph_binary_operand_kinds( + op: GraphBinaryOp, + left: GraphExprKind, + right: GraphExprKind, +) -> Result<(), EngineError> { + match op { + GraphBinaryOp::And + | GraphBinaryOp::Or + | GraphBinaryOp::Lt + | GraphBinaryOp::Le + | GraphBinaryOp::Gt + | GraphBinaryOp::Ge + | GraphBinaryOp::Add + | GraphBinaryOp::Sub + | GraphBinaryOp::Mul + | GraphBinaryOp::Div + | GraphBinaryOp::StartsWith + | GraphBinaryOp::EndsWith + | GraphBinaryOp::Contains => { + let operator = graph_binary_operator_name(op); + graph_require_scalar_operand(operator, left)?; + graph_require_scalar_operand(operator, right) + } + GraphBinaryOp::Eq | GraphBinaryOp::Neq | GraphBinaryOp::In => Ok(()), + } +} + +fn graph_require_scalar_operand(operator: &str, actual: GraphExprKind) -> Result<(), EngineError> { + if actual == GraphExprKind::Scalar { + return Ok(()); + } + Err(EngineError::InvalidOperation(format!( + "graph row operator {operator} expects scalar operands, got {}", + graph_expr_kind_name(actual) + ))) +} + +fn graph_merge_case_result_kind( + current: Option, + next: GraphExprKind, +) -> GraphExprKind { + let Some(current) = current else { + return next; + }; + if current == next { + return current; + } + if graph_expr_kind_is_list_or_map(current) { + return current; + } + if graph_expr_kind_is_list_or_map(next) { + return next; + } + if current != GraphExprKind::Scalar { + return current; + } + next +} + +fn graph_unary_operator_name(op: GraphUnaryOp) -> &'static str { + match op { + GraphUnaryOp::Not => "NOT", + GraphUnaryOp::Neg => "-", + } +} + +fn graph_binary_operator_name(op: GraphBinaryOp) -> &'static str { + match op { + GraphBinaryOp::Or => "OR", + GraphBinaryOp::And => "AND", + GraphBinaryOp::Eq => "=", + GraphBinaryOp::Neq => "<>", + GraphBinaryOp::Lt => "<", + GraphBinaryOp::Le => "<=", + GraphBinaryOp::Gt => ">", + GraphBinaryOp::Ge => ">=", + GraphBinaryOp::In => "IN", + GraphBinaryOp::Add => "+", + GraphBinaryOp::Sub => "-", + GraphBinaryOp::Mul => "*", + GraphBinaryOp::Div => "/", + GraphBinaryOp::StartsWith => "STARTS WITH", + GraphBinaryOp::EndsWith => "ENDS WITH", + GraphBinaryOp::Contains => "CONTAINS", + } +} + fn graph_function_kind_error( name: GraphFunction, expected: &str, @@ -1468,6 +1965,21 @@ fn graph_function_name(name: GraphFunction) -> &'static str { GraphFunction::EndNode => "end_node", GraphFunction::Nodes => "nodes", GraphFunction::Relationships => "relationships", + GraphFunction::Coalesce => "coalesce", + GraphFunction::ToString => "to_string", + GraphFunction::ToInteger => "to_integer", + GraphFunction::ToFloat => "to_float", + GraphFunction::Abs => "abs", + GraphFunction::Floor => "floor", + GraphFunction::Ceil => "ceil", + GraphFunction::Round => "round", + GraphFunction::Lower => "lower", + GraphFunction::Upper => "upper", + GraphFunction::Trim => "trim", + GraphFunction::Substring => "substring", + GraphFunction::Size => "size", + GraphFunction::Head => "head", + GraphFunction::Last => "last", } } @@ -1494,6 +2006,8 @@ fn graph_binding_alias_kind( Ok(GraphExprKind::Edge) } else if aliases.path_aliases.contains(alias) { Ok(GraphExprKind::Path) + } else if aliases.scalar_aliases.contains(alias) { + Ok(GraphExprKind::Scalar) } else { Err(unknown_graph_alias(alias)) } @@ -1521,6 +2035,9 @@ fn expand_graph_row_return_star( for alias in &aliases.optional_alias_order { items.push(graph_return_binding(alias)); } + for alias in &aliases.scalar_order { + items.push(graph_return_binding(alias)); + } if items.is_empty() { return Err(EngineError::InvalidOperation( "graph row RETURN * requires at least one user-visible alias".to_string(), @@ -1532,24 +2049,64 @@ fn expand_graph_row_return_star( fn build_graph_row_binding_schema( aliases: &GraphRowAliasState, pieces: &[GraphPatternPiece], + input_schema: Option<&crate::graph_row::GraphBindingSchema>, ) -> Result { let mut schema = crate::graph_row::GraphBindingSchema::new(); + if let Some(input_schema) = input_schema { + for slot in input_schema.slots() { + match (slot.user_alias.as_ref(), slot.kind) { + (Some(alias), crate::graph_row::GraphBindingSlotKind::Node) => { + schema.add_node_alias(alias.clone(), slot.nullable)?; + } + (Some(alias), crate::graph_row::GraphBindingSlotKind::Edge) => { + schema.add_edge_alias(alias.clone(), slot.nullable)?; + } + (Some(alias), crate::graph_row::GraphBindingSlotKind::Path) => { + schema.add_path_alias(alias.clone(), slot.nullable)?; + } + (Some(alias), crate::graph_row::GraphBindingSlotKind::Scalar) => { + schema.add_scalar_alias(alias.clone(), slot.nullable)?; + } + (None, crate::graph_row::GraphBindingSlotKind::Scalar) => { + schema.add_internal_scalar(slot.name.clone(), slot.nullable)?; + } + (_, crate::graph_row::GraphBindingSlotKind::HiddenOccurrence) | (None, _) => {} + } + } + } for alias in &aliases.node_order { + if schema.slot_for_alias(alias).is_some() { + continue; + } let nullable = aliases.node_first_scope.get(alias) == Some(&GraphAliasScope::Optional); schema.add_node_alias(alias.clone(), nullable)?; } for alias in &aliases.required_edge_order { + if schema.slot_for_alias(alias).is_some() { + continue; + } schema.add_edge_alias(alias.clone(), false)?; } for alias in &aliases.optional_alias_order { + if schema.slot_for_alias(alias).is_some() { + continue; + } if aliases.edge_aliases.contains(alias) { schema.add_edge_alias(alias.clone(), true)?; } } for alias in &aliases.path_order { + if schema.slot_for_alias(alias).is_some() { + continue; + } let nullable = aliases.path_first_scope.get(alias) == Some(&GraphAliasScope::Optional); schema.add_path_alias(alias.clone(), nullable)?; } + for alias in &aliases.scalar_order { + if schema.slot_for_alias(alias).is_none() { + schema.add_scalar_alias(alias.clone(), true)?; + } + } add_hidden_occurrence_slots(pieces, &mut schema, &mut 0, false)?; Ok(schema) } diff --git a/src/engine/read.rs b/src/engine/read.rs index 3a2369e..99cbb40 100644 --- a/src/engine/read.rs +++ b/src/engine/read.rs @@ -666,7 +666,7 @@ impl ReadView { let segment_candidates = self.filter_node_ids_by_current_label(segment_candidates, label_id)?; let batch_results = self.get_nodes_raw(&segment_candidates)?; - for (id, node) in segment_candidates.into_iter().zip(batch_results.into_iter()) { + for (id, node) in segment_candidates.into_iter().zip(batch_results) { if Self::ready_equality_node_matches(node.as_ref(), label_id, prop_key, prop_value) { visible.insert(id); } @@ -1889,7 +1889,7 @@ impl ReadView { let node_ids: Vec = pending.iter().map(|&(_, node_id)| node_id).collect(); let hydrated = self.get_nodes_raw(&node_ids)?; - for ((candidate_key, node_id), node) in pending.iter().zip(hydrated.into_iter()) { + for ((candidate_key, node_id), node) in pending.iter().zip(hydrated) { if visible.len() >= target_visible { break; } @@ -3447,7 +3447,7 @@ impl ReadView { let visibility = self.sources().find_node_visibility_meta(&ids)?; let mut filtered = Vec::with_capacity(ids.len()); - for (node_id, state) in ids.into_iter().zip(visibility.into_iter()) { + for (node_id, state) in ids.into_iter().zip(visibility) { if matches!( state, NodeVisibilityState::Live(meta) if meta.label_ids.contains(label_id) @@ -3473,7 +3473,7 @@ impl ReadView { let visibility = self.sources().find_node_visibility_meta(&ids)?; let mut filtered = Vec::with_capacity(ids.len()); - for (node_id, state) in ids.into_iter().zip(visibility.into_iter()) { + for (node_id, state) in ids.into_iter().zip(visibility) { if matches!( state, NodeVisibilityState::Live(meta) diff --git a/src/engine/tests/gql_execution.rs b/src/engine/tests/gql_execution.rs index 7acf8a6..31a5f89 100644 --- a/src/engine/tests/gql_execution.rs +++ b/src/engine/tests/gql_execution.rs @@ -139,6 +139,25 @@ fn gql_string_column(result: &GqlExecutionResult, index: usize) -> Vec { .collect() } +fn gql_u64_or_i64_values(result: &GqlExecutionResult, index: usize) -> Vec { + result + .rows + .iter() + .map(|row| match &row.values[index] { + GqlValue::Int(value) => value.to_string(), + GqlValue::UInt(value) => value.to_string(), + GqlValue::Float(value) => value.to_string(), + other => panic!("expected numeric column, got {other:?}"), + }) + .collect() +} + +fn return_star_id_rows(a: u64, b: u64) -> Vec { + vec![GqlRow { + values: vec![GqlValue::UInt(a), GqlValue::UInt(b)], + }] +} + fn gql_single_node(value: &GqlValue) -> &GqlNode { match value { GqlValue::Node(node) => node, @@ -170,6 +189,13 @@ fn gql_execution_options_default_matches_spec() { assert_eq!(options.max_cursor_bytes, 16 * 1024); assert_eq!(options.max_mutation_rows, 10_000); assert_eq!(options.max_mutation_ops, 50_000); + assert_eq!(options.max_pipeline_rows, 65_536); + assert_eq!(options.max_groups, 65_536); + assert_eq!(options.max_collect_items, 65_536); + assert_eq!(options.max_union_branches, 16); + assert_eq!(options.max_subquery_invocations, 4_096); + assert_eq!(options.max_subquery_depth, 2); + assert_eq!(options.max_shortest_path_pairs, 4_096); assert_eq!(options.max_query_bytes, 1_048_576); assert_eq!(options.max_param_bytes, 1_048_576); assert_eq!(options.max_ast_depth, 256); @@ -379,6 +405,7 @@ fn mutation_errors_surface_before_execution_validation() { &GqlParams::new(), &GqlExecutionOptions { allow_full_scan: true, + profile: true, ..gql_opts() }, ) @@ -572,475 +599,394 @@ fn gql_create_node_properties_are_visible_to_gql_indexed_reads() { } #[test] -fn gql_create_node_strict_duplicates_reject_before_write() { +fn gql_merge_node_creates_matches_duplicates_and_actions_are_atomic() { let (_dir, engine) = query_test_engine(); - insert_query_node(&engine, "Person", "gql-create-existing", &[], 1.0); - let visible = engine + let created = engine .execute_gql( - "CREATE (n:Person {key: 'gql-create-existing', name: 'new'}) RETURN n", + "MERGE (n:GqlMergeNode {key: 'n'}) ON CREATE SET n.status = 'created' ON MATCH SET n.status = 'matched' RETURN id(n), n.status", &GqlParams::new(), - &gql_opts(), + &GqlExecutionOptions { + profile: true, + ..gql_opts() + }, ) - .unwrap_err(); - assert!(matches!(visible, EngineError::InvalidOperation(message) if message.contains("already exists"))); - assert_eq!( - engine - .get_node_by_key("Person", "gql-create-existing") - .unwrap() - .unwrap() - .props - .get("name"), - None - ); + .unwrap(); + let created_id = gql_u64_column(&created, 0)[0]; + assert_eq!(created.rows[0].values[1], GqlValue::String("created".to_string())); + let stats = created.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.nodes_created, 1); + assert_eq!(stats.nodes_updated, 0); + assert!(stats.db_hits >= 1); - let duplicate = engine + let matched = engine .execute_gql( - "CREATE (a:GqlCreateDup {key: 'dup'}), (b:GqlCreateDup {key: 'dup'}) RETURN a", + "MERGE (n:GqlMergeNode {key: 'n'}) ON CREATE SET n.status = 'created-again' ON MATCH SET n.status = 'matched' RETURN id(n), n.status", &GqlParams::new(), &gql_opts(), ) - .unwrap_err(); - assert!(matches!(duplicate, EngineError::InvalidOperation(message) if message.contains("duplicate node CREATE target"))); - assert!(engine - .get_node_by_key("GqlCreateDup", "dup") - .unwrap() - .is_none()); + .unwrap(); + assert_eq!(gql_u64_column(&matched, 0), vec![created_id]); + assert_eq!(matched.rows[0].values[1], GqlValue::String("matched".to_string())); + let stats = matched.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.nodes_created, 0); + assert_eq!(stats.nodes_updated, 1); insert_query_node( &engine, - "GqlCreateFinalConflict", - "final-key", - &[("name", PropValue::String("old".to_string()))], - 1.0, - ); - let final_label_visible = engine - .execute_gql( - "CREATE (n:GqlCreateInitialOnly {key: 'final-key', name: 'new'}) SET n:GqlCreateFinalConflict RETURN n", - &GqlParams::new(), - &gql_opts(), - ) - .unwrap_err(); - assert!(matches!(final_label_visible, EngineError::InvalidOperation(message) if message.contains("already exists"))); - assert_eq!( - engine - .get_node_by_key("GqlCreateFinalConflict", "final-key") - .unwrap() - .unwrap() - .props - .get("name"), - Some(&PropValue::String("old".to_string())) - ); - assert!(engine - .get_node_by_key("GqlCreateInitialOnly", "final-key") - .unwrap() - .is_none()); - - let final_label_duplicate = engine - .execute_gql( - "CREATE (a:GqlCreateFinalLeft {key: 'final-dup'}), (b:GqlCreateFinalRight {key: 'final-dup'}) SET a:GqlCreateFinalShared SET b:GqlCreateFinalShared RETURN a", - &GqlParams::new(), - &gql_opts(), - ) - .unwrap_err(); - assert!(matches!(final_label_duplicate, EngineError::InvalidOperation(message) if message.contains("duplicate node CREATE target"))); - assert!(engine - .get_node_by_key("GqlCreateFinalShared", "final-dup") - .unwrap() - .is_none()); - assert!(engine - .get_node_by_key("GqlCreateFinalLeft", "final-dup") - .unwrap() - .is_none()); - assert!(engine - .get_node_by_key("GqlCreateFinalRight", "final-dup") - .unwrap() - .is_none()); - - let existing_old = insert_query_node( - &engine, - "GqlCreateRemovedOld", - "final-free", - &[("name", PropValue::String("old".to_string()))], + "GqlMergeCounter", + "n", + &[("count", PropValue::Int(1))], 1.0, ); - let final_removed_old = engine + let incremented = engine .execute_gql( - "CREATE (n:GqlCreateRemovedOld {key: 'final-free', name: 'new'}) SET n:GqlCreateFinalNew REMOVE n:GqlCreateRemovedOld RETURN id(n), labels(n)", + "MERGE (n:GqlMergeCounter {key: 'n'}) ON MATCH SET n.count = n.count + 1 RETURN n.count", &GqlParams::new(), &gql_opts(), ) .unwrap(); - let final_removed_old_id = match final_removed_old.rows[0].values[0] { - GqlValue::UInt(id) => id, - ref other => panic!("expected created id, got {other:?}"), - }; - assert_ne!(final_removed_old_id, existing_old); - assert_eq!( - engine - .get_node_by_key("GqlCreateRemovedOld", "final-free") - .unwrap() - .unwrap() - .id, - existing_old - ); - let final_new = engine - .get_node_by_key("GqlCreateFinalNew", "final-free") + assert_eq!(incremented.rows[0].values[0], GqlValue::Int(2)); + let stored_counter = engine + .get_node_by_key("GqlMergeCounter", "n") .unwrap() .unwrap(); - assert_eq!(final_new.id, final_removed_old_id); - assert!(!final_new - .labels - .contains(&"GqlCreateRemovedOld".to_string())); + assert_eq!(stored_counter.props.get("count"), Some(&PropValue::Int(2))); insert_query_node( &engine, - "GqlCreateSeed", - "seed-a", - &[("target", PropValue::String("same".to_string()))], + "GqlMergeSource", + "a", + &[ + ("target", PropValue::String("dup".to_string())), + ("rank", PropValue::Int(1)), + ], 1.0, ); insert_query_node( &engine, - "GqlCreateSeed", - "seed-b", - &[("target", PropValue::String("same".to_string()))], + "GqlMergeSource", + "b", + &[ + ("target", PropValue::String("dup".to_string())), + ("rank", PropValue::Int(2)), + ], 1.0, ); - let multi_row = engine + let duplicate = engine .execute_gql( - "MATCH (s:GqlCreateSeed) CREATE (n:GqlCreateRollback {key: s.target}) RETURN n", + "MATCH (s:GqlMergeSource) MERGE (n:GqlMergeDupNode {key: s.target}) \ + ON CREATE SET n.status = 'created', n.rank = s.rank \ + ON MATCH SET n.status = 'matched', n.rank = s.rank \ + RETURN n.status, n.rank ORDER BY s.rank", &GqlParams::new(), &GqlExecutionOptions { allow_full_scan: true, + profile: true, ..gql_opts() }, ) - .unwrap_err(); - assert!(matches!(multi_row, EngineError::InvalidOperation(message) if message.contains("duplicate node CREATE target"))); - assert!(engine - .get_node_by_key("GqlCreateRollback", "same") + .unwrap(); + assert_eq!(duplicate.rows.len(), 2); + let stats = duplicate.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.nodes_created, 1); + assert_eq!(stats.mutation_rows, 2); + assert_eq!(stats.duplicate_targets, 3); + assert!(stats.db_hits >= 1); + let stored = engine + .get_node_by_key("GqlMergeDupNode", "dup") .unwrap() - .is_none()); -} - -#[test] -fn gql_create_node_rejects_prune_hidden_existing_key_before_write() { - let (_dir, engine) = query_test_engine(); - insert_query_node( - &engine, - "GqlPruneHiddenCreate", - "hidden", - &[("source", PropValue::String("old".to_string()))], - 0.1, + .unwrap(); + assert_eq!( + stored.props.get("status"), + Some(&PropValue::String("matched".to_string())) ); - engine - .set_prune_policy( - "gql-hide-create-target", - PrunePolicy { - max_age_ms: None, - max_weight: Some(0.5), - label: Some("GqlPruneHiddenCreate".to_string()), + assert_eq!(stored.props.get("rank"), Some(&PropValue::Int(2))); + + let distinct = engine + .execute_gql( + "MATCH (s:GqlMergeSource) MERGE (n:GqlMergeDistinctNode {key: s.target}) RETURN DISTINCT n", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + profile: true, + ..gql_opts() }, ) .unwrap(); - assert!(engine - .get_node_by_key("GqlPruneHiddenCreate", "hidden") + assert_eq!(distinct.rows.len(), 1); + match &distinct.rows[0].values[0] { + GqlValue::Node(node) => assert_eq!(node.key.as_deref(), Some("dup")), + other => panic!("expected distinct MERGE node return, got {other:?}"), + } + let stats = distinct.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.nodes_created, 1); + assert_eq!(stats.mutation_rows, 1); + + let local_counter = engine + .execute_gql( + "MATCH (s:GqlMergeSource) WITH s, s.rank AS delta \ + MERGE (n:GqlMergeLocalCounter {key: s.target}) \ + ON CREATE SET n.count = delta \ + ON MATCH SET n.count = n.count + delta \ + RETURN n.count ORDER BY s.rank", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + ..gql_opts() + }, + ) + .unwrap(); + assert_eq!(local_counter.rows.len(), 2); + let stored_counter = engine + .get_node_by_key("GqlMergeLocalCounter", "dup") .unwrap() - .is_none()); + .unwrap(); + assert_eq!(stored_counter.props.get("count"), Some(&PropValue::Int(3))); - let hidden_duplicate = engine + let empty_key = engine .execute_gql( - "CREATE (n:GqlPruneHiddenCreate {key: 'hidden', name: 'new'}) RETURN n", + "MERGE (n:GqlMergeEmptyKey {key: ''})", &GqlParams::new(), &gql_opts(), ) .unwrap_err(); - assert!(matches!(hidden_duplicate, EngineError::InvalidOperation(message) if message.contains("already exists"))); - - assert!(engine.remove_prune_policy("gql-hide-create-target").unwrap()); - let original = engine - .get_node_by_key("GqlPruneHiddenCreate", "hidden") - .unwrap() - .unwrap(); - assert_eq!( - original.props.get("source"), - Some(&PropValue::String("old".to_string())) + assert!( + matches!(empty_key, EngineError::InvalidOperation(message) if message.contains("non-empty string")) ); - assert!(!original.props.contains_key("name")); -} -#[test] -fn gql_create_invalid_node_metadata_and_property_values_reject_before_write() { - let (_dir, engine) = query_test_engine(); - - let bad_key = engine + let bad_action = engine .execute_gql( - "CREATE (n:GqlBadKey {key: 42, name: 'bad'}) RETURN n", - &GqlParams::new(), + "MERGE (n:GqlMergeBadAction {key: 'n'}) ON CREATE SET n.bad = $bad", + &GqlParams::from([("bad".to_string(), GqlParamValue::Float(f64::NAN))]), &gql_opts(), ) .unwrap_err(); - assert!(matches!(bad_key, EngineError::InvalidOperation(message) if message.contains("key"))); + assert!(matches!(bad_action, EngineError::InvalidOperation(message) if message.contains("finite"))); assert!(engine - .get_node_by_key("GqlBadKey", "42") + .get_node_by_key("GqlMergeBadAction", "n") .unwrap() .is_none()); - let bad_weight = engine + let bad_local_metadata = engine .execute_gql( - "CREATE (n:GqlBadWeight {key: 'n', weight: 'heavy'}) RETURN n", + "MERGE (n:GqlMergeBadMetadata {key: 'n'}) ON CREATE SET n.source_id = id(n)", &GqlParams::new(), &gql_opts(), ) .unwrap_err(); - assert!( - matches!(bad_weight, EngineError::InvalidOperation(message) if message.contains("weight")) - ); + assert!(matches!( + bad_local_metadata, + EngineError::GqlSemantic { .. } + )); assert!(engine - .get_node_by_key("GqlBadWeight", "n") + .get_node_by_key("GqlMergeBadMetadata", "n") .unwrap() .is_none()); - let bad_property = engine + let bad_match_metadata = engine .execute_gql( - "CREATE (n:GqlBadProp {key: 'n', score: $bad}) RETURN n", - &GqlParams::from([("bad".to_string(), GqlParamValue::Float(f64::NAN))]), + "MERGE (n:GqlMergeBadMatchMetadata {key: 'n'}) ON MATCH SET n.source_id = id(n)", + &GqlParams::new(), &gql_opts(), ) .unwrap_err(); - assert!( - matches!(bad_property, EngineError::InvalidOperation(message) if message.contains("finite")) - ); + assert!(matches!(bad_match_metadata, EngineError::GqlSemantic { .. })); assert!(engine - .get_node_by_key("GqlBadProp", "n") + .get_node_by_key("GqlMergeBadMatchMetadata", "n") .unwrap() .is_none()); } #[test] -fn gql_create_edge_executes_for_matched_and_created_endpoints() { - let (_dir, engine) = query_test_engine(); - let a = insert_query_node(&engine, "Person", "gql-create-edge-a", &[], 1.0); - let b = insert_query_node(&engine, "Person", "gql-create-edge-b", &[], 1.0); - - let result = engine +fn gql_merge_node_caps_explain_indexes_and_reopen_preserve_atomicity() { + let (dir, engine) = query_test_engine(); + let db_path = dir.path().join("db"); + for key in ["a", "b"] { + insert_query_node(&engine, "GqlMergeCapSource", key, &[], 1.0); + } + let cap = engine .execute_gql( - "MATCH (a:Person) WHERE a.key = 'gql-create-edge-a' MATCH (b:Person) WHERE b.key = 'gql-create-edge-b' CREATE (a)-[r:Gql_CREATED {since: 2026, weight: 0.8, valid_from: 10, valid_to: 20}]->(b) RETURN r, id(r), r.since", + "MATCH (s:GqlMergeCapSource) MERGE (n:GqlMergeCapTarget {key: s.key})", &GqlParams::new(), - &gql_opts(), - ) - .unwrap(); - let edge_id = match result.rows[0].values[1] { - GqlValue::UInt(id) => id, - ref other => panic!("expected edge id UInt, got {other:?}"), - }; - assert_eq!(result.rows[0].values[2], GqlValue::Int(2026)); - let returned = gql_single_edge(&result.rows[0].values[0]); - assert_eq!(returned.id, Some(edge_id)); - assert_eq!(returned.from, Some(a)); - assert_eq!(returned.to, Some(b)); - let stored = engine.get_edge(edge_id).unwrap().unwrap(); - assert_eq!(stored.from, a); - assert_eq!(stored.to, b); - assert_eq!(stored.label, "Gql_CREATED"); - assert_eq!(stored.props.get("since"), Some(&PropValue::Int(2026))); - assert!(!stored.props.contains_key("weight")); - assert!(!stored.props.contains_key("valid_from")); - assert!(!stored.props.contains_key("valid_to")); - assert_eq!(stored.weight, 0.8); - assert_eq!(stored.valid_from, 10); - assert_eq!(stored.valid_to, 20); - - let chain = engine - .execute_gql( - "CREATE (a:GqlChain {key: 'a'})-[r:Gql_CHAIN {rank: 1}]->(b:GqlChain {key: 'b'}) RETURN id(a), id(r), id(b)", - &GqlParams::new(), - &gql_opts(), - ) - .unwrap(); - assert_eq!(chain.mutation_stats.as_ref().unwrap().nodes_created, 2); - assert_eq!(chain.mutation_stats.as_ref().unwrap().edges_created, 1); - let ids = gql_u64_column(&chain, 0); - assert_eq!(ids.len(), 1); - let edge_ids = gql_u64_column(&chain, 1); - let b_ids = gql_u64_column(&chain, 2); - let chain_edge = engine.get_edge(edge_ids[0]).unwrap().unwrap(); - assert_eq!(chain_edge.from, ids[0]); - assert_eq!(chain_edge.to, b_ids[0]); -} - -#[test] -fn gql_create_invalid_edge_validity_and_metadata_return_behaviors() { - let (_dir, engine) = query_test_engine(); - - let bad_valid_to = engine - .execute_gql( - "CREATE (a:GqlBadEdgeWindow {key: 'a'})-[r:Gql_BAD_WINDOW {valid_to: 0}]->(b:GqlBadEdgeWindow {key: 'b'}) RETURN r", - &GqlParams::new(), - &gql_opts(), + &GqlExecutionOptions { + allow_full_scan: true, + max_mutation_ops: 1, + ..gql_opts() + }, ) .unwrap_err(); - assert!( - matches!(bad_valid_to, EngineError::InvalidOperation(message) if message.contains("valid_from < valid_to")) - ); - assert!(engine - .get_node_by_key("GqlBadEdgeWindow", "a") - .unwrap() - .is_none()); - assert!(engine - .get_node_by_key("GqlBadEdgeWindow", "b") - .unwrap() - .is_none()); + assert!(matches!(cap, EngineError::InvalidOperation(message) if message.contains("max_mutation_ops"))); + for key in ["a", "b"] { + assert!(engine + .get_node_by_key("GqlMergeCapTarget", key) + .unwrap() + .is_none()); + } - let bad_valid_from = engine - .execute_gql( - "CREATE (a:GqlBadEdgeWindowMax {key: 'a'})-[r:Gql_BAD_WINDOW_MAX {valid_from: 9223372036854775807}]->(b:GqlBadEdgeWindowMax {key: 'b'}) RETURN r", + let wal_path = wal_generation_path(&db_path, 0); + let before_wal_len = std::fs::metadata(&wal_path).map(|metadata| metadata.len()).unwrap_or(0); + let explain = engine + .explain_gql( + "MERGE (n:GqlMergeExplain {key: 'n'}) ON CREATE SET n.status = 'planned' RETURN n", &GqlParams::new(), &gql_opts(), ) - .unwrap_err(); - assert!( - matches!(bad_valid_from, EngineError::InvalidOperation(message) if message.contains("valid_from < valid_to")) - ); - assert!(engine - .get_node_by_key("GqlBadEdgeWindowMax", "a") - .unwrap() - .is_none()); + .unwrap(); + assert!(explain + .mutation + .as_ref() + .is_some_and(|mutation| mutation.uses_transaction_snapshot)); + let after_wal_len = std::fs::metadata(&wal_path).map(|metadata| metadata.len()).unwrap_or(0); + assert_eq!(after_wal_len, before_wal_len); + assert_eq!(engine.get_node_label_id("GqlMergeExplain").unwrap(), None); assert!(engine - .get_node_by_key("GqlBadEdgeWindowMax", "b") + .get_node_by_key("GqlMergeExplain", "n") .unwrap() .is_none()); - let node_metadata_return = engine - .execute_gql( - "CREATE (n:GqlReturnNodeMetadata {key: 'n'}) RETURN n.created_at", - &GqlParams::new(), - &gql_opts(), - ) + engine + .ensure_node_property_index("GqlMergeIndexed", "status", SecondaryIndexKind::Equality) .unwrap(); - assert_eq!(node_metadata_return.rows.len(), 1); - assert!(matches!( - node_metadata_return.rows[0].values[0], - GqlValue::Int(value) if value > 0 - )); - assert!(engine - .get_node_by_key("GqlReturnNodeMetadata", "n") - .unwrap() - .is_some()); + let inserted = execute_gql_ok( + &engine, + "MERGE (n:GqlMergeIndexed {key: 'n'}) ON CREATE SET n.status = 'ready' RETURN id(n)", + ); + let node_id = gql_u64_column(&inserted, 0)[0]; + execute_gql_ok( + &engine, + "MERGE (n:GqlMergeIndexed {key: 'n'}) ON MATCH SET n.status = 'updated' RETURN n", + ); + let updated = execute_gql_ok( + &engine, + "MATCH (n:GqlMergeIndexed {status: 'updated'}) RETURN id(n)", + ); + assert_eq!(gql_u64_column(&updated, 0), vec![node_id]); + let ready = execute_gql_ok( + &engine, + "MATCH (n:GqlMergeIndexed {status: 'ready'}) RETURN id(n)", + ); + assert!(ready.rows.is_empty()); - let edge_metadata_return = engine - .execute_gql( - "CREATE (a:GqlReturnEdgeMetadata {key: 'a'})-[r:Gql_RETURN_META]->(b:GqlReturnEdgeMetadata {key: 'b'}) RETURN r.updated_at", - &GqlParams::new(), - &gql_opts(), - ) - .unwrap(); - assert_eq!(edge_metadata_return.rows.len(), 1); - assert!(matches!( - edge_metadata_return.rows[0].values[0], - GqlValue::Int(value) if value > 0 - )); - assert!(engine - .get_node_by_key("GqlReturnEdgeMetadata", "a") - .unwrap() - .is_some()); - assert!(engine - .get_node_by_key("GqlReturnEdgeMetadata", "b") - .unwrap() - .is_some()); + engine.flush().unwrap(); + let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); + let reopened_read = execute_gql_ok( + &reopened, + "MATCH (n:GqlMergeIndexed {status: 'updated'}) RETURN id(n)", + ); + assert_eq!(gql_u64_column(&reopened_read, 0), vec![node_id]); + reopened.close().unwrap(); } #[test] -fn gql_create_edge_strict_uniqueness_respects_engine_option() { - let (_dir, unique_engine) = gql_create_test_engine_with_options(DbOptions { +fn gql_merge_relationship_creates_matches_duplicates_and_skips_null_endpoints() { + let (_dir, engine) = gql_create_test_engine_with_options(DbOptions { edge_uniqueness: true, ..DbOptions::default() }); - let a = insert_query_node(&unique_engine, "Person", "gql-unique-a", &[], 1.0); - let b = insert_query_node(&unique_engine, "Person", "gql-unique-b", &[], 1.0); - unique_engine - .upsert_edge(a, b, "Gql_UNIQUE", UpsertEdgeOptions::default()) - .unwrap(); - let duplicate = unique_engine + let a = insert_query_node(&engine, "GqlMergeRelNode", "a", &[], 1.0); + let b = insert_query_node(&engine, "GqlMergeRelNode", "b", &[], 1.0); + + let created = engine .execute_gql( - "MATCH (a:Person) WHERE a.key = 'gql-unique-a' MATCH (b:Person) WHERE b.key = 'gql-unique-b' CREATE (a)-[:Gql_UNIQUE]->(b)", + "MATCH (a:GqlMergeRelNode) WHERE a.key = 'a' MATCH (b:GqlMergeRelNode) WHERE b.key = 'b' \ + MERGE (a)-[r:Gql_MERGE_REL]->(b) ON CREATE SET r.status = 'created' RETURN id(r), r.status", &GqlParams::new(), &gql_opts(), ) - .unwrap_err(); - assert!(matches!(duplicate, EngineError::InvalidOperation(message) if message.contains("already exists"))); + .unwrap(); + let edge_id = gql_u64_column(&created, 0)[0]; + assert_eq!(created.rows[0].values[1], GqlValue::String("created".to_string())); + assert_eq!(created.mutation_stats.as_ref().unwrap().edges_created, 1); - let (_dir, parallel_engine) = query_test_engine(); - insert_query_node(¶llel_engine, "Person", "gql-parallel-a", &[], 1.0); - insert_query_node(¶llel_engine, "Person", "gql-parallel-b", &[], 1.0); - let parallel = parallel_engine + let matched = engine .execute_gql( - "MATCH (a:Person) WHERE a.key = 'gql-parallel-a' MATCH (b:Person) WHERE b.key = 'gql-parallel-b' CREATE (a)-[:Gql_PARALLEL]->(b), (a)-[:Gql_PARALLEL]->(b)", + "MATCH (a:GqlMergeRelNode) WHERE a.key = 'a' MATCH (b:GqlMergeRelNode) WHERE b.key = 'b' \ + MERGE (a)-[r:Gql_MERGE_REL]->(b) ON MATCH SET r.status = 'matched' RETURN id(r), r.status", &GqlParams::new(), &gql_opts(), ) .unwrap(); - assert_eq!(parallel.mutation_stats.as_ref().unwrap().edges_created, 2); - assert_eq!(parallel.mutation_stats.as_ref().unwrap().mutation_ops, 2); -} + assert_eq!(gql_u64_column(&matched, 0), vec![edge_id]); + assert_eq!(matched.rows[0].values[1], GqlValue::String("matched".to_string())); + assert_eq!(matched.mutation_stats.as_ref().unwrap().edges_updated, 1); -#[test] -fn gql_create_match_backed_rows_caps_and_optional_null_skip_are_atomic() { - let (_dir, engine) = query_test_engine(); - insert_query_node(&engine, "GqlBatch", "a", &[], 1.0); - insert_query_node(&engine, "GqlBatch", "b", &[], 1.0); + let incremented = engine + .execute_gql( + "MATCH (a:GqlMergeRelNode) WHERE a.key = 'a' MATCH (b:GqlMergeRelNode) WHERE b.key = 'b' \ + MERGE (a)-[r:Gql_MERGE_REL]->(b) ON MATCH SET r.visits = coalesce(r.visits, 0) + 1 RETURN r.visits", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + assert_eq!(incremented.rows[0].values[0], GqlValue::Int(1)); - let cap = engine + insert_query_node(&engine, "GqlMergeRelSource", "a", &[], 1.0); + insert_query_node(&engine, "GqlMergeRelSource", "b", &[], 1.0); + let duplicate = engine .execute_gql( - "MATCH (s:GqlBatch) CREATE (n:GqlCapCreate {key: s.key}) RETURN n", + "MATCH (s:GqlMergeRelSource) MATCH (a:GqlMergeRelNode) WHERE a.key = 'a' MATCH (b:GqlMergeRelNode) WHERE b.key = 'b' \ + MERGE (a)-[r:Gql_MERGE_REL_DUP]->(b) ON CREATE SET r.status = 'created' ON MATCH SET r.status = 'matched' \ + RETURN r.status ORDER BY s.key", &GqlParams::new(), &GqlExecutionOptions { allow_full_scan: true, - max_mutation_rows: 1, + profile: true, ..gql_opts() }, ) - .unwrap_err(); - assert!(matches!(cap, EngineError::InvalidOperation(message) if message.contains("max_mutation_rows"))); - assert!(engine - .get_node_by_key("GqlCapCreate", "a") - .unwrap() - .is_none()); - assert!(engine - .get_node_by_key("GqlCapCreate", "b") - .unwrap() - .is_none()); + .unwrap(); + assert_eq!(duplicate.rows.len(), 2); + let stats = duplicate.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.edges_created, 1); + assert_eq!(stats.mutation_rows, 2); + assert_eq!(stats.duplicate_targets, 1); + assert!(stats.db_hits >= 1); + let dup_edges = engine + .query_edges(&EdgeQuery { + from_ids: vec![a], + to_ids: vec![b], + label: Some("Gql_MERGE_REL_DUP".to_string()), + ..Default::default() + }) + .unwrap(); + assert_eq!(dup_edges.edges.len(), 1); + assert_eq!( + dup_edges.edges[0].props.get("status"), + Some(&PropValue::String("matched".to_string())) + ); - let cursor_cap = engine + let duplicate_counter = engine .execute_gql( - "MATCH (s:GqlBatch) CREATE (n:GqlCursorCapCreate {key: s.key}) RETURN n", + "MATCH (s:GqlMergeRelSource) MATCH (a:GqlMergeRelNode) WHERE a.key = 'a' MATCH (b:GqlMergeRelNode) WHERE b.key = 'b' \ + MERGE (a)-[r:Gql_MERGE_REL_COUNT]->(b) ON CREATE SET r.count = 1 ON MATCH SET r.count = r.count + 1 \ + RETURN r.count ORDER BY s.key", &GqlParams::new(), &GqlExecutionOptions { allow_full_scan: true, - max_mutation_rows: 10, - max_intermediate_bindings: 1, ..gql_opts() }, ) - .unwrap_err(); - assert!( - matches!(cursor_cap, EngineError::InvalidOperation(ref message) if message.contains("max_page_limit")), - "{cursor_cap:?}" + .unwrap(); + assert_eq!(duplicate_counter.rows.len(), 2); + let counted_edges = engine + .query_edges(&EdgeQuery { + from_ids: vec![a], + to_ids: vec![b], + label: Some("Gql_MERGE_REL_COUNT".to_string()), + ..Default::default() + }) + .unwrap(); + assert_eq!(counted_edges.edges.len(), 1); + assert_eq!( + counted_edges.edges[0].props.get("count"), + Some(&PropValue::Int(2)) ); - assert!(engine - .get_node_by_key("GqlCursorCapCreate", "a") - .unwrap() - .is_none()); - assert!(engine - .get_node_by_key("GqlCursorCapCreate", "b") - .unwrap() - .is_none()); - let root = insert_query_node(&engine, "GqlOptionalRoot", "root", &[], 1.0); let skipped = engine .execute_gql( - "MATCH (a:GqlOptionalRoot) WHERE a.key = 'root' OPTIONAL MATCH (a)-[r:Gql_MISSING]->(b) CREATE (b)-[:Gql_SKIP]->(c:GqlSkipped {key: 'c'}) RETURN c", + "MATCH (a:GqlMergeRelNode) WHERE a.key = 'a' OPTIONAL MATCH (a)-[:Gql_MISSING_REL]->(b) \ + MERGE (a)-[r:Gql_MERGE_NULL]->(b) RETURN r", &GqlParams::new(), &gql_opts(), ) @@ -1048,1817 +994,1303 @@ fn gql_create_match_backed_rows_caps_and_optional_null_skip_are_atomic() { assert_eq!(skipped.rows.len(), 1); assert_eq!(skipped.rows[0].values[0], GqlValue::Null); let stats = skipped.mutation_stats.as_ref().unwrap(); - assert_eq!(stats.rows_matched, 1); - assert_eq!(stats.mutation_rows, 0); assert_eq!(stats.skipped_null_targets, 1); - assert_eq!(stats.nodes_created, 0); - assert!(engine.get_node_by_key("GqlSkipped", "c").unwrap().is_none()); - assert!(engine + assert_eq!(stats.edges_created, 0); + + let bad_local_metadata = engine + .execute_gql( + "MATCH (a:GqlMergeRelNode) WHERE a.key = 'a' MATCH (b:GqlMergeRelNode) WHERE b.key = 'b' \ + MERGE (a)-[r:Gql_MERGE_REL_BAD_META]->(b) ON CREATE SET r.source_id = id(r)", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!(matches!( + bad_local_metadata, + EngineError::GqlSemantic { .. } + )); + let bad_edges = engine .query_edges(&EdgeQuery { - from_ids: vec![root], - label: Some("Gql_SKIP".to_string()), + from_ids: vec![a], + to_ids: vec![b], + label: Some("Gql_MERGE_REL_BAD_META".to_string()), ..Default::default() }) - .unwrap() - .edges - .is_empty()); -} - -#[test] -fn gql_create_cap_fails_during_materialization_without_writes() { - let (_dir, engine) = query_test_engine(); - for key in ["a", "b", "c"] { - insert_query_node(&engine, "GqlCreateEarlyCapSource", key, &[], 1.0); - } + .unwrap(); + assert!(bad_edges.edges.is_empty()); - let err = engine + let bad_endpoint_metadata = engine .execute_gql( - "MATCH (s:GqlCreateEarlyCapSource) CREATE (n:GqlCreateEarlyCap {key: s.key})", + "MATCH (a:GqlMergeRelNode) WHERE a.key = 'a' MATCH (b:GqlMergeRelNode) WHERE b.key = 'b' \ + MERGE (a)-[r:Gql_MERGE_REL_BAD_FROM]->(b) ON MATCH SET r.source_from = r.from", &GqlParams::new(), - &GqlExecutionOptions { - allow_full_scan: true, - max_mutation_ops: 1, - ..gql_opts() - }, + &gql_opts(), ) .unwrap_err(); - assert!(matches!(err, EngineError::InvalidOperation(message) if message.contains("max_mutation_ops"))); - for key in ["a", "b", "c"] { - assert!(engine - .get_node_by_key("GqlCreateEarlyCap", key) - .unwrap() - .is_none()); - } + assert!(matches!( + bad_endpoint_metadata, + EngineError::GqlSemantic { .. } + )); + let bad_from_edges = engine + .query_edges(&EdgeQuery { + from_ids: vec![a], + to_ids: vec![b], + label: Some("Gql_MERGE_REL_BAD_FROM".to_string()), + ..Default::default() + }) + .unwrap(); + assert!(bad_from_edges.edges.is_empty()); } #[test] -fn gql_create_return_order_by_id_and_later_delete_executes() { +fn gql_merge_relationship_rejects_without_edge_uniqueness() { let (_dir, engine) = query_test_engine(); - let supported_return = engine + insert_query_node(&engine, "GqlMergeNoUnique", "a", &[], 1.0); + insert_query_node(&engine, "GqlMergeNoUnique", "b", &[], 1.0); + let err = engine .execute_gql( - "CREATE (n:GqlReturnSupportedOrder {key: 'n'}) RETURN n ORDER BY n.key", + "MATCH (a:GqlMergeNoUnique) WHERE a.key = 'a' MATCH (b:GqlMergeNoUnique) WHERE b.key = 'b' MERGE (a)-[r:Gql_NO_UNIQUE]->(b)", &GqlParams::new(), &gql_opts(), ) - .unwrap(); - assert_eq!(supported_return.rows.len(), 1); - let returned = gql_single_node(&supported_return.rows[0].values[0]); - assert_eq!(returned.key.as_deref(), Some("n")); - assert!(engine - .get_node_by_key("GqlReturnSupportedOrder", "n") - .unwrap() - .is_some()); + .unwrap_err(); + assert!(matches!(err, EngineError::InvalidOperation(message) if message.contains("edge_uniqueness=true"))); +} - let supported_set = engine - .execute_gql( - "CREATE (n:GqlUnsupportedSet {key: 'n'}) SET n.name = 'Ada'", - &GqlParams::new(), - &gql_opts(), - ) - .unwrap(); - assert_eq!(supported_set.mutation_stats.as_ref().unwrap().nodes_created, 1); - assert_eq!( - engine - .get_node_by_key("GqlUnsupportedSet", "n") - .unwrap() - .unwrap() - .props - .get("name"), - Some(&PropValue::String("Ada".to_string())) - ); +#[test] +fn gql_merge_commit_conflicts_for_node_keys_and_edge_triples() { + let (_dir, engine) = gql_create_test_engine_with_options(DbOptions { + edge_uniqueness: true, + ..DbOptions::default() + }); - let delete = engine - .execute_gql( - "MATCH (n:GqlUnsupportedSet) WHERE n.key = 'n' DETACH DELETE n", + let worker = DatabaseEngine { + runtime: std::sync::Arc::clone(&engine.runtime), + }; + let (ready_rx, release_tx) = engine.set_gql_mutation_before_commit_pause(); + let node_handle = std::thread::spawn(move || { + worker.execute_gql( + "MERGE (n:GqlMergeNodeConflict {key: 'n'}) ON CREATE SET n.status = 'worker'", &GqlParams::new(), &gql_opts(), ) + }); + ready_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("node MERGE did not pause before commit"); + engine + .upsert_node( + "GqlMergeNodeConflict", + "n", + UpsertNodeOptions { + props: query_test_props(&[( + "status", + PropValue::String("outside".to_string()), + )]), + ..Default::default() + }, + ) .unwrap(); - assert_eq!(delete.mutation_stats.as_ref().unwrap().nodes_deleted, 1); - assert!(engine - .get_node_by_key("GqlUnsupportedSet", "n") + release_tx.send(()).unwrap(); + let node_err = node_handle.join().unwrap().unwrap_err(); + assert!(matches!(node_err, EngineError::TxnConflict { .. })); + let stored = engine + .get_node_by_key("GqlMergeNodeConflict", "n") .unwrap() - .is_none()); -} - -#[test] -fn gql_set_node_property_updates_existing_node_index_and_return() { - let (_dir, engine) = query_test_engine(); - engine - .ensure_node_property_index("GqlSetIndexed", "status", SecondaryIndexKind::Equality) .unwrap(); - let node_id = insert_query_node( - &engine, - "GqlSetIndexed", - "n", - &[ - ("status", PropValue::String("old".to_string())), - ("rank", PropValue::Int(1)), - ], - 1.25, + assert_eq!( + stored.props.get("status"), + Some(&PropValue::String("outside".to_string())) ); - let before = engine.get_node(node_id).unwrap().unwrap(); - let result = engine - .execute_gql( - "MATCH (n:GqlSetIndexed) WHERE n.key = 'n' SET n.status = 'new' RETURN n, id(n), n.status, n.weight", + let a = insert_query_node(&engine, "GqlMergeEdgeConflictNode", "a", &[], 1.0); + let b = insert_query_node(&engine, "GqlMergeEdgeConflictNode", "b", &[], 1.0); + let worker = DatabaseEngine { + runtime: std::sync::Arc::clone(&engine.runtime), + }; + let (ready_rx, release_tx) = engine.set_gql_mutation_before_commit_pause(); + let edge_handle = std::thread::spawn(move || { + worker.execute_gql( + "MATCH (a:GqlMergeEdgeConflictNode) WHERE a.key = 'a' MATCH (b:GqlMergeEdgeConflictNode) WHERE b.key = 'b' \ + MERGE (a)-[r:Gql_MERGE_EDGE_CONFLICT]->(b) ON CREATE SET r.status = 'worker'", &GqlParams::new(), &gql_opts(), ) - .unwrap(); - assert_eq!(result.rows.len(), 1); - assert_eq!(result.rows[0].values[1], GqlValue::UInt(node_id)); - assert_eq!(result.rows[0].values[2], GqlValue::String("new".to_string())); - assert_eq!(result.rows[0].values[3], GqlValue::Float(1.25)); - let returned = gql_single_node(&result.rows[0].values[0]); - assert_eq!(returned.id, Some(node_id)); - assert_eq!( - returned.props.as_ref().unwrap().get("status"), - Some(&GqlValue::String("new".to_string())) - ); - - let stored = engine.get_node(node_id).unwrap().unwrap(); - assert_eq!(stored.id, node_id); - assert_eq!(stored.created_at, before.created_at); - assert!(stored.updated_at >= before.updated_at); - assert_eq!( - stored.props.get("status"), - Some(&PropValue::String("new".to_string())) - ); - let new_read = execute_gql_ok( - &engine, - "MATCH (n:GqlSetIndexed {status: 'new'}) RETURN id(n)", - ); - assert_eq!(gql_u64_column(&new_read, 0), vec![node_id]); - let old_read = execute_gql_ok( - &engine, - "MATCH (n:GqlSetIndexed {status: 'old'}) RETURN id(n)", - ); - assert!(old_read.rows.is_empty()); - let stats = result.mutation_stats.as_ref().unwrap(); - assert_eq!(stats.nodes_updated, 1); - assert_eq!(stats.properties_set, 1); - assert_eq!(stats.mutation_ops, 1); -} - -#[test] -fn gql_set_edge_property_and_metadata_preserves_edge_identity() { - let (_dir, engine) = gql_create_test_engine_with_options(DbOptions { - edge_uniqueness: false, - ..DbOptions::default() }); - let a = insert_query_node(&engine, "GqlEdgeSetNode", "a", &[], 1.0); - let b = insert_query_node(&engine, "GqlEdgeSetNode", "b", &[], 1.0); - let edge_id = engine + ready_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("edge MERGE did not pause before commit"); + let outside = engine .upsert_edge( a, b, - "Gql_SET_EDGE", + "Gql_MERGE_EDGE_CONFLICT", UpsertEdgeOptions { - props: query_test_props(&[("since", PropValue::Int(2020))]), - weight: 0.5, - valid_from: Some(0), - valid_to: Some(i64::MAX), + props: query_test_props(&[( + "status", + PropValue::String("outside".to_string()), + )]), + ..Default::default() }, ) .unwrap(); - let before = engine.get_edge(edge_id).unwrap().unwrap(); + release_tx.send(()).unwrap(); + let edge_err = edge_handle.join().unwrap().unwrap_err(); + assert!(matches!(edge_err, EngineError::TxnConflict { .. })); + let edges = engine + .query_edges(&EdgeQuery { + from_ids: vec![a], + to_ids: vec![b], + label: Some("Gql_MERGE_EDGE_CONFLICT".to_string()), + ..Default::default() + }) + .unwrap(); + assert_eq!(edges.edges.len(), 1); + assert_eq!(edges.edges[0].id, outside); + assert_eq!( + edges.edges[0].props.get("status"), + Some(&PropValue::String("outside".to_string())) + ); +} - let result = engine +#[test] +fn gql_merge_read_prefix_pipelines_support_with_call_union_and_exists() { + let (_dir, engine) = query_test_engine(); + insert_query_node(&engine, "GqlMergePrefixSeed", "with", &[], 1.0); + let with_prefix = engine .execute_gql( - "MATCH (a:GqlEdgeSetNode) WHERE a.key = 'a' MATCH (b:GqlEdgeSetNode) WHERE b.key = 'b' MATCH (a)-[r:Gql_SET_EDGE]->(b) \ - SET r.since = 2026 SET r.weight = 2.5 SET r.valid_from = 10 SET r.valid_to = 20 \ - RETURN id(r), r.since, r.weight, r.valid_from, r.valid_to", + "MATCH (s:GqlMergePrefixSeed) WITH s MERGE (n:GqlMergeWithPrefix {key: s.key}) RETURN n.key", &GqlParams::new(), - &gql_opts(), + &GqlExecutionOptions { + allow_full_scan: true, + ..gql_opts() + }, ) .unwrap(); - assert_eq!(result.rows[0].values[0], GqlValue::UInt(edge_id)); - assert_eq!(result.rows[0].values[1], GqlValue::Int(2026)); - assert_eq!(result.rows[0].values[2], GqlValue::Float(2.5)); - assert_eq!(result.rows[0].values[3], GqlValue::Int(10)); - assert_eq!(result.rows[0].values[4], GqlValue::Int(20)); - - let after = engine.get_edge(edge_id).unwrap().unwrap(); - assert_eq!(after.id, edge_id); - assert_eq!(after.from, a); - assert_eq!(after.to, b); - assert_eq!(after.label, "Gql_SET_EDGE"); - assert_eq!(after.created_at, before.created_at); - assert!(after.updated_at >= before.updated_at); - assert_eq!(after.props.get("since"), Some(&PropValue::Int(2026))); - assert_eq!(after.weight, 2.5); - assert_eq!(after.valid_from, 10); - assert_eq!(after.valid_to, 20); - assert_eq!(result.mutation_stats.as_ref().unwrap().edges_updated, 1); -} + assert_eq!(gql_string_column(&with_prefix, 0), vec!["with".to_string()]); -#[test] -fn gql_set_existing_edge_allows_same_statement_parallel_create_when_nonunique() { - let (_dir, engine) = gql_create_test_engine_with_options(DbOptions { - edge_uniqueness: false, - ..DbOptions::default() - }); - let a = insert_query_node(&engine, "GqlParallelRplNode", "a", &[], 1.0); - let b = insert_query_node(&engine, "GqlParallelRplNode", "b", &[], 1.0); - let existing = engine - .upsert_edge( - a, - b, - "Gql_PARALLEL_REPLACE", - UpsertEdgeOptions { - props: query_test_props(&[("kind", PropValue::String("old".to_string()))]), - ..Default::default() + insert_query_node(&engine, "GqlMergeExistsSeed", "exists", &[], 1.0); + insert_query_node(&engine, "GqlMergeExistsMarker", "marker", &[], 1.0); + let exists_prefix = engine + .execute_gql( + "MATCH (s:GqlMergeExistsSeed) WHERE EXISTS { MATCH (m:GqlMergeExistsMarker) RETURN m } \ + MERGE (n:GqlMergeExistsPrefix {key: s.key}) RETURN n.key", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + ..gql_opts() }, ) .unwrap(); + assert_eq!(gql_string_column(&exists_prefix, 0), vec!["exists".to_string()]); - let result = engine + insert_query_node(&engine, "GqlMergeCallA", "a", &[], 1.0); + insert_query_node(&engine, "GqlMergeCallB", "b", &[], 1.0); + let call_union_prefix = engine .execute_gql( - "MATCH (a:GqlParallelRplNode) WHERE a.key = 'a' \ - MATCH (b:GqlParallelRplNode) WHERE b.key = 'b' \ - MATCH (a)-[r:Gql_PARALLEL_REPLACE]->(b) \ - CREATE (a)-[x:Gql_PARALLEL_REPLACE {kind: 'new'}]->(b) \ - SET r.kind = 'updated' RETURN id(r), r.kind", + "CALL { MATCH (x:GqlMergeCallA) RETURN x.key AS k UNION MATCH (x:GqlMergeCallB) RETURN x.key AS k } \ + MERGE (n:GqlMergeCallPrefix {key: k}) RETURN n.key ORDER BY n.key", &GqlParams::new(), - &gql_opts(), + &GqlExecutionOptions { + allow_full_scan: true, + ..gql_opts() + }, ) .unwrap(); - assert_eq!(result.rows[0].values[0], GqlValue::UInt(existing)); - assert_eq!( - result.rows[0].values[1], - GqlValue::String("updated".to_string()) - ); - let edges = engine - .query_edges(&EdgeQuery { - label: Some("Gql_PARALLEL_REPLACE".to_string()), - from_ids: vec![a], - to_ids: vec![b], - ..Default::default() - }) - .unwrap() - .edges; - assert_eq!(edges.len(), 2); - let existing_after = engine.get_edge(existing).unwrap().unwrap(); - assert_eq!(existing_after.id, existing); assert_eq!( - existing_after.props.get("kind"), - Some(&PropValue::String("updated".to_string())) + gql_string_column(&call_union_prefix, 0), + vec!["a".to_string(), "b".to_string()] ); - assert!(edges.iter().any(|edge| { - edge.id != existing - && edge.props.get("kind") == Some(&PropValue::String("new".to_string())) - })); } #[test] -fn gql_set_map_merge_handles_nulls_and_weight_as_property() { +fn gql_create_node_strict_duplicates_reject_before_write() { let (_dir, engine) = query_test_engine(); - let node_id = insert_query_node( - &engine, - "GqlMapMerge", - "n", - &[ - ("old", PropValue::String("remove".to_string())), - ("keep", PropValue::Int(1)), - ], - 3.0, - ); - let params = GqlParams::from([( - "props".to_string(), - GqlParamValue::Map(BTreeMap::from([ - ("old".to_string(), GqlParamValue::Null), - ("keep".to_string(), GqlParamValue::Int(2)), - ( - "nested".to_string(), - GqlParamValue::List(vec![GqlParamValue::Null, GqlParamValue::String("x".to_string())]), - ), - ("weight".to_string(), GqlParamValue::String("stored-prop".to_string())), - ])), - )]); + insert_query_node(&engine, "Person", "gql-create-existing", &[], 1.0); - engine + let visible = engine .execute_gql( - "MATCH (n:GqlMapMerge) WHERE n.key = 'n' SET n += $props RETURN n.keep, n.old, n.nested, n.weight", - ¶ms, + "CREATE (n:Person {key: 'gql-create-existing', name: 'new'}) RETURN n", + &GqlParams::new(), &gql_opts(), ) - .unwrap(); - let stored = engine.get_node(node_id).unwrap().unwrap(); - assert_eq!(stored.weight, 3.0); - assert!(!stored.props.contains_key("old")); - assert_eq!(stored.props.get("keep"), Some(&PropValue::Int(2))); - assert_eq!( - stored.props.get("nested"), - Some(&PropValue::Array(vec![ - PropValue::Null, - PropValue::String("x".to_string()) - ])) - ); + .unwrap_err(); + assert!(matches!(visible, EngineError::InvalidOperation(message) if message.contains("already exists"))); assert_eq!( - stored.props.get("weight"), - Some(&PropValue::String("stored-prop".to_string())) + engine + .get_node_by_key("Person", "gql-create-existing") + .unwrap() + .unwrap() + .props + .get("name"), + None ); - let non_map = engine + let duplicate = engine .execute_gql( - "MATCH (n:GqlMapMerge) WHERE n.key = 'n' SET n += 1", + "CREATE (a:GqlCreateDup {key: 'dup'}), (b:GqlCreateDup {key: 'dup'}) RETURN a", &GqlParams::new(), &gql_opts(), ) .unwrap_err(); - assert!(matches!(non_map, EngineError::InvalidOperation(message) if message.contains("map"))); -} - -#[test] -fn gql_set_map_merge_rejects_reserved_metadata_keys() { - let (_dir, engine) = query_test_engine(); - let a = insert_query_node( - &engine, - "GqlReservedMapMerge", - "a", - &[("status", PropValue::String("old".to_string()))], - 1.0, - ); - let b = insert_query_node(&engine, "GqlReservedMapMerge", "b", &[], 1.0); - let edge_id = engine - .upsert_edge( - a, - b, - "Gql_RESERVED_MERGE_EDGE", - UpsertEdgeOptions { - props: BTreeMap::from([( - "status".to_string(), - PropValue::String("old".to_string()), - )]), - ..Default::default() - }, - ) - .unwrap(); - - for key in [ - "id", - "labels", - "key", - "created_at", - "updated_at", - "dense_vector", - "sparse_vector", - ] { - let err = engine - .execute_gql( - "MATCH (n:GqlReservedMapMerge) WHERE n.key = 'a' SET n += $props", - &GqlParams::from([( - "props".to_string(), - GqlParamValue::Map(BTreeMap::from([( - key.to_string(), - GqlParamValue::Int(1), - )])), - )]), - &gql_opts(), - ) - .unwrap_err(); - assert!( - matches!(&err, EngineError::InvalidOperation(message) if message.contains("reserved metadata")), - "expected reserved metadata error for node key {key}, got {err:?}" - ); - } - let node = engine.get_node(a).unwrap().unwrap(); - assert_eq!( - node.props.get("status"), - Some(&PropValue::String("old".to_string())) - ); - assert!(!node.props.contains_key("id")); - assert!(!node.props.contains_key("key")); - assert!(!node.props.contains_key("dense_vector")); - - for key in ["id", "from", "to", "label", "type", "created_at", "updated_at"] { - let err = engine - .execute_gql( - "MATCH (a:GqlReservedMapMerge) WHERE a.key = 'a' \ - MATCH (b:GqlReservedMapMerge) WHERE b.key = 'b' \ - MATCH (a)-[r:Gql_RESERVED_MERGE_EDGE]->(b) SET r += $props", - &GqlParams::from([( - "props".to_string(), - GqlParamValue::Map(BTreeMap::from([( - key.to_string(), - GqlParamValue::Int(1), - )])), - )]), - &gql_opts(), - ) - .unwrap_err(); - assert!( - matches!(&err, EngineError::InvalidOperation(message) if message.contains("reserved metadata")), - "expected reserved metadata error for edge key {key}, got {err:?}" - ); - } - let edge = engine.get_edge(edge_id).unwrap().unwrap(); - assert_eq!( - edge.props.get("status"), - Some(&PropValue::String("old".to_string())) - ); - assert!(!edge.props.contains_key("from")); - assert!(!edge.props.contains_key("type")); -} + assert!(matches!(duplicate, EngineError::InvalidOperation(message) if message.contains("duplicate node CREATE target"))); + assert!(engine + .get_node_by_key("GqlCreateDup", "dup") + .unwrap() + .is_none()); -#[test] -fn gql_remove_property_and_label_are_noop_safe_and_atomic() { - let (_dir, engine) = query_test_engine(); - let node_id = insert_query_node_with_labels( + insert_query_node( &engine, - &["GqlRemove", "GqlRemoveExtra"], - "n", - &[("drop", PropValue::Bool(true))], + "GqlCreateFinalConflict", + "final-key", + &[("name", PropValue::String("old".to_string()))], 1.0, ); - - let result = engine - .execute_gql( - "MATCH (n:GqlRemove) WHERE n.key = 'n' REMOVE n.drop REMOVE n.missing REMOVE n:GqlRemoveExtra RETURN n.drop, labels(n)", - &GqlParams::new(), - &gql_opts(), - ) - .unwrap(); - assert_eq!(result.rows[0].values[0], GqlValue::Null); - let stored = engine.get_node(node_id).unwrap().unwrap(); - assert!(!stored.props.contains_key("drop")); - assert!(stored.labels.iter().any(|label| label == "GqlRemove")); - assert!(!stored.labels.iter().any(|label| label == "GqlRemoveExtra")); - let stats = result.mutation_stats.as_ref().unwrap(); - assert_eq!(stats.labels_removed, 1); - assert_eq!(stats.properties_removed, 1); - - let last_label = engine + let final_label_visible = engine .execute_gql( - "MATCH (n:GqlRemove) WHERE n.key = 'n' REMOVE n:GqlRemove", + "CREATE (n:GqlCreateInitialOnly {key: 'final-key', name: 'new'}) SET n:GqlCreateFinalConflict RETURN n", &GqlParams::new(), &gql_opts(), ) .unwrap_err(); - assert!(matches!(last_label, EngineError::InvalidOperation(message) if message.contains("last node label"))); - assert!(engine.get_node(node_id).unwrap().unwrap().labels.contains(&"GqlRemove".to_string())); + assert!(matches!(final_label_visible, EngineError::InvalidOperation(message) if message.contains("already exists"))); + assert_eq!( + engine + .get_node_by_key("GqlCreateFinalConflict", "final-key") + .unwrap() + .unwrap() + .props + .get("name"), + Some(&PropValue::String("old".to_string())) + ); + assert!(engine + .get_node_by_key("GqlCreateInitialOnly", "final-key") + .unwrap() + .is_none()); - let optional = engine + let final_label_duplicate = engine .execute_gql( - "MATCH (n:GqlRemove) WHERE n.key = 'n' OPTIONAL MATCH (n)-[r:Gql_REMOVE_MISSING]->(m) SET m.name = 'x' REMOVE m.missing", + "CREATE (a:GqlCreateFinalLeft {key: 'final-dup'}), (b:GqlCreateFinalRight {key: 'final-dup'}) SET a:GqlCreateFinalShared SET b:GqlCreateFinalShared RETURN a", &GqlParams::new(), &gql_opts(), ) - .unwrap(); - assert_eq!(optional.mutation_stats.as_ref().unwrap().skipped_null_targets, 2); - assert_eq!(optional.mutation_stats.as_ref().unwrap().mutation_ops, 0); -} - -#[test] -fn gql_set_duplicate_targets_are_coalesced_last_write_wins() { - let (_dir, engine) = query_test_engine(); - let node_id = insert_query_node(&engine, "GqlDuplicateSet", "n", &[], 1.0); + .unwrap_err(); + assert!(matches!(final_label_duplicate, EngineError::InvalidOperation(message) if message.contains("duplicate node CREATE target"))); + assert!(engine + .get_node_by_key("GqlCreateFinalShared", "final-dup") + .unwrap() + .is_none()); + assert!(engine + .get_node_by_key("GqlCreateFinalLeft", "final-dup") + .unwrap() + .is_none()); + assert!(engine + .get_node_by_key("GqlCreateFinalRight", "final-dup") + .unwrap() + .is_none()); - let result = engine + let existing_old = insert_query_node( + &engine, + "GqlCreateRemovedOld", + "final-free", + &[("name", PropValue::String("old".to_string()))], + 1.0, + ); + let final_removed_old = engine .execute_gql( - "MATCH (n:GqlDuplicateSet) WHERE n.key = 'n' SET n.name = 'first' SET n.name = 'second' RETURN n.name", + "CREATE (n:GqlCreateRemovedOld {key: 'final-free', name: 'new'}) SET n:GqlCreateFinalNew REMOVE n:GqlCreateRemovedOld RETURN id(n), labels(n)", &GqlParams::new(), &gql_opts(), ) .unwrap(); - assert_eq!(result.rows[0].values[0], GqlValue::String("second".to_string())); + let final_removed_old_id = match final_removed_old.rows[0].values[0] { + GqlValue::UInt(id) => id, + ref other => panic!("expected created id, got {other:?}"), + }; + assert_ne!(final_removed_old_id, existing_old); assert_eq!( engine - .get_node(node_id) + .get_node_by_key("GqlCreateRemovedOld", "final-free") .unwrap() .unwrap() - .props - .get("name"), - Some(&PropValue::String("second".to_string())) + .id, + existing_old ); - let stats = result.mutation_stats.as_ref().unwrap(); - assert_eq!(stats.mutation_ops, 1); - assert_eq!(stats.duplicate_targets, 1); -} - -#[test] -fn gql_mixed_create_set_remove_returns_final_created_alias() { - let (_dir, engine) = query_test_engine(); - let result = engine - .execute_gql( - "CREATE (n:GqlMixedCreate {key: 'n', old: 'x'}) SET n.name = 'Ada' REMOVE n.old SET n:GqlMixedExtra RETURN n.name, n.old, labels(n)", - &GqlParams::new(), - &gql_opts(), - ) - .unwrap(); - assert_eq!(result.rows[0].values[0], GqlValue::String("Ada".to_string())); - assert_eq!(result.rows[0].values[1], GqlValue::Null); - match &result.rows[0].values[2] { - GqlValue::List(labels) => { - assert!(labels.contains(&GqlValue::String("GqlMixedCreate".to_string()))); - assert!(labels.contains(&GqlValue::String("GqlMixedExtra".to_string()))); - } - other => panic!("expected labels list, got {other:?}"), - } - let stored = engine - .get_node_by_key("GqlMixedExtra", "n") + let final_new = engine + .get_node_by_key("GqlCreateFinalNew", "final-free") .unwrap() .unwrap(); - assert_eq!(stored.props.get("name"), Some(&PropValue::String("Ada".to_string()))); - assert!(!stored.props.contains_key("old")); - let stats = result.mutation_stats.as_ref().unwrap(); - assert_eq!(stats.nodes_created, 1); - assert_eq!(stats.nodes_updated, 0); - assert_eq!(stats.mutation_ops, 1); -} + assert_eq!(final_new.id, final_removed_old_id); + assert!(!final_new + .labels + .contains(&"GqlCreateRemovedOld".to_string())); -#[test] -fn gql_set_remove_errors_leave_database_unchanged() { - let (_dir, engine) = query_test_engine(); - let node_id = insert_query_node( + insert_query_node( &engine, - "GqlSetAtomic", - "n", - &[("status", PropValue::String("old".to_string()))], + "GqlCreateSeed", + "seed-a", + &[("target", PropValue::String("same".to_string()))], 1.0, ); - let bad_prop = engine - .execute_gql( - "MATCH (n:GqlSetAtomic) WHERE n.key = 'n' SET n.status = 'new' SET n.bad = $bad", - &GqlParams::from([("bad".to_string(), GqlParamValue::Float(f64::NAN))]), - &gql_opts(), - ) - .unwrap_err(); - assert!(matches!(bad_prop, EngineError::InvalidOperation(message) if message.contains("finite"))); - let stored = engine.get_node(node_id).unwrap().unwrap(); - assert_eq!(stored.props.get("status"), Some(&PropValue::String("old".to_string()))); - assert!(!stored.props.contains_key("bad")); - - let a = insert_query_node(&engine, "GqlSetAtomicEdgeNode", "a", &[], 1.0); - let b = insert_query_node(&engine, "GqlSetAtomicEdgeNode", "b", &[], 1.0); - let edge_id = engine - .upsert_edge( - a, - b, - "Gql_SET_ATOMIC_EDGE", - UpsertEdgeOptions { - valid_from: Some(1), - valid_to: Some(i64::MAX), - ..Default::default() - }, - ) - .unwrap(); - let bad_window = engine - .execute_gql( - "MATCH (a:GqlSetAtomicEdgeNode) WHERE a.key = 'a' MATCH (b:GqlSetAtomicEdgeNode) WHERE b.key = 'b' MATCH (a)-[r:Gql_SET_ATOMIC_EDGE]->(b) SET r.valid_from = 9223372036854775807", - &GqlParams::new(), - &gql_opts(), - ) - .unwrap_err(); - assert!(matches!(bad_window, EngineError::InvalidOperation(message) if message.contains("valid_from < valid_to"))); - assert_eq!(engine.get_edge(edge_id).unwrap().unwrap().valid_from, 1); - - insert_query_node(&engine, "GqlSetCap", "a", &[], 1.0); - insert_query_node(&engine, "GqlSetCap", "b", &[], 1.0); - let cap = engine + insert_query_node( + &engine, + "GqlCreateSeed", + "seed-b", + &[("target", PropValue::String("same".to_string()))], + 1.0, + ); + let multi_row = engine .execute_gql( - "MATCH (n:GqlSetCap) SET n.flag = true", + "MATCH (s:GqlCreateSeed) CREATE (n:GqlCreateRollback {key: s.target}) RETURN n", &GqlParams::new(), &GqlExecutionOptions { - max_mutation_ops: 1, + allow_full_scan: true, ..gql_opts() }, ) .unwrap_err(); - assert!(matches!(cap, EngineError::InvalidOperation(message) if message.contains("max_mutation_ops"))); + assert!(matches!(multi_row, EngineError::InvalidOperation(message) if message.contains("duplicate node CREATE target"))); assert!(engine - .get_node_by_key("GqlSetCap", "a") - .unwrap() + .get_node_by_key("GqlCreateRollback", "same") .unwrap() - .props - .get("flag") .is_none()); } #[test] -fn gql_existing_update_cap_uses_final_replacement_count() { +fn gql_create_node_rejects_prune_hidden_existing_key_before_write() { let (_dir, engine) = query_test_engine(); - let node_id = insert_query_node( + insert_query_node( &engine, - "GqlSetRevertCap", - "n", - &[("status", PropValue::String("old".to_string()))], - 1.0, + "GqlPruneHiddenCreate", + "hidden", + &[("source", PropValue::String("old".to_string()))], + 0.1, ); - - let reverted = engine - .execute_gql( - "MATCH (n:GqlSetRevertCap) WHERE n.key = 'n' SET n.status = 'new' SET n.status = 'old'", - &GqlParams::new(), - &GqlExecutionOptions { - max_mutation_ops: 0, - ..gql_opts() + engine + .set_prune_policy( + "gql-hide-create-target", + PrunePolicy { + max_age_ms: None, + max_weight: Some(0.5), + label: Some("GqlPruneHiddenCreate".to_string()), }, ) .unwrap(); - assert_eq!(reverted.mutation_stats.as_ref().unwrap().mutation_ops, 0); - assert_eq!( - engine - .get_node(node_id) - .unwrap() - .unwrap() - .props - .get("status"), - Some(&PropValue::String("old".to_string())) - ); + assert!(engine + .get_node_by_key("GqlPruneHiddenCreate", "hidden") + .unwrap() + .is_none()); - let changed = engine + let hidden_duplicate = engine .execute_gql( - "MATCH (n:GqlSetRevertCap) WHERE n.key = 'n' SET n.status = 'new'", + "CREATE (n:GqlPruneHiddenCreate {key: 'hidden', name: 'new'}) RETURN n", &GqlParams::new(), - &GqlExecutionOptions { - max_mutation_ops: 0, - ..gql_opts() - }, + &gql_opts(), ) .unwrap_err(); - assert!(matches!(changed, EngineError::InvalidOperation(message) if message.contains("max_mutation_ops"))); + assert!(matches!(hidden_duplicate, EngineError::InvalidOperation(message) if message.contains("already exists"))); + + assert!(engine.remove_prune_policy("gql-hide-create-target").unwrap()); + let original = engine + .get_node_by_key("GqlPruneHiddenCreate", "hidden") + .unwrap() + .unwrap(); assert_eq!( - engine - .get_node(node_id) - .unwrap() - .unwrap() - .props - .get("status"), + original.props.get("source"), Some(&PropValue::String("old".to_string())) ); + assert!(!original.props.contains_key("name")); } #[test] -fn gql_set_label_preserves_vectors() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("db"); - let engine = DatabaseEngine::open( - &db_path, - &DbOptions { - dense_vector: Some(DenseVectorConfig { - dimension: 3, - metric: DenseMetric::Cosine, - hnsw: HnswConfig::default(), - }), - ..DbOptions::default() - }, - ) - .unwrap(); - seed_query_test_catalog(&engine); - let node_id = engine - .upsert_node( - "GqlVectorSet", - "n", - UpsertNodeOptions { - dense_vector: Some(vec![0.1, 0.2, 0.3]), - sparse_vector: Some(vec![(2, 1.0), (2, 0.5)]), - ..Default::default() - }, - ) - .unwrap(); - - engine - .execute_gql( - "MATCH (n:GqlVectorSet) WHERE n.key = 'n' SET n:GqlVectorSetExtra SET n.status = 'ok'", - &GqlParams::new(), - &gql_opts(), - ) - .unwrap(); - let stored = engine.get_node(node_id).unwrap().unwrap(); - assert!(stored.labels.iter().any(|label| label == "GqlVectorSetExtra")); - assert_eq!(stored.dense_vector, Some(vec![0.1, 0.2, 0.3])); - assert_eq!(stored.sparse_vector, Some(vec![(2, 1.5)])); -} - -#[test] -fn gql_set_label_transfer_uses_final_replacement_key_state() { +fn gql_create_invalid_node_metadata_and_property_values_reject_before_write() { let (_dir, engine) = query_test_engine(); - let source = insert_query_node_with_labels( - &engine, - &["GqlTransferSource", "GqlTransferLabel"], - "shared", - &[], - 1.0, - ); - let target = insert_query_node(&engine, "GqlTransferTarget", "shared", &[], 1.0); - engine + let bad_key = engine .execute_gql( - "MATCH (a:GqlTransferLabel) WHERE a.key = 'shared' \ - MATCH (b:GqlTransferTarget) WHERE b.key = 'shared' \ - SET b:GqlTransferLabel REMOVE a:GqlTransferLabel", + "CREATE (n:GqlBadKey {key: 42, name: 'bad'}) RETURN n", &GqlParams::new(), &gql_opts(), ) - .unwrap(); - assert_eq!( - engine - .get_node_by_key("GqlTransferLabel", "shared") - .unwrap() - .unwrap() - .id, - target - ); - assert!(!engine - .get_node(source) - .unwrap() + .unwrap_err(); + assert!(matches!(bad_key, EngineError::InvalidOperation(message) if message.contains("key"))); + assert!(engine + .get_node_by_key("GqlBadKey", "42") .unwrap() - .labels - .contains(&"GqlTransferLabel".to_string())); + .is_none()); - let held = insert_query_node(&engine, "GqlConflictHeld", "dup", &[], 1.0); - let candidate = insert_query_node(&engine, "GqlConflictCandidate", "dup", &[], 1.0); - let conflict = engine + let bad_weight = engine .execute_gql( - "MATCH (n:GqlConflictCandidate) WHERE n.key = 'dup' SET n:GqlConflictHeld", + "CREATE (n:GqlBadWeight {key: 'n', weight: 'heavy'}) RETURN n", &GqlParams::new(), &gql_opts(), ) .unwrap_err(); - assert!(matches!(conflict, EngineError::InvalidOperation(message) if message.contains("node key conflict"))); - assert_eq!( - engine - .get_node_by_key("GqlConflictHeld", "dup") - .unwrap() - .unwrap() - .id, - held + assert!( + matches!(bad_weight, EngineError::InvalidOperation(message) if message.contains("weight")) ); - assert!(!engine - .get_node(candidate) - .unwrap() + assert!(engine + .get_node_by_key("GqlBadWeight", "n") .unwrap() - .labels - .contains(&"GqlConflictHeld".to_string())); -} - -#[test] -fn gql_set_label_cyclic_transfer_rejects_without_index_corruption() { - let (_dir, engine) = query_test_engine(); - let left = insert_query_node(&engine, "GqlCycleLeft", "shared", &[], 1.0); - let right = insert_query_node(&engine, "GqlCycleRight", "shared", &[], 1.0); + .is_none()); - let err = engine + let bad_property = engine .execute_gql( - "MATCH (a:GqlCycleLeft) WHERE a.key = 'shared' \ - MATCH (b:GqlCycleRight) WHERE b.key = 'shared' \ - SET a:GqlCycleRight SET b:GqlCycleLeft REMOVE a:GqlCycleLeft REMOVE b:GqlCycleRight", - &GqlParams::new(), + "CREATE (n:GqlBadProp {key: 'n', score: $bad}) RETURN n", + &GqlParams::from([("bad".to_string(), GqlParamValue::Float(f64::NAN))]), &gql_opts(), ) .unwrap_err(); assert!( - matches!(err, EngineError::InvalidOperation(ref message) if message.contains("cyclic node label/key replacements")), - "{err:?}" - ); - assert_eq!( - engine - .get_node_by_key("GqlCycleLeft", "shared") - .unwrap() - .unwrap() - .id, - left - ); - assert_eq!( - engine - .get_node_by_key("GqlCycleRight", "shared") - .unwrap() - .unwrap() - .id, - right - ); - assert_eq!( - engine.get_node(left).unwrap().unwrap().labels, - vec!["GqlCycleLeft".to_string()] - ); - assert_eq!( - engine.get_node(right).unwrap().unwrap().labels, - vec!["GqlCycleRight".to_string()] + matches!(bad_property, EngineError::InvalidOperation(message) if message.contains("finite")) ); + assert!(engine + .get_node_by_key("GqlBadProp", "n") + .unwrap() + .is_none()); } #[test] -fn gql_mutation_return_non_mutated_existing_alias_projects_and_commits() { +fn gql_create_edge_executes_for_matched_and_created_endpoints() { let (_dir, engine) = query_test_engine(); - let node_id = insert_query_node( - &engine, - "GqlNoopReturn", - "n", - &[("status", PropValue::String("old".to_string()))], - 1.0, - ); + let a = insert_query_node(&engine, "Person", "gql-create-edge-a", &[], 1.0); + let b = insert_query_node(&engine, "Person", "gql-create-edge-b", &[], 1.0); let result = engine .execute_gql( - "MATCH (n:GqlNoopReturn) WHERE n.key = 'n' CREATE (c:GqlNoopReturnCreated {key: 'c'}) SET n.missing = null RETURN n", + "MATCH (a:Person) WHERE a.key = 'gql-create-edge-a' MATCH (b:Person) WHERE b.key = 'gql-create-edge-b' CREATE (a)-[r:Gql_CREATED {since: 2026, weight: 0.8, valid_from: 10, valid_to: 20}]->(b) RETURN r, id(r), r.since", &GqlParams::new(), &gql_opts(), ) .unwrap(); - assert_eq!(result.rows.len(), 1); - assert_eq!(result.stats.rows_returned, 1); - let stats = result.mutation_stats.as_ref().unwrap(); - assert_eq!(stats.nodes_created, 1); - assert_eq!(stats.mutation_ops, 1); - let returned = gql_single_node(&result.rows[0].values[0]); - assert_eq!(returned.id, Some(node_id)); - assert_eq!( - returned.props.as_ref().unwrap().get("status"), - Some(&GqlValue::String("old".to_string())) - ); - assert!(!returned.props.as_ref().unwrap().contains_key("missing")); - let stored = engine.get_node(node_id).unwrap().unwrap(); - assert_eq!( - stored.props.get("status"), - Some(&PropValue::String("old".to_string())) - ); - assert!(!stored.props.contains_key("missing")); - assert!(engine - .get_node_by_key("GqlNoopReturnCreated", "c") - .unwrap() - .is_some()); -} + let edge_id = match result.rows[0].values[1] { + GqlValue::UInt(id) => id, + ref other => panic!("expected edge id UInt, got {other:?}"), + }; + assert_eq!(result.rows[0].values[2], GqlValue::Int(2026)); + let returned = gql_single_edge(&result.rows[0].values[0]); + assert_eq!(returned.id, Some(edge_id)); + assert_eq!(returned.from, Some(a)); + assert_eq!(returned.to, Some(b)); + let stored = engine.get_edge(edge_id).unwrap().unwrap(); + assert_eq!(stored.from, a); + assert_eq!(stored.to, b); + assert_eq!(stored.label, "Gql_CREATED"); + assert_eq!(stored.props.get("since"), Some(&PropValue::Int(2026))); + assert!(!stored.props.contains_key("weight")); + assert!(!stored.props.contains_key("valid_from")); + assert!(!stored.props.contains_key("valid_to")); + assert_eq!(stored.weight, 0.8); + assert_eq!(stored.valid_from, 10); + assert_eq!(stored.valid_to, 20); -#[test] -fn gql_mutation_return_compact_rows_and_vectors_are_accepted() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("db"); - let engine = DatabaseEngine::open( - &db_path, - &DbOptions { - dense_vector: Some(DenseVectorConfig { - dimension: 3, - metric: DenseMetric::Cosine, - hnsw: HnswConfig::default(), - }), - ..DbOptions::default() - }, - ) - .unwrap(); - seed_query_test_catalog(&engine); - let node_id = engine - .upsert_node( - "GqlReturnOptions", - "n", - UpsertNodeOptions { - props: query_test_props(&[("status", PropValue::String("old".to_string()))]), - dense_vector: Some(vec![0.1, 0.2, 0.3]), - sparse_vector: Some(vec![(7, 2.5)]), - ..UpsertNodeOptions::default() - }, + let chain = engine + .execute_gql( + "CREATE (a:GqlChain {key: 'a'})-[r:Gql_CHAIN {rank: 1}]->(b:GqlChain {key: 'b'}) RETURN id(a), id(r), id(b)", + &GqlParams::new(), + &gql_opts(), ) .unwrap(); + assert_eq!(chain.mutation_stats.as_ref().unwrap().nodes_created, 2); + assert_eq!(chain.mutation_stats.as_ref().unwrap().edges_created, 1); + let ids = gql_u64_column(&chain, 0); + assert_eq!(ids.len(), 1); + let edge_ids = gql_u64_column(&chain, 1); + let b_ids = gql_u64_column(&chain, 2); + let chain_edge = engine.get_edge(edge_ids[0]).unwrap().unwrap(); + assert_eq!(chain_edge.from, ids[0]); + assert_eq!(chain_edge.to, b_ids[0]); +} - let omitted = engine +#[test] +fn gql_create_invalid_edge_validity_and_metadata_return_behaviors() { + let (_dir, engine) = query_test_engine(); + + let bad_valid_to = engine .execute_gql( - "MATCH (n:GqlReturnOptions) WHERE n.key = 'n' SET n.status = 'new' RETURN n", + "CREATE (a:GqlBadEdgeWindow {key: 'a'})-[r:Gql_BAD_WINDOW {valid_to: 0}]->(b:GqlBadEdgeWindow {key: 'b'}) RETURN r", &GqlParams::new(), &gql_opts(), ) - .unwrap(); - let returned = gql_single_node(&omitted.rows[0].values[0]); - assert_eq!(returned.id, Some(node_id)); - assert!(returned.dense_vector.is_none()); - assert!(returned.sparse_vector.is_none()); - assert_eq!( - returned.props.as_ref().unwrap().get("status"), - Some(&GqlValue::String("new".to_string())) + .unwrap_err(); + assert!( + matches!(bad_valid_to, EngineError::InvalidOperation(message) if message.contains("valid_from < valid_to")) ); + assert!(engine + .get_node_by_key("GqlBadEdgeWindow", "a") + .unwrap() + .is_none()); + assert!(engine + .get_node_by_key("GqlBadEdgeWindow", "b") + .unwrap() + .is_none()); - let vectors = engine + let bad_valid_from = engine .execute_gql( - "MATCH (n:GqlReturnOptions) WHERE n.key = 'n' SET n.status = 'newer' RETURN n", + "CREATE (a:GqlBadEdgeWindowMax {key: 'a'})-[r:Gql_BAD_WINDOW_MAX {valid_from: 9223372036854775807}]->(b:GqlBadEdgeWindowMax {key: 'b'}) RETURN r", &GqlParams::new(), - &GqlExecutionOptions { - include_vectors: true, - ..gql_opts() - }, + &gql_opts(), ) - .unwrap(); - let returned = gql_single_node(&vectors.rows[0].values[0]); - assert_eq!(returned.dense_vector.as_deref(), Some([0.1, 0.2, 0.3].as_slice())); - assert_eq!(returned.sparse_vector.as_deref(), Some([(7, 2.5)].as_slice())); - assert_eq!( - returned.props.as_ref().unwrap().get("status"), - Some(&GqlValue::String("newer".to_string())) + .unwrap_err(); + assert!( + matches!(bad_valid_from, EngineError::InvalidOperation(message) if message.contains("valid_from < valid_to")) ); + assert!(engine + .get_node_by_key("GqlBadEdgeWindowMax", "a") + .unwrap() + .is_none()); + assert!(engine + .get_node_by_key("GqlBadEdgeWindowMax", "b") + .unwrap() + .is_none()); - let compact = engine + let node_metadata_return = engine .execute_gql( - "MATCH (n:GqlReturnOptions) WHERE n.key = 'n' SET n.status = 'compact' RETURN n.status", + "CREATE (n:GqlReturnNodeMetadata {key: 'n'}) RETURN n.created_at", &GqlParams::new(), - &GqlExecutionOptions { - compact_rows: true, - ..gql_opts() - }, + &gql_opts(), ) .unwrap(); - assert_eq!(compact.columns, vec!["n.status"]); - assert_eq!(compact.rows[0].values[0], GqlValue::String("compact".to_string())); - assert_eq!( - engine - .get_node(node_id) - .unwrap() - .unwrap() - .props - .get("status"), - Some(&PropValue::String("compact".to_string())) - ); -} + assert_eq!(node_metadata_return.rows.len(), 1); + assert!(matches!( + node_metadata_return.rows[0].values[0], + GqlValue::Int(value) if value > 0 + )); + assert!(engine + .get_node_by_key("GqlReturnNodeMetadata", "n") + .unwrap() + .is_some()); -#[test] -fn gql_mutation_profile_db_hits_are_gated_and_nonzero_for_existing_reads() { - let (_dir, engine) = query_test_engine(); - let a = insert_query_node( - &engine, - "GqlMutationProfileHits", - "a", - &[("status", PropValue::String("left".to_string()))], - 1.0, - ); - let b = insert_query_node( - &engine, - "GqlMutationProfileHits", - "b", - &[("status", PropValue::String("old".to_string()))], - 1.0, - ); - engine - .upsert_edge(a, b, "Gql_PROFILE_HITS", UpsertEdgeOptions::default()) + let edge_metadata_return = engine + .execute_gql( + "CREATE (a:GqlReturnEdgeMetadata {key: 'a'})-[r:Gql_RETURN_META]->(b:GqlReturnEdgeMetadata {key: 'b'}) RETURN r.updated_at", + &GqlParams::new(), + &gql_opts(), + ) .unwrap(); + assert_eq!(edge_metadata_return.rows.len(), 1); + assert!(matches!( + edge_metadata_return.rows[0].values[0], + GqlValue::Int(value) if value > 0 + )); + assert!(engine + .get_node_by_key("GqlReturnEdgeMetadata", "a") + .unwrap() + .is_some()); + assert!(engine + .get_node_by_key("GqlReturnEdgeMetadata", "b") + .unwrap() + .is_some()); +} - let source = format!( - "MATCH (a:GqlMutationProfileHits)-[r:Gql_PROFILE_HITS]->(b:GqlMutationProfileHits) \ - WHERE id(a) = {a} \ - SET b.status = $status \ - RETURN b.key ORDER BY a.status, type(r)" - ); - let no_profile = engine +#[test] +fn gql_create_edge_strict_uniqueness_respects_engine_option() { + let (_dir, unique_engine) = gql_create_test_engine_with_options(DbOptions { + edge_uniqueness: true, + ..DbOptions::default() + }); + let a = insert_query_node(&unique_engine, "Person", "gql-unique-a", &[], 1.0); + let b = insert_query_node(&unique_engine, "Person", "gql-unique-b", &[], 1.0); + unique_engine + .upsert_edge(a, b, "Gql_UNIQUE", UpsertEdgeOptions::default()) + .unwrap(); + let duplicate = unique_engine .execute_gql( - &source, - &GqlParams::from([( - "status".to_string(), - GqlParamValue::String("first".to_string()), - )]), - &GqlExecutionOptions { - allow_full_scan: true, - ..gql_opts() - }, + "MATCH (a:Person) WHERE a.key = 'gql-unique-a' MATCH (b:Person) WHERE b.key = 'gql-unique-b' CREATE (a)-[:Gql_UNIQUE]->(b)", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!(matches!(duplicate, EngineError::InvalidOperation(message) if message.contains("already exists"))); + + let (_dir, parallel_engine) = query_test_engine(); + insert_query_node(¶llel_engine, "Person", "gql-parallel-a", &[], 1.0); + insert_query_node(¶llel_engine, "Person", "gql-parallel-b", &[], 1.0); + let parallel = parallel_engine + .execute_gql( + "MATCH (a:Person) WHERE a.key = 'gql-parallel-a' MATCH (b:Person) WHERE b.key = 'gql-parallel-b' CREATE (a)-[:Gql_PARALLEL]->(b), (a)-[:Gql_PARALLEL]->(b)", + &GqlParams::new(), + &gql_opts(), ) .unwrap(); - assert_eq!(no_profile.stats.db_hits, 0); - assert_eq!(no_profile.mutation_stats.as_ref().unwrap().db_hits, 0); + assert_eq!(parallel.mutation_stats.as_ref().unwrap().edges_created, 2); + assert_eq!(parallel.mutation_stats.as_ref().unwrap().mutation_ops, 2); +} - let profiled_create = engine +#[test] +fn gql_create_match_backed_rows_caps_and_optional_null_skip_are_atomic() { + let (_dir, engine) = query_test_engine(); + insert_query_node(&engine, "GqlBatch", "a", &[], 1.0); + insert_query_node(&engine, "GqlBatch", "b", &[], 1.0); + + let cap = engine .execute_gql( - "CREATE (n:GqlMutationProfileCreate {key: 'n'})", + "MATCH (s:GqlBatch) CREATE (n:GqlCapCreate {key: s.key}) RETURN n", &GqlParams::new(), &GqlExecutionOptions { - profile: true, + allow_full_scan: true, + max_mutation_rows: 1, ..gql_opts() }, ) - .unwrap(); - assert_eq!(profiled_create.stats.db_hits, 0); - assert_eq!( - profiled_create.mutation_stats.as_ref().unwrap().db_hits, - 0 - ); + .unwrap_err(); + assert!(matches!(cap, EngineError::InvalidOperation(message) if message.contains("max_mutation_rows"))); + assert!(engine + .get_node_by_key("GqlCapCreate", "a") + .unwrap() + .is_none()); + assert!(engine + .get_node_by_key("GqlCapCreate", "b") + .unwrap() + .is_none()); - let profiled = engine + let cursor_cap = engine .execute_gql( - &source, - &GqlParams::from([( - "status".to_string(), - GqlParamValue::String("second".to_string()), - )]), + "MATCH (s:GqlBatch) CREATE (n:GqlCursorCapCreate {key: s.key}) RETURN n", + &GqlParams::new(), &GqlExecutionOptions { allow_full_scan: true, - profile: true, + max_mutation_rows: 10, + max_intermediate_bindings: 1, ..gql_opts() }, ) - .unwrap(); - let mutation_stats = profiled.mutation_stats.as_ref().unwrap(); - assert!(profiled.stats.db_hits > 0); - assert_eq!(profiled.stats.db_hits, mutation_stats.db_hits); - assert!(profiled.stats.elapsed_us.is_some()); - assert!(mutation_stats.elapsed_us.is_some()); - assert_eq!( - engine - .get_node(b) - .unwrap() - .unwrap() - .props - .get("status"), - Some(&PropValue::String("second".to_string())) + .unwrap_err(); + assert!( + matches!(cursor_cap, EngineError::InvalidOperation(ref message) if message.contains("max_page_limit")), + "{cursor_cap:?}" ); + assert!(engine + .get_node_by_key("GqlCursorCapCreate", "a") + .unwrap() + .is_none()); + assert!(engine + .get_node_by_key("GqlCursorCapCreate", "b") + .unwrap() + .is_none()); + + let root = insert_query_node(&engine, "GqlOptionalRoot", "root", &[], 1.0); + let skipped = engine + .execute_gql( + "MATCH (a:GqlOptionalRoot) WHERE a.key = 'root' OPTIONAL MATCH (a)-[r:Gql_MISSING]->(b) CREATE (b)-[:Gql_SKIP]->(c:GqlSkipped {key: 'c'}) RETURN c", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + assert_eq!(skipped.rows.len(), 1); + assert_eq!(skipped.rows[0].values[0], GqlValue::Null); + let stats = skipped.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.rows_matched, 1); + assert_eq!(stats.mutation_rows, 0); + assert_eq!(stats.skipped_null_targets, 1); + assert_eq!(stats.nodes_created, 0); + assert!(engine.get_node_by_key("GqlSkipped", "c").unwrap().is_none()); + assert!(engine + .query_edges(&EdgeQuery { + from_ids: vec![root], + label: Some("Gql_SKIP".to_string()), + ..Default::default() + }) + .unwrap() + .edges + .is_empty()); } #[test] -fn gql_mutation_return_row_ops_affect_rows_not_mutations() { +fn gql_create_cap_fails_during_materialization_without_writes() { let (_dir, engine) = query_test_engine(); - for (key, rank) in [("a", 1), ("b", 2), ("c", 3)] { - insert_query_node( - &engine, - "GqlCreateReturnOpsSeed", - key, - &[("rank", PropValue::Int(rank))], - 1.0, - ); + for key in ["a", "b", "c"] { + insert_query_node(&engine, "GqlCreateEarlyCapSource", key, &[], 1.0); } - let options = GqlExecutionOptions { - allow_full_scan: true, - ..gql_opts() - }; - let created = engine + let err = engine .execute_gql( - "MATCH (s:GqlCreateReturnOpsSeed) CREATE (n:GqlCreateReturnOps {key: s.key, rank: s.rank}) RETURN n.key ORDER BY n.rank DESC SKIP 1 LIMIT 1", + "MATCH (s:GqlCreateEarlyCapSource) CREATE (n:GqlCreateEarlyCap {key: s.key})", &GqlParams::new(), - &options, + &GqlExecutionOptions { + allow_full_scan: true, + max_mutation_ops: 1, + ..gql_opts() + }, ) - .unwrap(); - assert_eq!(gql_string_column(&created, 0), vec!["b".to_string()]); - assert_eq!(created.stats.rows_returned, 1); - let stats = created.mutation_stats.as_ref().unwrap(); - assert_eq!(stats.mutation_rows, 3); - assert_eq!(stats.nodes_created, 3); + .unwrap_err(); + assert!(matches!(err, EngineError::InvalidOperation(message) if message.contains("max_mutation_ops"))); for key in ["a", "b", "c"] { assert!(engine - .get_node_by_key("GqlCreateReturnOps", key) + .get_node_by_key("GqlCreateEarlyCap", key) .unwrap() - .is_some()); + .is_none()); } +} - for (key, rank) in [("a", Some(1)), ("b", Some(1)), ("c", None)] { - let mut props = Vec::new(); - if let Some(rank) = rank { - props.push(("rank", PropValue::Int(rank))); - } - insert_query_node(&engine, "GqlSetReturnOps", key, &props, 1.0); - } - let set = engine +#[test] +fn gql_create_return_order_by_id_and_later_delete_executes() { + let (_dir, engine) = query_test_engine(); + let supported_return = engine .execute_gql( - "MATCH (n:GqlSetReturnOps) SET n.touched = true RETURN n.key ORDER BY n.rank, id(n) SKIP 1 LIMIT 2", + "CREATE (n:GqlReturnSupportedOrder {key: 'n'}) RETURN n ORDER BY n.key", &GqlParams::new(), - &options, + &gql_opts(), ) .unwrap(); + assert_eq!(supported_return.rows.len(), 1); + let returned = gql_single_node(&supported_return.rows[0].values[0]); + assert_eq!(returned.key.as_deref(), Some("n")); + assert!(engine + .get_node_by_key("GqlReturnSupportedOrder", "n") + .unwrap() + .is_some()); + + let supported_set = engine + .execute_gql( + "CREATE (n:GqlUnsupportedSet {key: 'n'}) SET n.name = 'Ada'", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + assert_eq!(supported_set.mutation_stats.as_ref().unwrap().nodes_created, 1); assert_eq!( - gql_string_column(&set, 0), - vec!["b".to_string(), "c".to_string()] + engine + .get_node_by_key("GqlUnsupportedSet", "n") + .unwrap() + .unwrap() + .props + .get("name"), + Some(&PropValue::String("Ada".to_string())) ); - assert_eq!(set.stats.rows_returned, 2); - let stats = set.mutation_stats.as_ref().unwrap(); - assert_eq!(stats.mutation_rows, 3); - assert_eq!(stats.nodes_updated, 3); - for key in ["a", "b", "c"] { - assert_eq!( - engine - .get_node_by_key("GqlSetReturnOps", key) - .unwrap() - .unwrap() - .props - .get("touched"), - Some(&PropValue::Bool(true)) - ); - } - for (key, rank) in [("low", Some(1)), ("high", Some(3)), ("missing", None)] { - let mut props = Vec::new(); - if let Some(rank) = rank { - props.push(("rank", PropValue::Int(rank))); - } - insert_query_node(&engine, "GqlNullDescReturnOps", key, &props, 1.0); - } - let null_desc = engine + let delete = engine .execute_gql( - "MATCH (n:GqlNullDescReturnOps) SET n.checked = true \ - RETURN n.key ORDER BY n.rank DESC LIMIT 1", + "MATCH (n:GqlUnsupportedSet) WHERE n.key = 'n' DETACH DELETE n", &GqlParams::new(), - &options, + &gql_opts(), ) .unwrap(); - assert_eq!(gql_string_column(&null_desc, 0), vec!["high".to_string()]); - let stats = null_desc.mutation_stats.as_ref().unwrap(); - assert_eq!(stats.mutation_rows, 3); - assert_eq!(stats.nodes_updated, 3); - for key in ["low", "high", "missing"] { - assert_eq!( - engine - .get_node_by_key("GqlNullDescReturnOps", key) - .unwrap() - .unwrap() - .props - .get("checked"), - Some(&PropValue::Bool(true)) - ); - } + assert_eq!(delete.mutation_stats.as_ref().unwrap().nodes_deleted, 1); + assert!(engine + .get_node_by_key("GqlUnsupportedSet", "n") + .unwrap() + .is_none()); +} - let limit_zero = engine - .execute_gql( - "MATCH (n:GqlSetReturnOps) SET n.limit_zero = true RETURN n.key ORDER BY n.rank LIMIT 0", - &GqlParams::new(), - &options, - ) +#[test] +fn gql_set_node_property_updates_existing_node_index_and_return() { + let (_dir, engine) = query_test_engine(); + engine + .ensure_node_property_index("GqlSetIndexed", "status", SecondaryIndexKind::Equality) .unwrap(); - assert!(limit_zero.rows.is_empty()); - assert_eq!(limit_zero.stats.rows_returned, 0); - let stats = limit_zero.mutation_stats.as_ref().unwrap(); - assert_eq!(stats.mutation_rows, 3); - assert_eq!(stats.nodes_updated, 3); - for key in ["a", "b", "c"] { - assert_eq!( - engine - .get_node_by_key("GqlSetReturnOps", key) - .unwrap() - .unwrap() - .props - .get("limit_zero"), - Some(&PropValue::Bool(true)) - ); - } + let node_id = insert_query_node( + &engine, + "GqlSetIndexed", + "n", + &[ + ("status", PropValue::String("old".to_string())), + ("rank", PropValue::Int(1)), + ], + 1.25, + ); + let before = engine.get_node(node_id).unwrap().unwrap(); - for (key, rank) in [("a", 1), ("b", 2), ("c", 3)] { - insert_query_node( - &engine, - "GqlRemoveReturnOps", - key, - &[("rank", PropValue::Int(rank)), ("drop", PropValue::String("x".to_string()))], - 1.0, - ); - } - let removed = engine + let result = engine .execute_gql( - "MATCH (n:GqlRemoveReturnOps) REMOVE n.drop RETURN n.key ORDER BY n.rank DESC LIMIT 2", + "MATCH (n:GqlSetIndexed) WHERE n.key = 'n' SET n.status = 'new' RETURN n, id(n), n.status, n.weight", &GqlParams::new(), - &options, + &gql_opts(), ) .unwrap(); + assert_eq!(result.rows.len(), 1); + assert_eq!(result.rows[0].values[1], GqlValue::UInt(node_id)); + assert_eq!(result.rows[0].values[2], GqlValue::String("new".to_string())); + assert_eq!(result.rows[0].values[3], GqlValue::Float(1.25)); + let returned = gql_single_node(&result.rows[0].values[0]); + assert_eq!(returned.id, Some(node_id)); assert_eq!( - gql_string_column(&removed, 0), - vec!["c".to_string(), "b".to_string()] + returned.props.as_ref().unwrap().get("status"), + Some(&GqlValue::String("new".to_string())) ); - assert_eq!(removed.stats.rows_returned, 2); - let stats = removed.mutation_stats.as_ref().unwrap(); - assert_eq!(stats.mutation_rows, 3); - assert_eq!(stats.nodes_updated, 3); - for key in ["a", "b", "c"] { - assert!(!engine - .get_node_by_key("GqlRemoveReturnOps", key) - .unwrap() - .unwrap() - .props - .contains_key("drop")); - } + + let stored = engine.get_node(node_id).unwrap().unwrap(); + assert_eq!(stored.id, node_id); + assert_eq!(stored.created_at, before.created_at); + assert!(stored.updated_at >= before.updated_at); + assert_eq!( + stored.props.get("status"), + Some(&PropValue::String("new".to_string())) + ); + let new_read = execute_gql_ok( + &engine, + "MATCH (n:GqlSetIndexed {status: 'new'}) RETURN id(n)", + ); + assert_eq!(gql_u64_column(&new_read, 0), vec![node_id]); + let old_read = execute_gql_ok( + &engine, + "MATCH (n:GqlSetIndexed {status: 'old'}) RETURN id(n)", + ); + assert!(old_read.rows.is_empty()); + let stats = result.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.nodes_updated, 1); + assert_eq!(stats.properties_set, 1); + assert_eq!(stats.mutation_ops, 1); } #[test] -fn gql_mutation_return_caps_and_order_errors_are_atomic() { - let (_dir, engine) = query_test_engine(); - for key in ["a", "b"] { - insert_query_node( - &engine, - "GqlReturnCapRows", - key, - &[("status", PropValue::String("old".to_string()))], - 1.0, - ); - } - let max_rows = engine - .execute_gql( - "MATCH (n:GqlReturnCapRows) SET n.status = 'new' RETURN n", - &GqlParams::new(), - &GqlExecutionOptions { - allow_full_scan: true, - max_rows: 1, - ..gql_opts() +fn gql_set_edge_property_and_metadata_preserves_edge_identity() { + let (_dir, engine) = gql_create_test_engine_with_options(DbOptions { + edge_uniqueness: false, + ..DbOptions::default() + }); + let a = insert_query_node(&engine, "GqlEdgeSetNode", "a", &[], 1.0); + let b = insert_query_node(&engine, "GqlEdgeSetNode", "b", &[], 1.0); + let edge_id = engine + .upsert_edge( + a, + b, + "Gql_SET_EDGE", + UpsertEdgeOptions { + props: query_test_props(&[("since", PropValue::Int(2020))]), + weight: 0.5, + valid_from: Some(0), + valid_to: Some(i64::MAX), }, ) - .unwrap_err(); - assert!( - max_rows.to_string().contains("max_rows"), - "unexpected error: {max_rows:?}" - ); - for key in ["a", "b"] { - assert_eq!( - engine - .get_node_by_key("GqlReturnCapRows", key) - .unwrap() - .unwrap() - .props - .get("status"), - Some(&PropValue::String("old".to_string())) - ); - } + .unwrap(); + let before = engine.get_edge(edge_id).unwrap().unwrap(); - let max_skip = engine + let result = engine .execute_gql( - "MATCH (n:GqlReturnCapRows) SET n.status = 'skip' RETURN n SKIP 2", + "MATCH (a:GqlEdgeSetNode) WHERE a.key = 'a' MATCH (b:GqlEdgeSetNode) WHERE b.key = 'b' MATCH (a)-[r:Gql_SET_EDGE]->(b) \ + SET r.since = 2026 SET r.weight = 2.5 SET r.valid_from = 10 SET r.valid_to = 20 \ + RETURN id(r), r.since, r.weight, r.valid_from, r.valid_to", &GqlParams::new(), - &GqlExecutionOptions { - allow_full_scan: true, - max_skip: 1, - ..gql_opts() - }, + &gql_opts(), ) - .unwrap_err(); - assert!( - max_skip.to_string().contains("max_skip"), - "unexpected error: {max_skip:?}" - ); + .unwrap(); + assert_eq!(result.rows[0].values[0], GqlValue::UInt(edge_id)); + assert_eq!(result.rows[0].values[1], GqlValue::Int(2026)); + assert_eq!(result.rows[0].values[2], GqlValue::Float(2.5)); + assert_eq!(result.rows[0].values[3], GqlValue::Int(10)); + assert_eq!(result.rows[0].values[4], GqlValue::Int(20)); - let max_order = engine - .execute_gql( - "MATCH (n:GqlReturnCapRows) SET n.status = 'ordered' RETURN n.key ORDER BY n.key", - &GqlParams::new(), - &GqlExecutionOptions { - allow_full_scan: true, - max_order_materialization: 1, - ..gql_opts() + let after = engine.get_edge(edge_id).unwrap().unwrap(); + assert_eq!(after.id, edge_id); + assert_eq!(after.from, a); + assert_eq!(after.to, b); + assert_eq!(after.label, "Gql_SET_EDGE"); + assert_eq!(after.created_at, before.created_at); + assert!(after.updated_at >= before.updated_at); + assert_eq!(after.props.get("since"), Some(&PropValue::Int(2026))); + assert_eq!(after.weight, 2.5); + assert_eq!(after.valid_from, 10); + assert_eq!(after.valid_to, 20); + assert_eq!(result.mutation_stats.as_ref().unwrap().edges_updated, 1); +} + +#[test] +fn gql_set_existing_edge_allows_same_statement_parallel_create_when_nonunique() { + let (_dir, engine) = gql_create_test_engine_with_options(DbOptions { + edge_uniqueness: false, + ..DbOptions::default() + }); + let a = insert_query_node(&engine, "GqlParallelRplNode", "a", &[], 1.0); + let b = insert_query_node(&engine, "GqlParallelRplNode", "b", &[], 1.0); + let existing = engine + .upsert_edge( + a, + b, + "Gql_PARALLEL_REPLACE", + UpsertEdgeOptions { + props: query_test_props(&[("kind", PropValue::String("old".to_string()))]), + ..Default::default() }, ) - .unwrap_err(); - assert!( - max_order.to_string().contains("max_order_materialization"), - "unexpected error: {max_order:?}" - ); + .unwrap(); - let unsupported_order = engine + let result = engine .execute_gql( - "MATCH (n:GqlReturnCapRows) SET n.status = 'bad-order' RETURN n.key ORDER BY n", + "MATCH (a:GqlParallelRplNode) WHERE a.key = 'a' \ + MATCH (b:GqlParallelRplNode) WHERE b.key = 'b' \ + MATCH (a)-[r:Gql_PARALLEL_REPLACE]->(b) \ + CREATE (a)-[x:Gql_PARALLEL_REPLACE {kind: 'new'}]->(b) \ + SET r.kind = 'updated' RETURN id(r), r.kind", &GqlParams::new(), - &GqlExecutionOptions { - allow_full_scan: true, - ..gql_opts() - }, + &gql_opts(), ) - .unwrap_err(); - assert!( - unsupported_order.to_string().contains("ORDER BY"), - "unexpected error: {unsupported_order:?}" + .unwrap(); + assert_eq!(result.rows[0].values[0], GqlValue::UInt(existing)); + assert_eq!( + result.rows[0].values[1], + GqlValue::String("updated".to_string()) ); - for key in ["a", "b"] { - assert_eq!( - engine - .get_node_by_key("GqlReturnCapRows", key) - .unwrap() - .unwrap() - .props - .get("status"), - Some(&PropValue::String("old".to_string())) - ); - } + let edges = engine + .query_edges(&EdgeQuery { + label: Some("Gql_PARALLEL_REPLACE".to_string()), + from_ids: vec![a], + to_ids: vec![b], + ..Default::default() + }) + .unwrap() + .edges; + assert_eq!(edges.len(), 2); + let existing_after = engine.get_edge(existing).unwrap().unwrap(); + assert_eq!(existing_after.id, existing); + assert_eq!( + existing_after.props.get("kind"), + Some(&PropValue::String("updated".to_string())) + ); + assert!(edges.iter().any(|edge| { + edge.id != existing + && edge.props.get("kind") == Some(&PropValue::String("new".to_string())) + })); } #[test] -fn gql_mutation_return_prevalidates_order_and_projection_against_final_state() { +fn gql_set_map_merge_handles_nulls_and_weight_as_property() { let (_dir, engine) = query_test_engine(); - let rank_id = insert_query_node( + let node_id = insert_query_node( &engine, - "GqlReturnFinalValidation", - "rank", - &[("rank", PropValue::Int(1))], - 1.0, - ); - let order_err = engine - .execute_gql( - "MATCH (n:GqlReturnFinalValidation) WHERE n.key = 'rank' \ - SET n.rank = [1] RETURN n.key ORDER BY n.rank", - &GqlParams::new(), - &gql_opts(), - ) - .unwrap_err(); - assert!( - order_err.to_string().contains("ORDER BY"), - "unexpected error: {order_err:?}" - ); - assert_eq!( - engine - .get_node(rank_id) - .unwrap() - .unwrap() - .props - .get("rank"), - Some(&PropValue::Int(1)) + "GqlMapMerge", + "n", + &[ + ("old", PropValue::String("remove".to_string())), + ("keep", PropValue::Int(1)), + ], + 3.0, ); + let params = GqlParams::from([( + "props".to_string(), + GqlParamValue::Map(BTreeMap::from([ + ("old".to_string(), GqlParamValue::Null), + ("keep".to_string(), GqlParamValue::Int(2)), + ( + "nested".to_string(), + GqlParamValue::List(vec![GqlParamValue::Null, GqlParamValue::String("x".to_string())]), + ), + ("weight".to_string(), GqlParamValue::String("stored-prop".to_string())), + ])), + )]); - let mut payload = BTreeMap::new(); - payload.insert("inner".to_string(), PropValue::String("ok".to_string())); - let nested_id = insert_query_node( - &engine, - "GqlReturnFinalValidation", - "nested", - &[("payload", PropValue::Map(payload.clone()))], - 1.0, - ); - let projection_err = engine + engine .execute_gql( - "MATCH (n:GqlReturnFinalValidation) WHERE n.key = 'nested' \ - SET n.payload = 7 RETURN n.payload.inner", - &GqlParams::new(), + "MATCH (n:GqlMapMerge) WHERE n.key = 'n' SET n += $props RETURN n.keep, n.old, n.nested, n.weight", + ¶ms, &gql_opts(), ) - .unwrap_err(); - assert!( - matches!(projection_err, EngineError::GqlSemantic { .. }), - "unexpected error: {projection_err:?}" + .unwrap(); + let stored = engine.get_node(node_id).unwrap().unwrap(); + assert_eq!(stored.weight, 3.0); + assert!(!stored.props.contains_key("old")); + assert_eq!(stored.props.get("keep"), Some(&PropValue::Int(2))); + assert_eq!( + stored.props.get("nested"), + Some(&PropValue::Array(vec![ + PropValue::Null, + PropValue::String("x".to_string()) + ])) ); assert_eq!( - engine - .get_node(nested_id) - .unwrap() - .unwrap() - .props - .get("payload"), - Some(&PropValue::Map(payload)) + stored.props.get("weight"), + Some(&PropValue::String("stored-prop".to_string())) ); - let metadata_id_err = engine + let non_map = engine .execute_gql( - "MATCH (n:GqlReturnFinalValidation) WHERE n.key = 'rank' \ - SET n.status = 'metadata-id-bad' RETURN n.updated_at.inner", + "MATCH (n:GqlMapMerge) WHERE n.key = 'n' SET n += 1", &GqlParams::new(), &gql_opts(), ) .unwrap_err(); - assert!( - matches!(metadata_id_err, EngineError::GqlSemantic { .. }), - "unexpected error: {metadata_id_err:?}" - ); - assert_eq!( - engine - .get_node(rank_id) - .unwrap() - .unwrap() - .props - .get("status"), - None - ); + assert!(matches!(non_map, EngineError::InvalidOperation(message) if message.contains("map"))); } #[test] -fn gql_mutation_return_volatile_metadata_order_rejects_before_write() { +fn gql_set_map_merge_rejects_reserved_metadata_keys() { let (_dir, engine) = query_test_engine(); - for key in ["a", "b", "c"] { - insert_query_node(&engine, "GqlReturnCreatedMetaSeed", key, &[], 1.0); - } - let options = GqlExecutionOptions { - allow_full_scan: true, - ..gql_opts() - }; - - let node_order = engine - .execute_gql( - "MATCH (s:GqlReturnCreatedMetaSeed) \ - CREATE (n:GqlReturnCreatedMeta {key: s.key}) \ - RETURN n.key ORDER BY n.id DESC SKIP 1 LIMIT 1", - &GqlParams::new(), - &options, - ) - .unwrap_err(); - assert!( - node_order.to_string().contains("ORDER BY"), - "unexpected error: {node_order:?}" - ); - for key in ["a", "b", "c"] { - assert!(engine - .get_node_by_key("GqlReturnCreatedMeta", key) - .unwrap() - .is_none()); - } - - let root = insert_query_node(&engine, "GqlReturnCreatedEdgeMetaRoot", "root", &[], 1.0); - for key in ["a", "b", "c"] { - insert_query_node(&engine, "GqlReturnCreatedEdgeMetaTarget", key, &[], 1.0); - } - let edge_order = engine - .execute_gql( - &format!( - "MATCH (from:GqlReturnCreatedEdgeMetaRoot) \ - MATCH (to:GqlReturnCreatedEdgeMetaTarget) \ - WHERE id(from) = {root} \ - CREATE (from)-[r:Gql_RETURN_CREATED_EDGE_META]->(to) \ - RETURN to.key ORDER BY r.to DESC SKIP 1 LIMIT 1" - ), - &GqlParams::new(), - &options, - ) - .unwrap_err(); - assert!( - edge_order.to_string().contains("ORDER BY"), - "unexpected error: {edge_order:?}" - ); - assert!(engine - .query_edges(&EdgeQuery { - label: Some("Gql_RETURN_CREATED_EDGE_META".to_string()), - ..EdgeQuery::default() - }) - .unwrap() - .edges - .is_empty()); - - let changed = insert_query_node( + let a = insert_query_node( &engine, - "GqlReturnChangedUpdatedAt", - "n", + "GqlReservedMapMerge", + "a", &[("status", PropValue::String("old".to_string()))], 1.0, ); - let updated_at_order = engine - .execute_gql( - "MATCH (n:GqlReturnChangedUpdatedAt) \ - SET n.status = 'new' RETURN n.key ORDER BY n.updated_at", - &GqlParams::new(), - &options, + let b = insert_query_node(&engine, "GqlReservedMapMerge", "b", &[], 1.0); + let edge_id = engine + .upsert_edge( + a, + b, + "Gql_RESERVED_MERGE_EDGE", + UpsertEdgeOptions { + props: BTreeMap::from([( + "status".to_string(), + PropValue::String("old".to_string()), + )]), + ..Default::default() + }, ) - .unwrap_err(); - assert!( - updated_at_order.to_string().contains("ORDER BY"), - "unexpected error: {updated_at_order:?}" - ); + .unwrap(); + + for key in [ + "id", + "labels", + "key", + "created_at", + "updated_at", + "dense_vector", + "sparse_vector", + ] { + let err = engine + .execute_gql( + "MATCH (n:GqlReservedMapMerge) WHERE n.key = 'a' SET n += $props", + &GqlParams::from([( + "props".to_string(), + GqlParamValue::Map(BTreeMap::from([( + key.to_string(), + GqlParamValue::Int(1), + )])), + )]), + &gql_opts(), + ) + .unwrap_err(); + assert!( + matches!(&err, EngineError::InvalidOperation(message) if message.contains("reserved metadata")), + "expected reserved metadata error for node key {key}, got {err:?}" + ); + } + let node = engine.get_node(a).unwrap().unwrap(); assert_eq!( - engine - .get_node(changed) - .unwrap() - .unwrap() - .props - .get("status"), + node.props.get("status"), + Some(&PropValue::String("old".to_string())) + ); + assert!(!node.props.contains_key("id")); + assert!(!node.props.contains_key("key")); + assert!(!node.props.contains_key("dense_vector")); + + for key in ["id", "from", "to", "label", "type", "created_at", "updated_at"] { + let err = engine + .execute_gql( + "MATCH (a:GqlReservedMapMerge) WHERE a.key = 'a' \ + MATCH (b:GqlReservedMapMerge) WHERE b.key = 'b' \ + MATCH (a)-[r:Gql_RESERVED_MERGE_EDGE]->(b) SET r += $props", + &GqlParams::from([( + "props".to_string(), + GqlParamValue::Map(BTreeMap::from([( + key.to_string(), + GqlParamValue::Int(1), + )])), + )]), + &gql_opts(), + ) + .unwrap_err(); + assert!( + matches!(&err, EngineError::InvalidOperation(message) if message.contains("reserved metadata")), + "expected reserved metadata error for edge key {key}, got {err:?}" + ); + } + let edge = engine.get_edge(edge_id).unwrap().unwrap(); + assert_eq!( + edge.props.get("status"), Some(&PropValue::String("old".to_string())) ); + assert!(!edge.props.contains_key("from")); + assert!(!edge.props.contains_key("type")); } #[test] -fn gql_mutation_return_gql_read_set_conflicts_for_returned_and_ordered_hydration() { +fn gql_remove_property_and_label_are_noop_safe_and_atomic() { let (_dir, engine) = query_test_engine(); - let a = insert_query_node( - &engine, - "GqlReturnReadSet", - "a", - &[("status", PropValue::String("old-a".to_string()))], - 1.0, - ); - let b = insert_query_node( + let node_id = insert_query_node_with_labels( &engine, - "GqlReturnReadSet", - "b", - &[("status", PropValue::String("old-b".to_string()))], + &["GqlRemove", "GqlRemoveExtra"], + "n", + &[("drop", PropValue::Bool(true))], 1.0, ); - let edge = engine - .upsert_edge(a, b, "Gql_RETURN_READ_SET", UpsertEdgeOptions::default()) - .unwrap(); - - let run_paused = |source: String, engine: &DatabaseEngine| { - let worker = DatabaseEngine { - runtime: std::sync::Arc::clone(&engine.runtime), - }; - let (ready_rx, release_tx) = engine.set_gql_mutation_before_commit_pause(); - let handle = std::thread::spawn(move || { - worker.execute_gql( - &source, - &GqlParams::new(), - &GqlExecutionOptions { - allow_full_scan: true, - ..GqlExecutionOptions::default() - }, - ) - }); - ready_rx - .recv_timeout(std::time::Duration::from_secs(5)) - .expect("GQL mutation did not pause before commit"); - (release_tx, handle) - }; - let source = format!( - "MATCH (a:GqlReturnReadSet)-[:Gql_RETURN_READ_SET]->(b:GqlReturnReadSet) \ - WHERE id(a) = {a} SET b.status = 'returned-existing-conflict' RETURN a" - ); - let (release_tx, handle) = run_paused(source, &engine); - engine - .upsert_node( - "GqlReturnReadSet", - "a", - UpsertNodeOptions { - props: query_test_props(&[( - "status", - PropValue::String("outside-a".to_string()), - )]), - ..UpsertNodeOptions::default() - }, + let result = engine + .execute_gql( + "MATCH (n:GqlRemove) WHERE n.key = 'n' REMOVE n.drop REMOVE n.missing REMOVE n:GqlRemoveExtra RETURN n.drop, labels(n)", + &GqlParams::new(), + &gql_opts(), ) .unwrap(); - release_tx.send(()).unwrap(); - let err = handle.join().unwrap().unwrap_err(); - assert!(matches!(err, EngineError::TxnConflict(_)), "{err:?}"); - assert_eq!( - engine - .get_node(b) - .unwrap() - .unwrap() - .props - .get("status"), - Some(&PropValue::String("old-b".to_string())) - ); + assert_eq!(result.rows[0].values[0], GqlValue::Null); + let stored = engine.get_node(node_id).unwrap().unwrap(); + assert!(!stored.props.contains_key("drop")); + assert!(stored.labels.iter().any(|label| label == "GqlRemove")); + assert!(!stored.labels.iter().any(|label| label == "GqlRemoveExtra")); + let stats = result.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.labels_removed, 1); + assert_eq!(stats.properties_removed, 1); - let source = format!( - "MATCH (a:GqlReturnReadSet)-[:Gql_RETURN_READ_SET]->(b:GqlReturnReadSet) \ - WHERE id(a) = {a} SET b.status = 'order-only-node-conflict' \ - RETURN b.key ORDER BY a.status" - ); - let (release_tx, handle) = run_paused(source, &engine); - engine - .upsert_node( - "GqlReturnReadSet", - "a", - UpsertNodeOptions { - props: query_test_props(&[( - "status", - PropValue::String("outside-order-a".to_string()), - )]), - ..UpsertNodeOptions::default() - }, + let last_label = engine + .execute_gql( + "MATCH (n:GqlRemove) WHERE n.key = 'n' REMOVE n:GqlRemove", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!(matches!(last_label, EngineError::InvalidOperation(message) if message.contains("last node label"))); + assert!(engine.get_node(node_id).unwrap().unwrap().labels.contains(&"GqlRemove".to_string())); + + let optional = engine + .execute_gql( + "MATCH (n:GqlRemove) WHERE n.key = 'n' OPTIONAL MATCH (n)-[r:Gql_REMOVE_MISSING]->(m) SET m.name = 'x' REMOVE m.missing", + &GqlParams::new(), + &gql_opts(), ) .unwrap(); - release_tx.send(()).unwrap(); - let err = handle.join().unwrap().unwrap_err(); - assert!(matches!(err, EngineError::TxnConflict(_)), "{err:?}"); - assert_eq!( - engine - .get_node(b) - .unwrap() - .unwrap() - .props - .get("status"), - Some(&PropValue::String("old-b".to_string())) - ); + assert_eq!(optional.mutation_stats.as_ref().unwrap().skipped_null_targets, 2); + assert_eq!(optional.mutation_stats.as_ref().unwrap().mutation_ops, 0); +} - let source = format!( - "MATCH (a:GqlReturnReadSet)-[r:Gql_RETURN_READ_SET]->(b:GqlReturnReadSet) \ - WHERE id(a) = {a} SET b.status = 'order-only-edge-conflict' \ - RETURN b.key ORDER BY r.status" - ); - let (release_tx, handle) = run_paused(source, &engine); - engine.delete_edge(edge).unwrap(); - release_tx.send(()).unwrap(); - let err = handle.join().unwrap().unwrap_err(); - assert!(matches!(err, EngineError::TxnConflict(_)), "{err:?}"); - assert_eq!( - engine - .get_node(b) - .unwrap() - .unwrap() - .props - .get("status"), - Some(&PropValue::String("old-b".to_string())) - ); +#[test] +fn gql_set_duplicate_targets_are_coalesced_last_write_wins() { + let (_dir, engine) = query_test_engine(); + let node_id = insert_query_node(&engine, "GqlDuplicateSet", "n", &[], 1.0); - let edge = engine - .upsert_edge(a, b, "Gql_RETURN_READ_SET", UpsertEdgeOptions::default()) + let result = engine + .execute_gql( + "MATCH (n:GqlDuplicateSet) WHERE n.key = 'n' SET n.name = 'first' SET n.name = 'second' RETURN n.name", + &GqlParams::new(), + &gql_opts(), + ) .unwrap(); - let source = format!( - "MATCH p = (a:GqlReturnReadSet)-[:Gql_RETURN_READ_SET]->(b:GqlReturnReadSet) \ - WHERE id(a) = {a} SET b.status = 'path-conflict' RETURN p" - ); - let (release_tx, handle) = run_paused(source, &engine); - engine.delete_edge(edge).unwrap(); - release_tx.send(()).unwrap(); - let err = handle.join().unwrap().unwrap_err(); - assert!(matches!(err, EngineError::TxnConflict(_)), "{err:?}"); + assert_eq!(result.rows[0].values[0], GqlValue::String("second".to_string())); assert_eq!( engine - .get_node(b) + .get_node(node_id) .unwrap() .unwrap() .props - .get("status"), - Some(&PropValue::String("old-b".to_string())) + .get("name"), + Some(&PropValue::String("second".to_string())) ); + let stats = result.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.mutation_ops, 1); + assert_eq!(stats.duplicate_targets, 1); +} - let edge = engine - .upsert_edge(a, b, "Gql_RETURN_READ_SET", UpsertEdgeOptions::default()) - .unwrap(); - let source = format!( - "MATCH p = (a:GqlReturnReadSet)-[:Gql_RETURN_READ_SET]->(b:GqlReturnReadSet) \ - WHERE id(a) = {a} SET b.status = 'start-node-conflict' RETURN start_node(p)" - ); - let (release_tx, handle) = run_paused(source, &engine); - engine - .upsert_node( - "GqlReturnReadSet", - "a", - UpsertNodeOptions { - props: query_test_props(&[( - "status", - PropValue::String("outside-start".to_string()), - )]), - ..UpsertNodeOptions::default() - }, +#[test] +fn gql_mixed_create_set_remove_returns_final_created_alias() { + let (_dir, engine) = query_test_engine(); + let result = engine + .execute_gql( + "CREATE (n:GqlMixedCreate {key: 'n', old: 'x'}) SET n.name = 'Ada' REMOVE n.old SET n:GqlMixedExtra RETURN n.name, n.old, labels(n)", + &GqlParams::new(), + &gql_opts(), ) .unwrap(); - release_tx.send(()).unwrap(); - let err = handle.join().unwrap().unwrap_err(); - assert!(matches!(err, EngineError::TxnConflict(_)), "{err:?}"); - assert_eq!( - engine - .get_node(b) - .unwrap() - .unwrap() - .props - .get("status"), - Some(&PropValue::String("old-b".to_string())) - ); - - let source = format!( - "MATCH p = (a:GqlReturnReadSet)-[:Gql_RETURN_READ_SET]->(b:GqlReturnReadSet) \ - WHERE id(a) = {a} SET b.status = 'relationships-conflict' RETURN relationships(p)" - ); - let (release_tx, handle) = run_paused(source, &engine); - engine.delete_edge(edge).unwrap(); - release_tx.send(()).unwrap(); - let err = handle.join().unwrap().unwrap_err(); - assert!(matches!(err, EngineError::TxnConflict(_)), "{err:?}"); - assert_eq!( - engine - .get_node(b) - .unwrap() - .unwrap() - .props - .get("status"), - Some(&PropValue::String("old-b".to_string())) - ); - - let edge = engine - .upsert_edge(a, b, "Gql_RETURN_READ_SET", UpsertEdgeOptions::default()) - .unwrap(); - let source = format!( - "MATCH p = (a:GqlReturnReadSet)-[:Gql_RETURN_READ_SET]->(b:GqlReturnReadSet) \ - WHERE id(a) = {a} SET b.status = 'path-helper-no-conflict' RETURN node_ids(p)" - ); - let (release_tx, handle) = run_paused(source, &engine); - engine.delete_edge(edge).unwrap(); - release_tx.send(()).unwrap(); - let helper = handle.join().unwrap().unwrap(); - assert_eq!(helper.stats.rows_returned, 1); - assert_eq!( - engine - .get_node(b) - .unwrap() - .unwrap() - .props - .get("status"), - Some(&PropValue::String("path-helper-no-conflict".to_string())) - ); - - let edge = engine - .upsert_edge(a, b, "Gql_RETURN_READ_SET", UpsertEdgeOptions::default()) + assert_eq!(result.rows[0].values[0], GqlValue::String("Ada".to_string())); + assert_eq!(result.rows[0].values[1], GqlValue::Null); + match &result.rows[0].values[2] { + GqlValue::List(labels) => { + assert!(labels.contains(&GqlValue::String("GqlMixedCreate".to_string()))); + assert!(labels.contains(&GqlValue::String("GqlMixedExtra".to_string()))); + } + other => panic!("expected labels list, got {other:?}"), + } + let stored = engine + .get_node_by_key("GqlMixedExtra", "n") + .unwrap() .unwrap(); - let source = format!( - "MATCH p = (a:GqlReturnReadSet)-[:Gql_RETURN_READ_SET]->(b:GqlReturnReadSet) \ - WHERE id(a) = {a} SET b.status = 'limit-zero-no-conflict' RETURN p LIMIT 0" - ); - let (release_tx, handle) = run_paused(source, &engine); - engine.delete_edge(edge).unwrap(); - release_tx.send(()).unwrap(); - let limit_zero = handle.join().unwrap().unwrap(); - assert!(limit_zero.rows.is_empty()); - assert_eq!( - engine - .get_node(b) - .unwrap() - .unwrap() - .props - .get("status"), - Some(&PropValue::String("limit-zero-no-conflict".to_string())) - ); + assert_eq!(stored.props.get("name"), Some(&PropValue::String("Ada".to_string()))); + assert!(!stored.props.contains_key("old")); + let stats = result.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.nodes_created, 1); + assert_eq!(stats.nodes_updated, 0); + assert_eq!(stats.mutation_ops, 1); } #[test] -fn gql_mutation_return_paths_and_existing_aliases_project_after_commit() { +fn gql_set_remove_errors_leave_database_unchanged() { let (_dir, engine) = query_test_engine(); - let a = insert_query_node( - &engine, - "GqlReturnPath", - "a", - &[("status", PropValue::String("old-a".to_string()))], - 1.0, - ); - let b = insert_query_node( + let node_id = insert_query_node( &engine, - "GqlReturnPath", - "b", - &[("status", PropValue::String("old-b".to_string()))], + "GqlSetAtomic", + "n", + &[("status", PropValue::String("old".to_string()))], 1.0, ); - let edge = engine + let bad_prop = engine + .execute_gql( + "MATCH (n:GqlSetAtomic) WHERE n.key = 'n' SET n.status = 'new' SET n.bad = $bad", + &GqlParams::from([("bad".to_string(), GqlParamValue::Float(f64::NAN))]), + &gql_opts(), + ) + .unwrap_err(); + assert!(matches!(bad_prop, EngineError::InvalidOperation(message) if message.contains("finite"))); + let stored = engine.get_node(node_id).unwrap().unwrap(); + assert_eq!(stored.props.get("status"), Some(&PropValue::String("old".to_string()))); + assert!(!stored.props.contains_key("bad")); + + let a = insert_query_node(&engine, "GqlSetAtomicEdgeNode", "a", &[], 1.0); + let b = insert_query_node(&engine, "GqlSetAtomicEdgeNode", "b", &[], 1.0); + let edge_id = engine .upsert_edge( a, b, - "Gql_RETURN_PATH", + "Gql_SET_ATOMIC_EDGE", UpsertEdgeOptions { - props: query_test_props(&[("kind", PropValue::String("direct".to_string()))]), - ..UpsertEdgeOptions::default() + valid_from: Some(1), + valid_to: Some(i64::MAX), + ..Default::default() }, ) .unwrap(); - - let source = format!( - "MATCH p = (a:GqlReturnPath)-[r:Gql_RETURN_PATH]->(b:GqlReturnPath) \ - WHERE id(a) = {a} \ - SET b.status = 'new-b' \ - RETURN p, a.status, b.status, length(p), node_ids(p), edge_ids(p), r.kind, \ - start_node(p), end_node(p), nodes(p), relationships(p)" - ); - let result = engine - .execute_gql(&source, &GqlParams::new(), &gql_opts()) - .unwrap(); - assert_eq!(result.rows.len(), 1); - let values = &result.rows[0].values; - let path = gql_single_path(&values[0]); - assert_eq!(path.node_ids, vec![a, b]); - assert_eq!(path.edge_ids, vec![edge]); - assert_eq!(path.nodes.as_ref().unwrap().len(), 2); - assert_eq!(path.edges.as_ref().unwrap().len(), 1); - assert_eq!(values[1], GqlValue::String("old-a".to_string())); - assert_eq!(values[2], GqlValue::String("new-b".to_string())); - assert_eq!(values[3], GqlValue::UInt(1)); - assert_eq!( - values[4], - GqlValue::List(vec![GqlValue::UInt(a), GqlValue::UInt(b)]) - ); - assert_eq!(values[5], GqlValue::List(vec![GqlValue::UInt(edge)])); - assert_eq!(values[6], GqlValue::String("direct".to_string())); - assert_eq!(gql_single_node(&values[7]).id, Some(a)); - assert_eq!(gql_single_node(&values[8]).id, Some(b)); - let GqlValue::List(nodes) = &values[9] else { - panic!("expected nodes(p) list"); - }; - assert_eq!(nodes.len(), 2); - assert_eq!(gql_single_node(&nodes[0]).id, Some(a)); - assert_eq!(gql_single_node(&nodes[1]).id, Some(b)); - let GqlValue::List(edges) = &values[10] else { - panic!("expected relationships(p) list"); - }; - assert_eq!(edges.len(), 1); - assert_eq!(gql_single_edge(&edges[0]).id, Some(edge)); - assert_eq!( - engine - .get_node(b) - .unwrap() - .unwrap() - .props - .get("status"), - Some(&PropValue::String("new-b".to_string())) - ); - - let invalid_projection = engine + let bad_window = engine .execute_gql( - &format!( - "MATCH p = (a:GqlReturnPath)-[:Gql_RETURN_PATH]->(b:GqlReturnPath) \ - WHERE id(a) = {a} SET b.status = 'bad-limit-zero' \ - RETURN start_node(p).key LIMIT 0" - ), + "MATCH (a:GqlSetAtomicEdgeNode) WHERE a.key = 'a' MATCH (b:GqlSetAtomicEdgeNode) WHERE b.key = 'b' MATCH (a)-[r:Gql_SET_ATOMIC_EDGE]->(b) SET r.valid_from = 9223372036854775807", &GqlParams::new(), &gql_opts(), ) .unwrap_err(); + assert!(matches!(bad_window, EngineError::InvalidOperation(message) if message.contains("valid_from < valid_to"))); + assert_eq!(engine.get_edge(edge_id).unwrap().unwrap().valid_from, 1); + + insert_query_node(&engine, "GqlSetCap", "a", &[], 1.0); + insert_query_node(&engine, "GqlSetCap", "b", &[], 1.0); + let cap = engine + .execute_gql( + "MATCH (n:GqlSetCap) SET n.flag = true", + &GqlParams::new(), + &GqlExecutionOptions { + max_mutation_ops: 1, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!(matches!(cap, EngineError::InvalidOperation(message) if message.contains("max_mutation_ops"))); assert!( - matches!(invalid_projection, EngineError::GqlSemantic { .. }), - "unexpected error: {invalid_projection:?}" - ); - assert_eq!( - engine - .get_node(b) + !engine + .get_node_by_key("GqlSetCap", "a") .unwrap() .unwrap() .props - .get("status"), - Some(&PropValue::String("new-b".to_string())) + .contains_key("flag") ); } #[test] -fn gql_mutation_return_missing_params_and_unsupported_projection_are_atomic() { +fn gql_existing_update_cap_uses_final_replacement_count() { let (_dir, engine) = query_test_engine(); let node_id = insert_query_node( &engine, - "GqlReturnPrevalidate", + "GqlSetRevertCap", "n", &[("status", PropValue::String("old".to_string()))], 1.0, ); - let missing = engine + let reverted = engine .execute_gql( - "MATCH (n:GqlReturnPrevalidate) WHERE n.key = 'n' SET n.status = 'new' RETURN $missing", + "MATCH (n:GqlSetRevertCap) WHERE n.key = 'n' SET n.status = 'new' SET n.status = 'old'", &GqlParams::new(), - &gql_opts(), + &GqlExecutionOptions { + max_mutation_ops: 0, + ..gql_opts() + }, ) - .unwrap_err(); - assert_gql_param_error(missing, "missing", "missing"); + .unwrap(); + assert_eq!(reverted.mutation_stats.as_ref().unwrap().mutation_ops, 0); assert_eq!( engine .get_node(node_id) @@ -2869,17 +2301,17 @@ fn gql_mutation_return_missing_params_and_unsupported_projection_are_atomic() { Some(&PropValue::String("old".to_string())) ); - let unsupported = engine + let changed = engine .execute_gql( - "MATCH (n:GqlReturnPrevalidate) WHERE n.key = 'n' SET n.status = 'new' RETURN relationships(n)", + "MATCH (n:GqlSetRevertCap) WHERE n.key = 'n' SET n.status = 'new'", &GqlParams::new(), - &gql_opts(), + &GqlExecutionOptions { + max_mutation_ops: 0, + ..gql_opts() + }, ) .unwrap_err(); - assert!( - matches!(unsupported, EngineError::GqlSemantic { .. } | EngineError::GqlUnsupported { .. }), - "{unsupported:?}" - ); + assert!(matches!(changed, EngineError::InvalidOperation(message) if message.contains("max_mutation_ops"))); assert_eq!( engine .get_node(node_id) @@ -2892,3747 +2324,8083 @@ fn gql_mutation_return_missing_params_and_unsupported_projection_are_atomic() { } #[test] -fn gql_set_remove_edge_index_flush_reopen_and_stale_candidates() { - let (dir, engine) = query_test_engine(); +fn gql_set_label_preserves_vectors() { + let dir = TempDir::new().unwrap(); let db_path = dir.path().join("db"); - engine - .ensure_edge_property_index("Gql_EDGE_INDEX", "status", SecondaryIndexKind::Equality) - .unwrap(); - let a = insert_query_node(&engine, "GqlEdgeIndexNode", "a", &[], 1.0); - let b = insert_query_node(&engine, "GqlEdgeIndexNode", "b", &[], 1.0); - let edge_id = engine - .upsert_edge( - a, - b, - "Gql_EDGE_INDEX", - UpsertEdgeOptions { - props: query_test_props(&[("status", PropValue::String("old".to_string()))]), + let engine = DatabaseEngine::open( + &db_path, + &DbOptions { + dense_vector: Some(DenseVectorConfig { + dimension: 3, + metric: DenseMetric::Cosine, + hnsw: HnswConfig::default(), + }), + ..DbOptions::default() + }, + ) + .unwrap(); + seed_query_test_catalog(&engine); + let node_id = engine + .upsert_node( + "GqlVectorSet", + "n", + UpsertNodeOptions { + dense_vector: Some(vec![0.1, 0.2, 0.3]), + sparse_vector: Some(vec![(2, 1.0), (2, 0.5)]), ..Default::default() }, ) .unwrap(); - let edge_ids_for = |engine: &DatabaseEngine, status: &str| { - engine - .query_edge_ids(&EdgeQuery { - label: Some("Gql_EDGE_INDEX".to_string()), - filter: Some(EdgeFilterExpr::PropertyEquals { - key: "status".to_string(), - value: PropValue::String(status.to_string()), - }), - ..Default::default() - }) - .unwrap() - .edge_ids - }; engine .execute_gql( - "MATCH (a:GqlEdgeIndexNode) WHERE a.key = 'a' \ - MATCH (b:GqlEdgeIndexNode) WHERE b.key = 'b' \ - MATCH (a)-[r:Gql_EDGE_INDEX]->(b) SET r.status = 'new'", + "MATCH (n:GqlVectorSet) WHERE n.key = 'n' SET n:GqlVectorSetExtra SET n.status = 'ok'", &GqlParams::new(), &gql_opts(), ) .unwrap(); - assert_eq!(edge_ids_for(&engine, "new"), vec![edge_id]); - assert!(edge_ids_for(&engine, "old").is_empty()); - engine.flush().unwrap(); - assert_eq!(edge_ids_for(&engine, "new"), vec![edge_id]); + let stored = engine.get_node(node_id).unwrap().unwrap(); + assert!(stored.labels.iter().any(|label| label == "GqlVectorSetExtra")); + assert_eq!(stored.dense_vector, Some(vec![0.1, 0.2, 0.3])); + assert_eq!(stored.sparse_vector, Some(vec![(2, 1.5)])); +} - drop(engine); - let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); - assert_eq!(edge_ids_for(&reopened, "new"), vec![edge_id]); - let removed = reopened +#[test] +fn gql_set_label_transfer_uses_final_replacement_key_state() { + let (_dir, engine) = query_test_engine(); + let source = insert_query_node_with_labels( + &engine, + &["GqlTransferSource", "GqlTransferLabel"], + "shared", + &[], + 1.0, + ); + let target = insert_query_node(&engine, "GqlTransferTarget", "shared", &[], 1.0); + + engine .execute_gql( - "MATCH (a:GqlEdgeIndexNode) WHERE a.key = 'a' \ - MATCH (b:GqlEdgeIndexNode) WHERE b.key = 'b' \ - MATCH (a)-[r:Gql_EDGE_INDEX]->(b) REMOVE r.status RETURN r.status", + "MATCH (a:GqlTransferLabel) WHERE a.key = 'shared' \ + MATCH (b:GqlTransferTarget) WHERE b.key = 'shared' \ + SET b:GqlTransferLabel REMOVE a:GqlTransferLabel", &GqlParams::new(), &gql_opts(), ) .unwrap(); - assert_eq!(removed.rows[0].values[0], GqlValue::Null); - assert!(edge_ids_for(&reopened, "new").is_empty()); - let stale_candidate_read = execute_gql_ok( - &reopened, - "MATCH ()-[r:Gql_EDGE_INDEX {status: 'new'}]->() RETURN id(r)", + assert_eq!( + engine + .get_node_by_key("GqlTransferLabel", "shared") + .unwrap() + .unwrap() + .id, + target ); - assert!(stale_candidate_read.rows.is_empty()); -} - -#[test] -fn gql_delete_edge_dedupes_updates_indexes_and_survives_reopen() { - let (dir, engine) = query_test_engine(); - let db_path = dir.path().join("db"); - engine - .ensure_edge_property_index("Gql_DELETE_EDGE", "status", SecondaryIndexKind::Equality) - .unwrap(); - let a = insert_query_node(&engine, "GqlDeleteEdgeNode", "a", &[], 1.0); - let b = insert_query_node(&engine, "GqlDeleteEdgeNode", "b", &[], 1.0); - let edge_id = engine - .upsert_edge( - a, - b, - "Gql_DELETE_EDGE", - UpsertEdgeOptions { - props: query_test_props(&[("status", PropValue::String("live".to_string()))]), - ..Default::default() - }, - ) - .unwrap(); + assert!(!engine + .get_node(source) + .unwrap() + .unwrap() + .labels + .contains(&"GqlTransferLabel".to_string())); - let result = engine + let held = insert_query_node(&engine, "GqlConflictHeld", "dup", &[], 1.0); + let candidate = insert_query_node(&engine, "GqlConflictCandidate", "dup", &[], 1.0); + let conflict = engine .execute_gql( - "MATCH (a:GqlDeleteEdgeNode) WHERE a.key = 'a' \ - MATCH (b:GqlDeleteEdgeNode) WHERE b.key = 'b' \ - MATCH (a)-[r:Gql_DELETE_EDGE {status: 'live'}]->(b) DELETE r DELETE r", + "MATCH (n:GqlConflictCandidate) WHERE n.key = 'dup' SET n:GqlConflictHeld", &GqlParams::new(), &gql_opts(), ) - .unwrap(); - assert!(result.rows.is_empty()); - let stats = result.mutation_stats.as_ref().unwrap(); - assert_eq!(stats.rows_matched, 1); - assert_eq!(stats.mutation_rows, 1); - assert_eq!(stats.mutation_ops, 1); - assert_eq!(stats.edges_deleted, 1); - assert_eq!(stats.duplicate_targets, 1); - assert!(engine.get_edge(edge_id).unwrap().is_none()); - let stale_index_read = execute_gql_ok( - &engine, - "MATCH ()-[r:Gql_DELETE_EDGE {status: 'live'}]->() RETURN id(r)", + .unwrap_err(); + assert!(matches!(conflict, EngineError::InvalidOperation(message) if message.contains("node key conflict"))); + assert_eq!( + engine + .get_node_by_key("GqlConflictHeld", "dup") + .unwrap() + .unwrap() + .id, + held ); - assert!(stale_index_read.rows.is_empty()); - - drop(engine); - let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); - assert!(reopened.get_edge(edge_id).unwrap().is_none()); + assert!(!engine + .get_node(candidate) + .unwrap() + .unwrap() + .labels + .contains(&"GqlConflictHeld".to_string())); } #[test] -fn gql_delete_same_edge_across_multiple_rows_deletes_once() { +fn gql_set_label_cyclic_transfer_rejects_without_index_corruption() { let (_dir, engine) = query_test_engine(); - let a = insert_query_node(&engine, "GqlDeleteRowsNode", "a", &[], 1.0); - let b = insert_query_node(&engine, "GqlDeleteRowsNode", "b", &[], 1.0); - insert_query_node(&engine, "GqlDeleteRowsMarker", "x1", &[], 1.0); - insert_query_node(&engine, "GqlDeleteRowsMarker", "x2", &[], 1.0); - let edge_id = engine - .upsert_edge(a, b, "Gql_DELETE_ROWS", UpsertEdgeOptions::default()) - .unwrap(); + let left = insert_query_node(&engine, "GqlCycleLeft", "shared", &[], 1.0); + let right = insert_query_node(&engine, "GqlCycleRight", "shared", &[], 1.0); - let result = engine + let err = engine .execute_gql( - "MATCH (a:GqlDeleteRowsNode) WHERE a.key = 'a' \ - MATCH (b:GqlDeleteRowsNode) WHERE b.key = 'b' \ - MATCH (a)-[r:Gql_DELETE_ROWS]->(b) MATCH (x:GqlDeleteRowsMarker) DELETE r", + "MATCH (a:GqlCycleLeft) WHERE a.key = 'shared' \ + MATCH (b:GqlCycleRight) WHERE b.key = 'shared' \ + SET a:GqlCycleRight SET b:GqlCycleLeft REMOVE a:GqlCycleLeft REMOVE b:GqlCycleRight", &GqlParams::new(), &gql_opts(), ) - .unwrap(); - let stats = result.mutation_stats.as_ref().unwrap(); - assert_eq!(stats.rows_matched, 2); - assert_eq!(stats.edges_deleted, 1); - assert_eq!(stats.mutation_ops, 1); - assert_eq!(stats.duplicate_targets, 1); - assert!(engine.get_edge(edge_id).unwrap().is_none()); + .unwrap_err(); + assert!( + matches!(err, EngineError::InvalidOperation(ref message) if message.contains("cyclic node label/key replacements")), + "{err:?}" + ); + assert_eq!( + engine + .get_node_by_key("GqlCycleLeft", "shared") + .unwrap() + .unwrap() + .id, + left + ); + assert_eq!( + engine + .get_node_by_key("GqlCycleRight", "shared") + .unwrap() + .unwrap() + .id, + right + ); + assert_eq!( + engine.get_node(left).unwrap().unwrap().labels, + vec!["GqlCycleLeft".to_string()] + ); + assert_eq!( + engine.get_node(right).unwrap().unwrap().labels, + vec!["GqlCycleRight".to_string()] + ); } #[test] -fn gql_detach_delete_node_cascades_active_and_segment_edges_once() { - let (dir, engine) = query_test_engine(); - let db_path = dir.path().join("db"); - let hub = insert_query_node(&engine, "GqlDetachNode", "hub", &[], 1.0); - let left = insert_query_node(&engine, "GqlDetachNode", "left", &[], 1.0); - let right = insert_query_node(&engine, "GqlDetachNode", "right", &[], 1.0); - let segment_edge = engine - .upsert_edge(hub, left, "Gql_DETACH_EDGE", UpsertEdgeOptions::default()) - .unwrap(); - engine.flush().unwrap(); - let active_edge = engine - .upsert_edge(right, hub, "Gql_DETACH_EDGE", UpsertEdgeOptions::default()) - .unwrap(); +fn gql_mutation_return_non_mutated_existing_alias_projects_and_commits() { + let (_dir, engine) = query_test_engine(); + let node_id = insert_query_node( + &engine, + "GqlNoopReturn", + "n", + &[("status", PropValue::String("old".to_string()))], + 1.0, + ); let result = engine .execute_gql( - "MATCH (n:GqlDetachNode) WHERE n.key = 'hub' DETACH DELETE n", + "MATCH (n:GqlNoopReturn) WHERE n.key = 'n' CREATE (c:GqlNoopReturnCreated {key: 'c'}) SET n.missing = null RETURN n", &GqlParams::new(), &gql_opts(), ) .unwrap(); + assert_eq!(result.rows.len(), 1); + assert_eq!(result.stats.rows_returned, 1); let stats = result.mutation_stats.as_ref().unwrap(); - assert_eq!(stats.nodes_deleted, 1); - assert_eq!(stats.edges_deleted, 2); - assert_eq!(stats.mutation_ops, 3); - assert!(engine.get_node(hub).unwrap().is_none()); - assert!(engine.get_edge(segment_edge).unwrap().is_none()); - assert!(engine.get_edge(active_edge).unwrap().is_none()); - - drop(engine); - let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); - assert!(reopened.get_node(hub).unwrap().is_none()); - assert!(reopened.get_edge(segment_edge).unwrap().is_none()); - assert!(reopened.get_edge(active_edge).unwrap().is_none()); + assert_eq!(stats.nodes_created, 1); + assert_eq!(stats.mutation_ops, 1); + let returned = gql_single_node(&result.rows[0].values[0]); + assert_eq!(returned.id, Some(node_id)); + assert_eq!( + returned.props.as_ref().unwrap().get("status"), + Some(&GqlValue::String("old".to_string())) + ); + assert!(!returned.props.as_ref().unwrap().contains_key("missing")); + let stored = engine.get_node(node_id).unwrap().unwrap(); + assert_eq!( + stored.props.get("status"), + Some(&PropValue::String("old".to_string())) + ); + assert!(!stored.props.contains_key("missing")); + assert!(engine + .get_node_by_key("GqlNoopReturnCreated", "c") + .unwrap() + .is_some()); } #[test] -fn gql_detach_delete_dedupes_shared_and_direct_cascade_edges() { - let (_dir, engine) = query_test_engine(); - let a = insert_query_node(&engine, "GqlDetachDedupeNode", "a", &[], 1.0); - let b = insert_query_node(&engine, "GqlDetachDedupeNode", "b", &[], 1.0); - let shared = engine - .upsert_edge(a, b, "Gql_DETACH_DEDUPE", UpsertEdgeOptions::default()) +fn gql_mutation_return_compact_rows_and_vectors_are_accepted() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("db"); + let engine = DatabaseEngine::open( + &db_path, + &DbOptions { + dense_vector: Some(DenseVectorConfig { + dimension: 3, + metric: DenseMetric::Cosine, + hnsw: HnswConfig::default(), + }), + ..DbOptions::default() + }, + ) + .unwrap(); + seed_query_test_catalog(&engine); + let node_id = engine + .upsert_node( + "GqlReturnOptions", + "n", + UpsertNodeOptions { + props: query_test_props(&[("status", PropValue::String("old".to_string()))]), + dense_vector: Some(vec![0.1, 0.2, 0.3]), + sparse_vector: Some(vec![(7, 2.5)]), + ..UpsertNodeOptions::default() + }, + ) .unwrap(); - let shared_result = engine + let omitted = engine .execute_gql( - "MATCH (a:GqlDetachDedupeNode) WHERE a.key = 'a' \ - MATCH (b:GqlDetachDedupeNode) WHERE b.key = 'b' DETACH DELETE a DETACH DELETE b", + "MATCH (n:GqlReturnOptions) WHERE n.key = 'n' SET n.status = 'new' RETURN n", &GqlParams::new(), &gql_opts(), ) .unwrap(); - let shared_stats = shared_result.mutation_stats.as_ref().unwrap(); - assert_eq!(shared_stats.nodes_deleted, 2); - assert_eq!(shared_stats.edges_deleted, 1); - assert_eq!(shared_stats.mutation_ops, 3); - assert!(engine.get_edge(shared).unwrap().is_none()); + let returned = gql_single_node(&omitted.rows[0].values[0]); + assert_eq!(returned.id, Some(node_id)); + assert!(returned.dense_vector.is_none()); + assert!(returned.sparse_vector.is_none()); + assert_eq!( + returned.props.as_ref().unwrap().get("status"), + Some(&GqlValue::String("new".to_string())) + ); - let c = insert_query_node(&engine, "GqlDetachDedupeNode", "c", &[], 1.0); - let d = insert_query_node(&engine, "GqlDetachDedupeNode", "d", &[], 1.0); - let direct = engine - .upsert_edge(c, d, "Gql_DETACH_DIRECT", UpsertEdgeOptions::default()) - .unwrap(); - let direct_result = engine + let vectors = engine .execute_gql( - "MATCH (c:GqlDetachDedupeNode) WHERE c.key = 'c' \ - MATCH (d:GqlDetachDedupeNode) WHERE d.key = 'd' \ - MATCH (c)-[r:Gql_DETACH_DIRECT]->(d) DELETE r DETACH DELETE c", + "MATCH (n:GqlReturnOptions) WHERE n.key = 'n' SET n.status = 'newer' RETURN n", &GqlParams::new(), - &gql_opts(), + &GqlExecutionOptions { + include_vectors: true, + ..gql_opts() + }, ) .unwrap(); - let direct_stats = direct_result.mutation_stats.as_ref().unwrap(); - assert_eq!(direct_stats.nodes_deleted, 1); - assert_eq!(direct_stats.edges_deleted, 1); - assert_eq!(direct_stats.mutation_ops, 2); - assert_eq!(direct_stats.duplicate_targets, 1); - assert!(engine.get_edge(direct).unwrap().is_none()); - assert!(engine.get_node(d).unwrap().is_some()); -} + let returned = gql_single_node(&vectors.rows[0].values[0]); + assert_eq!(returned.dense_vector.as_deref(), Some([0.1, 0.2, 0.3].as_slice())); + assert_eq!(returned.sparse_vector.as_deref(), Some([(7, 2.5)].as_slice())); + assert_eq!( + returned.props.as_ref().unwrap().get("status"), + Some(&GqlValue::String("newer".to_string())) + ); -#[test] -fn gql_delete_optional_null_targets_are_noops() { - let (_dir, engine) = query_test_engine(); - let root = insert_query_node(&engine, "GqlDeleteOptional", "root", &[], 1.0); - let result = engine + let compact = engine .execute_gql( - "MATCH (n:GqlDeleteOptional) WHERE n.key = 'root' \ - OPTIONAL MATCH (n)-[r:Gql_DELETE_MISSING]->(m) DELETE r DETACH DELETE m", + "MATCH (n:GqlReturnOptions) WHERE n.key = 'n' SET n.status = 'compact' RETURN n.status", &GqlParams::new(), - &gql_opts(), + &GqlExecutionOptions { + compact_rows: true, + ..gql_opts() + }, ) .unwrap(); - let stats = result.mutation_stats.as_ref().unwrap(); - assert_eq!(stats.rows_matched, 1); - assert_eq!(stats.mutation_rows, 0); - assert_eq!(stats.mutation_ops, 0); - assert_eq!(stats.skipped_null_targets, 2); - assert_eq!(stats.nodes_deleted, 0); - assert_eq!(stats.edges_deleted, 0); - assert!(engine.get_node(root).unwrap().is_some()); + assert_eq!(compact.columns, vec!["n.status"]); + assert_eq!(compact.rows[0].values[0], GqlValue::String("compact".to_string())); + assert_eq!( + engine + .get_node(node_id) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("compact".to_string())) + ); } #[test] -fn gql_delete_wins_over_earlier_replacements() { +fn gql_mutation_profile_db_hits_are_gated_and_nonzero_for_existing_reads() { let (_dir, engine) = query_test_engine(); - let a = insert_query_node(&engine, "GqlDeleteWinsNode", "a", &[], 1.0); - let b = insert_query_node(&engine, "GqlDeleteWinsNode", "b", &[], 1.0); - let edge_id = engine - .upsert_edge( - a, - b, - "Gql_DELETE_WINS", - UpsertEdgeOptions { - props: query_test_props(&[("status", PropValue::String("old".to_string()))]), - ..Default::default() - }, - ) + let a = insert_query_node( + &engine, + "GqlMutationProfileHits", + "a", + &[("status", PropValue::String("left".to_string()))], + 1.0, + ); + let b = insert_query_node( + &engine, + "GqlMutationProfileHits", + "b", + &[("status", PropValue::String("old".to_string()))], + 1.0, + ); + engine + .upsert_edge(a, b, "Gql_PROFILE_HITS", UpsertEdgeOptions::default()) .unwrap(); - let result = engine + let source = format!( + "MATCH (a:GqlMutationProfileHits)-[r:Gql_PROFILE_HITS]->(b:GqlMutationProfileHits) \ + WHERE id(a) = {a} \ + SET b.status = $status \ + RETURN b.key ORDER BY a.status, type(r)" + ); + let no_profile = engine .execute_gql( - "MATCH (a:GqlDeleteWinsNode) WHERE a.key = 'a' \ - MATCH (b:GqlDeleteWinsNode) WHERE b.key = 'b' \ - MATCH (a)-[r:Gql_DELETE_WINS]->(b) SET r.status = 'new' DELETE r", - &GqlParams::new(), - &gql_opts(), + &source, + &GqlParams::from([( + "status".to_string(), + GqlParamValue::String("first".to_string()), + )]), + &GqlExecutionOptions { + allow_full_scan: true, + ..gql_opts() + }, ) .unwrap(); - let stats = result.mutation_stats.as_ref().unwrap(); - assert_eq!(stats.edges_deleted, 1); - assert_eq!(stats.edges_updated, 0); - assert_eq!(stats.properties_set, 0); - assert_eq!(stats.mutation_ops, 1); - assert_eq!(stats.duplicate_targets, 1); - assert!(engine.get_edge(edge_id).unwrap().is_none()); -} + assert_eq!(no_profile.stats.db_hits, 0); + assert_eq!(no_profile.mutation_stats.as_ref().unwrap().db_hits, 0); -#[test] -fn gql_delete_created_edge_and_detach_created_node_use_local_refs() { - let (_dir, engine) = query_test_engine(); - let direct = engine + let profiled_create = engine .execute_gql( - "CREATE (a:GqlCreatedEdgeDelete {key: 'a'})-[r:Gql_CREATED_EDGE_DELETE]->(b:GqlCreatedEdgeDelete {key: 'b'}) DELETE r", + "CREATE (n:GqlMutationProfileCreate {key: 'n'})", &GqlParams::new(), - &gql_opts(), + &GqlExecutionOptions { + profile: true, + ..gql_opts() + }, ) .unwrap(); - let direct_stats = direct.mutation_stats.as_ref().unwrap(); - assert_eq!(direct_stats.nodes_created, 2); - assert_eq!(direct_stats.edges_created, 0); - assert_eq!(direct_stats.edges_deleted, 0); - assert!(engine - .query_edges(&EdgeQuery { - label: Some("Gql_CREATED_EDGE_DELETE".to_string()), - ..Default::default() - }) - .unwrap() - .edges - .is_empty()); + assert_eq!(profiled_create.stats.db_hits, 0); + assert_eq!( + profiled_create.mutation_stats.as_ref().unwrap().db_hits, + 0 + ); - let detached = engine + let profiled = engine .execute_gql( - "CREATE (a:GqlCreatedDetach {key: 'a'})-[r:Gql_CREATED_DETACH]->(b:GqlCreatedDetach {key: 'b'}) DETACH DELETE a", - &GqlParams::new(), - &gql_opts(), + &source, + &GqlParams::from([( + "status".to_string(), + GqlParamValue::String("second".to_string()), + )]), + &GqlExecutionOptions { + allow_full_scan: true, + profile: true, + ..gql_opts() + }, ) .unwrap(); - let detached_stats = detached.mutation_stats.as_ref().unwrap(); - assert_eq!(detached_stats.nodes_created, 1); - assert_eq!(detached_stats.nodes_deleted, 0); - assert_eq!(detached_stats.edges_created, 0); - assert_eq!(detached_stats.edges_deleted, 0); - assert!(engine - .get_node_by_key("GqlCreatedDetach", "a") - .unwrap() - .is_none()); - assert!(engine - .get_node_by_key("GqlCreatedDetach", "b") - .unwrap() - .is_some()); - assert!(engine - .query_edges(&EdgeQuery { - label: Some("Gql_CREATED_DETACH".to_string()), - ..Default::default() - }) - .unwrap() - .edges - .is_empty()); + let mutation_stats = profiled.mutation_stats.as_ref().unwrap(); + assert!(profiled.stats.db_hits > 0); + assert_eq!(profiled.stats.db_hits, mutation_stats.db_hits); + assert!(profiled.stats.elapsed_us.is_some()); + assert!(mutation_stats.elapsed_us.is_some()); + assert_eq!( + engine + .get_node(b) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("second".to_string())) + ); } #[test] -fn gql_delete_caps_fail_before_staging_or_commit() { +fn gql_mutation_return_row_ops_affect_rows_not_mutations() { let (_dir, engine) = query_test_engine(); - let a = insert_query_node(&engine, "GqlDeleteCapNode", "a", &[], 1.0); - let b = insert_query_node(&engine, "GqlDeleteCapNode", "b", &[], 1.0); - let edge_id = engine - .upsert_edge(a, b, "Gql_DELETE_CAP", UpsertEdgeOptions::default()) + for (key, rank) in [("a", 1), ("b", 2), ("c", 3)] { + insert_query_node( + &engine, + "GqlCreateReturnOpsSeed", + key, + &[("rank", PropValue::Int(rank))], + 1.0, + ); + } + let options = GqlExecutionOptions { + allow_full_scan: true, + ..gql_opts() + }; + + let created = engine + .execute_gql( + "MATCH (s:GqlCreateReturnOpsSeed) CREATE (n:GqlCreateReturnOps {key: s.key, rank: s.rank}) RETURN n.key ORDER BY n.rank DESC SKIP 1 LIMIT 1", + &GqlParams::new(), + &options, + ) .unwrap(); + assert_eq!(gql_string_column(&created, 0), vec!["b".to_string()]); + assert_eq!(created.stats.rows_returned, 1); + let stats = created.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.mutation_rows, 3); + assert_eq!(stats.nodes_created, 3); + for key in ["a", "b", "c"] { + assert!(engine + .get_node_by_key("GqlCreateReturnOps", key) + .unwrap() + .is_some()); + } - let direct_cap = engine + for (key, rank) in [("a", Some(1)), ("b", Some(1)), ("c", None)] { + let mut props = Vec::new(); + if let Some(rank) = rank { + props.push(("rank", PropValue::Int(rank))); + } + insert_query_node(&engine, "GqlSetReturnOps", key, &props, 1.0); + } + let set = engine .execute_gql( - "MATCH (a:GqlDeleteCapNode) WHERE a.key = 'a' \ - MATCH (b:GqlDeleteCapNode) WHERE b.key = 'b' \ - MATCH (a)-[r:Gql_DELETE_CAP]->(b) DELETE r", + "MATCH (n:GqlSetReturnOps) SET n.touched = true RETURN n.key ORDER BY n.rank, id(n) SKIP 1 LIMIT 2", &GqlParams::new(), - &GqlExecutionOptions { - max_mutation_ops: 0, - ..gql_opts() - }, + &options, ) - .unwrap_err(); - assert!(matches!(direct_cap, EngineError::InvalidOperation(message) if message.contains("max_mutation_ops"))); - assert!(engine.get_edge(edge_id).unwrap().is_some()); + .unwrap(); + assert_eq!( + gql_string_column(&set, 0), + vec!["b".to_string(), "c".to_string()] + ); + assert_eq!(set.stats.rows_returned, 2); + let stats = set.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.mutation_rows, 3); + assert_eq!(stats.nodes_updated, 3); + for key in ["a", "b", "c"] { + assert_eq!( + engine + .get_node_by_key("GqlSetReturnOps", key) + .unwrap() + .unwrap() + .props + .get("touched"), + Some(&PropValue::Bool(true)) + ); + } - let detach_cap = engine + for (key, rank) in [("low", Some(1)), ("high", Some(3)), ("missing", None)] { + let mut props = Vec::new(); + if let Some(rank) = rank { + props.push(("rank", PropValue::Int(rank))); + } + insert_query_node(&engine, "GqlNullDescReturnOps", key, &props, 1.0); + } + let null_desc = engine .execute_gql( - "MATCH (n:GqlDeleteCapNode) WHERE n.key = 'a' DETACH DELETE n", + "MATCH (n:GqlNullDescReturnOps) SET n.checked = true \ + RETURN n.key ORDER BY n.rank DESC LIMIT 1", &GqlParams::new(), - &GqlExecutionOptions { - max_mutation_ops: 1, - ..gql_opts() - }, + &options, ) - .unwrap_err(); - assert!(matches!(detach_cap, EngineError::InvalidOperation(message) if message.contains("max_mutation_ops"))); - assert!(engine.get_node(a).unwrap().is_some()); - assert!(engine.get_edge(edge_id).unwrap().is_some()); + .unwrap(); + assert_eq!(gql_string_column(&null_desc, 0), vec!["high".to_string()]); + let stats = null_desc.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.mutation_rows, 3); + assert_eq!(stats.nodes_updated, 3); + for key in ["low", "high", "missing"] { + assert_eq!( + engine + .get_node_by_key("GqlNullDescReturnOps", key) + .unwrap() + .unwrap() + .props + .get("checked"), + Some(&PropValue::Bool(true)) + ); + } - let row_cap = engine + let limit_zero = engine .execute_gql( - "MATCH (a:GqlDeleteCapNode)-[r:Gql_DELETE_CAP]->(b:GqlDeleteCapNode) DELETE r", + "MATCH (n:GqlSetReturnOps) SET n.limit_zero = true RETURN n.key ORDER BY n.rank LIMIT 0", &GqlParams::new(), - &GqlExecutionOptions { - max_mutation_rows: 0, - ..gql_opts() - }, + &options, ) - .unwrap_err(); - assert!(matches!(row_cap, EngineError::InvalidOperation(message) if message.contains("max_mutation_rows"))); - assert!(engine.get_edge(edge_id).unwrap().is_some()); -} + .unwrap(); + assert!(limit_zero.rows.is_empty()); + assert_eq!(limit_zero.stats.rows_returned, 0); + let stats = limit_zero.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.mutation_rows, 3); + assert_eq!(stats.nodes_updated, 3); + for key in ["a", "b", "c"] { + assert_eq!( + engine + .get_node_by_key("GqlSetReturnOps", key) + .unwrap() + .unwrap() + .props + .get("limit_zero"), + Some(&PropValue::Bool(true)) + ); + } -#[test] -fn gql_detach_delete_cap_bounds_high_fanout_cascade() { - let (_dir, engine) = query_test_engine(); - let hub = insert_query_node(&engine, "GqlDetachCapHub", "hub", &[], 1.0); - let mut edge_ids = Vec::new(); - for idx in 0..8 { - let leaf = insert_query_node( + for (key, rank) in [("a", 1), ("b", 2), ("c", 3)] { + insert_query_node( &engine, - "GqlDetachCapLeaf", - &format!("segment-{idx}"), - &[], + "GqlRemoveReturnOps", + key, + &[("rank", PropValue::Int(rank)), ("drop", PropValue::String("x".to_string()))], 1.0, ); - edge_ids.push( - engine - .upsert_edge(hub, leaf, "Gql_DETACH_CAP_FANOUT", UpsertEdgeOptions::default()) - .unwrap(), - ); } - engine.flush().unwrap(); - for idx in 0..8 { - let leaf = insert_query_node( + let removed = engine + .execute_gql( + "MATCH (n:GqlRemoveReturnOps) REMOVE n.drop RETURN n.key ORDER BY n.rank DESC LIMIT 2", + &GqlParams::new(), + &options, + ) + .unwrap(); + assert_eq!( + gql_string_column(&removed, 0), + vec!["c".to_string(), "b".to_string()] + ); + assert_eq!(removed.stats.rows_returned, 2); + let stats = removed.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.mutation_rows, 3); + assert_eq!(stats.nodes_updated, 3); + for key in ["a", "b", "c"] { + assert!(!engine + .get_node_by_key("GqlRemoveReturnOps", key) + .unwrap() + .unwrap() + .props + .contains_key("drop")); + } +} + +#[test] +fn gql_mutation_return_caps_and_order_errors_are_atomic() { + let (_dir, engine) = query_test_engine(); + for key in ["a", "b"] { + insert_query_node( &engine, - "GqlDetachCapLeaf", - &format!("active-{idx}"), - &[], + "GqlReturnCapRows", + key, + &[("status", PropValue::String("old".to_string()))], 1.0, ); - edge_ids.push( - engine - .upsert_edge(hub, leaf, "Gql_DETACH_CAP_FANOUT", UpsertEdgeOptions::default()) - .unwrap(), - ); } - - let err = engine + let max_rows = engine .execute_gql( - "MATCH (n:GqlDetachCapHub) WHERE n.key = 'hub' DETACH DELETE n", + "MATCH (n:GqlReturnCapRows) SET n.status = 'new' RETURN n", &GqlParams::new(), &GqlExecutionOptions { - max_mutation_ops: 3, + allow_full_scan: true, + max_rows: 1, ..gql_opts() }, ) .unwrap_err(); - assert!(matches!(err, EngineError::InvalidOperation(message) if message.contains("max_mutation_ops"))); - assert!(engine.get_node(hub).unwrap().is_some()); - for edge_id in edge_ids { - assert!(engine.get_edge(edge_id).unwrap().is_some()); - } -} - -#[test] -fn gql_detach_delete_commit_budget_bounds_edges_added_after_snapshot() { - let (_dir, engine) = query_test_engine(); - let hub = insert_query_node(&engine, "GqlDetachCommitCapHub", "hub", &[], 1.0); - let worker = DatabaseEngine { - runtime: std::sync::Arc::clone(&engine.runtime), - }; - let (ready_rx, release_tx) = engine.set_gql_mutation_before_commit_pause(); - let handle = std::thread::spawn(move || { - worker.execute_gql( - "MATCH (n:GqlDetachCommitCapHub) WHERE n.key = 'hub' DETACH DELETE n", + assert!( + max_rows.to_string().contains("max_rows"), + "unexpected error: {max_rows:?}" + ); + for key in ["a", "b"] { + assert_eq!( + engine + .get_node_by_key("GqlReturnCapRows", key) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("old".to_string())) + ); + } + + let max_skip = engine + .execute_gql( + "MATCH (n:GqlReturnCapRows) SET n.status = 'skip' RETURN n SKIP 2", &GqlParams::new(), &GqlExecutionOptions { - max_mutation_ops: 2, - ..GqlExecutionOptions::default() + allow_full_scan: true, + max_skip: 1, + ..gql_opts() }, ) - }); - ready_rx - .recv_timeout(std::time::Duration::from_secs(5)) - .expect("GQL mutation did not pause before commit"); + .unwrap_err(); + assert!( + max_skip.to_string().contains("max_skip"), + "unexpected error: {max_skip:?}" + ); - let mut edge_ids = Vec::new(); - for idx in 0..8 { - let leaf = insert_query_node( - &engine, - "GqlDetachCommitCapLeaf", - &format!("leaf-{idx}"), - &[], - 1.0, - ); - edge_ids.push( + let max_order = engine + .execute_gql( + "MATCH (n:GqlReturnCapRows) SET n.status = 'ordered' RETURN n.key ORDER BY n.key", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_order_materialization: 1, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + max_order.to_string().contains("max_order_materialization"), + "unexpected error: {max_order:?}" + ); + + let unsupported_order = engine + .execute_gql( + "MATCH (n:GqlReturnCapRows) SET n.status = 'bad-order' RETURN n.key ORDER BY n", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + unsupported_order.to_string().contains("ORDER BY"), + "unexpected error: {unsupported_order:?}" + ); + for key in ["a", "b"] { + assert_eq!( engine - .upsert_edge( - hub, - leaf, - "Gql_DETACH_COMMIT_CAP", - UpsertEdgeOptions::default(), - ) - .unwrap(), + .get_node_by_key("GqlReturnCapRows", key) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("old".to_string())) ); } - release_tx.send(()).unwrap(); - let err = handle.join().unwrap().unwrap_err(); - assert!(matches!(err, EngineError::InvalidOperation(message) if message.contains("max_mutation_ops"))); - assert!(engine.get_node(hub).unwrap().is_some()); - for edge_id in edge_ids { - assert!(engine.get_edge(edge_id).unwrap().is_some()); - } } #[test] -fn gql_delete_rejections_still_happen_before_writes() { +fn gql_mutation_return_distinct_caps_are_precommit_atomic() { let (_dir, engine) = query_test_engine(); - let node_id = insert_query_node(&engine, "GqlDeleteReject", "n", &[], 1.0); - let delete_node = engine + for key in ["a", "b"] { + insert_query_node(&engine, "GqlReturnDistinctCapSeed", key, &[], 1.0); + } + let options = GqlExecutionOptions { + allow_full_scan: true, + max_groups: 1, + ..gql_opts() + }; + + let cap_err = engine .execute_gql( - "MATCH (n:GqlDeleteReject) WHERE n.key = 'n' DELETE n", + "MATCH (s:GqlReturnDistinctCapSeed) \ + CREATE (n:GqlReturnDistinctCap {key: s.key}) \ + RETURN DISTINCT n.key AS key", &GqlParams::new(), - &gql_opts(), + &options, ) .unwrap_err(); - assert!(matches!( - delete_node, - EngineError::GqlSemantic { - code: GqlSemanticErrorCode::InvalidReturnExpression, - .. - } - )); - assert!(engine.get_node(node_id).unwrap().is_some()); + assert!( + cap_err.to_string().contains("max_groups"), + "unexpected error: {cap_err:?}" + ); + for key in ["a", "b"] { + assert!(engine + .get_node_by_key("GqlReturnDistinctCap", key) + .unwrap() + .is_none()); + } - let return_after_delete = engine + let same = engine .execute_gql( - "MATCH (n:GqlDeleteReject) WHERE n.key = 'n' DETACH DELETE n RETURN n", + "MATCH (s:GqlReturnDistinctCapSeed) \ + CREATE (n:GqlReturnDistinctSame {key: s.key}) \ + RETURN DISTINCT 'same' AS key", &GqlParams::new(), - &gql_opts(), + &options, ) - .unwrap_err(); - assert!(matches!( - return_after_delete, - EngineError::GqlSemantic { - code: GqlSemanticErrorCode::InvalidReturnExpression, - .. - } - )); - assert!(engine.get_node(node_id).unwrap().is_some()); + .unwrap(); + assert_eq!(same.rows.len(), 1); + assert_eq!(same.rows[0].values[0], GqlValue::String("same".to_string())); + for key in ["a", "b"] { + assert!(engine + .get_node_by_key("GqlReturnDistinctSame", key) + .unwrap() + .is_some()); + } - let cursor_first = engine + for key in ["left", "right"] { + insert_query_node( + &engine, + "GqlReturnDistinctGraphSeed", + key, + &[("target", PropValue::String("shared".to_string()))], + 1.0, + ); + } + let nested_graph = engine .execute_gql( - "MATCH (n:GqlDeleteReject) WHERE n.key = 'n' DETACH DELETE n", + "MATCH (s:GqlReturnDistinctGraphSeed) \ + MERGE (n:GqlReturnDistinctGraph {key: s.target}) \ + RETURN DISTINCT [n] AS bucket", &GqlParams::new(), - &GqlExecutionOptions { - cursor: Some("read-cursor".to_string()), - mode: GqlExecutionMode::ReadOnly, - ..gql_opts() - }, + &options, ) - .unwrap_err(); - match cursor_first { - EngineError::InvalidCursor { message } => { - assert_eq!(message, "GQL mutation statements do not accept cursors"); - } - err => panic!("expected mutation cursor error, got {err:?}"), + .unwrap(); + assert_eq!(nested_graph.rows.len(), 1); + match &nested_graph.rows[0].values[0] { + GqlValue::List(values) => assert_eq!(values.len(), 1), + other => panic!("expected nested graph list, got {other:?}"), } - assert!(engine.get_node(node_id).unwrap().is_some()); } #[test] -fn gql_replacement_adapter_static_audit_keeps_public_surfaces_clean() { - let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let forbidden = [ - ["Replace", "Node"].concat(), - ["Replace", "Edge"].concat(), - ]; - for path in [ - "src/types.rs", - "overgraph-node/src/lib.rs", - "overgraph-node/index.d.ts", - "overgraph-node/query-types.d.ts", - "overgraph-python/src/lib.rs", - "overgraph-python/python/overgraph/__init__.pyi", - "overgraph-python/python/overgraph/async_api.py", - ] { - let contents = std::fs::read_to_string(manifest_dir.join(path)).unwrap(); - for needle in &forbidden { - assert!( - !contents.contains(needle), - "{path} exposes a public replacement transaction API" - ); - } - } +fn gql_mutation_return_distinct_rejects_commit_assigned_metadata_before_write() { + let (_dir, engine) = query_test_engine(); + let err = engine + .execute_gql( + "CREATE (n:GqlReturnDistinctMetadata {key: 'n'}) RETURN DISTINCT id(n)", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!( + matches!(err, EngineError::GqlSemantic { .. }), + "unexpected error: {err:?}" + ); + assert!(engine + .get_node_by_key("GqlReturnDistinctMetadata", "n") + .unwrap() + .is_none()); } #[test] -fn gql_delete_static_audit_uses_transaction_intents_not_public_delete_loops() { - let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let query = std::fs::read_to_string(manifest_dir.join("src/engine/query.rs")).unwrap(); - assert!(query.contains("TxnIntent::DeleteNode")); - assert!(query.contains("TxnIntent::DeleteEdge")); - assert!(query.contains("txn_delete_incident_edge_ids_limited")); - assert!(!query.contains(".delete_node(")); - assert!(!query.contains(".delete_edge(")); +fn gql_mutation_return_distinct_rejects_volatile_updated_at_before_write() { + let (_dir, engine) = query_test_engine(); + let node = insert_query_node( + &engine, + "GqlReturnDistinctUpdatedAt", + "n", + &[("status", PropValue::String("old".to_string()))], + 1.0, + ); - let txn = std::fs::read_to_string(manifest_dir.join("src/engine/txn.rs")).unwrap(); - assert!(txn.contains("pub(crate) struct TxnGraphOpBudget")); - assert!(txn.contains("fn incident_edge_ids_for_txn_delete_limited")); - assert!(txn.contains("fn limited_scan_len")); - for needle in [ - "pub struct TxnGraphOpBudget", - "pub fn gql_apply_mutation_op_budget", - ] { - assert!( - !txn.contains(needle), - "transaction mutation budget helper leaked into the public API" - ); - } + let err = engine + .execute_gql( + "MATCH (n:GqlReturnDistinctUpdatedAt) WHERE n.key = 'n' \ + SET n.status = 'new' RETURN DISTINCT n.updated_at", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!( + err.to_string().contains("RETURN DISTINCT"), + "unexpected error: {err:?}" + ); + assert_eq!( + engine + .get_node(node) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("old".to_string())) + ); } #[test] -fn gql_mutation_return_static_audit_keeps_read_set_private_and_projection_batched() { - let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let txn = std::fs::read_to_string(manifest_dir.join("src/engine/txn.rs")).unwrap(); - assert!(txn.contains("pub(crate) struct TxnReturnReadSet")); - assert!(txn.contains("pub(crate) fn gql_validate_return_read_set")); - assert!(txn.contains("pub(crate) fn commit_with_gql_return_view")); - let read_set_start = txn.find("fn validate_gql_return_read_set").unwrap(); - let read_set_end = txn[read_set_start..] - .find("fn resolve_node_ref_required") - .map(|offset| read_set_start + offset) - .unwrap(); - let read_set_body = &txn[read_set_start..read_set_end]; - assert!(read_set_body.contains("self.get_nodes_raw(&node_ids)?")); - assert!(read_set_body.contains("self.get_edges(&edge_ids)?")); - assert!(!read_set_body.contains("validate_node_id_conflict")); - assert!(!read_set_body.contains("validate_edge_id_conflict")); - for needle in [ - "pub struct TxnReturnReadSet", - "pub fn gql_validate_return_read_set", - "pub fn commit_with_gql_return_view", - ] { - assert!( - !txn.contains(needle), - "GQL mutation RETURN read-set/view helper leaked into the public transaction API" - ); - } - - let query = std::fs::read_to_string(manifest_dir.join("src/engine/query.rs")).unwrap(); - assert!(query.contains("view.get_nodes_raw(&node_ids)")); - assert!(query.contains("view.get_edges(&edge_ids)")); - assert!(!query.contains(".get_node(")); - assert!(!query.contains(".get_edge(")); - assert!(query.contains("fn execute_gql_mutation(")); - assert!(query.contains("fn explain_gql_mutation(")); - let execute_start = query.find("fn execute_gql_create_mutation").unwrap(); - let execute_end = query[execute_start..] - .find("fn gql_create_input_rows") - .map(|offset| execute_start + offset) - .unwrap(); - let execute_body = &query[execute_start..execute_end]; - assert!(execute_body.contains("let snapshot = txn.gql_snapshot()?;")); - assert!(execute_body.contains("build_gql_mutation_explain_with_snapshot")); - assert!( - execute_body.find("let snapshot = txn.gql_snapshot()?;").unwrap() - < execute_body - .find("build_gql_mutation_explain_with_snapshot") - .unwrap(), - "embedded mutation explain must use the transaction snapshot" - ); - let explain_start = query - .find("fn build_gql_mutation_explain_with_snapshot") - .unwrap(); - let explain_end = query[explain_start..] - .find("fn gql_execution_cap_summary") - .map(|offset| explain_start + offset) - .unwrap(); - let explain_body = &query[explain_start..explain_end]; - assert!( - !explain_body.contains("published_snapshot"), - "snapshot-specific mutation explain builder must not capture a second snapshot" +fn gql_mutation_return_prevalidates_order_and_projection_against_final_state() { + let (_dir, engine) = query_test_engine(); + let rank_id = insert_query_node( + &engine, + "GqlReturnFinalValidation", + "rank", + &[("rank", PropValue::Int(1))], + 1.0, ); - assert!(query.contains("gql_mutation_return_needs_committed_view")); - assert!(query.contains("if selected.is_empty()")); -} - -#[test] -fn gql_create_node_survives_reopen() { - let (dir, engine) = query_test_engine(); - let db_path = dir.path().join("db"); - engine + let order_err = engine .execute_gql( - "CREATE (n:GqlReopen {key: 'persisted', name: 'stored'}) RETURN id(n)", + "MATCH (n:GqlReturnFinalValidation) WHERE n.key = 'rank' \ + SET n.rank = [1] RETURN n.key ORDER BY n.rank", &GqlParams::new(), &gql_opts(), ) - .unwrap(); - drop(engine); - - let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); - let node = reopened - .get_node_by_key("GqlReopen", "persisted") - .unwrap() - .unwrap(); - assert_eq!(node.props.get("name"), Some(&PropValue::String("stored".to_string()))); -} + .unwrap_err(); + assert!( + order_err.to_string().contains("ORDER BY"), + "unexpected error: {order_err:?}" + ); + assert_eq!( + engine + .get_node(rank_id) + .unwrap() + .unwrap() + .props + .get("rank"), + Some(&PropValue::Int(1)) + ); -#[test] -fn gql_create_edge_label_survives_reopen() { - let (dir, engine) = query_test_engine(); - let db_path = dir.path().join("db"); - let result = engine + let mut payload = BTreeMap::new(); + payload.insert("inner".to_string(), PropValue::String("ok".to_string())); + let nested_id = insert_query_node( + &engine, + "GqlReturnFinalValidation", + "nested", + &[("payload", PropValue::Map(payload.clone()))], + 1.0, + ); + let projection_err = engine .execute_gql( - "CREATE (a:GqlEdgeReopen {key: 'a'})-[r:Gql_EDGE_REOPEN {since: 7}]->(b:GqlEdgeReopen {key: 'b'}) RETURN id(a), id(r), id(b)", + "MATCH (n:GqlReturnFinalValidation) WHERE n.key = 'nested' \ + SET n.payload = 7 RETURN n.payload.inner", &GqlParams::new(), &gql_opts(), ) - .unwrap(); - let a_id = gql_u64_column(&result, 0)[0]; - let edge_id = gql_u64_column(&result, 1)[0]; - let b_id = gql_u64_column(&result, 2)[0]; - drop(engine); - - let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); - assert_eq!( - reopened - .get_node_by_key("GqlEdgeReopen", "a") - .unwrap() - .unwrap() - .id, - a_id + .unwrap_err(); + assert!( + matches!(projection_err, EngineError::GqlSemantic { .. }), + "unexpected error: {projection_err:?}" ); assert_eq!( - reopened - .get_node_by_key("GqlEdgeReopen", "b") + engine + .get_node(nested_id) .unwrap() .unwrap() - .id, - b_id + .props + .get("payload"), + Some(&PropValue::Map(payload)) + ); + + let metadata_id_err = engine + .execute_gql( + "MATCH (n:GqlReturnFinalValidation) WHERE n.key = 'rank' \ + SET n.status = 'metadata-id-bad' RETURN n.updated_at.inner", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!( + matches!(metadata_id_err, EngineError::GqlSemantic { .. }), + "unexpected error: {metadata_id_err:?}" ); - let edge = reopened.get_edge(edge_id).unwrap().unwrap(); - assert_eq!(edge.from, a_id); - assert_eq!(edge.to, b_id); - assert_eq!(edge.label, "Gql_EDGE_REOPEN"); - assert_eq!(edge.props.get("since"), Some(&PropValue::Int(7))); assert_eq!( - reopened - .get_edge_by_triple(a_id, b_id, "Gql_EDGE_REOPEN") + engine + .get_node(rank_id) .unwrap() .unwrap() - .id, - edge_id + .props + .get("status"), + None ); } #[test] -fn mutation_explain_includes_read_prefix_and_operations() { +fn gql_mutation_return_volatile_metadata_order_rejects_before_write() { let (_dir, engine) = query_test_engine(); - insert_query_node(&engine, "Person", "explain-mutation-ada", &[], 1.0); + for key in ["a", "b", "c"] { + insert_query_node(&engine, "GqlReturnCreatedMetaSeed", key, &[], 1.0); + } + let options = GqlExecutionOptions { + allow_full_scan: true, + ..gql_opts() + }; - let explain = engine - .explain_gql( - "MATCH (n:Person {key: 'explain-mutation-ada'}) SET n.name = 'Ada' RETURN n.name ORDER BY n.name SKIP 0 LIMIT 1", + let node_order = engine + .execute_gql( + "MATCH (s:GqlReturnCreatedMetaSeed) \ + CREATE (n:GqlReturnCreatedMeta {key: s.key}) \ + RETURN n.key ORDER BY n.id DESC SKIP 1 LIMIT 1", &GqlParams::new(), - &gql_opts(), - ) - .unwrap(); - assert_eq!(explain.kind, GqlStatementKind::Mutation); - assert_eq!(explain.columns, vec!["n.name"]); - assert!(matches!( - explain.read.as_ref().map(|read| read.target), - Some(GqlLoweringTarget::GraphRowQuery) - )); - let mutation = explain.mutation.expect("mutation explain"); - assert!(mutation.uses_write_txn); - assert!(mutation.uses_transaction_snapshot); - assert!(mutation.atomic_commit); - assert!(mutation.replacement_adapters); - let read_prefix = mutation.read_prefix.expect("read prefix explain"); - assert_eq!(read_prefix.graph_row_target.target, GqlLoweringTarget::GraphRowQuery); - assert!(read_prefix - .internal_columns - .iter() - .any(|column| column.contains("target id: n"))); - assert!(mutation - .operations - .iter() - .any(|op| op.op == "SET PROPERTY" && op.target_alias.as_deref() == Some("n"))); - let return_plan = mutation.return_plan.as_ref().expect("return explain"); - assert_eq!(return_plan.columns, vec!["n.name"]); - assert_eq!(return_plan.order_items, 1); - assert_eq!(return_plan.skip, 0); - assert_eq!(return_plan.limit, Some(1)); - assert!(return_plan.post_commit_hydration.contains("prevalidates")); - assert!(return_plan.post_commit_hydration.contains("read-set")); - - let param_explain = engine - .explain_gql( - "MATCH (n:Person {key: 'explain-mutation-ada'}) SET n.name = 'Ada' \ - RETURN n.name ORDER BY n.name SKIP $skip LIMIT $limit", - &GqlParams::from([ - ("skip".to_string(), GqlParamValue::UInt(2)), - ("limit".to_string(), GqlParamValue::Int(3)), - ]), - &gql_opts(), + &options, ) - .unwrap(); - let mutation = param_explain.mutation.expect("mutation explain"); - let return_plan = mutation.return_plan.as_ref().expect("return explain"); - assert_eq!(return_plan.skip, 2); - assert_eq!(return_plan.limit, Some(3)); + .unwrap_err(); + assert!( + node_order.to_string().contains("ORDER BY"), + "unexpected error: {node_order:?}" + ); + for key in ["a", "b", "c"] { + assert!(engine + .get_node_by_key("GqlReturnCreatedMeta", key) + .unwrap() + .is_none()); + } - let full_scan_explain = engine - .explain_gql( - "MATCH (n) SET n.name = 'Ada'", - &GqlParams::new(), - &GqlExecutionOptions { - allow_full_scan: true, - ..gql_opts() - }, + let root = insert_query_node(&engine, "GqlReturnCreatedEdgeMetaRoot", "root", &[], 1.0); + for key in ["a", "b", "c"] { + insert_query_node(&engine, "GqlReturnCreatedEdgeMetaTarget", key, &[], 1.0); + } + let edge_order = engine + .execute_gql( + &format!( + "MATCH (from:GqlReturnCreatedEdgeMetaRoot) \ + MATCH (to:GqlReturnCreatedEdgeMetaTarget) \ + WHERE id(from) = {root} \ + CREATE (from)-[r:Gql_RETURN_CREATED_EDGE_META]->(to) \ + RETURN to.key ORDER BY r.to DESC SKIP 1 LIMIT 1" + ), + &GqlParams::new(), + &options, ) - .unwrap(); - let mutation = full_scan_explain.mutation.expect("mutation explain"); - let read_prefix = mutation.read_prefix.expect("read prefix explain"); - assert!(read_prefix - .graph_row_target - .warnings - .iter() - .any(|warning| warning.contains("full scan"))); -} - -#[derive(Clone)] -struct RichGqlGraph { - alice: u64, - bob: u64, - acme: u64, - globex: u64, - lead_edge: u64, - review_edge: u64, - startup_edge: u64, - mentor_edge: u64, -} - -#[derive(Clone, Copy)] -struct RichGqlIndexes { - employee_status: u64, - employee_score: u64, - works_role: u64, - works_hours: u64, -} - -fn seed_rich_gql_graph(engine: &DatabaseEngine) -> RichGqlGraph { - let acme = insert_query_node( - engine, - "Company", - "rich-acme", - &[("tier", PropValue::String("enterprise".to_string()))], - 3.0, - ); - let globex = insert_query_node( - engine, - "Company", - "rich-globex", - &[("tier", PropValue::String("startup".to_string()))], - 2.0, - ); - let alice = insert_query_node_with_labels( - engine, - &["Person", "Employee", "Manager"], - "rich-alice", - &[ - ("status", PropValue::String("focus".to_string())), - ("score", PropValue::Int(91)), - ("department", PropValue::String("platform".to_string())), - ("rank", PropValue::Int(2)), - ], - 1.25, - ); - let bob = insert_query_node_with_labels( - engine, - &["Person", "Employee"], - "rich-bob", - &[ - ("status", PropValue::String("focus".to_string())), - ("score", PropValue::Int(76)), - ("department", PropValue::String("platform".to_string())), - ("rank", PropValue::Int(1)), - ], - 1.5, + .unwrap_err(); + assert!( + edge_order.to_string().contains("ORDER BY"), + "unexpected error: {edge_order:?}" ); - insert_query_node_with_labels( - engine, - &["Person", "Employee"], - "rich-carol", - &[ - ("status", PropValue::String("inactive".to_string())), - ("score", PropValue::Int(88)), - ("department", PropValue::String("research".to_string())), - ("rank", PropValue::Null), - ], + assert!(engine + .query_edges(&EdgeQuery { + label: Some("Gql_RETURN_CREATED_EDGE_META".to_string()), + ..EdgeQuery::default() + }) + .unwrap() + .edges + .is_empty()); + + let changed = insert_query_node( + &engine, + "GqlReturnChangedUpdatedAt", + "n", + &[("status", PropValue::String("old".to_string()))], 1.0, ); - insert_query_node_with_labels( - engine, - &["Person", "Contractor"], - "rich-dana", - &[ - ("status", PropValue::String("focus".to_string())), - ("score", PropValue::Int(85)), - ], - 1.0, + let updated_at_order = engine + .execute_gql( + "MATCH (n:GqlReturnChangedUpdatedAt) \ + SET n.status = 'new' RETURN n.key ORDER BY n.updated_at", + &GqlParams::new(), + &options, + ) + .unwrap_err(); + assert!( + updated_at_order.to_string().contains("ORDER BY"), + "unexpected error: {updated_at_order:?}" ); - insert_query_node( - engine, - "Person", - "rich-eve", - &[ - ("status", PropValue::String("focus".to_string())), - ("score", PropValue::Int(82)), - ], - 1.0, + assert_eq!( + engine + .get_node(changed) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("old".to_string())) ); - insert_query_node_with_labels( - engine, - &["Person", "Employee"], - "rich-frank", - &[ - ("status", PropValue::String("focus".to_string())), - ("score", PropValue::Int(63)), - ], +} + +#[test] +fn gql_mutation_return_gql_read_set_conflicts_for_returned_and_ordered_hydration() { + let (_dir, engine) = query_test_engine(); + let a = insert_query_node( + &engine, + "GqlReturnReadSet", + "a", + &[("status", PropValue::String("old-a".to_string()))], 1.0, ); - insert_query_node_with_labels( - engine, - &["Person", "Employee"], - "rich-grace", - &[("score", PropValue::Int(99))], + let b = insert_query_node( + &engine, + "GqlReturnReadSet", + "b", + &[("status", PropValue::String("old-b".to_string()))], 1.0, ); + let edge = engine + .upsert_edge(a, b, "Gql_RETURN_READ_SET", UpsertEdgeOptions::default()) + .unwrap(); - for index in 0..24 { - let status = if index % 4 == 0 { "focus" } else { "inactive" }; - let filler = insert_query_node_with_labels( - engine, - &["Person", "Employee"], - &format!("rich-filler-{index:02}"), - &[ - ("status", PropValue::String(status.to_string())), - ("score", PropValue::Int(20 + i64::from(index))), - ], - 0.5, - ); - if index < 12 { - engine - .upsert_edge( - filler, - globex, - "WORKS_ON", - UpsertEdgeOptions { - props: query_test_props(&[ - ("role", PropValue::String("support".to_string())), - ("hours", PropValue::Int(5 + i64::from(index))), - ]), - weight: 0.25, - valid_from: Some(10), - valid_to: Some(20), - }, - ) - .unwrap(); - } - } + let run_paused = |source: String, engine: &DatabaseEngine| { + let worker = DatabaseEngine { + runtime: std::sync::Arc::clone(&engine.runtime), + }; + let (ready_rx, release_tx) = engine.set_gql_mutation_before_commit_pause(); + let handle = std::thread::spawn(move || { + worker.execute_gql( + &source, + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + ..GqlExecutionOptions::default() + }, + ) + }); + ready_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("GQL mutation did not pause before commit"); + (release_tx, handle) + }; - let lead_edge = engine - .upsert_edge( - alice, - acme, - "WORKS_ON", - UpsertEdgeOptions { - props: query_test_props(&[ - ("role", PropValue::String("lead".to_string())), - ("hours", PropValue::Int(40)), - ]), - weight: 2.5, - valid_from: Some(0), - valid_to: Some(i64::MAX), - }, - ) - .unwrap(); - let review_edge = engine - .upsert_edge( - bob, - acme, - "WORKS_ON", - UpsertEdgeOptions { - props: query_test_props(&[ - ("role", PropValue::String("reviewer".to_string())), - ("hours", PropValue::Int(35)), - ]), - weight: 1.75, - valid_from: Some(0), - valid_to: Some(i64::MAX), + let source = format!( + "MATCH (a:GqlReturnReadSet)-[:Gql_RETURN_READ_SET]->(b:GqlReturnReadSet) \ + WHERE id(a) = {a} SET b.status = 'returned-existing-conflict' RETURN a" + ); + let (release_tx, handle) = run_paused(source, &engine); + engine + .upsert_node( + "GqlReturnReadSet", + "a", + UpsertNodeOptions { + props: query_test_props(&[( + "status", + PropValue::String("outside-a".to_string()), + )]), + ..UpsertNodeOptions::default() }, ) .unwrap(); - let startup_edge = engine - .upsert_edge( - alice, - globex, - "WORKS_ON", - UpsertEdgeOptions { - props: query_test_props(&[ - ("role", PropValue::String("lead".to_string())), - ("hours", PropValue::Int(10)), - ]), - weight: 0.75, - valid_from: Some(0), - valid_to: Some(i64::MAX), + release_tx.send(()).unwrap(); + let err = handle.join().unwrap().unwrap_err(); + assert!(matches!(err, EngineError::TxnConflict(_)), "{err:?}"); + assert_eq!( + engine + .get_node(b) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("old-b".to_string())) + ); + + let source = format!( + "MATCH (a:GqlReturnReadSet)-[:Gql_RETURN_READ_SET]->(b:GqlReturnReadSet) \ + WHERE id(a) = {a} SET b.status = 'order-only-node-conflict' \ + RETURN b.key ORDER BY a.status" + ); + let (release_tx, handle) = run_paused(source, &engine); + engine + .upsert_node( + "GqlReturnReadSet", + "a", + UpsertNodeOptions { + props: query_test_props(&[( + "status", + PropValue::String("outside-order-a".to_string()), + )]), + ..UpsertNodeOptions::default() }, ) .unwrap(); - let mentor_edge = engine - .upsert_edge( - alice, - bob, - "MENTORS", - UpsertEdgeOptions { - props: query_test_props(&[("role", PropValue::String("mentor".to_string()))]), - weight: 1.0, - ..UpsertEdgeOptions::default() + release_tx.send(()).unwrap(); + let err = handle.join().unwrap().unwrap_err(); + assert!(matches!(err, EngineError::TxnConflict(_)), "{err:?}"); + assert_eq!( + engine + .get_node(b) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("old-b".to_string())) + ); + + let source = format!( + "MATCH (a:GqlReturnReadSet)-[r:Gql_RETURN_READ_SET]->(b:GqlReturnReadSet) \ + WHERE id(a) = {a} SET b.status = 'order-only-edge-conflict' \ + RETURN b.key ORDER BY r.status" + ); + let (release_tx, handle) = run_paused(source, &engine); + engine.delete_edge(edge).unwrap(); + release_tx.send(()).unwrap(); + let err = handle.join().unwrap().unwrap_err(); + assert!(matches!(err, EngineError::TxnConflict(_)), "{err:?}"); + assert_eq!( + engine + .get_node(b) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("old-b".to_string())) + ); + + let edge = engine + .upsert_edge(a, b, "Gql_RETURN_READ_SET", UpsertEdgeOptions::default()) + .unwrap(); + let source = format!( + "MATCH p = (a:GqlReturnReadSet)-[:Gql_RETURN_READ_SET]->(b:GqlReturnReadSet) \ + WHERE id(a) = {a} SET b.status = 'path-conflict' RETURN p" + ); + let (release_tx, handle) = run_paused(source, &engine); + engine.delete_edge(edge).unwrap(); + release_tx.send(()).unwrap(); + let err = handle.join().unwrap().unwrap_err(); + assert!(matches!(err, EngineError::TxnConflict(_)), "{err:?}"); + assert_eq!( + engine + .get_node(b) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("old-b".to_string())) + ); + + let edge = engine + .upsert_edge(a, b, "Gql_RETURN_READ_SET", UpsertEdgeOptions::default()) + .unwrap(); + let source = format!( + "MATCH p = (a:GqlReturnReadSet)-[:Gql_RETURN_READ_SET]->(b:GqlReturnReadSet) \ + WHERE id(a) = {a} SET b.status = 'start-node-conflict' RETURN start_node(p)" + ); + let (release_tx, handle) = run_paused(source, &engine); + engine + .upsert_node( + "GqlReturnReadSet", + "a", + UpsertNodeOptions { + props: query_test_props(&[( + "status", + PropValue::String("outside-start".to_string()), + )]), + ..UpsertNodeOptions::default() }, ) .unwrap(); - engine + release_tx.send(()).unwrap(); + let err = handle.join().unwrap().unwrap_err(); + assert!(matches!(err, EngineError::TxnConflict(_)), "{err:?}"); + assert_eq!( + engine + .get_node(b) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("old-b".to_string())) + ); + + let source = format!( + "MATCH p = (a:GqlReturnReadSet)-[:Gql_RETURN_READ_SET]->(b:GqlReturnReadSet) \ + WHERE id(a) = {a} SET b.status = 'relationships-conflict' RETURN relationships(p)" + ); + let (release_tx, handle) = run_paused(source, &engine); + engine.delete_edge(edge).unwrap(); + release_tx.send(()).unwrap(); + let err = handle.join().unwrap().unwrap_err(); + assert!(matches!(err, EngineError::TxnConflict(_)), "{err:?}"); + assert_eq!( + engine + .get_node(b) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("old-b".to_string())) + ); + + let edge = engine + .upsert_edge(a, b, "Gql_RETURN_READ_SET", UpsertEdgeOptions::default()) + .unwrap(); + let source = format!( + "MATCH p = (a:GqlReturnReadSet)-[:Gql_RETURN_READ_SET]->(b:GqlReturnReadSet) \ + WHERE id(a) = {a} SET b.status = 'path-helper-no-conflict' RETURN node_ids(p)" + ); + let (release_tx, handle) = run_paused(source, &engine); + engine.delete_edge(edge).unwrap(); + release_tx.send(()).unwrap(); + let helper = handle.join().unwrap().unwrap(); + assert_eq!(helper.stats.rows_returned, 1); + assert_eq!( + engine + .get_node(b) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("path-helper-no-conflict".to_string())) + ); + + let edge = engine + .upsert_edge(a, b, "Gql_RETURN_READ_SET", UpsertEdgeOptions::default()) + .unwrap(); + let source = format!( + "MATCH p = (a:GqlReturnReadSet)-[:Gql_RETURN_READ_SET]->(b:GqlReturnReadSet) \ + WHERE id(a) = {a} SET b.status = 'limit-zero-no-conflict' RETURN p LIMIT 0" + ); + let (release_tx, handle) = run_paused(source, &engine); + engine.delete_edge(edge).unwrap(); + release_tx.send(()).unwrap(); + let limit_zero = handle.join().unwrap().unwrap(); + assert!(limit_zero.rows.is_empty()); + assert_eq!( + engine + .get_node(b) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("limit-zero-no-conflict".to_string())) + ); +} + +#[test] +fn gql_mutation_return_paths_and_existing_aliases_project_after_commit() { + let (_dir, engine) = query_test_engine(); + let a = insert_query_node( + &engine, + "GqlReturnPath", + "a", + &[("status", PropValue::String("old-a".to_string()))], + 1.0, + ); + let b = insert_query_node( + &engine, + "GqlReturnPath", + "b", + &[("status", PropValue::String("old-b".to_string()))], + 1.0, + ); + let edge = engine .upsert_edge( - bob, - globex, - "MENTORS", + a, + b, + "Gql_RETURN_PATH", UpsertEdgeOptions { - props: query_test_props(&[("role", PropValue::String("mentor".to_string()))]), + props: query_test_props(&[("kind", PropValue::String("direct".to_string()))]), ..UpsertEdgeOptions::default() }, ) .unwrap(); - RichGqlGraph { - alice, - bob, - acme, - globex, - lead_edge, - review_edge, + let source = format!( + "MATCH p = (a:GqlReturnPath)-[r:Gql_RETURN_PATH]->(b:GqlReturnPath) \ + WHERE id(a) = {a} \ + SET b.status = 'new-b' \ + RETURN p, a.status, b.status, length(p), node_ids(p), edge_ids(p), r.kind, \ + start_node(p), end_node(p), nodes(p), relationships(p)" + ); + let result = engine + .execute_gql(&source, &GqlParams::new(), &gql_opts()) + .unwrap(); + assert_eq!(result.rows.len(), 1); + let values = &result.rows[0].values; + let path = gql_single_path(&values[0]); + assert_eq!(path.node_ids, vec![a, b]); + assert_eq!(path.edge_ids, vec![edge]); + assert_eq!(path.nodes.as_ref().unwrap().len(), 2); + assert_eq!(path.edges.as_ref().unwrap().len(), 1); + assert_eq!(values[1], GqlValue::String("old-a".to_string())); + assert_eq!(values[2], GqlValue::String("new-b".to_string())); + assert_eq!(values[3], GqlValue::UInt(1)); + assert_eq!( + values[4], + GqlValue::List(vec![GqlValue::UInt(a), GqlValue::UInt(b)]) + ); + assert_eq!(values[5], GqlValue::List(vec![GqlValue::UInt(edge)])); + assert_eq!(values[6], GqlValue::String("direct".to_string())); + assert_eq!(gql_single_node(&values[7]).id, Some(a)); + assert_eq!(gql_single_node(&values[8]).id, Some(b)); + let GqlValue::List(nodes) = &values[9] else { + panic!("expected nodes(p) list"); + }; + assert_eq!(nodes.len(), 2); + assert_eq!(gql_single_node(&nodes[0]).id, Some(a)); + assert_eq!(gql_single_node(&nodes[1]).id, Some(b)); + let GqlValue::List(edges) = &values[10] else { + panic!("expected relationships(p) list"); + }; + assert_eq!(edges.len(), 1); + assert_eq!(gql_single_edge(&edges[0]).id, Some(edge)); + assert_eq!( + engine + .get_node(b) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("new-b".to_string())) + ); + + let invalid_projection = engine + .execute_gql( + &format!( + "MATCH p = (a:GqlReturnPath)-[:Gql_RETURN_PATH]->(b:GqlReturnPath) \ + WHERE id(a) = {a} SET b.status = 'bad-limit-zero' \ + RETURN start_node(p).key LIMIT 0" + ), + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!( + matches!(invalid_projection, EngineError::GqlSemantic { .. }), + "unexpected error: {invalid_projection:?}" + ); + assert_eq!( + engine + .get_node(b) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("new-b".to_string())) + ); +} + +#[test] +fn gql_mutation_return_missing_params_and_unsupported_projection_are_atomic() { + let (_dir, engine) = query_test_engine(); + let node_id = insert_query_node( + &engine, + "GqlReturnPrevalidate", + "n", + &[("status", PropValue::String("old".to_string()))], + 1.0, + ); + + let missing = engine + .execute_gql( + "MATCH (n:GqlReturnPrevalidate) WHERE n.key = 'n' SET n.status = 'new' RETURN $missing", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert_gql_param_error(missing, "missing", "missing"); + assert_eq!( + engine + .get_node(node_id) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("old".to_string())) + ); + + let unsupported = engine + .execute_gql( + "MATCH (n:GqlReturnPrevalidate) WHERE n.key = 'n' SET n.status = 'new' RETURN relationships(n)", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!( + matches!(unsupported, EngineError::GqlSemantic { .. } | EngineError::GqlUnsupported { .. }), + "{unsupported:?}" + ); + assert_eq!( + engine + .get_node(node_id) + .unwrap() + .unwrap() + .props + .get("status"), + Some(&PropValue::String("old".to_string())) + ); +} + +#[test] +fn gql_set_remove_edge_index_flush_reopen_and_stale_candidates() { + let (dir, engine) = query_test_engine(); + let db_path = dir.path().join("db"); + engine + .ensure_edge_property_index("Gql_EDGE_INDEX", "status", SecondaryIndexKind::Equality) + .unwrap(); + let a = insert_query_node(&engine, "GqlEdgeIndexNode", "a", &[], 1.0); + let b = insert_query_node(&engine, "GqlEdgeIndexNode", "b", &[], 1.0); + let edge_id = engine + .upsert_edge( + a, + b, + "Gql_EDGE_INDEX", + UpsertEdgeOptions { + props: query_test_props(&[("status", PropValue::String("old".to_string()))]), + ..Default::default() + }, + ) + .unwrap(); + let edge_ids_for = |engine: &DatabaseEngine, status: &str| { + engine + .query_edge_ids(&EdgeQuery { + label: Some("Gql_EDGE_INDEX".to_string()), + filter: Some(EdgeFilterExpr::PropertyEquals { + key: "status".to_string(), + value: PropValue::String(status.to_string()), + }), + ..Default::default() + }) + .unwrap() + .edge_ids + }; + + engine + .execute_gql( + "MATCH (a:GqlEdgeIndexNode) WHERE a.key = 'a' \ + MATCH (b:GqlEdgeIndexNode) WHERE b.key = 'b' \ + MATCH (a)-[r:Gql_EDGE_INDEX]->(b) SET r.status = 'new'", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + assert_eq!(edge_ids_for(&engine, "new"), vec![edge_id]); + assert!(edge_ids_for(&engine, "old").is_empty()); + engine.flush().unwrap(); + assert_eq!(edge_ids_for(&engine, "new"), vec![edge_id]); + + drop(engine); + let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); + assert_eq!(edge_ids_for(&reopened, "new"), vec![edge_id]); + let removed = reopened + .execute_gql( + "MATCH (a:GqlEdgeIndexNode) WHERE a.key = 'a' \ + MATCH (b:GqlEdgeIndexNode) WHERE b.key = 'b' \ + MATCH (a)-[r:Gql_EDGE_INDEX]->(b) REMOVE r.status RETURN r.status", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + assert_eq!(removed.rows[0].values[0], GqlValue::Null); + assert!(edge_ids_for(&reopened, "new").is_empty()); + let stale_candidate_read = execute_gql_ok( + &reopened, + "MATCH ()-[r:Gql_EDGE_INDEX {status: 'new'}]->() RETURN id(r)", + ); + assert!(stale_candidate_read.rows.is_empty()); +} + +#[test] +fn gql_delete_edge_dedupes_updates_indexes_and_survives_reopen() { + let (dir, engine) = query_test_engine(); + let db_path = dir.path().join("db"); + engine + .ensure_edge_property_index("Gql_DELETE_EDGE", "status", SecondaryIndexKind::Equality) + .unwrap(); + let a = insert_query_node(&engine, "GqlDeleteEdgeNode", "a", &[], 1.0); + let b = insert_query_node(&engine, "GqlDeleteEdgeNode", "b", &[], 1.0); + let edge_id = engine + .upsert_edge( + a, + b, + "Gql_DELETE_EDGE", + UpsertEdgeOptions { + props: query_test_props(&[("status", PropValue::String("live".to_string()))]), + ..Default::default() + }, + ) + .unwrap(); + + let result = engine + .execute_gql( + "MATCH (a:GqlDeleteEdgeNode) WHERE a.key = 'a' \ + MATCH (b:GqlDeleteEdgeNode) WHERE b.key = 'b' \ + MATCH (a)-[r:Gql_DELETE_EDGE {status: 'live'}]->(b) DELETE r DELETE r", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + assert!(result.rows.is_empty()); + let stats = result.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.rows_matched, 1); + assert_eq!(stats.mutation_rows, 1); + assert_eq!(stats.mutation_ops, 1); + assert_eq!(stats.edges_deleted, 1); + assert_eq!(stats.duplicate_targets, 1); + assert!(engine.get_edge(edge_id).unwrap().is_none()); + let stale_index_read = execute_gql_ok( + &engine, + "MATCH ()-[r:Gql_DELETE_EDGE {status: 'live'}]->() RETURN id(r)", + ); + assert!(stale_index_read.rows.is_empty()); + + drop(engine); + let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); + assert!(reopened.get_edge(edge_id).unwrap().is_none()); +} + +#[test] +fn gql_delete_same_edge_across_multiple_rows_deletes_once() { + let (_dir, engine) = query_test_engine(); + let a = insert_query_node(&engine, "GqlDeleteRowsNode", "a", &[], 1.0); + let b = insert_query_node(&engine, "GqlDeleteRowsNode", "b", &[], 1.0); + insert_query_node(&engine, "GqlDeleteRowsMarker", "x1", &[], 1.0); + insert_query_node(&engine, "GqlDeleteRowsMarker", "x2", &[], 1.0); + let edge_id = engine + .upsert_edge(a, b, "Gql_DELETE_ROWS", UpsertEdgeOptions::default()) + .unwrap(); + + let result = engine + .execute_gql( + "MATCH (a:GqlDeleteRowsNode) WHERE a.key = 'a' \ + MATCH (b:GqlDeleteRowsNode) WHERE b.key = 'b' \ + MATCH (a)-[r:Gql_DELETE_ROWS]->(b) MATCH (x:GqlDeleteRowsMarker) DELETE r", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + let stats = result.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.rows_matched, 2); + assert_eq!(stats.edges_deleted, 1); + assert_eq!(stats.mutation_ops, 1); + assert_eq!(stats.duplicate_targets, 1); + assert!(engine.get_edge(edge_id).unwrap().is_none()); +} + +#[test] +fn gql_detach_delete_node_cascades_active_and_segment_edges_once() { + let (dir, engine) = query_test_engine(); + let db_path = dir.path().join("db"); + let hub = insert_query_node(&engine, "GqlDetachNode", "hub", &[], 1.0); + let left = insert_query_node(&engine, "GqlDetachNode", "left", &[], 1.0); + let right = insert_query_node(&engine, "GqlDetachNode", "right", &[], 1.0); + let segment_edge = engine + .upsert_edge(hub, left, "Gql_DETACH_EDGE", UpsertEdgeOptions::default()) + .unwrap(); + engine.flush().unwrap(); + let active_edge = engine + .upsert_edge(right, hub, "Gql_DETACH_EDGE", UpsertEdgeOptions::default()) + .unwrap(); + + let result = engine + .execute_gql( + "MATCH (n:GqlDetachNode) WHERE n.key = 'hub' DETACH DELETE n", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + let stats = result.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.nodes_deleted, 1); + assert_eq!(stats.edges_deleted, 2); + assert_eq!(stats.mutation_ops, 3); + assert!(engine.get_node(hub).unwrap().is_none()); + assert!(engine.get_edge(segment_edge).unwrap().is_none()); + assert!(engine.get_edge(active_edge).unwrap().is_none()); + + drop(engine); + let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); + assert!(reopened.get_node(hub).unwrap().is_none()); + assert!(reopened.get_edge(segment_edge).unwrap().is_none()); + assert!(reopened.get_edge(active_edge).unwrap().is_none()); +} + +#[test] +fn gql_detach_delete_dedupes_shared_and_direct_cascade_edges() { + let (_dir, engine) = query_test_engine(); + let a = insert_query_node(&engine, "GqlDetachDedupeNode", "a", &[], 1.0); + let b = insert_query_node(&engine, "GqlDetachDedupeNode", "b", &[], 1.0); + let shared = engine + .upsert_edge(a, b, "Gql_DETACH_DEDUPE", UpsertEdgeOptions::default()) + .unwrap(); + + let shared_result = engine + .execute_gql( + "MATCH (a:GqlDetachDedupeNode) WHERE a.key = 'a' \ + MATCH (b:GqlDetachDedupeNode) WHERE b.key = 'b' DETACH DELETE a DETACH DELETE b", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + let shared_stats = shared_result.mutation_stats.as_ref().unwrap(); + assert_eq!(shared_stats.nodes_deleted, 2); + assert_eq!(shared_stats.edges_deleted, 1); + assert_eq!(shared_stats.mutation_ops, 3); + assert!(engine.get_edge(shared).unwrap().is_none()); + + let c = insert_query_node(&engine, "GqlDetachDedupeNode", "c", &[], 1.0); + let d = insert_query_node(&engine, "GqlDetachDedupeNode", "d", &[], 1.0); + let direct = engine + .upsert_edge(c, d, "Gql_DETACH_DIRECT", UpsertEdgeOptions::default()) + .unwrap(); + let direct_result = engine + .execute_gql( + "MATCH (c:GqlDetachDedupeNode) WHERE c.key = 'c' \ + MATCH (d:GqlDetachDedupeNode) WHERE d.key = 'd' \ + MATCH (c)-[r:Gql_DETACH_DIRECT]->(d) DELETE r DETACH DELETE c", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + let direct_stats = direct_result.mutation_stats.as_ref().unwrap(); + assert_eq!(direct_stats.nodes_deleted, 1); + assert_eq!(direct_stats.edges_deleted, 1); + assert_eq!(direct_stats.mutation_ops, 2); + assert_eq!(direct_stats.duplicate_targets, 1); + assert!(engine.get_edge(direct).unwrap().is_none()); + assert!(engine.get_node(d).unwrap().is_some()); +} + +#[test] +fn gql_delete_optional_null_targets_are_noops() { + let (_dir, engine) = query_test_engine(); + let root = insert_query_node(&engine, "GqlDeleteOptional", "root", &[], 1.0); + let result = engine + .execute_gql( + "MATCH (n:GqlDeleteOptional) WHERE n.key = 'root' \ + OPTIONAL MATCH (n)-[r:Gql_DELETE_MISSING]->(m) DELETE r DETACH DELETE m", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + let stats = result.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.rows_matched, 1); + assert_eq!(stats.mutation_rows, 0); + assert_eq!(stats.mutation_ops, 0); + assert_eq!(stats.skipped_null_targets, 2); + assert_eq!(stats.nodes_deleted, 0); + assert_eq!(stats.edges_deleted, 0); + assert!(engine.get_node(root).unwrap().is_some()); +} + +#[test] +fn gql_delete_wins_over_earlier_replacements() { + let (_dir, engine) = query_test_engine(); + let a = insert_query_node(&engine, "GqlDeleteWinsNode", "a", &[], 1.0); + let b = insert_query_node(&engine, "GqlDeleteWinsNode", "b", &[], 1.0); + let edge_id = engine + .upsert_edge( + a, + b, + "Gql_DELETE_WINS", + UpsertEdgeOptions { + props: query_test_props(&[("status", PropValue::String("old".to_string()))]), + ..Default::default() + }, + ) + .unwrap(); + + let result = engine + .execute_gql( + "MATCH (a:GqlDeleteWinsNode) WHERE a.key = 'a' \ + MATCH (b:GqlDeleteWinsNode) WHERE b.key = 'b' \ + MATCH (a)-[r:Gql_DELETE_WINS]->(b) SET r.status = 'new' DELETE r", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + let stats = result.mutation_stats.as_ref().unwrap(); + assert_eq!(stats.edges_deleted, 1); + assert_eq!(stats.edges_updated, 0); + assert_eq!(stats.properties_set, 0); + assert_eq!(stats.mutation_ops, 1); + assert_eq!(stats.duplicate_targets, 1); + assert!(engine.get_edge(edge_id).unwrap().is_none()); +} + +#[test] +fn gql_delete_created_edge_and_detach_created_node_use_local_refs() { + let (_dir, engine) = query_test_engine(); + let direct = engine + .execute_gql( + "CREATE (a:GqlCreatedEdgeDelete {key: 'a'})-[r:Gql_CREATED_EDGE_DELETE]->(b:GqlCreatedEdgeDelete {key: 'b'}) DELETE r", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + let direct_stats = direct.mutation_stats.as_ref().unwrap(); + assert_eq!(direct_stats.nodes_created, 2); + assert_eq!(direct_stats.edges_created, 0); + assert_eq!(direct_stats.edges_deleted, 0); + assert!(engine + .query_edges(&EdgeQuery { + label: Some("Gql_CREATED_EDGE_DELETE".to_string()), + ..Default::default() + }) + .unwrap() + .edges + .is_empty()); + + let detached = engine + .execute_gql( + "CREATE (a:GqlCreatedDetach {key: 'a'})-[r:Gql_CREATED_DETACH]->(b:GqlCreatedDetach {key: 'b'}) DETACH DELETE a", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + let detached_stats = detached.mutation_stats.as_ref().unwrap(); + assert_eq!(detached_stats.nodes_created, 1); + assert_eq!(detached_stats.nodes_deleted, 0); + assert_eq!(detached_stats.edges_created, 0); + assert_eq!(detached_stats.edges_deleted, 0); + assert!(engine + .get_node_by_key("GqlCreatedDetach", "a") + .unwrap() + .is_none()); + assert!(engine + .get_node_by_key("GqlCreatedDetach", "b") + .unwrap() + .is_some()); + assert!(engine + .query_edges(&EdgeQuery { + label: Some("Gql_CREATED_DETACH".to_string()), + ..Default::default() + }) + .unwrap() + .edges + .is_empty()); +} + +#[test] +fn gql_delete_caps_fail_before_staging_or_commit() { + let (_dir, engine) = query_test_engine(); + let a = insert_query_node(&engine, "GqlDeleteCapNode", "a", &[], 1.0); + let b = insert_query_node(&engine, "GqlDeleteCapNode", "b", &[], 1.0); + let edge_id = engine + .upsert_edge(a, b, "Gql_DELETE_CAP", UpsertEdgeOptions::default()) + .unwrap(); + + let direct_cap = engine + .execute_gql( + "MATCH (a:GqlDeleteCapNode) WHERE a.key = 'a' \ + MATCH (b:GqlDeleteCapNode) WHERE b.key = 'b' \ + MATCH (a)-[r:Gql_DELETE_CAP]->(b) DELETE r", + &GqlParams::new(), + &GqlExecutionOptions { + max_mutation_ops: 0, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!(matches!(direct_cap, EngineError::InvalidOperation(message) if message.contains("max_mutation_ops"))); + assert!(engine.get_edge(edge_id).unwrap().is_some()); + + let detach_cap = engine + .execute_gql( + "MATCH (n:GqlDeleteCapNode) WHERE n.key = 'a' DETACH DELETE n", + &GqlParams::new(), + &GqlExecutionOptions { + max_mutation_ops: 1, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!(matches!(detach_cap, EngineError::InvalidOperation(message) if message.contains("max_mutation_ops"))); + assert!(engine.get_node(a).unwrap().is_some()); + assert!(engine.get_edge(edge_id).unwrap().is_some()); + + let row_cap = engine + .execute_gql( + "MATCH (a:GqlDeleteCapNode)-[r:Gql_DELETE_CAP]->(b:GqlDeleteCapNode) DELETE r", + &GqlParams::new(), + &GqlExecutionOptions { + max_mutation_rows: 0, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!(matches!(row_cap, EngineError::InvalidOperation(message) if message.contains("max_mutation_rows"))); + assert!(engine.get_edge(edge_id).unwrap().is_some()); +} + +#[test] +fn gql_detach_delete_cap_bounds_high_fanout_cascade() { + let (_dir, engine) = query_test_engine(); + let hub = insert_query_node(&engine, "GqlDetachCapHub", "hub", &[], 1.0); + let mut edge_ids = Vec::new(); + for idx in 0..8 { + let leaf = insert_query_node( + &engine, + "GqlDetachCapLeaf", + &format!("segment-{idx}"), + &[], + 1.0, + ); + edge_ids.push( + engine + .upsert_edge(hub, leaf, "Gql_DETACH_CAP_FANOUT", UpsertEdgeOptions::default()) + .unwrap(), + ); + } + engine.flush().unwrap(); + for idx in 0..8 { + let leaf = insert_query_node( + &engine, + "GqlDetachCapLeaf", + &format!("active-{idx}"), + &[], + 1.0, + ); + edge_ids.push( + engine + .upsert_edge(hub, leaf, "Gql_DETACH_CAP_FANOUT", UpsertEdgeOptions::default()) + .unwrap(), + ); + } + + let err = engine + .execute_gql( + "MATCH (n:GqlDetachCapHub) WHERE n.key = 'hub' DETACH DELETE n", + &GqlParams::new(), + &GqlExecutionOptions { + max_mutation_ops: 3, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!(matches!(err, EngineError::InvalidOperation(message) if message.contains("max_mutation_ops"))); + assert!(engine.get_node(hub).unwrap().is_some()); + for edge_id in edge_ids { + assert!(engine.get_edge(edge_id).unwrap().is_some()); + } +} + +#[test] +fn gql_detach_delete_commit_budget_bounds_edges_added_after_snapshot() { + let (_dir, engine) = query_test_engine(); + let hub = insert_query_node(&engine, "GqlDetachCommitCapHub", "hub", &[], 1.0); + let worker = DatabaseEngine { + runtime: std::sync::Arc::clone(&engine.runtime), + }; + let (ready_rx, release_tx) = engine.set_gql_mutation_before_commit_pause(); + let handle = std::thread::spawn(move || { + worker.execute_gql( + "MATCH (n:GqlDetachCommitCapHub) WHERE n.key = 'hub' DETACH DELETE n", + &GqlParams::new(), + &GqlExecutionOptions { + max_mutation_ops: 2, + ..GqlExecutionOptions::default() + }, + ) + }); + ready_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("GQL mutation did not pause before commit"); + + let mut edge_ids = Vec::new(); + for idx in 0..8 { + let leaf = insert_query_node( + &engine, + "GqlDetachCommitCapLeaf", + &format!("leaf-{idx}"), + &[], + 1.0, + ); + edge_ids.push( + engine + .upsert_edge( + hub, + leaf, + "Gql_DETACH_COMMIT_CAP", + UpsertEdgeOptions::default(), + ) + .unwrap(), + ); + } + release_tx.send(()).unwrap(); + let err = handle.join().unwrap().unwrap_err(); + assert!(matches!(err, EngineError::InvalidOperation(message) if message.contains("max_mutation_ops"))); + assert!(engine.get_node(hub).unwrap().is_some()); + for edge_id in edge_ids { + assert!(engine.get_edge(edge_id).unwrap().is_some()); + } +} + +#[test] +fn gql_delete_rejections_still_happen_before_writes() { + let (_dir, engine) = query_test_engine(); + let node_id = insert_query_node(&engine, "GqlDeleteReject", "n", &[], 1.0); + let delete_node = engine + .execute_gql( + "MATCH (n:GqlDeleteReject) WHERE n.key = 'n' DELETE n", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!(matches!( + delete_node, + EngineError::GqlSemantic { + code: GqlSemanticErrorCode::InvalidReturnExpression, + .. + } + )); + assert!(engine.get_node(node_id).unwrap().is_some()); + + let return_after_delete = engine + .execute_gql( + "MATCH (n:GqlDeleteReject) WHERE n.key = 'n' DETACH DELETE n RETURN n", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!(matches!( + return_after_delete, + EngineError::GqlSemantic { + code: GqlSemanticErrorCode::InvalidReturnExpression, + .. + } + )); + assert!(engine.get_node(node_id).unwrap().is_some()); + + let cursor_first = engine + .execute_gql( + "MATCH (n:GqlDeleteReject) WHERE n.key = 'n' DETACH DELETE n", + &GqlParams::new(), + &GqlExecutionOptions { + cursor: Some("read-cursor".to_string()), + mode: GqlExecutionMode::ReadOnly, + ..gql_opts() + }, + ) + .unwrap_err(); + match cursor_first { + EngineError::InvalidCursor { message } => { + assert_eq!(message, "GQL mutation statements do not accept cursors"); + } + err => panic!("expected mutation cursor error, got {err:?}"), + } + assert!(engine.get_node(node_id).unwrap().is_some()); +} + +#[test] +fn gql_replacement_adapter_static_audit_keeps_public_surfaces_clean() { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let forbidden = [ + ["Replace", "Node"].concat(), + ["Replace", "Edge"].concat(), + ]; + for path in [ + "src/types.rs", + "overgraph-node/src/lib.rs", + "overgraph-node/index.d.ts", + "overgraph-node/query-types.d.ts", + "overgraph-python/src/lib.rs", + "overgraph-python/python/overgraph/__init__.pyi", + "overgraph-python/python/overgraph/async_api.py", + ] { + let contents = std::fs::read_to_string(manifest_dir.join(path)).unwrap(); + for needle in &forbidden { + assert!( + !contents.contains(needle), + "{path} exposes a public replacement transaction API" + ); + } + } +} + +#[test] +fn gql_delete_static_audit_uses_transaction_intents_not_public_delete_loops() { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let query = std::fs::read_to_string(manifest_dir.join("src/engine/query.rs")).unwrap(); + assert!(query.contains("TxnIntent::DeleteNode")); + assert!(query.contains("TxnIntent::DeleteEdge")); + assert!(query.contains("txn_delete_incident_edge_ids_limited")); + assert!(!query.contains(".delete_node(")); + assert!(!query.contains(".delete_edge(")); + + let txn = std::fs::read_to_string(manifest_dir.join("src/engine/txn.rs")).unwrap(); + assert!(txn.contains("pub(crate) struct TxnGraphOpBudget")); + assert!(txn.contains("fn incident_edge_ids_for_txn_delete_limited")); + assert!(txn.contains("fn limited_scan_len")); + for needle in [ + "pub struct TxnGraphOpBudget", + "pub fn gql_apply_mutation_op_budget", + ] { + assert!( + !txn.contains(needle), + "transaction mutation budget helper leaked into the public API" + ); + } +} + +#[test] +fn gql_mutation_return_static_audit_keeps_read_set_private_and_projection_batched() { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let txn = std::fs::read_to_string(manifest_dir.join("src/engine/txn.rs")).unwrap(); + assert!(txn.contains("pub(crate) struct TxnReturnReadSet")); + assert!(txn.contains("pub(crate) fn gql_validate_return_read_set")); + assert!(txn.contains("pub(crate) fn commit_with_gql_return_view")); + let read_set_start = txn.find("fn validate_gql_return_read_set").unwrap(); + let read_set_end = txn[read_set_start..] + .find("fn resolve_node_ref_required") + .map(|offset| read_set_start + offset) + .unwrap(); + let read_set_body = &txn[read_set_start..read_set_end]; + assert!(read_set_body.contains("self.get_nodes_raw(&node_ids)?")); + assert!(read_set_body.contains("self.get_edges(&edge_ids)?")); + assert!(!read_set_body.contains("validate_node_id_conflict")); + assert!(!read_set_body.contains("validate_edge_id_conflict")); + for needle in [ + "pub struct TxnReturnReadSet", + "pub fn gql_validate_return_read_set", + "pub fn commit_with_gql_return_view", + ] { + assert!( + !txn.contains(needle), + "GQL mutation RETURN read-set/view helper leaked into the public transaction API" + ); + } + + let query = std::fs::read_to_string(manifest_dir.join("src/engine/query.rs")).unwrap(); + assert!(query.contains("view.get_nodes_raw(&node_ids)")); + assert!(query.contains("view.get_edges(&edge_ids)")); + assert!(!query.contains(".get_node(")); + assert!(!query.contains(".get_edge(")); + assert!(query.contains("fn execute_gql_mutation(")); + assert!(query.contains("fn explain_gql_mutation(")); + let execute_start = query.find("fn execute_gql_create_mutation").unwrap(); + let execute_end = query[execute_start..] + .find("fn gql_create_input_rows") + .map(|offset| execute_start + offset) + .unwrap(); + let execute_body = &query[execute_start..execute_end]; + assert!(execute_body.contains("let snapshot = txn.gql_snapshot()?;")); + assert!(execute_body.contains("build_gql_mutation_explain_with_snapshot")); + assert!( + execute_body.find("let snapshot = txn.gql_snapshot()?;").unwrap() + < execute_body + .find("build_gql_mutation_explain_with_snapshot") + .unwrap(), + "embedded mutation explain must use the transaction snapshot" + ); + let explain_start = query + .find("fn build_gql_mutation_explain_with_snapshot") + .unwrap(); + let explain_end = query[explain_start..] + .find("fn gql_execution_cap_summary") + .map(|offset| explain_start + offset) + .unwrap(); + let explain_body = &query[explain_start..explain_end]; + assert!( + !explain_body.contains("published_snapshot"), + "snapshot-specific mutation explain builder must not capture a second snapshot" + ); + assert!(query.contains("gql_mutation_return_needs_committed_view")); + assert!(query.contains("if selected.is_empty()")); +} + +#[test] +fn gql_create_node_survives_reopen() { + let (dir, engine) = query_test_engine(); + let db_path = dir.path().join("db"); + engine + .execute_gql( + "CREATE (n:GqlReopen {key: 'persisted', name: 'stored'}) RETURN id(n)", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + drop(engine); + + let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); + let node = reopened + .get_node_by_key("GqlReopen", "persisted") + .unwrap() + .unwrap(); + assert_eq!(node.props.get("name"), Some(&PropValue::String("stored".to_string()))); +} + +#[test] +fn gql_create_edge_label_survives_reopen() { + let (dir, engine) = query_test_engine(); + let db_path = dir.path().join("db"); + let result = engine + .execute_gql( + "CREATE (a:GqlEdgeReopen {key: 'a'})-[r:Gql_EDGE_REOPEN {since: 7}]->(b:GqlEdgeReopen {key: 'b'}) RETURN id(a), id(r), id(b)", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + let a_id = gql_u64_column(&result, 0)[0]; + let edge_id = gql_u64_column(&result, 1)[0]; + let b_id = gql_u64_column(&result, 2)[0]; + drop(engine); + + let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); + assert_eq!( + reopened + .get_node_by_key("GqlEdgeReopen", "a") + .unwrap() + .unwrap() + .id, + a_id + ); + assert_eq!( + reopened + .get_node_by_key("GqlEdgeReopen", "b") + .unwrap() + .unwrap() + .id, + b_id + ); + let edge = reopened.get_edge(edge_id).unwrap().unwrap(); + assert_eq!(edge.from, a_id); + assert_eq!(edge.to, b_id); + assert_eq!(edge.label, "Gql_EDGE_REOPEN"); + assert_eq!(edge.props.get("since"), Some(&PropValue::Int(7))); + assert_eq!( + reopened + .get_edge_by_triple(a_id, b_id, "Gql_EDGE_REOPEN") + .unwrap() + .unwrap() + .id, + edge_id + ); +} + +#[test] +fn mutation_explain_includes_read_prefix_and_operations() { + let (_dir, engine) = query_test_engine(); + insert_query_node(&engine, "Person", "explain-mutation-ada", &[], 1.0); + + let explain = engine + .explain_gql( + "MATCH (n:Person {key: 'explain-mutation-ada'}) SET n.name = 'Ada' RETURN n.name ORDER BY n.name SKIP 0 LIMIT 1", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + assert_eq!(explain.kind, GqlStatementKind::Mutation); + assert_eq!(explain.columns, vec!["n.name"]); + assert!(matches!( + explain.read.as_ref().map(|read| read.target), + Some(GqlLoweringTarget::GraphRowQuery) + )); + let mutation = explain.mutation.expect("mutation explain"); + assert!(mutation.uses_write_txn); + assert!(mutation.uses_transaction_snapshot); + assert!(mutation.atomic_commit); + assert!(mutation.replacement_adapters); + let read_prefix = mutation.read_prefix.expect("read prefix explain"); + assert_eq!(read_prefix.graph_row_target.target, GqlLoweringTarget::GraphRowQuery); + assert!(read_prefix + .internal_columns + .iter() + .any(|column| column.contains("target id: n"))); + assert!(mutation + .operations + .iter() + .any(|op| op.op == "SET PROPERTY" && op.target_alias.as_deref() == Some("n"))); + let return_plan = mutation.return_plan.as_ref().expect("return explain"); + assert_eq!(return_plan.columns, vec!["n.name"]); + assert_eq!(return_plan.order_items, 1); + assert_eq!(return_plan.skip, 0); + assert_eq!(return_plan.limit, Some(1)); + assert!(return_plan.post_commit_hydration.contains("prevalidates")); + assert!(return_plan.post_commit_hydration.contains("read-set")); + + let param_explain = engine + .explain_gql( + "MATCH (n:Person {key: 'explain-mutation-ada'}) SET n.name = 'Ada' \ + RETURN n.name ORDER BY n.name SKIP $skip LIMIT $limit", + &GqlParams::from([ + ("skip".to_string(), GqlParamValue::UInt(2)), + ("limit".to_string(), GqlParamValue::Int(3)), + ]), + &gql_opts(), + ) + .unwrap(); + let mutation = param_explain.mutation.expect("mutation explain"); + let return_plan = mutation.return_plan.as_ref().expect("return explain"); + assert_eq!(return_plan.skip, 2); + assert_eq!(return_plan.limit, Some(3)); + + let full_scan_explain = engine + .explain_gql( + "MATCH (n) SET n.name = 'Ada'", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + ..gql_opts() + }, + ) + .unwrap(); + let mutation = full_scan_explain.mutation.expect("mutation explain"); + let read_prefix = mutation.read_prefix.expect("read prefix explain"); + assert!(read_prefix + .graph_row_target + .warnings + .iter() + .any(|warning| warning.contains("full scan"))); +} + +#[derive(Clone)] +struct RichGqlGraph { + alice: u64, + bob: u64, + acme: u64, + globex: u64, + lead_edge: u64, + review_edge: u64, + startup_edge: u64, + mentor_edge: u64, +} + +#[derive(Clone, Copy)] +struct RichGqlIndexes { + employee_status: u64, + employee_score: u64, + works_role: u64, + works_hours: u64, +} + +fn seed_rich_gql_graph(engine: &DatabaseEngine) -> RichGqlGraph { + let acme = insert_query_node( + engine, + "Company", + "rich-acme", + &[("tier", PropValue::String("enterprise".to_string()))], + 3.0, + ); + let globex = insert_query_node( + engine, + "Company", + "rich-globex", + &[("tier", PropValue::String("startup".to_string()))], + 2.0, + ); + let alice = insert_query_node_with_labels( + engine, + &["Person", "Employee", "Manager"], + "rich-alice", + &[ + ("status", PropValue::String("focus".to_string())), + ("score", PropValue::Int(91)), + ("department", PropValue::String("platform".to_string())), + ("rank", PropValue::Int(2)), + ], + 1.25, + ); + let bob = insert_query_node_with_labels( + engine, + &["Person", "Employee"], + "rich-bob", + &[ + ("status", PropValue::String("focus".to_string())), + ("score", PropValue::Int(76)), + ("department", PropValue::String("platform".to_string())), + ("rank", PropValue::Int(1)), + ], + 1.5, + ); + insert_query_node_with_labels( + engine, + &["Person", "Employee"], + "rich-carol", + &[ + ("status", PropValue::String("inactive".to_string())), + ("score", PropValue::Int(88)), + ("department", PropValue::String("research".to_string())), + ("rank", PropValue::Null), + ], + 1.0, + ); + insert_query_node_with_labels( + engine, + &["Person", "Contractor"], + "rich-dana", + &[ + ("status", PropValue::String("focus".to_string())), + ("score", PropValue::Int(85)), + ], + 1.0, + ); + insert_query_node( + engine, + "Person", + "rich-eve", + &[ + ("status", PropValue::String("focus".to_string())), + ("score", PropValue::Int(82)), + ], + 1.0, + ); + insert_query_node_with_labels( + engine, + &["Person", "Employee"], + "rich-frank", + &[ + ("status", PropValue::String("focus".to_string())), + ("score", PropValue::Int(63)), + ], + 1.0, + ); + insert_query_node_with_labels( + engine, + &["Person", "Employee"], + "rich-grace", + &[("score", PropValue::Int(99))], + 1.0, + ); + + for index in 0..24 { + let status = if index % 4 == 0 { "focus" } else { "inactive" }; + let filler = insert_query_node_with_labels( + engine, + &["Person", "Employee"], + &format!("rich-filler-{index:02}"), + &[ + ("status", PropValue::String(status.to_string())), + ("score", PropValue::Int(20 + i64::from(index))), + ], + 0.5, + ); + if index < 12 { + engine + .upsert_edge( + filler, + globex, + "WORKS_ON", + UpsertEdgeOptions { + props: query_test_props(&[ + ("role", PropValue::String("support".to_string())), + ("hours", PropValue::Int(5 + i64::from(index))), + ]), + weight: 0.25, + valid_from: Some(10), + valid_to: Some(20), + }, + ) + .unwrap(); + } + } + + let lead_edge = engine + .upsert_edge( + alice, + acme, + "WORKS_ON", + UpsertEdgeOptions { + props: query_test_props(&[ + ("role", PropValue::String("lead".to_string())), + ("hours", PropValue::Int(40)), + ]), + weight: 2.5, + valid_from: Some(0), + valid_to: Some(i64::MAX), + }, + ) + .unwrap(); + let review_edge = engine + .upsert_edge( + bob, + acme, + "WORKS_ON", + UpsertEdgeOptions { + props: query_test_props(&[ + ("role", PropValue::String("reviewer".to_string())), + ("hours", PropValue::Int(35)), + ]), + weight: 1.75, + valid_from: Some(0), + valid_to: Some(i64::MAX), + }, + ) + .unwrap(); + let startup_edge = engine + .upsert_edge( + alice, + globex, + "WORKS_ON", + UpsertEdgeOptions { + props: query_test_props(&[ + ("role", PropValue::String("lead".to_string())), + ("hours", PropValue::Int(10)), + ]), + weight: 0.75, + valid_from: Some(0), + valid_to: Some(i64::MAX), + }, + ) + .unwrap(); + let mentor_edge = engine + .upsert_edge( + alice, + bob, + "MENTORS", + UpsertEdgeOptions { + props: query_test_props(&[("role", PropValue::String("mentor".to_string()))]), + weight: 1.0, + ..UpsertEdgeOptions::default() + }, + ) + .unwrap(); + engine + .upsert_edge( + bob, + globex, + "MENTORS", + UpsertEdgeOptions { + props: query_test_props(&[("role", PropValue::String("mentor".to_string()))]), + ..UpsertEdgeOptions::default() + }, + ) + .unwrap(); + + RichGqlGraph { + alice, + bob, + acme, + globex, + lead_edge, + review_edge, startup_edge, mentor_edge, } -} +} + +fn install_rich_gql_indexes(engine: &DatabaseEngine) -> RichGqlIndexes { + let employee_status = engine + .ensure_node_property_index("Employee", "status", SecondaryIndexKind::Equality) + .unwrap() + .index_id; + wait_for_property_index_state(engine, employee_status, SecondaryIndexState::Ready); + wait_for_published_property_index_state(engine, employee_status, SecondaryIndexState::Ready); + + let employee_score = engine + .ensure_node_property_index( + "Employee", + "score", + SecondaryIndexKind::Range, + ) + .unwrap() + .index_id; + wait_for_property_index_state(engine, employee_score, SecondaryIndexState::Ready); + wait_for_published_property_index_state(engine, employee_score, SecondaryIndexState::Ready); + + let works_role = engine + .ensure_edge_property_index("WORKS_ON", "role", SecondaryIndexKind::Equality) + .unwrap() + .index_id; + wait_for_edge_property_index_state(engine, works_role, SecondaryIndexState::Ready); + wait_for_published_property_index_state(engine, works_role, SecondaryIndexState::Ready); + + let works_hours = engine + .ensure_edge_property_index( + "WORKS_ON", + "hours", + SecondaryIndexKind::Range, + ) + .unwrap() + .index_id; + wait_for_edge_property_index_state(engine, works_hours, SecondaryIndexState::Ready); + wait_for_published_property_index_state(engine, works_hours, SecondaryIndexState::Ready); + + RichGqlIndexes { + employee_status, + employee_score, + works_role, + works_hours, + } +} + +fn node_prop_i64(engine: &DatabaseEngine, id: u64, key: &str) -> i64 { + match engine + .get_node(id) + .unwrap() + .unwrap() + .props + .get(key) + .unwrap() + { + PropValue::Int(value) => *value, + other => panic!("expected int node property {key}, got {other:?}"), + } +} + +fn edge_prop_i64(engine: &DatabaseEngine, id: u64, key: &str) -> i64 { + match engine + .get_edge(id) + .unwrap() + .unwrap() + .props + .get(key) + .unwrap() + { + PropValue::Int(value) => *value, + other => panic!("expected int edge property {key}, got {other:?}"), + } +} + +fn sorted_rich_employee_focus_score_oracle(engine: &DatabaseEngine, min_score: i64) -> Vec { + let mut native = engine + .query_node_ids(&NodeQuery { + label_filter: Some(node_label_filter( + &["Person", "Employee"], + LabelMatchMode::All, + )), + filter: Some(NodeFilterExpr::And(vec![ + NodeFilterExpr::PropertyIn { + key: "status".to_string(), + values: vec![PropValue::String("focus".to_string())], + }, + NodeFilterExpr::PropertyRange { + key: "score".to_string(), + lower: Some(PropertyRangeBound::Included(PropValue::Int(min_score))), + upper: None, + }, + ])), + ..NodeQuery::default() + }) + .unwrap() + .items; + native.sort_by(|left, right| { + let left_node = engine.get_node(*left).unwrap().unwrap(); + let right_node = engine.get_node(*right).unwrap().unwrap(); + node_prop_i64(engine, *left, "score") + .cmp(&node_prop_i64(engine, *right, "score")) + .then_with(|| left_node.key.cmp(&right_node.key)) + .then_with(|| left.cmp(right)) + }); + native +} + +fn sorted_rich_work_edge_oracle(engine: &DatabaseEngine, min_hours: i64) -> Vec { + let mut native = engine + .query_edge_ids(&EdgeQuery { + label: Some("WORKS_ON".to_string()), + filter: Some(EdgeFilterExpr::And(vec![ + EdgeFilterExpr::PropertyIn { + key: "role".to_string(), + values: vec![ + PropValue::String("lead".to_string()), + PropValue::String("reviewer".to_string()), + ], + }, + EdgeFilterExpr::PropertyRange { + key: "hours".to_string(), + lower: Some(PropertyRangeBound::Included(PropValue::Int(min_hours))), + upper: None, + }, + ])), + ..EdgeQuery::default() + }) + .unwrap() + .edge_ids; + native.sort_by(|left, right| { + edge_prop_i64(engine, *left, "hours") + .cmp(&edge_prop_i64(engine, *right, "hours")) + .then_with(|| left.cmp(right)) + }); + native +} + +fn rich_pattern_oracle(engine: &DatabaseEngine, role: &str) -> Vec<(u64, u64, u64)> { + let mut query = GraphRowQuery { + nodes: vec![ + GraphNodePattern { + alias: "p".to_string(), + label_filter: Some(NodeLabelFilter { + labels: vec!["Person".to_string(), "Employee".to_string()], + mode: LabelMatchMode::All, + }), + ids: Vec::new(), + keys: Vec::new(), + filter: Some(NodeFilterExpr::PropertyEquals { + key: "status".to_string(), + value: PropValue::String("focus".to_string()), + }), + }, + GraphNodePattern { + alias: "c".to_string(), + label_filter: Some(NodeLabelFilter { + labels: vec!["Company".to_string()], + mode: LabelMatchMode::All, + }), + ids: Vec::new(), + keys: Vec::new(), + filter: Some(NodeFilterExpr::PropertyEquals { + key: "tier".to_string(), + value: PropValue::String("enterprise".to_string()), + }), + }, + ], + pieces: vec![GraphPatternPiece::Edge(GraphEdgePattern { + alias: Some("r".to_string()), + from_alias: "p".to_string(), + to_alias: "c".to_string(), + direction: Direction::Outgoing, + label_filter: vec!["WORKS_ON".to_string()], + filter: Some(EdgeFilterExpr::PropertyEquals { + key: "role".to_string(), + value: PropValue::String(role.to_string()), + }), + })], + where_: None, + return_items: Some(vec![ + GraphReturnItem { + expr: GraphExpr::Binding("p".to_string()), + projection: GraphReturnProjection::IdOnly, + alias: Some("p".to_string()), + }, + GraphReturnItem { + expr: GraphExpr::Binding("r".to_string()), + projection: GraphReturnProjection::IdOnly, + alias: Some("r".to_string()), + }, + GraphReturnItem { + expr: GraphExpr::Binding("c".to_string()), + projection: GraphReturnProjection::IdOnly, + alias: Some("c".to_string()), + }, + ]), + order_by: Vec::new(), + page: GraphPageRequest { + skip: 0, + limit: 100, + cursor: None, + }, + at_epoch: None, + params: BTreeMap::new(), + output: GraphOutputOptions::default(), + options: GraphQueryOptions::default(), + }; + query.options.allow_full_scan = true; + let mut matches = engine + .query_graph_rows(&query) + .unwrap() + .rows + .into_iter() + .map(|row| match row.values.as_slice() { + [ + GraphValue::NodeId(p), + GraphValue::EdgeId(r), + GraphValue::NodeId(c), + ] => (*p, *r, *c), + other => panic!("expected graph-row id tuple, got {other:?}"), + }) + .collect::>(); + matches.sort_by(|left, right| { + engine + .get_node(left.0) + .unwrap() + .unwrap() + .key + .cmp(&engine.get_node(right.0).unwrap().unwrap().key) + .then_with(|| left.1.cmp(&right.1)) + }); + matches +} + +#[test] +fn gql_node_query_executes_and_matches_native_node_oracle() { + let (_dir, engine) = query_test_engine(); + let active = insert_query_node( + &engine, + "Person", + "active-node", + &[("status", PropValue::String("active".to_string()))], + 1.0, + ); + insert_query_node( + &engine, + "Person", + "inactive-node", + &[("status", PropValue::String("inactive".to_string()))], + 1.0, + ); + + let native = engine + .query_node_ids(&NodeQuery { + label_filter: Some(node_label_filter(&["Person"], LabelMatchMode::All)), + filter: Some(NodeFilterExpr::PropertyEquals { + key: "status".to_string(), + value: PropValue::String("active".to_string()), + }), + ..NodeQuery::default() + }) + .unwrap() + .items; + let gql = execute_gql_ok( + &engine, + "MATCH (n:Person {status: 'active'}) RETURN id(n) AS id", + ); + + assert_eq!(native, vec![active]); + assert_eq!(gql.columns, vec!["id"]); + assert_eq!(gql_u64_column(&gql, 0), native); + assert_eq!(gql.stats.rows_matched, 1); + assert_eq!(gql.stats.rows_after_filter, 1); + assert_eq!(gql.stats.rows_returned, 1); + + let id_float_eq = execute_gql_ok( + &engine, + &format!("MATCH (n) WHERE id(n) = {active}.0 RETURN id(n)"), + ); + assert_eq!(gql_u64_column(&id_float_eq, 0), vec![active]); + + let id_float_in = execute_gql_ok( + &engine, + &format!("MATCH (n) WHERE id(n) IN [{active}.0] RETURN id(n)"), + ); + assert_eq!(gql_u64_column(&id_float_in, 0), vec![active]); +} + +#[test] +fn gql_edge_query_executes_and_matches_native_edge_oracle() { + let (_dir, engine) = query_test_engine(); + let from = insert_query_node(&engine, "Person", "edge-from", &[], 1.0); + let to = insert_query_node(&engine, "Article", "edge-to", &[], 1.0); + let other_to = insert_query_node(&engine, "Article", "edge-other-to", &[], 1.0); + let keep = engine + .upsert_edge( + from, + to, + "LIKES", + UpsertEdgeOptions { + props: query_test_props(&[("since", PropValue::Int(2024))]), + ..UpsertEdgeOptions::default() + }, + ) + .unwrap(); + engine + .upsert_edge( + from, + other_to, + "MENTIONS", + UpsertEdgeOptions { + props: query_test_props(&[("since", PropValue::Int(2025))]), + ..UpsertEdgeOptions::default() + }, + ) + .unwrap(); + engine + .upsert_edge( + to, + from, + "LIKES", + UpsertEdgeOptions { + props: query_test_props(&[("since", PropValue::Int(2019))]), + ..UpsertEdgeOptions::default() + }, + ) + .unwrap(); + + let native = engine + .query_edge_ids(&EdgeQuery { + label: Some("LIKES".to_string()), + filter: Some(EdgeFilterExpr::PropertyRange { + key: "since".to_string(), + lower: Some(PropertyRangeBound::Included(PropValue::Int(2020))), + upper: None, + }), + ..EdgeQuery::default() + }) + .unwrap() + .edge_ids; + let gql = execute_gql_ok( + &engine, + "MATCH ()-[r:LIKES]->() WHERE r.since >= 2020 RETURN id(r) AS id", + ); + + assert_eq!(native, vec![keep]); + assert_eq!(gql_u64_column(&gql, 0), native); + + let endpoint_float_ids = execute_gql_ok( + &engine, + &format!("MATCH ()-[r:LIKES]->() WHERE r.from = {from}.0 AND r.to IN [{to}.0] RETURN id(r)"), + ); + assert_eq!(gql_u64_column(&endpoint_float_ids, 0), vec![keep]); + + let id_float_eq = execute_gql_ok( + &engine, + &format!("MATCH ()-[r]->() WHERE id(r) = {keep}.0 RETURN id(r)"), + ); + assert_eq!(gql_u64_column(&id_float_eq, 0), vec![keep]); + + let id_float_in = execute_gql_ok( + &engine, + &format!("MATCH ()-[r]->() WHERE id(r) IN [{keep}.0] RETURN id(r)"), + ); + assert_eq!(gql_u64_column(&id_float_in, 0), vec![keep]); + + let mut edge_id_params = GqlParams::new(); + edge_id_params.insert("rid".to_string(), GqlParamValue::UInt(keep)); + let id_param = execute_gql_with_params( + &engine, + "MATCH ()-[r]->() WHERE id(r) = $rid RETURN id(r)", + edge_id_params.clone(), + ); + assert_eq!(gql_u64_column(&id_param, 0), vec![keep]); + + let explain = engine + .explain_gql( + "MATCH ()-[r]->() WHERE id(r) = $rid RETURN id(r)", + &edge_id_params, + &gql_opts(), + ) + .unwrap(); + let explain = gql_read_explain(&explain); + assert!(!explain.caps.allow_full_scan); + assert!(explain + .pushed_down + .iter() + .any(|push| push == &format!("id(r) = {keep}"))); + + let rejected_optional = engine + .execute_gql( + "MATCH ()-[r]->() WHERE id(r) = $rid \ + OPTIONAL MATCH ()-[s]->() RETURN id(r), id(s)", + &edge_id_params, + &gql_opts(), + ) + .unwrap_err(); + assert!(matches!( + rejected_optional, + EngineError::GqlSemantic { + code: GqlSemanticErrorCode::FullScanNotAllowed, + .. + } + )); + + for index in 0..4 { + insert_query_node(&engine, "Person", &format!("edge-id-cap-extra-{index}"), &[], 1.0); + } + let capped_edge_id = execute_gql_with_options( + &engine, + &format!("MATCH ()-[r]->() WHERE id(r) = {keep} RETURN id(r)"), + GqlExecutionOptions { + max_intermediate_bindings: 1, + ..GqlExecutionOptions::default() + }, + ); + assert_eq!(gql_u64_column(&capped_edge_id, 0), vec![keep]); + + let capped_endpoint_and_edge_id = execute_gql_with_options( + &engine, + &format!("MATCH ()-[r]->() WHERE r.from = {from} AND id(r) = {keep} RETURN id(r)"), + GqlExecutionOptions { + max_intermediate_bindings: 1, + ..GqlExecutionOptions::default() + }, + ); + assert_eq!(gql_u64_column(&capped_endpoint_and_edge_id, 0), vec![keep]); +} + +#[test] +fn gql_fixed_one_hop_and_chained_patterns_match_native_oracles() { + let (_dir, engine) = query_test_engine(); + let a = insert_query_node(&engine, "Person", "chain-a", &[], 1.0); + let b = insert_query_node(&engine, "Person", "chain-b", &[], 1.0); + let c = insert_query_node(&engine, "Article", "chain-c", &[], 1.0); + let knows = engine + .upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default()) + .unwrap(); + let likes = engine + .upsert_edge(b, c, "LIKES", UpsertEdgeOptions::default()) + .unwrap(); + + let one_hop = execute_gql_ok( + &engine, + "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN id(a), id(r), id(b)", + ); + assert_eq!(one_hop.rows.len(), 1); + assert_eq!(one_hop.rows[0].values, vec![ + GqlValue::UInt(a), + GqlValue::UInt(knows), + GqlValue::UInt(b), + ]); + + let edge_id_eq = execute_gql_ok( + &engine, + &format!( + "MATCH (a:Person)-[r:KNOWS]->(b:Person) \ + WHERE id(r) = {knows}.0 RETURN id(r)" + ), + ); + assert_eq!(gql_u64_column(&edge_id_eq, 0), vec![knows]); + + let edge_id_in = execute_gql_ok( + &engine, + &format!( + "MATCH (a:Person)-[r:KNOWS]->(b:Person) \ + WHERE id(r) IN [{knows}.0] RETURN id(r)" + ), + ); + assert_eq!(gql_u64_column(&edge_id_in, 0), vec![knows]); + + let low_cap_edge_id_pattern = execute_gql_with_options( + &engine, + &format!("MATCH (a)-[r]->(b) WHERE id(r) = {likes} RETURN id(a), id(r), id(b)"), + GqlExecutionOptions { + max_intermediate_bindings: 1, + ..GqlExecutionOptions::default() + }, + ); + assert_eq!(low_cap_edge_id_pattern.rows.len(), 1); + assert_eq!(low_cap_edge_id_pattern.rows[0].values, vec![ + GqlValue::UInt(b), + GqlValue::UInt(likes), + GqlValue::UInt(c), + ]); + + let conflicting_edge_id_pattern = execute_gql_ok( + &engine, + &format!("MATCH (a)-[r]->(b) WHERE id(r) = {knows} AND id(r) = {likes} RETURN id(r)"), + ); + assert!(conflicting_edge_id_pattern.rows.is_empty()); + + let chained = execute_gql_ok( + &engine, + "MATCH (a:Person)-[r:KNOWS]->(b:Person)-[s:LIKES]->(c:Article) \ + RETURN id(a), id(r), id(b), id(s), id(c)", + ); + assert_eq!(chained.rows.len(), 1); + assert_eq!(chained.rows[0].values, vec![ + GqlValue::UInt(a), + GqlValue::UInt(knows), + GqlValue::UInt(b), + GqlValue::UInt(likes), + GqlValue::UInt(c), + ]); +} + +#[test] +fn gql_optional_match_preserves_graph_row_outer_apply_semantics() { + let (_dir, engine) = query_test_engine(); + let a_hit = insert_query_node(&engine, "Person", "gql-optional-hit-a", &[], 1.0); + let b_hit = insert_query_node(&engine, "Person", "gql-optional-hit-b", &[], 1.0); + let a_miss = insert_query_node(&engine, "Person", "gql-optional-miss-a", &[], 1.0); + let b_miss = insert_query_node(&engine, "Person", "gql-optional-miss-b", &[], 1.0); + let c1 = insert_query_node(&engine, "Company", "gql-optional-c1", &[], 1.0); + let c2 = insert_query_node(&engine, "Company", "gql-optional-c2", &[], 1.0); + engine + .upsert_edge( + a_hit, + b_hit, + "GQL_OPTIONAL_REQUIRED", + UpsertEdgeOptions::default(), + ) + .unwrap(); + engine + .upsert_edge( + a_miss, + b_miss, + "GQL_OPTIONAL_REQUIRED", + UpsertEdgeOptions::default(), + ) + .unwrap(); + let s1 = engine + .upsert_edge( + b_hit, + c1, + "GQL_OPTIONAL_HIT", + UpsertEdgeOptions::default(), + ) + .unwrap(); + let s2 = engine + .upsert_edge( + b_hit, + c2, + "GQL_OPTIONAL_HIT", + UpsertEdgeOptions::default(), + ) + .unwrap(); + + let result = execute_gql_ok( + &engine, + "MATCH (a:Person)-[:GQL_OPTIONAL_REQUIRED]->(b:Person) \ + OPTIONAL MATCH (b)-[s:GQL_OPTIONAL_HIT]->(c:Company) \ + RETURN id(a), id(s), id(c) ORDER BY id(a), id(c)", + ); + assert_eq!( + result.rows.iter().map(|row| row.values.clone()).collect::>(), + vec![ + vec![GqlValue::UInt(a_hit), GqlValue::UInt(s1), GqlValue::UInt(c1)], + vec![GqlValue::UInt(a_hit), GqlValue::UInt(s2), GqlValue::UInt(c2)], + vec![GqlValue::UInt(a_miss), GqlValue::Null, GqlValue::Null], + ] + ); + + let filtered_miss = execute_gql_ok( + &engine, + &format!( + "MATCH (a:Person)-[:GQL_OPTIONAL_REQUIRED]->(b:Person) \ + WHERE id(a) = {a_hit} \ + OPTIONAL MATCH (b)-[s:GQL_OPTIONAL_HIT]->(c:Company) WHERE s.status = 'active' \ + RETURN id(a), id(s), id(c)" + ), + ); + assert_eq!( + filtered_miss.rows[0].values, + vec![GqlValue::UInt(a_hit), GqlValue::Null, GqlValue::Null] + ); + + let chained_miss = execute_gql_ok( + &engine, + &format!( + "MATCH (a:Person)-[:GQL_OPTIONAL_REQUIRED]->(b:Person) \ + WHERE id(a) = {a_hit} \ + OPTIONAL MATCH (b)-[s:GQL_OPTIONAL_MISSING]->(c:Company) \ + OPTIONAL MATCH (c)-[t:GQL_OPTIONAL_SECOND]->(d:Topic) \ + RETURN id(s), id(c), id(t), id(d)" + ), + ); + assert_eq!( + chained_miss.rows[0].values, + vec![GqlValue::Null, GqlValue::Null, GqlValue::Null, GqlValue::Null] + ); +} + +#[test] +fn gql_optional_reused_node_constraints_are_optional_local() { + let (_dir, engine) = query_test_engine(); + let a = insert_query_node(&engine, "Person", "gql-optional-reuse-a", &[], 1.0); + let b = insert_query_node(&engine, "Company", "gql-optional-reuse-b", &[], 1.0); + let c = insert_query_node(&engine, "Topic", "gql-optional-reuse-c", &[], 1.0); + engine + .upsert_edge(a, b, "GQL_OPTIONAL_REUSE_R", UpsertEdgeOptions::default()) + .unwrap(); + engine + .upsert_edge(b, c, "GQL_OPTIONAL_REUSE_S", UpsertEdgeOptions::default()) + .unwrap(); + + let result = execute_gql_ok( + &engine, + &format!( + "MATCH (a:Person) WHERE id(a) = {a} \ + OPTIONAL MATCH (a)-[:GQL_OPTIONAL_REUSE_R]->(b:Company) \ + OPTIONAL MATCH (b:Person)-[:GQL_OPTIONAL_REUSE_S]->(c) \ + RETURN id(b), id(c)" + ), + ); + assert_eq!( + result.rows[0].values, + vec![GqlValue::UInt(b), GqlValue::Null] + ); +} + +#[test] +fn gql_bounded_vlp_path_assignment_functions_and_cursors_match_graph_row() { + let (_dir, engine) = query_test_engine(); + let a = insert_query_node(&engine, "PathStart", "gql-path-a", &[], 1.0); + let b = insert_query_node(&engine, "PathNode", "gql-path-b", &[], 1.0); + let c = insert_query_node(&engine, "PathNode", "gql-path-c", &[], 1.0); + let ab = engine + .upsert_edge(a, b, "GQL_PATH", UpsertEdgeOptions::default()) + .unwrap(); + let ac = engine + .upsert_edge(a, c, "GQL_PATH", UpsertEdgeOptions::default()) + .unwrap(); + let bc = engine + .upsert_edge(b, c, "GQL_PATH", UpsertEdgeOptions::default()) + .unwrap(); + let ca = engine + .upsert_edge(c, a, "GQL_PATH", UpsertEdgeOptions::default()) + .unwrap(); + + let source = format!( + "MATCH p = (a)-[:GQL_PATH*0..2]->(z) WHERE id(a) = {a} \ + RETURN p, node_ids(p), edge_ids(p), length(p) \ + ORDER BY p" + ); + let gql = execute_gql_ok(&engine, &source); + + let mut native = graph_query( + &["a", "z"], + vec![graph_vlp(Some("p"), None, "a", "z", 0, 2)], + ); + native.nodes[0].ids = vec![a]; + if let GraphPatternPiece::VariableLength(path) = &mut native.pieces[0] { + path.label_filter = vec!["GQL_PATH".to_string()]; + } + native.return_items = Some(vec![graph_return_binding( + "p", + GraphReturnProjection::Element(GraphElementProjection::Full), + )]); + native.order_by = vec![ + GraphOrderItem { + expr: GraphExpr::Binding("p".to_string()), + direction: GraphOrderDirection::Asc, + }, + ]; + let native_paths = graph_row_path_ids(engine.query_graph_rows(&native).unwrap()); + let gql_paths = gql + .rows + .iter() + .map(|row| { + let path = gql_single_path(&row.values[0]); + assert_eq!( + row.values[1], + GqlValue::List(path.node_ids.iter().copied().map(GqlValue::UInt).collect()) + ); + assert_eq!( + row.values[2], + GqlValue::List(path.edge_ids.iter().copied().map(GqlValue::UInt).collect()) + ); + assert_eq!(row.values[3], GqlValue::UInt(path.edge_ids.len() as u64)); + (path.node_ids.clone(), path.edge_ids.clone()) + }) + .collect::>(); + assert_eq!(gql_paths, native_paths); + assert_eq!( + gql_paths, + vec![ + (vec![a], vec![]), + (vec![a, b], vec![ab]), + (vec![a, c], vec![ac]), + (vec![a, b, c], vec![ab, bc]), + (vec![a, c, a], vec![ac, ca]), + ] + ); + + let two_hop = execute_gql_ok( + &engine, + &format!( + "MATCH p = (a)-[:GQL_PATH*0..2]->(z) \ + WHERE id(a) = {a} AND length(p) = 2 \ + RETURN edge_ids(p) ORDER BY p" + ), + ); + assert_eq!( + two_hop.rows.iter().map(|row| row.values[0].clone()).collect::>(), + vec![ + GqlValue::List(vec![GqlValue::UInt(ab), GqlValue::UInt(bc)]), + GqlValue::List(vec![GqlValue::UInt(ac), GqlValue::UInt(ca)]), + ] + ); + + let path_function_values = execute_gql_ok( + &engine, + &format!( + "MATCH p = (a)-[:GQL_PATH*1..1]->(z) WHERE id(a) = {a} \ + RETURN start_node(p), end_node(p), nodes(p), relationships(p) ORDER BY p LIMIT 1" + ), + ); + let values = &path_function_values.rows[0].values; + assert_eq!(values[0], GqlValue::UInt(a)); + assert_eq!(values[1], GqlValue::UInt(b)); + let GqlValue::List(nodes) = &values[2] else { + panic!("expected nodes(p) list"); + }; + assert_eq!(nodes, &vec![GqlValue::UInt(a), GqlValue::UInt(b)]); + let GqlValue::List(edges) = &values[3] else { + panic!("expected relationships(p) list"); + }; + assert_eq!(edges, &vec![GqlValue::UInt(ab)]); + + let mut page_options = GqlExecutionOptions { + max_rows: 1, + ..GqlExecutionOptions::default() + }; + let mut cursor = None; + let mut paged = Vec::new(); + loop { + page_options.cursor = cursor.take(); + let page = execute_gql_with_options(&engine, &source, page_options.clone()); + if let Some(next) = page.next_cursor.clone() { + assert!(next.starts_with("ogr32c1_")); + cursor = Some(next); + } + paged.extend(page.rows.into_iter().map(|row| { + let path = gql_single_path(&row.values[0]); + (path.node_ids.clone(), path.edge_ids.clone()) + })); + if cursor.is_none() { + break; + } + } + assert_eq!(paged, native_paths); + + let compact = execute_gql_with_options( + &engine, + &source, + GqlExecutionOptions { + compact_rows: true, + ..GqlExecutionOptions::default() + }, + ); + assert_eq!( + compact + .rows + .iter() + .map(|row| { + let path = gql_single_path(&row.values[0]); + (path.node_ids.clone(), path.edge_ids.clone()) + }) + .collect::>(), + native_paths + ); + + let first_page_cursor = execute_gql_with_options( + &engine, + &source, + GqlExecutionOptions { + max_rows: 1, + ..GqlExecutionOptions::default() + }, + ) + .next_cursor; + page_options.cursor = first_page_cursor.clone(); + let mismatch = engine + .execute_gql( + &format!( + "MATCH p = (a)-[:GQL_PATH*0..2]->(z) WHERE id(a) = {a} \ + RETURN p ORDER BY length(p)" + ), + &GqlParams::new(), + &page_options, + ) + .unwrap_err(); + assert!(matches!(mismatch, EngineError::InvalidCursor { .. })); + + let oversized_cursor = engine + .execute_gql( + &source, + &GqlParams::new(), + &GqlExecutionOptions { + cursor: first_page_cursor, + max_rows: 1, + max_cursor_bytes: 8, + ..GqlExecutionOptions::default() + }, + ) + .unwrap_err(); + assert!(matches!(oversized_cursor, EngineError::InvalidCursor { .. })); +} + +#[test] +fn gql_shortest_path_executes_native_stage_and_projects_path() { + let (_dir, engine) = query_test_engine(); + let a = insert_query_node(&engine, "ShortestStart", "gql-sp-a", &[], 1.0); + let mid = insert_query_node(&engine, "ShortestMid", "gql-sp-mid", &[], 1.0); + let b = insert_query_node(&engine, "ShortestEnd", "gql-sp-b", &[], 1.0); + engine + .upsert_edge(a, b, "GQL_SP_OTHER", UpsertEdgeOptions::default()) + .unwrap(); + let first = engine + .upsert_edge(a, mid, "GQL_SP", UpsertEdgeOptions::default()) + .unwrap(); + let second = engine + .upsert_edge(mid, b, "GQL_SP", UpsertEdgeOptions::default()) + .unwrap(); + + let source = format!( + "MATCH (a:ShortestStart) WHERE id(a) = {a} \ + WITH a \ + MATCH (b:ShortestEnd) WHERE id(b) = {b} \ + WITH a, b \ + MATCH p = shortestPath((a)-[:GQL_SP*1..5]->(b)) \ + RETURN p, node_ids(p), edge_ids(p), length(p), nodes(p), relationships(p)" + ); + let result = execute_gql_with_options( + &engine, + &source, + GqlExecutionOptions { + include_plan: true, + ..gql_opts() + }, + ); + assert_eq!(result.rows.len(), 1); + let values = &result.rows[0].values; + let path = gql_single_path(&values[0]); + assert_eq!(path.node_ids, vec![a, mid, b]); + assert_eq!(path.edge_ids, vec![first, second]); + assert_eq!( + values[1], + GqlValue::List(vec![GqlValue::UInt(a), GqlValue::UInt(mid), GqlValue::UInt(b)]) + ); + assert_eq!( + values[2], + GqlValue::List(vec![GqlValue::UInt(first), GqlValue::UInt(second)]) + ); + assert_eq!(values[3], GqlValue::UInt(2)); + assert_eq!( + values[4], + GqlValue::List(vec![GqlValue::UInt(a), GqlValue::UInt(mid), GqlValue::UInt(b)]) + ); + assert_eq!( + values[5], + GqlValue::List(vec![GqlValue::UInt(first), GqlValue::UInt(second)]) + ); + let read = gql_read_explain(result.plan.as_ref().expect("include_plan should return plan")); + assert!(read.projection.iter().any(|line| { + line.contains("ShortestPath") + && line.contains("algorithm=bidirectional_bfs") + && line.contains("distinct_pair_count=1") + && line.contains("emitted_path_count=1") + })); +} + +#[test] +fn gql_all_shortest_paths_direction_and_min_hops() { + let (_dir, engine) = query_test_engine(); + let a = insert_query_node(&engine, "AllShortestStart", "gql-asp-a", &[], 1.0); + let m1 = insert_query_node(&engine, "AllShortestMid", "gql-asp-m1", &[], 1.0); + let m2 = insert_query_node(&engine, "AllShortestMid", "gql-asp-m2", &[], 1.0); + let b = insert_query_node(&engine, "AllShortestEnd", "gql-asp-b", &[], 1.0); + let am1 = engine + .upsert_edge(a, m1, "GQL_ASP", UpsertEdgeOptions::default()) + .unwrap(); + let m1b = engine + .upsert_edge(m1, b, "GQL_ASP", UpsertEdgeOptions::default()) + .unwrap(); + let am2 = engine + .upsert_edge(a, m2, "GQL_ASP", UpsertEdgeOptions::default()) + .unwrap(); + let m2b = engine + .upsert_edge(m2, b, "GQL_ASP", UpsertEdgeOptions::default()) + .unwrap(); + + let all = execute_gql_with_options( + &engine, + &format!( + "MATCH (a:AllShortestStart) WHERE id(a) = {a} \ + WITH a \ + MATCH (b:AllShortestEnd) WHERE id(b) = {b} \ + WITH a, b \ + MATCH p = allShortestPaths((a)-[:GQL_ASP*1..3]->(b)) \ + RETURN p" + ), + GqlExecutionOptions { + include_plan: true, + max_paths_per_start: 2, + ..gql_opts() + }, + ); + let mut paths = all + .rows + .iter() + .map(|row| { + let path = gql_single_path(&row.values[0]); + (path.node_ids.clone(), path.edge_ids.clone()) + }) + .collect::>(); + paths.sort(); + assert_eq!( + paths, + vec![(vec![a, m1, b], vec![am1, m1b]), (vec![a, m2, b], vec![am2, m2b])] + ); + let read = gql_read_explain(all.plan.as_ref().expect("include_plan should return plan")); + assert!(read.projection.iter().any(|line| { + line.contains("ShortestPath") + && line.contains("max_paths=2") + && line.contains("emitted_path_count=2") + })); + + let row_cap_err = engine + .execute_gql( + &format!( + "MATCH (a:AllShortestStart) WHERE id(a) = {a} \ + WITH a \ + MATCH (d:AllShortestMid) \ + WITH a \ + MATCH (b:AllShortestEnd) WHERE id(b) = {b} \ + WITH a, b \ + MATCH p = allShortestPaths((a)-[:GQL_ASP*1..3]->(b)) \ + RETURN p" + ), + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_pipeline_rows: 3, + max_paths_per_start: 2, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + matches!(row_cap_err, EngineError::InvalidOperation(message) if message.contains("max_pipeline_rows")) + ); + + let incoming = execute_gql_ok( + &engine, + &format!( + "MATCH (a:AllShortestStart) WHERE id(a) = {a} \ + WITH a \ + MATCH (m:AllShortestMid) WHERE id(m) = {m1} \ + WITH a, m \ + MATCH p = shortestPath((m)<-[:GQL_ASP*1..1]-(a)) \ + RETURN p" + ), + ); + let incoming_path = gql_single_path(&incoming.rows[0].values[0]); + assert_eq!(incoming_path.node_ids, vec![m1, a]); + assert_eq!(incoming_path.edge_ids, vec![am1]); + + let undirected = execute_gql_ok( + &engine, + &format!( + "MATCH (a:AllShortestStart) WHERE id(a) = {a} \ + WITH a \ + MATCH (m:AllShortestMid) WHERE id(m) = {m1} \ + WITH a, m \ + MATCH p = shortestPath((m)-[:GQL_ASP*1..1]-(a)) \ + RETURN p" + ), + ); + let undirected_path = gql_single_path(&undirected.rows[0].values[0]); + assert_eq!(undirected_path.node_ids, vec![m1, a]); + assert_eq!(undirected_path.edge_ids, vec![am1]); + + let min_filtered = execute_gql_ok( + &engine, + &format!( + "MATCH (a:AllShortestStart) WHERE id(a) = {a} \ + WITH a \ + MATCH (b:AllShortestEnd) WHERE id(b) = {b} \ + WITH a, b \ + MATCH p = shortestPath((a)-[:GQL_ASP*3..3]->(b)) \ + RETURN p" + ), + ); + assert!(min_filtered.rows.is_empty()); +} + +#[test] +fn gql_shortest_path_optional_cache_and_pair_cap_semantics() { + let (_dir, engine) = query_test_engine(); + let a = insert_query_node(&engine, "ShortestCapStart", "gql-sp-cap-a", &[], 1.0); + let a2 = insert_query_node(&engine, "ShortestCapStart", "gql-sp-cap-a2", &[], 1.0); + let b = insert_query_node(&engine, "ShortestCapEnd", "gql-sp-cap-b", &[], 1.0); + let duplicate_1 = insert_query_node(&engine, "ShortestDup", "gql-sp-dup-1", &[], 1.0); + let duplicate_2 = insert_query_node(&engine, "ShortestDup", "gql-sp-dup-2", &[], 1.0); + let edge = engine + .upsert_edge(a, b, "GQL_SP_CACHE", UpsertEdgeOptions::default()) + .unwrap(); + assert_ne!(duplicate_1, duplicate_2); + + let required_miss = execute_gql_ok( + &engine, + &format!( + "MATCH (a:ShortestCapStart) WHERE id(a) = {a2} \ + WITH a \ + MATCH (b:ShortestCapEnd) WHERE id(b) = {b} \ + WITH a, b \ + MATCH p = shortestPath((a)-[:GQL_SP_CACHE*1..2]->(b)) \ + RETURN p" + ), + ); + assert!(required_miss.rows.is_empty()); + + let optional_miss = execute_gql_ok( + &engine, + &format!( + "MATCH (a:ShortestCapStart) WHERE id(a) = {a2} \ + WITH a \ + MATCH (b:ShortestCapEnd) WHERE id(b) = {b} \ + WITH a, b \ + OPTIONAL MATCH p = shortestPath((a)-[:GQL_SP_CACHE*1..2]->(b)) \ + RETURN id(a), p" + ), + ); + assert_eq!(optional_miss.rows.len(), 1); + assert_eq!(optional_miss.rows[0].values, vec![GqlValue::UInt(a2), GqlValue::Null]); + + let cached = execute_gql_with_options( + &engine, + &format!( + "MATCH (a:ShortestCapStart) WHERE id(a) = {a} \ + WITH a \ + MATCH (d:ShortestDup) \ + WITH a \ + MATCH (b:ShortestCapEnd) WHERE id(b) = {b} \ + WITH a, b \ + MATCH p = shortestPath((a)-[:GQL_SP_CACHE*1..2]->(b)) \ + RETURN p" + ), + GqlExecutionOptions { + allow_full_scan: true, + include_plan: true, + ..gql_opts() + }, + ); + assert_eq!(cached.rows.len(), 2); + for row in &cached.rows { + let path = gql_single_path(&row.values[0]); + assert_eq!(path.node_ids, vec![a, b]); + assert_eq!(path.edge_ids, vec![edge]); + } + let read = gql_read_explain(cached.plan.as_ref().expect("include_plan should return plan")); + assert!(read.projection.iter().any(|line| { + line.contains("ShortestPath") + && line.contains("distinct_pair_count=1") + && line.contains("cache_hits=1") + })); + + let cap_err = engine + .execute_gql( + &format!( + "MATCH (a:ShortestCapStart) \ + WITH a \ + MATCH (b:ShortestCapEnd) WHERE id(b) = {b} \ + WITH a, b \ + MATCH p = shortestPath((a)-[:GQL_SP_CACHE*1..2]->(b)) \ + RETURN p" + ), + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_shortest_path_pairs: 1, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + matches!(cap_err, EngineError::InvalidOperation(message) if message.contains("max_shortest_path_pairs")) + ); + + let hop_cap_err = engine + .execute_gql( + &format!( + "MATCH (a:ShortestCapStart) WHERE id(a) = {a} \ + WITH a \ + MATCH (b:ShortestCapEnd) WHERE id(b) = {b} \ + WITH a, b \ + MATCH p = shortestPath((a)-[:GQL_SP_CACHE*1..2]->(b)) \ + RETURN p" + ), + &GqlParams::new(), + &GqlExecutionOptions { + max_path_hops: 1, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + matches!(hop_cap_err, EngineError::InvalidOperation(message) if message.contains("max_path_hops")) + ); +} + +#[test] +fn gql_shortest_path_survives_flush_reopen_and_compact() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("db"); + let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); + let a = insert_query_node(&engine, "ShortestLifecycle", "gql-sp-life-a", &[], 1.0); + let b = insert_query_node(&engine, "ShortestLifecycle", "gql-sp-life-b", &[], 1.0); + let c = insert_query_node(&engine, "ShortestLifecycle", "gql-sp-life-c", &[], 1.0); + let ab = engine + .upsert_edge(a, b, "GQL_SP_LIFE", UpsertEdgeOptions::default()) + .unwrap(); + engine.flush().unwrap(); + let bc = engine + .upsert_edge(b, c, "GQL_SP_LIFE", UpsertEdgeOptions::default()) + .unwrap(); + engine.flush().unwrap(); + engine.compact().unwrap(); + engine.close().unwrap(); + + let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); + let result = execute_gql_ok( + &reopened, + &format!( + "MATCH (a:ShortestLifecycle) WHERE id(a) = {a} \ + WITH a \ + MATCH (c:ShortestLifecycle) WHERE id(c) = {c} \ + WITH a, c \ + MATCH p = shortestPath((a)-[:GQL_SP_LIFE*1..3]->(c)) \ + RETURN p" + ), + ); + let path = gql_single_path(&result.rows[0].values[0]); + assert_eq!(path.node_ids, vec![a, b, c]); + assert_eq!(path.edge_ids, vec![ab, bc]); + reopened.close().unwrap(); +} + +#[test] +fn gql_fixed_multi_hop_path_assignment_composes_after_fixed_matching() { + let (_dir, engine) = query_test_engine(); + let a = insert_query_node(&engine, "FixedPathStart", "gql-fixed-path-a", &[], 1.0); + let b = insert_query_node(&engine, "FixedPathMid", "gql-fixed-path-b", &[], 1.0); + let c = insert_query_node(&engine, "FixedPathEnd", "gql-fixed-path-c", &[], 1.0); + let ab = engine + .upsert_edge( + a, + b, + "GQL_FIXED_PATH_R", + UpsertEdgeOptions { + props: query_test_props(&[("kind", PropValue::String("first".to_string()))]), + ..UpsertEdgeOptions::default() + }, + ) + .unwrap(); + let cb = engine + .upsert_edge(c, b, "GQL_FIXED_PATH_S", UpsertEdgeOptions::default()) + .unwrap(); + engine + .upsert_edge(a, c, "GQL_FIXED_PATH_R", UpsertEdgeOptions::default()) + .unwrap(); + + let source = format!( + "MATCH p = (a:FixedPathStart)-[:GQL_FIXED_PATH_R {{kind: 'first'}}]->(b)<-[s:GQL_FIXED_PATH_S]-(c) \ + WHERE id(a) = {a} \ + RETURN p, node_ids(p), edge_ids(p), length(p), id(s)" + ); + let result = execute_gql_ok(&engine, &source); + assert_eq!(result.rows.len(), 1); + let values = &result.rows[0].values; + let path = gql_single_path(&values[0]); + assert_eq!(path.node_ids, vec![a, b, c]); + assert_eq!(path.edge_ids, vec![ab, cb]); + assert_eq!( + values[1], + GqlValue::List(vec![GqlValue::UInt(a), GqlValue::UInt(b), GqlValue::UInt(c)]) + ); + assert_eq!( + values[2], + GqlValue::List(vec![GqlValue::UInt(ab), GqlValue::UInt(cb)]) + ); + assert_eq!(values[3], GqlValue::UInt(2)); + assert_eq!(values[4], GqlValue::UInt(cb)); + + let explain = engine + .explain_gql( + &source, + &GqlParams::new(), + &GqlExecutionOptions { + include_plan: true, + ..gql_opts() + }, + ) + .unwrap(); + let explain = gql_read_explain(&explain); + assert!(explain + .projection + .iter() + .any(|item| item.contains("FixedPathCompose"))); +} + +#[test] +fn gql_optional_fixed_multi_hop_path_assignment_null_extends_and_filters() { + let (_dir, engine) = query_test_engine(); + let hit = insert_query_node(&engine, "FixedPathAnchor", "gql-fixed-path-hit", &[], 1.0); + let miss = insert_query_node(&engine, "FixedPathAnchor", "gql-fixed-path-miss", &[], 1.0); + let mid = insert_query_node(&engine, "FixedPathMid", "gql-fixed-path-mid", &[], 1.0); + let end = insert_query_node(&engine, "FixedPathEnd", "gql-fixed-path-end", &[], 1.0); + let hm = engine + .upsert_edge(hit, mid, "GQL_OPTIONAL_FIXED_R", UpsertEdgeOptions::default()) + .unwrap(); + let me = engine + .upsert_edge(mid, end, "GQL_OPTIONAL_FIXED_S", UpsertEdgeOptions::default()) + .unwrap(); + + let result = execute_gql_ok( + &engine, + "MATCH (a:FixedPathAnchor) \ + OPTIONAL MATCH p = (a)-[:GQL_OPTIONAL_FIXED_R]->(b)-[:GQL_OPTIONAL_FIXED_S]->(c) \ + WHERE length(p) = 2 \ + RETURN id(a), p, length(p) ORDER BY id(a)", + ); + assert_eq!(result.rows.len(), 2); + assert_eq!(result.rows[0].values[0], GqlValue::UInt(hit)); + let path = gql_single_path(&result.rows[0].values[1]); + assert_eq!(path.node_ids, vec![hit, mid, end]); + assert_eq!(path.edge_ids, vec![hm, me]); + assert_eq!(result.rows[0].values[2], GqlValue::UInt(2)); + assert_eq!(result.rows[1].values[0], GqlValue::UInt(miss)); + assert_eq!(result.rows[1].values[1], GqlValue::Null); + assert_eq!(result.rows[1].values[2], GqlValue::Null); +} + +#[test] +fn gql_fixed_multi_hop_path_assignment_uses_final_row_cursors() { + let (_dir, engine) = query_test_engine(); + let a1 = insert_query_node(&engine, "FixedPathPageStart", "gql-fixed-page-a1", &[], 1.0); + let b1 = insert_query_node(&engine, "FixedPathPageMid", "gql-fixed-page-b1", &[], 1.0); + let c1 = insert_query_node(&engine, "FixedPathPageEnd", "gql-fixed-page-c1", &[], 1.0); + let a2 = insert_query_node(&engine, "FixedPathPageStart", "gql-fixed-page-a2", &[], 1.0); + let b2 = insert_query_node(&engine, "FixedPathPageMid", "gql-fixed-page-b2", &[], 1.0); + let c2 = insert_query_node(&engine, "FixedPathPageEnd", "gql-fixed-page-c2", &[], 1.0); + let a1b1 = engine + .upsert_edge(a1, b1, "GQL_FIXED_PAGE_R", UpsertEdgeOptions::default()) + .unwrap(); + let b1c1 = engine + .upsert_edge(b1, c1, "GQL_FIXED_PAGE_S", UpsertEdgeOptions::default()) + .unwrap(); + let a2b2 = engine + .upsert_edge(a2, b2, "GQL_FIXED_PAGE_R", UpsertEdgeOptions::default()) + .unwrap(); + let b2c2 = engine + .upsert_edge(b2, c2, "GQL_FIXED_PAGE_S", UpsertEdgeOptions::default()) + .unwrap(); + + let source = "MATCH p = (a:FixedPathPageStart)-[:GQL_FIXED_PAGE_R]->(b)-[:GQL_FIXED_PAGE_S]->(c) \ + RETURN p ORDER BY p"; + let mut options = GqlExecutionOptions { + max_rows: 1, + ..GqlExecutionOptions::default() + }; + let mut cursor = None; + let mut paths = Vec::new(); + loop { + options.cursor = cursor.take(); + let page = execute_gql_with_options(&engine, source, options.clone()); + paths.extend(page.rows.iter().map(|row| { + let path = gql_single_path(&row.values[0]); + (path.node_ids.clone(), path.edge_ids.clone()) + })); + cursor = page.next_cursor; + if cursor.is_none() { + break; + } + } + assert_eq!( + paths, + vec![ + (vec![a1, b1, c1], vec![a1b1, b1c1]), + (vec![a2, b2, c2], vec![a2b2, b2c2]), + ] + ); + + let first_cursor = execute_gql_with_options( + &engine, + source, + GqlExecutionOptions { + max_rows: 1, + ..GqlExecutionOptions::default() + }, + ) + .next_cursor + .expect("first page should emit a cursor"); + let mismatch = engine + .execute_gql( + "MATCH p = (a:FixedPathPageStart)-[:GQL_FIXED_PAGE_R]->(b)-[:GQL_FIXED_PAGE_S]->(c) \ + RETURN edge_ids(p) ORDER BY p", + &GqlParams::new(), + &GqlExecutionOptions { + cursor: Some(first_cursor), + max_rows: 1, + ..GqlExecutionOptions::default() + }, + ) + .unwrap_err(); + assert!(matches!(mismatch, EngineError::InvalidCursor { .. })); +} + +#[test] +fn gql_vlp_direction_self_loop_and_parallel_edges_match_graph_row() { + let (_dir, engine) = query_test_engine(); + let a = insert_query_node(&engine, "DirectionPath", "gql-direction-a", &[], 1.0); + let b = insert_query_node(&engine, "DirectionPath", "gql-direction-b", &[], 1.0); + let incoming_edge = engine + .upsert_edge(b, a, "GQL_INCOMING_PATH", UpsertEdgeOptions::default()) + .unwrap(); + + let incoming_gql = execute_gql_ok( + &engine, + &format!( + "MATCH p = (a)<-[:GQL_INCOMING_PATH*1..1]-(b) \ + WHERE id(a) = {a} AND id(b) = {b} RETURN p" + ), + ); + let incoming_path = gql_single_path(&incoming_gql.rows[0].values[0]); + assert_eq!(incoming_path.node_ids, vec![a, b]); + assert_eq!(incoming_path.edge_ids, vec![incoming_edge]); + + let mut incoming_native = graph_query( + &["a", "b"], + vec![graph_vlp(Some("p"), None, "a", "b", 1, 1)], + ); + if let GraphPatternPiece::VariableLength(path) = &mut incoming_native.pieces[0] { + path.direction = Direction::Incoming; + path.label_filter = vec!["GQL_INCOMING_PATH".to_string()]; + } + incoming_native.nodes[0].ids = vec![a]; + incoming_native.nodes[1].ids = vec![b]; + incoming_native.return_items = Some(vec![graph_return_binding( + "p", + GraphReturnProjection::Element(GraphElementProjection::Full), + )]); + assert_eq!( + vec![(incoming_path.node_ids.clone(), incoming_path.edge_ids.clone())], + graph_row_path_ids(engine.query_graph_rows(&incoming_native).unwrap()) + ); + + let loop_node = insert_query_node(&engine, "DirectionPath", "gql-direction-loop", &[], 1.0); + let loop_edge = engine + .upsert_edge( + loop_node, + loop_node, + "GQL_BOTH_PATH", + UpsertEdgeOptions::default(), + ) + .unwrap(); + let p1 = engine + .upsert_edge(a, b, "GQL_BOTH_PATH", UpsertEdgeOptions::default()) + .unwrap(); + let p2 = engine + .upsert_edge(a, b, "GQL_BOTH_PATH", UpsertEdgeOptions::default()) + .unwrap(); + + let self_loop = execute_gql_ok( + &engine, + &format!( + "MATCH p = (n)-[:GQL_BOTH_PATH*1..1]-(n) WHERE id(n) = {loop_node} RETURN p" + ), + ); + let loop_path = gql_single_path(&self_loop.rows[0].values[0]); + assert_eq!(loop_path.node_ids, vec![loop_node, loop_node]); + assert_eq!(loop_path.edge_ids, vec![loop_edge]); + + let parallel = execute_gql_ok( + &engine, + &format!( + "MATCH p = (a)-[:GQL_BOTH_PATH*1..1]-(b) \ + WHERE id(a) = {a} AND id(b) = {b} RETURN p ORDER BY p" + ), + ); + let parallel_paths = parallel + .rows + .iter() + .map(|row| { + let path = gql_single_path(&row.values[0]); + (path.node_ids.clone(), path.edge_ids.clone()) + }) + .collect::>(); + assert_eq!(parallel_paths, vec![(vec![a, b], vec![p1]), (vec![a, b], vec![p2])]); +} + +#[test] +fn gql_vlp_caps_surface_graph_row_errors() { + let (_dir, engine) = query_test_engine(); + let start = insert_query_node(&engine, "GqlVlpCap", "gql-vlp-cap-start", &[], 1.0); + let a = insert_query_node(&engine, "GqlVlpCap", "gql-vlp-cap-a", &[], 1.0); + let b = insert_query_node(&engine, "GqlVlpCap", "gql-vlp-cap-b", &[], 1.0); + engine + .upsert_edge(start, a, "GQL_VLP_CAP", UpsertEdgeOptions::default()) + .unwrap(); + engine + .upsert_edge(start, b, "GQL_VLP_CAP", UpsertEdgeOptions::default()) + .unwrap(); + + let err = engine + .execute_gql( + &format!( + "MATCH p = (a)-[:GQL_VLP_CAP*1..1]->(b) WHERE id(a) = {start} RETURN p" + ), + &GqlParams::new(), + &GqlExecutionOptions { + max_intermediate_bindings: 1, + max_frontier: 1, + ..GqlExecutionOptions::default() + }, + ) + .unwrap_err(); + let message = err.to_string(); + assert!(message.contains("max_frontier")); + assert!(message.contains("configured cap 1")); + assert!(message.contains("path=p")); +} + +#[test] +fn gql_vlp_source_correctness_matches_graph_row_oracle() { + let (_dir, engine) = query_test_engine(); + let start = insert_query_node(&engine, "GqlVlpSource", "gql-vlp-source-start", &[], 1.0); + let keep_mid = insert_query_node(&engine, "GqlVlpSource", "gql-vlp-source-mid", &[], 1.0); + let keep_end = insert_query_node( + &engine, + "GqlVlpEnd", + "gql-vlp-source-keep", + &[("status", PropValue::String("keep".to_string()))], + 1.0, + ); + let drop_end = insert_query_node( + &engine, + "GqlVlpEnd", + "gql-vlp-source-drop", + &[("status", PropValue::String("drop".to_string()))], + 1.0, + ); + let deleted_end = insert_query_node( + &engine, + "GqlVlpEnd", + "gql-vlp-source-deleted", + &[("status", PropValue::String("keep".to_string()))], + 1.0, + ); + let pruned_end = insert_query_node( + &engine, + "GqlVlpEnd", + "gql-vlp-source-pruned", + &[("status", PropValue::String("keep".to_string()))], + 0.1, + ); + let first = engine + .upsert_edge( + start, + keep_mid, + "GQL_VLP_SOURCE", + UpsertEdgeOptions { + props: query_test_props(&[("status", PropValue::String("open".to_string()))]), + ..UpsertEdgeOptions::default() + }, + ) + .unwrap(); + let second = engine + .upsert_edge( + keep_mid, + keep_end, + "GQL_VLP_SOURCE", + UpsertEdgeOptions { + props: query_test_props(&[("status", PropValue::String("open".to_string()))]), + ..UpsertEdgeOptions::default() + }, + ) + .unwrap(); + engine + .upsert_edge( + start, + drop_end, + "GQL_VLP_SOURCE", + UpsertEdgeOptions { + props: query_test_props(&[("status", PropValue::String("open".to_string()))]), + ..UpsertEdgeOptions::default() + }, + ) + .unwrap(); + let deleted_edge = engine + .upsert_edge( + start, + deleted_end, + "GQL_VLP_SOURCE", + UpsertEdgeOptions { + props: query_test_props(&[("status", PropValue::String("open".to_string()))]), + ..UpsertEdgeOptions::default() + }, + ) + .unwrap(); + engine + .upsert_edge( + start, + pruned_end, + "GQL_VLP_SOURCE", + UpsertEdgeOptions { + props: query_test_props(&[("status", PropValue::String("open".to_string()))]), + ..UpsertEdgeOptions::default() + }, + ) + .unwrap(); + engine.delete_node(deleted_end).unwrap(); + engine.delete_edge(deleted_edge).unwrap(); + engine + .set_prune_policy( + "gql-vlp-low-weight", + PrunePolicy { + max_age_ms: None, + max_weight: Some(0.5), + label: Some("GqlVlpEnd".to_string()), + }, + ) + .unwrap(); + + let source = format!( + "MATCH p = (a)-[:GQL_VLP_SOURCE*1..2 {{status: 'open'}}]->(b:GqlVlpEnd {{status: 'keep'}}) \ + WHERE id(a) = {start} RETURN p ORDER BY p" + ); + let gql = execute_gql_ok(&engine, &source); + let gql_paths = gql + .rows + .iter() + .map(|row| { + let path = gql_single_path(&row.values[0]); + (path.node_ids.clone(), path.edge_ids.clone()) + }) + .collect::>(); + + let mut native = graph_query( + &["a", "b"], + vec![graph_vlp(Some("p"), None, "a", "b", 1, 2)], + ); + native.nodes[0].ids = vec![start]; + native.nodes[1].label_filter = Some(NodeLabelFilter { + labels: vec!["GqlVlpEnd".to_string()], + mode: LabelMatchMode::All, + }); + native.nodes[1].filter = Some(NodeFilterExpr::PropertyEquals { + key: "status".to_string(), + value: PropValue::String("keep".to_string()), + }); + if let GraphPatternPiece::VariableLength(path) = &mut native.pieces[0] { + path.label_filter = vec!["GQL_VLP_SOURCE".to_string()]; + path.filter = Some(EdgeFilterExpr::PropertyEquals { + key: "status".to_string(), + value: PropValue::String("open".to_string()), + }); + } + native.return_items = Some(vec![graph_return_binding( + "p", + GraphReturnProjection::Element(GraphElementProjection::Full), + )]); + native.order_by = vec![GraphOrderItem { + expr: GraphExpr::Binding("p".to_string()), + direction: GraphOrderDirection::Asc, + }]; + let native_paths = graph_row_path_ids(engine.query_graph_rows(&native).unwrap()); + assert_eq!(gql_paths, native_paths); + assert_eq!(native_paths, vec![(vec![start, keep_mid, keep_end], vec![first, second])]); +} + +#[test] +fn gql_path_outputs_hydrate_elements_and_respect_vector_policy() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("db"); + let engine = DatabaseEngine::open( + &db_path, + &DbOptions { + dense_vector: Some(DenseVectorConfig { + dimension: 3, + metric: DenseMetric::Cosine, + hnsw: HnswConfig::default(), + }), + ..DbOptions::default() + }, + ) + .unwrap(); + seed_query_test_catalog(&engine); + let a = engine + .upsert_node( + "PathVector", + "gql-path-vector-a", + UpsertNodeOptions { + dense_vector: Some(vec![0.1, 0.2, 0.3]), + sparse_vector: Some(vec![(1, 1.0)]), + ..UpsertNodeOptions::default() + }, + ) + .unwrap(); + let b = engine + .upsert_node( + "PathVector", + "gql-path-vector-b", + UpsertNodeOptions { + dense_vector: Some(vec![0.4, 0.5, 0.6]), + sparse_vector: Some(vec![(2, 2.0)]), + ..UpsertNodeOptions::default() + }, + ) + .unwrap(); + let edge = engine + .upsert_edge(a, b, "GQL_PATH_VECTOR", UpsertEdgeOptions::default()) + .unwrap(); + + let source = format!("MATCH p = (a)-[:GQL_PATH_VECTOR*1..1]->(b) WHERE id(a) = {a} RETURN p"); + let default_path = gql_single_path(&execute_gql_ok(&engine, &source).rows[0].values[0]).clone(); + assert_eq!(default_path.node_ids, vec![a, b]); + assert_eq!(default_path.edge_ids, vec![edge]); + let nodes = default_path.nodes.as_ref().expect("direct path should hydrate nodes"); + let edges = default_path.edges.as_ref().expect("direct path should hydrate edges"); + assert_eq!(nodes.len(), 2); + assert_eq!(edges.len(), 1); + assert!(nodes.iter().all(|node| node.dense_vector.is_none())); + assert!(nodes.iter().all(|node| node.sparse_vector.is_none())); + + let vector_path = gql_single_path( + &execute_gql_with_options( + &engine, + &source, + GqlExecutionOptions { + include_vectors: true, + ..GqlExecutionOptions::default() + }, + ) + .rows[0] + .values[0], + ) + .clone(); + let nodes = vector_path.nodes.as_ref().unwrap(); + assert_eq!(nodes[0].dense_vector.as_deref(), Some([0.1, 0.2, 0.3].as_slice())); + assert_eq!(nodes[1].sparse_vector.as_deref(), Some([(2, 2.0)].as_slice())); +} + +#[test] +fn gql_optional_vlp_path_explain_surfaces_graph_row_root() { + let (_dir, engine) = query_test_engine(); + let a = insert_query_node(&engine, "Person", "gql-explain-path-a", &[], 1.0); + let b = insert_query_node(&engine, "Person", "gql-explain-path-b", &[], 1.0); + engine + .upsert_edge(a, b, "GQL_EXPLAIN_PATH", UpsertEdgeOptions::default()) + .unwrap(); + + let explain = engine + .explain_gql( + &format!( + "MATCH (a:Person) WHERE id(a) = {a} \ + OPTIONAL MATCH p = (a)-[:GQL_EXPLAIN_PATH*1..2]->(b) \ + RETURN p ORDER BY length(p) LIMIT 1" + ), + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + let explain = gql_read_explain(&explain); + assert_eq!(explain.target, GqlLoweringTarget::GraphRowQuery); + assert!(explain.native_plan.is_none()); + for expected in [ + "GraphRowPhysicalPlan", + "VariableLengthPath", + "Optional", + "path element p", + ] { + assert!( + explain + .projection + .iter() + .any(|item| item.contains(expected)), + "expected explain projection to contain {expected:?}, got {:?}", + explain.projection + ); + } +} + +#[test] +fn gql_fixed_pattern_explain_asserts_fanout_aware_physical_choice() { + let (_dir, engine) = query_test_engine(); + let small = insert_query_node(&engine, "GQL_FANOUT_SMALL", "gql-fanout-small", &[], 1.0); + let bridge_hit = insert_query_node( + &engine, + "GQL_FANOUT_BRIDGE", + "gql-fanout-bridge-hit", + &[], + 1.0, + ); + engine + .upsert_edge( + small, + bridge_hit, + "GQL_FANOUT_HIGH", + UpsertEdgeOptions::default(), + ) + .unwrap(); + for index in 0..39 { + let bridge = insert_query_node( + &engine, + "GQL_FANOUT_BRIDGE", + &format!("gql-fanout-bridge-{index}"), + &[], + 1.0, + ); + engine + .upsert_edge(small, bridge, "GQL_FANOUT_HIGH", UpsertEdgeOptions::default()) + .unwrap(); + } + let mut expected = Vec::new(); + for index in 0..5 { + let larger = insert_query_node( + &engine, + "GQL_FANOUT_LARGER", + &format!("gql-fanout-larger-{index}"), + &[], + 1.0, + ); + expected.push(larger); + engine + .upsert_edge( + larger, + bridge_hit, + "GQL_FANOUT_LOW", + UpsertEdgeOptions::default(), + ) + .unwrap(); + } + engine.flush().unwrap(); + expected.sort_unstable(); + + let source = "MATCH (small:GQL_FANOUT_SMALL)-[high_edge:GQL_FANOUT_HIGH]->\ + (bridge:GQL_FANOUT_BRIDGE)<-[low_edge:GQL_FANOUT_LOW]-\ + (larger:GQL_FANOUT_LARGER) \ + RETURN id(larger) ORDER BY id(larger)"; + let result = execute_gql_ok(&engine, source); + assert_eq!(gql_u64_column(&result, 0), expected); + + let explain = engine + .explain_gql(source, &GqlParams::new(), &gql_opts()) + .unwrap(); + let explain = gql_read_explain(&explain); + assert_eq!(explain.target, GqlLoweringTarget::GraphRowQuery); + assert!(explain.native_plan.is_none()); + for expected in [ + "graph row plan: GraphRowPhysicalPlan", + "physical_edge_order=[\"alias:low_edge\", \"alias:high_edge\"]", + "initial_driver=EdgeAnchor(edge=alias:low_edge", + "graph row plan: GraphRowPlanAlternative", + "chosen; kind=EdgeAnchor", + "source=EdgeCandidateSource", + ] { + assert!( + explain + .projection + .iter() + .any(|item| item.contains(expected)), + "expected GQL explain projection to contain {expected:?}, got {:?}", + explain.projection + ); + } +} + +#[test] +fn gql_fixed_match_uses_graph_row_relaxed_distinctness_for_self_loops() { + let (_dir, engine) = query_test_engine(); + let node = insert_query_node(&engine, "Person", "gql-self-loop", &[], 1.0); + let edge = engine + .upsert_edge(node, node, "LOOP", UpsertEdgeOptions::default()) + .unwrap(); + + let result = execute_gql_ok( + &engine, + "MATCH (a:Person)-[r:LOOP]->(b:Person) RETURN id(a), id(r), id(b)", + ); + + assert_eq!(result.rows.len(), 1); + assert_eq!( + result.rows[0].values, + vec![GqlValue::UInt(node), GqlValue::UInt(edge), GqlValue::UInt(node)] + ); +} + +#[test] +fn gql_rich_graph_indexed_queries_match_native_oracles() { + let (_dir, engine) = query_test_engine(); + let fixture = seed_rich_gql_graph(&engine); + engine.flush().unwrap(); + let _indexes = install_rich_gql_indexes(&engine); + + let node_query = "MATCH (n:Person:Employee) \ + WHERE n.status IN $statuses AND n.score >= $min_score \ + RETURN id(n) AS id, n.key AS key, labels(n) AS labels, n.weight AS weight, \ + n.created_at AS created_at, n.updated_at AS updated_at, \ + $payload AS payload, $shape AS shape \ + ORDER BY n.score ASC, n.key ASC"; + let node_params = GqlParams::from([ + ( + "statuses".to_string(), + GqlParamValue::List(vec![GqlParamValue::String("focus".to_string())]), + ), + ("min_score".to_string(), GqlParamValue::Int(70)), + ( + "payload".to_string(), + GqlParamValue::Bytes(vec![7, 8, 9]), + ), + ( + "shape".to_string(), + GqlParamValue::Map(BTreeMap::from([ + ( + "kind".to_string(), + GqlParamValue::String("employee-score".to_string()), + ), + ( + "thresholds".to_string(), + GqlParamValue::List(vec![ + GqlParamValue::Int(70), + GqlParamValue::String("focus".to_string()), + ]), + ), + ])), + ), + ]); + let node_result = execute_gql_with_params(&engine, node_query, node_params.clone()); + let native_node_ids = sorted_rich_employee_focus_score_oracle(&engine, 70); + assert_eq!( + node_result.columns, + vec!["id", "key", "labels", "weight", "created_at", "updated_at", "payload", "shape"] + ); + assert_eq!(gql_u64_column(&node_result, 0), native_node_ids); + assert_eq!(native_node_ids, vec![fixture.bob, fixture.alice]); + + let expected_payload = GqlValue::Bytes(vec![7, 8, 9]); + let expected_shape = GqlValue::Map(BTreeMap::from([ + ( + "kind".to_string(), + GqlValue::String("employee-score".to_string()), + ), + ( + "thresholds".to_string(), + GqlValue::List(vec![ + GqlValue::Int(70), + GqlValue::String("focus".to_string()), + ]), + ), + ])); + for (row, node_id) in node_result.rows.iter().zip(native_node_ids.iter().copied()) { + let node = engine.get_node(node_id).unwrap().unwrap(); + assert_eq!(row.values[1], GqlValue::String(node.key)); + assert_eq!( + row.values[2], + GqlValue::List(node.labels.into_iter().map(GqlValue::String).collect()) + ); + assert_eq!(row.values[3], GqlValue::Float(node.weight as f64)); + assert_eq!(row.values[4], GqlValue::Int(node.created_at)); + assert_eq!(row.values[5], GqlValue::Int(node.updated_at)); + assert_eq!(row.values[6], expected_payload); + assert_eq!(row.values[7], expected_shape); + } + + let alice_labels = node_result + .rows + .iter() + .find(|row| row.values[0] == GqlValue::UInt(fixture.alice)) + .map(|row| row.values[2].clone()) + .unwrap(); + assert_eq!( + alice_labels, + GqlValue::List( + engine + .get_node(fixture.alice) + .unwrap() + .unwrap() + .labels + .into_iter() + .map(GqlValue::String) + .collect() + ) + ); + + let node_explain = engine + .explain_gql(node_query, &node_params, &gql_opts()) + .unwrap(); + let node_explain = gql_read_explain(&node_explain); + assert_eq!(node_explain.target, GqlLoweringTarget::GraphRowQuery); + assert!(node_explain + .pushed_down + .iter() + .any(|item| item.contains("n.status"))); + assert!(node_explain + .pushed_down + .iter() + .any(|item| item.contains("n.score"))); + assert!(node_explain.native_plan.is_none()); + + let range_explain = engine + .explain_gql( + "MATCH (n:Person:Employee) WHERE n.score >= $min_score RETURN id(n)", + &GqlParams::from([("min_score".to_string(), GqlParamValue::Int(70))]), + &gql_opts(), + ) + .unwrap(); + let range_explain = gql_read_explain(&range_explain); + assert_eq!(range_explain.target, GqlLoweringTarget::GraphRowQuery); + assert!(range_explain.native_plan.is_none()); + assert!(range_explain + .pushed_down + .iter() + .any(|item| item.contains("n.score"))); + + let fallback_result = execute_gql_ok( + &engine, + "MATCH (n:Person:Employee) WHERE n.department = 'platform' \ + RETURN id(n) ORDER BY id(n)", + ); + let mut fallback_native = engine + .query_node_ids(&NodeQuery { + label_filter: Some(node_label_filter( + &["Person", "Employee"], + LabelMatchMode::All, + )), + filter: Some(NodeFilterExpr::PropertyEquals { + key: "department".to_string(), + value: PropValue::String("platform".to_string()), + }), + ..NodeQuery::default() + }) + .unwrap() + .items; + fallback_native.sort_unstable(); + assert_eq!(gql_u64_column(&fallback_result, 0), fallback_native); + let fallback_explain = engine + .explain_gql( + "MATCH (n:Person:Employee) WHERE n.department = 'platform' RETURN id(n)", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + let fallback_explain = gql_read_explain(&fallback_explain); + assert!(fallback_explain.native_plan.is_none()); + + let edge_query = "MATCH ()-[r:WORKS_ON]->() \ + WHERE r.role IN $roles AND r.hours >= $min_hours \ + RETURN id(r) AS id, r.from AS from, r.to AS to, type(r) AS label, \ + r.hours AS hours, r.weight AS weight, r.created_at AS created_at, \ + r.updated_at AS updated_at, r.valid_from AS valid_from, r.valid_to AS valid_to \ + ORDER BY r.hours ASC, id(r) ASC"; + let edge_params = GqlParams::from([ + ( + "roles".to_string(), + GqlParamValue::List(vec![ + GqlParamValue::String("lead".to_string()), + GqlParamValue::String("reviewer".to_string()), + ]), + ), + ("min_hours".to_string(), GqlParamValue::Int(30)), + ]); + let edge_result = execute_gql_with_params(&engine, edge_query, edge_params.clone()); + let native_edge_ids = sorted_rich_work_edge_oracle(&engine, 30); + assert_eq!(gql_u64_column(&edge_result, 0), native_edge_ids); + assert_eq!(native_edge_ids, vec![fixture.review_edge, fixture.lead_edge]); + for (row, edge_id) in edge_result.rows.iter().zip(native_edge_ids.iter().copied()) { + let edge = engine.get_edge(edge_id).unwrap().unwrap(); + assert_eq!(row.values[1], GqlValue::UInt(edge.from)); + assert_eq!(row.values[2], GqlValue::UInt(edge.to)); + assert_eq!(row.values[3], GqlValue::String(edge.label)); + assert_eq!(row.values[4], GqlValue::Int(edge_prop_i64(&engine, edge_id, "hours"))); + assert_eq!(row.values[5], GqlValue::Float(edge.weight as f64)); + assert_eq!(row.values[6], GqlValue::Int(edge.created_at)); + assert_eq!(row.values[7], GqlValue::Int(edge.updated_at)); + assert_eq!(row.values[8], GqlValue::Int(edge.valid_from)); + assert_eq!(row.values[9], GqlValue::Int(edge.valid_to)); + } -fn install_rich_gql_indexes(engine: &DatabaseEngine) -> RichGqlIndexes { - let employee_status = engine - .ensure_node_property_index("Employee", "status", SecondaryIndexKind::Equality) + let edge_explain = engine + .explain_gql(edge_query, &edge_params, &gql_opts()) + .unwrap(); + let edge_explain = gql_read_explain(&edge_explain); + assert_eq!(edge_explain.target, GqlLoweringTarget::GraphRowQuery); + assert!(edge_explain + .pushed_down + .iter() + .any(|item| item.contains("r.role"))); + assert!(edge_explain + .pushed_down + .iter() + .any(|item| item.contains("r.hours"))); + assert!(edge_explain.native_plan.is_none()); + let edge_range_explain = engine + .explain_gql( + "MATCH ()-[r:WORKS_ON]->() WHERE r.hours >= $min_hours RETURN id(r)", + &GqlParams::from([("min_hours".to_string(), GqlParamValue::Int(30))]), + &gql_opts(), + ) + .unwrap(); + let edge_range_explain = gql_read_explain(&edge_range_explain); + assert_eq!(edge_range_explain.target, GqlLoweringTarget::GraphRowQuery); + assert!(edge_range_explain.native_plan.is_none()); + assert!(edge_range_explain + .pushed_down + .iter() + .any(|item| item.contains("r.hours"))); + + let endpoint_result = execute_gql_with_params( + &engine, + "MATCH ()-[r:WORKS_ON]->() \ + WHERE r.from = $from AND r.to IN $targets RETURN id(r) ORDER BY id(r)", + GqlParams::from([ + ("from".to_string(), GqlParamValue::UInt(fixture.alice)), + ( + "targets".to_string(), + GqlParamValue::List(vec![ + GqlParamValue::UInt(fixture.acme), + GqlParamValue::UInt(fixture.globex), + ]), + ), + ]), + ); + let mut endpoint_native = engine + .query_edge_ids(&EdgeQuery { + label: Some("WORKS_ON".to_string()), + from_ids: vec![fixture.alice], + to_ids: vec![fixture.acme, fixture.globex], + ..EdgeQuery::default() + }) .unwrap() - .index_id; - wait_for_property_index_state(engine, employee_status, SecondaryIndexState::Ready); - wait_for_published_property_index_state(engine, employee_status, SecondaryIndexState::Ready); + .edge_ids; + endpoint_native.sort_unstable(); + assert_eq!(gql_u64_column(&endpoint_result, 0), endpoint_native); + assert_eq!(endpoint_native, vec![fixture.lead_edge, fixture.startup_edge]); - let employee_score = engine - .ensure_node_property_index( - "Employee", - "score", - SecondaryIndexKind::Range, + let pattern_query = "MATCH (p:Person:Employee)-[r:WORKS_ON]->(c:Company) \ + WHERE p.status = 'focus' AND r.role = 'lead' AND c.tier = 'enterprise' \ + RETURN id(p), id(r), id(c) ORDER BY p.key, id(r)"; + let pattern_result = execute_gql_ok(&engine, pattern_query); + let pattern_native = rich_pattern_oracle(&engine, "lead"); + let pattern_gql = pattern_result + .rows + .iter() + .map(|row| match (&row.values[0], &row.values[1], &row.values[2]) { + (GqlValue::UInt(p), GqlValue::UInt(r), GqlValue::UInt(c)) => (*p, *r, *c), + other => panic!("expected id tuple, got {other:?}"), + }) + .collect::>(); + assert_eq!(pattern_gql, pattern_native); + assert_eq!(pattern_native, vec![(fixture.alice, fixture.lead_edge, fixture.acme)]); + let pattern_explain = engine + .explain_gql(pattern_query, &GqlParams::new(), &gql_opts()) + .unwrap(); + let pattern_explain = gql_read_explain(&pattern_explain); + assert_eq!(pattern_explain.target, GqlLoweringTarget::GraphRowQuery); + assert!(pattern_explain.residual.is_empty()); + assert!(pattern_explain + .pushed_down + .iter() + .any(|item| item.contains("p.status"))); + assert!(pattern_explain + .pushed_down + .iter() + .any(|item| item.contains("r.role"))); + assert!(pattern_explain + .pushed_down + .iter() + .any(|item| item.contains("c.tier"))); + assert!(pattern_explain.native_plan.is_none()); + + let alt_result = execute_gql_ok( + &engine, + &format!( + "MATCH (p:Person)-[r:WORKS_ON|MENTORS]->(x) \ + WHERE id(p) = {} RETURN id(r) ORDER BY id(r)", + fixture.alice + ), + ); + assert_eq!( + gql_u64_column(&alt_result, 0), + vec![fixture.lead_edge, fixture.startup_edge, fixture.mentor_edge] + ); +} + +#[test] +fn gql_residual_where_filters_with_null_semantics_after_pushdown() { + let (_dir, engine) = query_test_engine(); + let keep = insert_query_node( + &engine, + "Person", + "residual-keep", + &[("status", PropValue::String("active".to_string()))], + 1.0, + ); + insert_query_node( + &engine, + "Person", + "residual-drop", + &[ + ("status", PropValue::String("active".to_string())), + ("blocked", PropValue::Bool(true)), + ], + 1.0, + ); + insert_query_node( + &engine, + "Person", + "residual-inactive", + &[("status", PropValue::String("inactive".to_string()))], + 1.0, + ); + + let result = execute_gql_ok( + &engine, + "MATCH (n:Person) \ + WHERE n.status = 'active' AND n.blocked IS NULL AND n.missing <> 'x' \ + RETURN id(n)", + ); + assert_eq!(gql_u64_column(&result, 0), Vec::::new()); + + let result = execute_gql_ok( + &engine, + "MATCH (n:Person) \ + WHERE n.status = 'active' AND n.blocked IS NULL \ + RETURN id(n)", + ); + assert_eq!(gql_u64_column(&result, 0), vec![keep]); +} + +#[test] +fn gql_execution_rich_expressions_in_read_surfaces_use_graph_row_semantics() { + let (_dir, engine) = query_test_engine(); + insert_query_node( + &engine, + "GqlRichRead", + "ada", + &[ + ("status", PropValue::String("active".to_string())), + ("name", PropValue::String("Ada".to_string())), + ("age", PropValue::Int(37)), + ], + 1.0, + ); + insert_query_node( + &engine, + "GqlRichRead", + "bob", + &[ + ("status", PropValue::String("active".to_string())), + ("name", PropValue::String("Bob".to_string())), + ("age", PropValue::Int(29)), + ], + 1.0, + ); + + let result = execute_gql_ok( + &engine, + "MATCH (n:GqlRichRead) \ + WHERE n.status = 'active' AND lower(n.name) STARTS WITH 'a' \ + RETURN n.name AS name, n.age + 5 AS adjusted, \ + CASE WHEN n.age > 30 THEN upper(n.name) ELSE 'young' END AS bucket \ + ORDER BY n.age / 2 DESC", + ); + assert_eq!(result.columns, vec!["name", "adjusted", "bucket"]); + assert_eq!(result.rows.len(), 1); + assert_eq!(result.rows[0].values[0], GqlValue::String("Ada".to_string())); + assert_eq!(result.rows[0].values[1], GqlValue::Int(42)); + assert_eq!(result.rows[0].values[2], GqlValue::String("ADA".to_string())); +} + +#[test] +fn gql_execution_rich_residual_preserves_simple_pushdown_and_narrow_needs() { + let (_dir, engine) = query_test_engine(); + engine + .ensure_node_property_index("GqlRichPushdown", "status", SecondaryIndexKind::Equality) + .unwrap(); + insert_query_node( + &engine, + "GqlRichPushdown", + "ada", + &[ + ("status", PropValue::String("active".to_string())), + ("name", PropValue::String("Ada".to_string())), + ], + 1.0, + ); + + let explain = engine + .explain_gql( + "MATCH (n:GqlRichPushdown) \ + WHERE n.status = 'active' AND lower(n.name) STARTS WITH 'a' \ + RETURN id(n)", + &GqlParams::new(), + &gql_opts(), ) - .unwrap() - .index_id; - wait_for_property_index_state(engine, employee_score, SecondaryIndexState::Ready); - wait_for_published_property_index_state(engine, employee_score, SecondaryIndexState::Ready); + .unwrap(); + let explain = gql_read_explain(&explain); + assert!(explain + .pushed_down + .iter() + .any(|item| item.contains("n.status"))); + assert!(explain + .residual + .iter() + .any(|item| item.contains("STARTS WITH"))); - let works_role = engine - .ensure_edge_property_index("WORKS_ON", "role", SecondaryIndexKind::Equality) - .unwrap() - .index_id; - wait_for_edge_property_index_state(engine, works_role, SecondaryIndexState::Ready); - wait_for_published_property_index_state(engine, works_role, SecondaryIndexState::Ready); + let lowered = lowered_gql_for_projection_test( + "MATCH (n:GqlRichPushdown) \ + WHERE n.status = 'active' AND lower(n.name) STARTS WITH 'a' \ + RETURN id(n)", + ); + let alias_projection = gql_alias_projection_map(&lowered); + let projection_alias = alias_projection.get("n").unwrap(); + let residual_projection = crate::gql::eval::build_runtime_projection_for_need_class( + &lowered.residual_predicates, + &lowered.semantic, + &alias_projection, + false, + false, + crate::row_projection::ProjectionNeedClass::Residual, + ) + .unwrap(); + assert_node_need_props( + &residual_projection.plan.needs.residual, + projection_alias, + &["name"], + ); + assert_entity_needs_do_not_request_all_properties(&residual_projection.plan.needs.residual); +} - let works_hours = engine - .ensure_edge_property_index( - "WORKS_ON", - "hours", - SecondaryIndexKind::Range, - ) - .unwrap() - .index_id; - wait_for_edge_property_index_state(engine, works_hours, SecondaryIndexState::Ready); - wait_for_published_property_index_state(engine, works_hours, SecondaryIndexState::Ready); +#[test] +fn gql_execution_rich_mutation_set_return_and_error_prevalidation() { + let (_dir, engine) = query_test_engine(); + let node = insert_query_node( + &engine, + "GqlRichMutation", + "n", + &[ + ("name", PropValue::String(" Ada ".to_string())), + ("score", PropValue::Int(40)), + ], + 1.0, + ); - RichGqlIndexes { - employee_status, - employee_score, - works_role, - works_hours, - } -} + let result = engine + .execute_gql( + "MATCH (n:GqlRichMutation) WHERE n.key = 'n' \ + SET n.score = n.score + 2 SET n.slug = lower(trim(n.name)) \ + RETURN n.score + 1 AS next_score, n.slug, \ + CASE n.slug WHEN 'ada' THEN 'ok' ELSE 'bad' END AS status", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + assert_eq!(result.rows.len(), 1); + assert_eq!(result.rows[0].values[0], GqlValue::Int(43)); + assert_eq!(result.rows[0].values[1], GqlValue::String("ada".to_string())); + assert_eq!(result.rows[0].values[2], GqlValue::String("ok".to_string())); -fn node_prop_i64(engine: &DatabaseEngine, id: u64, key: &str) -> i64 { - match engine - .get_node(id) - .unwrap() - .unwrap() - .props - .get(key) - .unwrap() - { - PropValue::Int(value) => *value, - other => panic!("expected int node property {key}, got {other:?}"), - } -} + let err = engine + .execute_gql( + "MATCH (n:GqlRichMutation) WHERE n.key = 'n' SET n.score = n.score / 0 RETURN n", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!(matches!(err, EngineError::InvalidOperation(message) if message.contains("division by zero"))); + let stored = engine.get_node(node).unwrap().unwrap(); + assert_eq!(stored.props.get("score"), Some(&PropValue::Int(42))); -fn edge_prop_i64(engine: &DatabaseEngine, id: u64, key: &str) -> i64 { - match engine - .get_edge(id) - .unwrap() - .unwrap() - .props - .get(key) - .unwrap() - { - PropValue::Int(value) => *value, - other => panic!("expected int edge property {key}, got {other:?}"), - } -} + let direct_id = engine + .execute_gql( + "CREATE (n:GqlRichCreatedId {key: 'ok'}) RETURN id(n) AS id", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + assert!(matches!(direct_id.rows[0].values[0], GqlValue::UInt(_))); -fn sorted_rich_employee_focus_score_oracle(engine: &DatabaseEngine, min_score: i64) -> Vec { - let mut native = engine - .query_node_ids(&NodeQuery { - label_filter: Some(node_label_filter( - &["Person", "Employee"], - LabelMatchMode::All, - )), - filter: Some(NodeFilterExpr::And(vec![ - NodeFilterExpr::PropertyIn { - key: "status".to_string(), - values: vec![PropValue::String("focus".to_string())], - }, - NodeFilterExpr::PropertyRange { - key: "score".to_string(), - lower: Some(PropertyRangeBound::Included(PropValue::Int(min_score))), - upper: None, - }, - ])), - ..NodeQuery::default() - }) - .unwrap() - .items; - native.sort_by(|left, right| { - let left_node = engine.get_node(*left).unwrap().unwrap(); - let right_node = engine.get_node(*right).unwrap().unwrap(); - node_prop_i64(engine, *left, "score") - .cmp(&node_prop_i64(engine, *right, "score")) - .then_with(|| left_node.key.cmp(&right_node.key)) - .then_with(|| left.cmp(right)) - }); - native -} + let err = engine + .execute_gql( + "CREATE (n:GqlRichCreatedIdError {key: 'bad'}) \ + RETURN 1 / (id(n) - id(n)) AS unsafe", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!(matches!(err, EngineError::GqlSemantic { message, .. } if message.contains("commit-assigned created alias metadata"))); + let committed = execute_gql_ok( + &engine, + "MATCH (n:GqlRichCreatedIdError) RETURN id(n)", + ); + assert!(committed.rows.is_empty()); -fn sorted_rich_work_edge_oracle(engine: &DatabaseEngine, min_hours: i64) -> Vec { - let mut native = engine - .query_edge_ids(&EdgeQuery { - label: Some("WORKS_ON".to_string()), - filter: Some(EdgeFilterExpr::And(vec![ - EdgeFilterExpr::PropertyIn { - key: "role".to_string(), - values: vec![ - PropValue::String("lead".to_string()), - PropValue::String("reviewer".to_string()), - ], - }, - EdgeFilterExpr::PropertyRange { - key: "hours".to_string(), - lower: Some(PropertyRangeBound::Included(PropValue::Int(min_hours))), - upper: None, - }, - ])), - ..EdgeQuery::default() - }) - .unwrap() - .edge_ids; - native.sort_by(|left, right| { - edge_prop_i64(engine, *left, "hours") - .cmp(&edge_prop_i64(engine, *right, "hours")) - .then_with(|| left.cmp(right)) - }); - native -} + let err = engine + .execute_gql( + "CREATE (n:GqlRichCreatedOrderIdError {key: 'bad'}) \ + RETURN n ORDER BY 1 / (id(n) - id(n))", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!(matches!(err, EngineError::GqlSemantic { message, .. } if message.contains("commit-assigned created alias metadata"))); + let committed = execute_gql_ok( + &engine, + "MATCH (n:GqlRichCreatedOrderIdError) RETURN id(n)", + ); + assert!(committed.rows.is_empty()); -fn rich_pattern_oracle(engine: &DatabaseEngine, role: &str) -> Vec<(u64, u64, u64)> { - let mut query = GraphRowQuery { - nodes: vec![ - GraphNodePattern { - alias: "p".to_string(), - label_filter: Some(NodeLabelFilter { - labels: vec!["Person".to_string(), "Employee".to_string()], - mode: LabelMatchMode::All, - }), - ids: Vec::new(), - keys: Vec::new(), - filter: Some(NodeFilterExpr::PropertyEquals { - key: "status".to_string(), - value: PropValue::String("focus".to_string()), - }), - }, - GraphNodePattern { - alias: "c".to_string(), - label_filter: Some(NodeLabelFilter { - labels: vec!["Company".to_string()], - mode: LabelMatchMode::All, - }), - ids: Vec::new(), - keys: Vec::new(), - filter: Some(NodeFilterExpr::PropertyEquals { - key: "tier".to_string(), - value: PropValue::String("enterprise".to_string()), - }), - }, - ], - pieces: vec![GraphPatternPiece::Edge(GraphEdgePattern { - alias: Some("r".to_string()), - from_alias: "p".to_string(), - to_alias: "c".to_string(), - direction: Direction::Outgoing, - label_filter: vec!["WORKS_ON".to_string()], - filter: Some(EdgeFilterExpr::PropertyEquals { - key: "role".to_string(), - value: PropValue::String(role.to_string()), - }), - })], - where_: None, - return_items: Some(vec![ - GraphReturnItem { - expr: GraphExpr::Binding("p".to_string()), - projection: GraphReturnProjection::IdOnly, - alias: Some("p".to_string()), - }, - GraphReturnItem { - expr: GraphExpr::Binding("r".to_string()), - projection: GraphReturnProjection::IdOnly, - alias: Some("r".to_string()), - }, - GraphReturnItem { - expr: GraphExpr::Binding("c".to_string()), - projection: GraphReturnProjection::IdOnly, - alias: Some("c".to_string()), - }, - ]), - order_by: Vec::new(), - page: GraphPageRequest { - skip: 0, - limit: 100, - cursor: None, - }, - at_epoch: None, - params: BTreeMap::new(), - output: GraphOutputOptions::default(), - options: GraphQueryOptions::default(), - }; - query.options.allow_full_scan = true; - let mut matches = engine - .query_graph_rows(&query) - .unwrap() - .rows - .into_iter() - .map(|row| match row.values.as_slice() { - [ - GraphValue::NodeId(p), - GraphValue::EdgeId(r), - GraphValue::NodeId(c), - ] => (*p, *r, *c), - other => panic!("expected graph-row id tuple, got {other:?}"), - }) - .collect::>(); - matches.sort_by(|left, right| { - engine - .get_node(left.0) - .unwrap() - .unwrap() - .key - .cmp(&engine.get_node(right.0).unwrap().unwrap().key) - .then_with(|| left.1.cmp(&right.1)) - }); - matches + let err = engine + .execute_gql( + "CREATE (n:GqlRichCoalesceNan {key: 'bad'}) RETURN coalesce($bad, 1)", + &GqlParams::from([("bad".to_string(), GqlParamValue::Float(f64::NAN))]), + &gql_opts(), + ) + .unwrap_err(); + assert!(matches!( + err, + EngineError::InvalidOperation(message) + if message.contains("scalar function result must be finite") + )); + let committed = execute_gql_ok( + &engine, + "MATCH (n:GqlRichCoalesceNan) RETURN id(n)", + ); + assert!(committed.rows.is_empty()); } #[test] -fn gql_node_query_executes_and_matches_native_node_oracle() { +fn gql_return_scalars_missing_null_params_and_duplicate_columns() { let (_dir, engine) = query_test_engine(); - let active = insert_query_node( + let node = insert_query_node_with_labels( &engine, - "Person", - "active-node", - &[("status", PropValue::String("active".to_string()))], + &["Person", "Topic"], + "scalar-node", + &[ + ("name", PropValue::String("Ada".to_string())), + ("optional", PropValue::Null), + ], 1.0, ); - insert_query_node( + let params = GqlParams::from([ + ("wanted".to_string(), GqlParamValue::String("Ada".to_string())), + ("answer".to_string(), GqlParamValue::Int(42)), + ]); + let result = execute_gql_with_params( &engine, - "Person", - "inactive-node", - &[("status", PropValue::String("inactive".to_string()))], - 1.0, + "MATCH (n:Person) WHERE n.name = $wanted \ + RETURN id(n) AS id, labels(n) AS labels, n.name AS x, n.missing AS missing, \ + n.optional AS opt, n.key AS x, $answer", + params, ); - let native = engine - .query_node_ids(&NodeQuery { - label_filter: Some(node_label_filter(&["Person"], LabelMatchMode::All)), - filter: Some(NodeFilterExpr::PropertyEquals { - key: "status".to_string(), - value: PropValue::String("active".to_string()), - }), - ..NodeQuery::default() - }) - .unwrap() - .items; - let gql = execute_gql_ok( - &engine, - "MATCH (n:Person {status: 'active'}) RETURN id(n) AS id", + assert_eq!(result.columns, vec!["id", "labels", "x", "missing", "opt", "x", "$answer"]); + assert_eq!(result.rows.len(), 1); + assert_eq!(result.rows[0].values[0], GqlValue::UInt(node)); + assert_eq!( + result.rows[0].values[1], + GqlValue::List(vec![ + GqlValue::String("Person".to_string()), + GqlValue::String("Topic".to_string()), + ]) ); + assert_eq!(result.rows[0].values[2], GqlValue::String("Ada".to_string())); + assert_eq!(result.rows[0].values[3], GqlValue::Null); + assert_eq!(result.rows[0].values[4], GqlValue::Null); + assert_eq!(result.rows[0].values[5], GqlValue::String("scalar-node".to_string())); + assert_eq!(result.rows[0].values[6], GqlValue::Int(42)); - assert_eq!(native, vec![active]); - assert_eq!(gql.columns, vec!["id"]); - assert_eq!(gql_u64_column(&gql, 0), native); - assert_eq!(gql.stats.rows_matched, 1); - assert_eq!(gql.stats.rows_after_filter, 1); - assert_eq!(gql.stats.rows_returned, 1); - - let id_float_eq = execute_gql_ok( + let numeric_result = execute_gql_with_params( &engine, - &format!("MATCH (n) WHERE id(n) = {active}.0 RETURN id(n)"), + &format!( + "MATCH (n:Person) WHERE n.name = $wanted \ + RETURN id(n) = {node}.0 AS eq, id(n) IN [{node}.0] AS in_id" + ), + GqlParams::from([( + "wanted".to_string(), + GqlParamValue::String("Ada".to_string()), + )]), + ); + assert_eq!( + numeric_result.rows[0].values, + vec![GqlValue::Bool(true), GqlValue::Bool(true)] ); - assert_eq!(gql_u64_column(&id_float_eq, 0), vec![active]); - let id_float_in = execute_gql_ok( + let ambiguous_order = engine + .execute_gql( + "MATCH (n:Person) RETURN n.name AS x, n.key AS x ORDER BY x", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!(matches!( + ambiguous_order, + EngineError::GqlSemantic { + code: GqlSemanticErrorCode::InvalidReturnExpression, + .. + } + )); + + let ambiguous_limit = engine + .execute_gql( + "MATCH (n:Person) RETURN 1 AS x, 2 AS x LIMIT x", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!(matches!( + ambiguous_limit, + EngineError::GqlSemantic { + code: GqlSemanticErrorCode::InvalidReturnExpression, + .. + } + )); + + let bound_variable_takes_priority = execute_gql_ok( &engine, - &format!("MATCH (n) WHERE id(n) IN [{active}.0] RETURN id(n)"), + "MATCH (x:Person) RETURN 0 AS x ORDER BY x.name", ); - assert_eq!(gql_u64_column(&id_float_in, 0), vec![active]); + assert_eq!(bound_variable_takes_priority.rows.len(), 1); } #[test] -fn gql_edge_query_executes_and_matches_native_edge_oracle() { - let (_dir, engine) = query_test_engine(); - let from = insert_query_node(&engine, "Person", "edge-from", &[], 1.0); - let to = insert_query_node(&engine, "Article", "edge-to", &[], 1.0); - let other_to = insert_query_node(&engine, "Article", "edge-other-to", &[], 1.0); - let keep = engine - .upsert_edge( - from, - to, - "LIKES", - UpsertEdgeOptions { - props: query_test_props(&[("since", PropValue::Int(2024))]), - ..UpsertEdgeOptions::default() +fn gql_numeric_property_predicates_match_native_semantics_without_indexes() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("gql-numeric-semantics"); + let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); + + let mut expected_nodes = Vec::new(); + for (key, value) in [ + ("score-int", PropValue::Int(1)), + ("score-uint", PropValue::UInt(1)), + ("score-float", PropValue::Float(1.0)), + ] { + expected_nodes.push( + engine + .upsert_node( + "Person", + key, + UpsertNodeOptions { + props: query_test_props(&[("score", value)]), + ..Default::default() + }, + ) + .unwrap(), + ); + } + engine + .upsert_node( + "Person", + "score-string", + UpsertNodeOptions { + props: query_test_props(&[("score", PropValue::String("1".to_string()))]), + ..Default::default() }, ) .unwrap(); + + let eq = execute_gql_ok( + &engine, + "MATCH (n:Person) WHERE n.score = 1.0 RETURN id(n)", + ); + assert_eq!(gql_u64_column(&eq, 0), expected_nodes); + + let in_result = execute_gql_ok( + &engine, + "MATCH (n:Person) WHERE n.score IN [1, 1.0] RETURN id(n)", + ); + assert_eq!(gql_u64_column(&in_result, 0), expected_nodes); + + let range_result = execute_gql_ok( + &engine, + "MATCH (n:Person) WHERE n.score >= -0.0 AND n.score <= 1.0 RETURN id(n)", + ); + assert_eq!(gql_u64_column(&range_result, 0), expected_nodes); + + let a = expected_nodes[0]; + let b = expected_nodes[1]; + let mut expected_edges = Vec::new(); + for value in [PropValue::Int(1), PropValue::UInt(1), PropValue::Float(1.0)] { + expected_edges.push( + engine + .upsert_edge( + a, + b, + "LIKES", + UpsertEdgeOptions { + props: query_test_props(&[("score", value)]), + ..Default::default() + }, + ) + .unwrap(), + ); + } + let edge_eq = execute_gql_ok( + &engine, + "MATCH ()-[r:LIKES]->() WHERE r.score = 1.0 RETURN id(r)", + ); + assert_eq!(gql_u64_column(&edge_eq, 0), expected_edges); + + engine.close().unwrap(); +} + +#[test] +fn gql_numeric_equality_uses_semantic_equality_indexes() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("gql-indexed-numeric-equality"); + let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); + + let node_index = engine + .ensure_node_property_index("Person", "score", SecondaryIndexKind::Equality) + .unwrap() + .index_id; + let edge_index = engine + .ensure_edge_property_index("LIKES", "score", SecondaryIndexKind::Equality) + .unwrap() + .index_id; + wait_for_property_index_state(&engine, node_index, SecondaryIndexState::Ready); + wait_for_edge_property_index_state(&engine, edge_index, SecondaryIndexState::Ready); + + let mut expected_nodes = Vec::new(); + for (key, value) in [ + ("score-index-int", PropValue::Int(1)), + ("score-index-uint", PropValue::UInt(1)), + ("score-index-float", PropValue::Float(1.0)), + ] { + expected_nodes.push( + engine + .upsert_node( + "Person", + key, + UpsertNodeOptions { + props: query_test_props(&[("score", value)]), + ..Default::default() + }, + ) + .unwrap(), + ); + } engine - .upsert_edge( - from, - other_to, - "MENTIONS", - UpsertEdgeOptions { - props: query_test_props(&[("since", PropValue::Int(2025))]), - ..UpsertEdgeOptions::default() + .upsert_node( + "Person", + "score-index-string", + UpsertNodeOptions { + props: query_test_props(&[("score", PropValue::String("1".to_string()))]), + ..Default::default() }, ) .unwrap(); + + let mut expected_edges = Vec::new(); + for value in [PropValue::Int(1), PropValue::UInt(1), PropValue::Float(1.0)] { + expected_edges.push( + engine + .upsert_edge( + expected_nodes[0], + expected_nodes[1], + "LIKES", + UpsertEdgeOptions { + props: query_test_props(&[("score", value)]), + ..Default::default() + }, + ) + .unwrap(), + ); + } engine .upsert_edge( - to, - from, + expected_nodes[0], + expected_nodes[2], "LIKES", UpsertEdgeOptions { - props: query_test_props(&[("since", PropValue::Int(2019))]), - ..UpsertEdgeOptions::default() + props: query_test_props(&[("score", PropValue::String("1".to_string()))]), + ..Default::default() }, ) .unwrap(); + engine.flush().unwrap(); - let native = engine - .query_edge_ids(&EdgeQuery { - label: Some("LIKES".to_string()), - filter: Some(EdgeFilterExpr::PropertyRange { - key: "since".to_string(), - lower: Some(PropertyRangeBound::Included(PropValue::Int(2020))), - upper: None, - }), - ..EdgeQuery::default() - }) - .unwrap() - .edge_ids; - let gql = execute_gql_ok( + expected_nodes.sort_unstable(); + expected_edges.sort_unstable(); + + let where_eq = execute_gql_ok( &engine, - "MATCH ()-[r:LIKES]->() WHERE r.since >= 2020 RETURN id(r) AS id", + "MATCH (n:Person) WHERE n.score = 1.0 RETURN id(n) ORDER BY id(n)", ); - - assert_eq!(native, vec![keep]); - assert_eq!(gql_u64_column(&gql, 0), native); - - let endpoint_float_ids = execute_gql_ok( + assert_eq!(gql_u64_column(&where_eq, 0), expected_nodes); + let map_eq = execute_gql_ok( &engine, - &format!("MATCH ()-[r:LIKES]->() WHERE r.from = {from}.0 AND r.to IN [{to}.0] RETURN id(r)"), + "MATCH (n:Person {score: 1.0}) RETURN id(n) ORDER BY id(n)", ); - assert_eq!(gql_u64_column(&endpoint_float_ids, 0), vec![keep]); - - let id_float_eq = execute_gql_ok( + assert_eq!(gql_u64_column(&map_eq, 0), expected_nodes); + let in_eq = execute_gql_ok( &engine, - &format!("MATCH ()-[r]->() WHERE id(r) = {keep}.0 RETURN id(r)"), + "MATCH (n:Person) WHERE n.score IN [1, 1.0] RETURN id(n) ORDER BY id(n)", ); - assert_eq!(gql_u64_column(&id_float_eq, 0), vec![keep]); + assert_eq!(gql_u64_column(&in_eq, 0), expected_nodes); - let id_float_in = execute_gql_ok( + let node_explain = engine + .explain_gql( + "MATCH (n:Person) WHERE n.score = 1.0 RETURN id(n)", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + let node_explain = gql_read_explain(&node_explain); + assert_eq!(node_explain.target, GqlLoweringTarget::GraphRowQuery); + assert!(node_explain.native_plan.is_none()); + assert!(node_explain + .pushed_down + .iter() + .any(|item| item.contains("n.score"))); + + let edge_eq = execute_gql_ok( &engine, - &format!("MATCH ()-[r]->() WHERE id(r) IN [{keep}.0] RETURN id(r)"), + "MATCH ()-[r:LIKES]->() WHERE r.score = 1.0 RETURN id(r) ORDER BY id(r)", ); - assert_eq!(gql_u64_column(&id_float_in, 0), vec![keep]); - - let mut edge_id_params = GqlParams::new(); - edge_id_params.insert("rid".to_string(), GqlParamValue::UInt(keep)); - let id_param = execute_gql_with_params( + assert_eq!(gql_u64_column(&edge_eq, 0), expected_edges); + let edge_in = execute_gql_ok( &engine, - "MATCH ()-[r]->() WHERE id(r) = $rid RETURN id(r)", - edge_id_params.clone(), + "MATCH ()-[r:LIKES]->() WHERE r.score IN [1, 1.0] RETURN id(r) ORDER BY id(r)", ); - assert_eq!(gql_u64_column(&id_param, 0), vec![keep]); - - let explain = engine + assert_eq!(gql_u64_column(&edge_in, 0), expected_edges); + let edge_explain = engine .explain_gql( - "MATCH ()-[r]->() WHERE id(r) = $rid RETURN id(r)", - &edge_id_params, + "MATCH ()-[r:LIKES]->() WHERE r.score = 1.0 RETURN id(r)", + &GqlParams::new(), &gql_opts(), ) .unwrap(); - let explain = gql_read_explain(&explain); - assert!(!explain.caps.allow_full_scan); - assert!(explain + let edge_explain = gql_read_explain(&edge_explain); + assert_eq!(edge_explain.target, GqlLoweringTarget::GraphRowQuery); + assert!(edge_explain.native_plan.is_none()); + assert!(edge_explain .pushed_down .iter() - .any(|push| push == &format!("id(r) = {keep}"))); + .any(|item| item.contains("r.score"))); - let rejected_optional = engine - .execute_gql( - "MATCH ()-[r]->() WHERE id(r) = $rid \ - OPTIONAL MATCH ()-[s]->() RETURN id(r), id(s)", - &edge_id_params, - &gql_opts(), - ) - .unwrap_err(); - assert!(matches!( - rejected_optional, - EngineError::GqlSemantic { - code: GqlSemanticErrorCode::FullScanNotAllowed, - .. - } - )); + engine.close().unwrap(); +} - for index in 0..4 { - insert_query_node(&engine, "Person", &format!("edge-id-cap-extra-{index}"), &[], 1.0); +#[test] +fn gql_numeric_range_uses_domainless_indexes_for_mixed_numeric_values() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("gql-indexed-numeric-range"); + let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); + + let node_index = engine + .ensure_node_property_index("Person", "score", SecondaryIndexKind::Range) + .unwrap() + .index_id; + let edge_index = engine + .ensure_edge_property_index("LIKES", "score", SecondaryIndexKind::Range) + .unwrap() + .index_id; + wait_for_property_index_state(&engine, node_index, SecondaryIndexState::Ready); + wait_for_published_property_index_state(&engine, node_index, SecondaryIndexState::Ready); + wait_for_edge_property_index_state(&engine, edge_index, SecondaryIndexState::Ready); + wait_for_published_property_index_state(&engine, edge_index, SecondaryIndexState::Ready); + + fn assert_domainless_indexed_range_gql( + engine: &DatabaseEngine, + expected_nodes: &[u64], + expected_edges: &[u64], + ) { + let node_range = execute_gql_ok( + engine, + "MATCH (n:Person) WHERE n.score >= 1 AND n.score <= 1.0 \ + RETURN id(n) ORDER BY id(n)", + ); + assert_eq!(gql_u64_column(&node_range, 0), expected_nodes); + let node_range_explain = engine + .explain_gql( + "MATCH (n:Person) WHERE n.score >= 1 AND n.score <= 1.0 RETURN id(n)", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + let node_range_explain = gql_read_explain(&node_range_explain); + assert_eq!(node_range_explain.target, GqlLoweringTarget::GraphRowQuery); + assert!(node_range_explain.native_plan.is_none()); + assert!(node_range_explain + .pushed_down + .iter() + .any(|item| item.contains("n.score"))); + + let edge_range = execute_gql_ok( + engine, + "MATCH ()-[r:LIKES]->() WHERE r.score >= 1 AND r.score <= 1.0 \ + RETURN id(r) ORDER BY id(r)", + ); + assert_eq!(gql_u64_column(&edge_range, 0), expected_edges); + let edge_range_explain = engine + .explain_gql( + "MATCH ()-[r:LIKES]->() WHERE r.score >= 1 AND r.score <= 1.0 RETURN id(r)", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap(); + let edge_range_explain = gql_read_explain(&edge_range_explain); + assert_eq!(edge_range_explain.target, GqlLoweringTarget::GraphRowQuery); + assert!(edge_range_explain.native_plan.is_none()); + assert!(edge_range_explain + .pushed_down + .iter() + .any(|item| item.contains("r.score"))); + } + + let mut expected_nodes = Vec::new(); + for (key, value) in [ + ("score-range-int", PropValue::Int(1)), + ("score-range-uint", PropValue::UInt(1)), + ("score-range-float", PropValue::Float(1.0)), + ] { + expected_nodes.push( + engine + .upsert_node( + "Person", + key, + UpsertNodeOptions { + props: query_test_props(&[("score", value)]), + ..Default::default() + }, + ) + .unwrap(), + ); + } + for (key, value) in [ + ("score-range-higher", PropValue::Float(2.5)), + ("score-range-string", PropValue::String("1".to_string())), + ("score-range-nan", PropValue::Float(f64::NAN)), + ] { + engine + .upsert_node( + "Person", + key, + UpsertNodeOptions { + props: query_test_props(&[("score", value)]), + ..Default::default() + }, + ) + .unwrap(); + } + + let mut expected_edges = Vec::new(); + for value in [PropValue::Int(1), PropValue::UInt(1), PropValue::Float(1.0)] { + expected_edges.push( + engine + .upsert_edge( + expected_nodes[0], + expected_nodes[1], + "LIKES", + UpsertEdgeOptions { + props: query_test_props(&[("score", value)]), + ..Default::default() + }, + ) + .unwrap(), + ); + } + for value in [ + PropValue::Float(2.5), + PropValue::String("1".to_string()), + PropValue::Float(f64::NAN), + ] { + engine + .upsert_edge( + expected_nodes[0], + expected_nodes[2], + "LIKES", + UpsertEdgeOptions { + props: query_test_props(&[("score", value)]), + ..Default::default() + }, + ) + .unwrap(); } - let capped_edge_id = execute_gql_with_options( - &engine, - &format!("MATCH ()-[r]->() WHERE id(r) = {keep} RETURN id(r)"), - GqlExecutionOptions { - max_intermediate_bindings: 1, - ..GqlExecutionOptions::default() - }, - ); - assert_eq!(gql_u64_column(&capped_edge_id, 0), vec![keep]); - let capped_endpoint_and_edge_id = execute_gql_with_options( - &engine, - &format!("MATCH ()-[r]->() WHERE r.from = {from} AND id(r) = {keep} RETURN id(r)"), - GqlExecutionOptions { - max_intermediate_bindings: 1, - ..GqlExecutionOptions::default() - }, - ); - assert_eq!(gql_u64_column(&capped_endpoint_and_edge_id, 0), vec![keep]); + expected_nodes.sort_unstable(); + expected_edges.sort_unstable(); + assert_domainless_indexed_range_gql(&engine, &expected_nodes, &expected_edges); + + engine.flush().unwrap(); + assert_domainless_indexed_range_gql(&engine, &expected_nodes, &expected_edges); + + engine.close().unwrap(); } #[test] -fn gql_fixed_one_hop_and_chained_patterns_match_native_oracles() { +fn gql_empty_results_and_parameter_values_use_public_handler_path() { let (_dir, engine) = query_test_engine(); - let a = insert_query_node(&engine, "Person", "chain-a", &[], 1.0); - let b = insert_query_node(&engine, "Person", "chain-b", &[], 1.0); - let c = insert_query_node(&engine, "Article", "chain-c", &[], 1.0); - let knows = engine - .upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default()) - .unwrap(); - let likes = engine - .upsert_edge(b, c, "LIKES", UpsertEdgeOptions::default()) - .unwrap(); - - let one_hop = execute_gql_ok( - &engine, - "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN id(a), id(r), id(b)", - ); - assert_eq!(one_hop.rows.len(), 1); - assert_eq!(one_hop.rows[0].values, vec![ - GqlValue::UInt(a), - GqlValue::UInt(knows), - GqlValue::UInt(b), - ]); - - let edge_id_eq = execute_gql_ok( - &engine, - &format!( - "MATCH (a:Person)-[r:KNOWS]->(b:Person) \ - WHERE id(r) = {knows}.0 RETURN id(r)" - ), - ); - assert_eq!(gql_u64_column(&edge_id_eq, 0), vec![knows]); - - let edge_id_in = execute_gql_ok( + let node = insert_query_node( &engine, - &format!( - "MATCH (a:Person)-[r:KNOWS]->(b:Person) \ - WHERE id(r) IN [{knows}.0] RETURN id(r)" - ), + "Person", + "boundary-node", + &[("name", PropValue::String("Ada".to_string()))], + 1.0, ); - assert_eq!(gql_u64_column(&edge_id_in, 0), vec![knows]); + let from = insert_query_node(&engine, "Person", "boundary-from", &[], 1.0); + let to = insert_query_node(&engine, "Person", "boundary-to", &[], 1.0); + let edge = engine + .upsert_edge(from, to, "KNOWS", UpsertEdgeOptions::default()) + .unwrap(); - let low_cap_edge_id_pattern = execute_gql_with_options( - &engine, - &format!("MATCH (a)-[r]->(b) WHERE id(r) = {likes} RETURN id(a), id(r), id(b)"), - GqlExecutionOptions { - max_intermediate_bindings: 1, - ..GqlExecutionOptions::default() - }, - ); - assert_eq!(low_cap_edge_id_pattern.rows.len(), 1); - assert_eq!(low_cap_edge_id_pattern.rows[0].values, vec![ - GqlValue::UInt(b), - GqlValue::UInt(likes), - GqlValue::UInt(c), - ]); + let unknown_nodes = execute_gql_ok(&engine, "MATCH (n:DefinitelyMissing) RETURN id(n)"); + assert!(unknown_nodes.rows.is_empty()); + assert_eq!(engine.get_node_label_id("DefinitelyMissing").unwrap(), None); - let conflicting_edge_id_pattern = execute_gql_ok( - &engine, - &format!("MATCH (a)-[r]->(b) WHERE id(r) = {knows} AND id(r) = {likes} RETURN id(r)"), - ); - assert!(conflicting_edge_id_pattern.rows.is_empty()); + let unknown_edges = execute_gql_ok(&engine, "MATCH ()-[r:DEFINITELY_MISSING]->() RETURN id(r)"); + assert!(unknown_edges.rows.is_empty()); + assert_eq!(engine.get_edge_label_id("DEFINITELY_MISSING").unwrap(), None); - let chained = execute_gql_ok( + let missing_property = execute_gql_ok( &engine, - "MATCH (a:Person)-[r:KNOWS]->(b:Person)-[s:LIKES]->(c:Article) \ - RETURN id(a), id(r), id(b), id(s), id(c)", + "MATCH (n:Person) WHERE n.no_such_property = 'x' RETURN id(n)", ); - assert_eq!(chained.rows.len(), 1); - assert_eq!(chained.rows[0].values, vec![ - GqlValue::UInt(a), - GqlValue::UInt(knows), - GqlValue::UInt(b), - GqlValue::UInt(likes), - GqlValue::UInt(c), - ]); -} - -#[test] -fn gql_optional_match_preserves_graph_row_outer_apply_semantics() { - let (_dir, engine) = query_test_engine(); - let a_hit = insert_query_node(&engine, "Person", "gql-optional-hit-a", &[], 1.0); - let b_hit = insert_query_node(&engine, "Person", "gql-optional-hit-b", &[], 1.0); - let a_miss = insert_query_node(&engine, "Person", "gql-optional-miss-a", &[], 1.0); - let b_miss = insert_query_node(&engine, "Person", "gql-optional-miss-b", &[], 1.0); - let c1 = insert_query_node(&engine, "Company", "gql-optional-c1", &[], 1.0); - let c2 = insert_query_node(&engine, "Company", "gql-optional-c2", &[], 1.0); - engine - .upsert_edge( - a_hit, - b_hit, - "GQL_OPTIONAL_REQUIRED", - UpsertEdgeOptions::default(), - ) - .unwrap(); - engine - .upsert_edge( - a_miss, - b_miss, - "GQL_OPTIONAL_REQUIRED", - UpsertEdgeOptions::default(), - ) - .unwrap(); - let s1 = engine - .upsert_edge( - b_hit, - c1, - "GQL_OPTIONAL_HIT", - UpsertEdgeOptions::default(), - ) - .unwrap(); - let s2 = engine - .upsert_edge( - b_hit, - c2, - "GQL_OPTIONAL_HIT", - UpsertEdgeOptions::default(), - ) - .unwrap(); + assert!(missing_property.rows.is_empty()); - let result = execute_gql_ok( + let impossible_node_id = execute_gql_ok( &engine, - "MATCH (a:Person)-[:GQL_OPTIONAL_REQUIRED]->(b:Person) \ - OPTIONAL MATCH (b)-[s:GQL_OPTIONAL_HIT]->(c:Company) \ - RETURN id(a), id(s), id(c) ORDER BY id(a), id(c)", - ); - assert_eq!( - result.rows.iter().map(|row| row.values.clone()).collect::>(), - vec![ - vec![GqlValue::UInt(a_hit), GqlValue::UInt(s1), GqlValue::UInt(c1)], - vec![GqlValue::UInt(a_hit), GqlValue::UInt(s2), GqlValue::UInt(c2)], - vec![GqlValue::UInt(a_miss), GqlValue::Null, GqlValue::Null], - ] + &format!("MATCH (n) WHERE id(n) = {}.5 RETURN id(n)", node), ); + assert!(impossible_node_id.rows.is_empty()); + assert_eq!(impossible_node_id.stats.rows_matched, 0); - let filtered_miss = execute_gql_ok( + let impossible_edge_id = execute_gql_ok( &engine, - &format!( - "MATCH (a:Person)-[:GQL_OPTIONAL_REQUIRED]->(b:Person) \ - WHERE id(a) = {a_hit} \ - OPTIONAL MATCH (b)-[s:GQL_OPTIONAL_HIT]->(c:Company) WHERE s.status = 'active' \ - RETURN id(a), id(s), id(c)" - ), - ); - assert_eq!( - filtered_miss.rows[0].values, - vec![GqlValue::UInt(a_hit), GqlValue::Null, GqlValue::Null] + &format!("MATCH ()-[r]->() WHERE id(r) = {}.5 RETURN id(r)", edge), ); + assert!(impossible_edge_id.rows.is_empty()); + assert_eq!(impossible_edge_id.stats.rows_matched, 0); - let chained_miss = execute_gql_ok( + let result = execute_gql_with_params( &engine, - &format!( - "MATCH (a:Person)-[:GQL_OPTIONAL_REQUIRED]->(b:Person) \ - WHERE id(a) = {a_hit} \ - OPTIONAL MATCH (b)-[s:GQL_OPTIONAL_MISSING]->(c:Company) \ - OPTIONAL MATCH (c)-[t:GQL_OPTIONAL_SECOND]->(d:Topic) \ - RETURN id(s), id(c), id(t), id(d)" - ), + "MATCH (n:Person) WHERE n.key = $key \ + RETURN $payload AS payload, $shape AS shape, $names AS names, n.name", + GqlParams::from([ + ( + "key".to_string(), + GqlParamValue::String("boundary-node".to_string()), + ), + ( + "payload".to_string(), + GqlParamValue::Bytes(vec![1, 2, 3, 4]), + ), + ( + "shape".to_string(), + GqlParamValue::Map(BTreeMap::from([ + ("enabled".to_string(), GqlParamValue::Bool(true)), + ("score".to_string(), GqlParamValue::Float(1.5)), + ])), + ), + ( + "names".to_string(), + GqlParamValue::List(vec![ + GqlParamValue::String("Ada".to_string()), + GqlParamValue::Null, + ]), + ), + ]), ); + assert_eq!(result.rows.len(), 1); + assert_eq!(result.rows[0].values[0], GqlValue::Bytes(vec![1, 2, 3, 4])); assert_eq!( - chained_miss.rows[0].values, - vec![GqlValue::Null, GqlValue::Null, GqlValue::Null, GqlValue::Null] - ); -} - -#[test] -fn gql_optional_reused_node_constraints_are_optional_local() { - let (_dir, engine) = query_test_engine(); - let a = insert_query_node(&engine, "Person", "gql-optional-reuse-a", &[], 1.0); - let b = insert_query_node(&engine, "Company", "gql-optional-reuse-b", &[], 1.0); - let c = insert_query_node(&engine, "Topic", "gql-optional-reuse-c", &[], 1.0); - engine - .upsert_edge(a, b, "GQL_OPTIONAL_REUSE_R", UpsertEdgeOptions::default()) - .unwrap(); - engine - .upsert_edge(b, c, "GQL_OPTIONAL_REUSE_S", UpsertEdgeOptions::default()) - .unwrap(); - - let result = execute_gql_ok( - &engine, - &format!( - "MATCH (a:Person) WHERE id(a) = {a} \ - OPTIONAL MATCH (a)-[:GQL_OPTIONAL_REUSE_R]->(b:Company) \ - OPTIONAL MATCH (b:Person)-[:GQL_OPTIONAL_REUSE_S]->(c) \ - RETURN id(b), id(c)" - ), + result.rows[0].values[1], + GqlValue::Map(BTreeMap::from([ + ("enabled".to_string(), GqlValue::Bool(true)), + ("score".to_string(), GqlValue::Float(1.5)), + ])) ); assert_eq!( - result.rows[0].values, - vec![GqlValue::UInt(b), GqlValue::Null] + result.rows[0].values[2], + GqlValue::List(vec![GqlValue::String("Ada".to_string()), GqlValue::Null]) ); + assert_eq!(result.rows[0].values[3], GqlValue::String("Ada".to_string())); } #[test] -fn gql_bounded_vlp_path_assignment_functions_and_cursors_match_graph_row() { +fn gql_return_relationship_type_properties_and_elements() { let (_dir, engine) = query_test_engine(); - let a = insert_query_node(&engine, "PathStart", "gql-path-a", &[], 1.0); - let b = insert_query_node(&engine, "PathNode", "gql-path-b", &[], 1.0); - let c = insert_query_node(&engine, "PathNode", "gql-path-c", &[], 1.0); - let ab = engine - .upsert_edge(a, b, "GQL_PATH", UpsertEdgeOptions::default()) - .unwrap(); - let ac = engine - .upsert_edge(a, c, "GQL_PATH", UpsertEdgeOptions::default()) - .unwrap(); - let bc = engine - .upsert_edge(b, c, "GQL_PATH", UpsertEdgeOptions::default()) - .unwrap(); - let ca = engine - .upsert_edge(c, a, "GQL_PATH", UpsertEdgeOptions::default()) + let from = insert_query_node(&engine, "Person", "element-from", &[], 1.0); + let to = insert_query_node(&engine, "Article", "element-to", &[], 1.0); + let edge = engine + .upsert_edge( + from, + to, + "LIKES", + UpsertEdgeOptions { + props: query_test_props(&[("since", PropValue::Int(2025))]), + ..UpsertEdgeOptions::default() + }, + ) .unwrap(); - let source = format!( - "MATCH p = (a)-[:GQL_PATH*0..2]->(z) WHERE id(a) = {a} \ - RETURN p, node_ids(p), edge_ids(p), length(p) \ - ORDER BY p" - ); - let gql = execute_gql_ok(&engine, &source); - - let mut native = graph_query( - &["a", "z"], - vec![graph_vlp(Some("p"), None, "a", "z", 0, 2)], + let result = execute_gql_ok( + &engine, + "MATCH ()-[r:LIKES]->() RETURN type(r) AS t, r.since AS since, r", ); - native.nodes[0].ids = vec![a]; - if let GraphPatternPiece::VariableLength(path) = &mut native.pieces[0] { - path.label_filter = vec!["GQL_PATH".to_string()]; - } - native.return_items = Some(vec![graph_return_binding( - "p", - GraphReturnProjection::Element(GraphElementProjection::Full), - )]); - native.order_by = vec![ - GraphOrderItem { - expr: GraphExpr::Binding("p".to_string()), - direction: GraphOrderDirection::Asc, - }, - ]; - let native_paths = graph_row_path_ids(engine.query_graph_rows(&native).unwrap()); - let gql_paths = gql - .rows - .iter() - .map(|row| { - let path = gql_single_path(&row.values[0]); - assert_eq!( - row.values[1], - GqlValue::List(path.node_ids.iter().copied().map(GqlValue::UInt).collect()) - ); - assert_eq!( - row.values[2], - GqlValue::List(path.edge_ids.iter().copied().map(GqlValue::UInt).collect()) - ); - assert_eq!(row.values[3], GqlValue::UInt(path.edge_ids.len() as u64)); - (path.node_ids.clone(), path.edge_ids.clone()) - }) - .collect::>(); - assert_eq!(gql_paths, native_paths); + assert_eq!(result.columns, vec!["t", "since", "r"]); + assert_eq!(result.rows[0].values[0], GqlValue::String("LIKES".to_string())); + assert_eq!(result.rows[0].values[1], GqlValue::Int(2025)); + let projected = gql_single_edge(&result.rows[0].values[2]); + assert_eq!(projected.id, Some(edge)); + assert_eq!(projected.from, Some(from)); + assert_eq!(projected.to, Some(to)); + assert_eq!(projected.label.as_deref(), Some("LIKES")); assert_eq!( - gql_paths, - vec![ - (vec![a], vec![]), - (vec![a, b], vec![ab]), - (vec![a, c], vec![ac]), - (vec![a, b, c], vec![ab, bc]), - (vec![a, c, a], vec![ac, ca]), - ] + projected.props.as_ref().unwrap().get("since"), + Some(&GqlValue::Int(2025)) ); +} - let two_hop = execute_gql_ok( +#[test] +fn gql_return_node_element_star_order_and_anonymous_alias_omission() { + let (_dir, engine) = query_test_engine(); + let a = insert_query_node( &engine, - &format!( - "MATCH p = (a)-[:GQL_PATH*0..2]->(z) \ - WHERE id(a) = {a} AND length(p) = 2 \ - RETURN edge_ids(p) ORDER BY p" - ), - ); - assert_eq!( - two_hop.rows.iter().map(|row| row.values[0].clone()).collect::>(), - vec![ - GqlValue::List(vec![GqlValue::UInt(ab), GqlValue::UInt(bc)]), - GqlValue::List(vec![GqlValue::UInt(ac), GqlValue::UInt(ca)]), - ] + "Person", + "star-a", + &[("name", PropValue::String("A".to_string()))], + 1.0, ); - - let path_function_values = execute_gql_ok( + let b = insert_query_node( &engine, - &format!( - "MATCH p = (a)-[:GQL_PATH*1..1]->(z) WHERE id(a) = {a} \ - RETURN start_node(p), end_node(p), nodes(p), relationships(p) ORDER BY p LIMIT 1" - ), + "Person", + "star-b", + &[("name", PropValue::String("B".to_string()))], + 1.0, ); - let values = &path_function_values.rows[0].values; - assert_eq!(values[0], GqlValue::UInt(a)); - assert_eq!(values[1], GqlValue::UInt(b)); - let GqlValue::List(nodes) = &values[2] else { - panic!("expected nodes(p) list"); - }; - assert_eq!(nodes, &vec![GqlValue::UInt(a), GqlValue::UInt(b)]); - let GqlValue::List(edges) = &values[3] else { - panic!("expected relationships(p) list"); - }; - assert_eq!(edges, &vec![GqlValue::UInt(ab)]); + let edge = engine + .upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default()) + .unwrap(); - let mut page_options = GqlExecutionOptions { - max_rows: 1, - ..GqlExecutionOptions::default() - }; - let mut cursor = None; - let mut paged = Vec::new(); - loop { - page_options.cursor = cursor.take(); - let page = execute_gql_with_options(&engine, &source, page_options.clone()); - if let Some(next) = page.next_cursor.clone() { - assert!(next.starts_with("ogr32c1_")); - cursor = Some(next); - } - paged.extend(page.rows.into_iter().map(|row| { - let path = gql_single_path(&row.values[0]); - (path.node_ids.clone(), path.edge_ids.clone()) - })); - if cursor.is_none() { - break; - } - } - assert_eq!(paged, native_paths); + let node_result = execute_gql_ok(&engine, "MATCH (n:Person) WHERE id(n) = 1 RETURN n"); + let node = gql_single_node(&node_result.rows[0].values[0]); + assert!(node.dense_vector.is_none()); + assert!(node.sparse_vector.is_none()); + assert!(node.props.as_ref().unwrap().contains_key("name")); - let compact = execute_gql_with_options( + let star = execute_gql_ok(&engine, "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN *"); + assert_eq!(star.columns, vec!["a", "r", "b"]); + assert_eq!(gql_single_node(&star.rows[0].values[0]).id, Some(a)); + assert_eq!(gql_single_edge(&star.rows[0].values[1]).id, Some(edge)); + assert_eq!(gql_single_node(&star.rows[0].values[2]).id, Some(b)); + + let anonymous = execute_gql_ok(&engine, "MATCH (:Person)-[r:KNOWS]->(:Person) RETURN *"); + assert_eq!(anonymous.columns, vec!["r"]); + assert_eq!(gql_single_edge(&anonymous.rows[0].values[0]).id, Some(edge)); +} + +#[test] +fn gql_parameter_and_deferred_feature_errors_are_clear() { + let (_dir, engine) = query_test_engine(); + insert_query_node( &engine, - &source, - GqlExecutionOptions { - compact_rows: true, - ..GqlExecutionOptions::default() - }, - ); - assert_eq!( - compact - .rows - .iter() - .map(|row| { - let path = gql_single_path(&row.values[0]); - (path.node_ids.clone(), path.edge_ids.clone()) - }) - .collect::>(), - native_paths + "Person", + "param-node", + &[("name", PropValue::String("Ada".to_string()))], + 1.0, ); - let first_page_cursor = execute_gql_with_options( - &engine, - &source, - GqlExecutionOptions { - max_rows: 1, - ..GqlExecutionOptions::default() - }, - ) - .next_cursor; - page_options.cursor = first_page_cursor.clone(); - let mismatch = engine + let missing = engine .execute_gql( - &format!( - "MATCH p = (a)-[:GQL_PATH*0..2]->(z) WHERE id(a) = {a} \ - RETURN p ORDER BY length(p)" - ), + "MATCH (n:Person) WHERE n.name = $name RETURN n.name", &GqlParams::new(), - &page_options, + &gql_opts(), ) .unwrap_err(); - assert!(matches!(mismatch, EngineError::InvalidCursor { .. })); + assert!(matches!( + missing, + EngineError::GqlParameter { ref name, .. } if name == "name" + )); +} - let oversized_cursor = engine - .execute_gql( - &source, - &GqlParams::new(), - &GqlExecutionOptions { - cursor: first_page_cursor, - max_rows: 1, - max_cursor_bytes: 8, - ..GqlExecutionOptions::default() - }, +#[test] +fn gql_referenced_param_list_cap_rejects_before_native_execution() { + let (_dir, engine) = query_test_engine(); + insert_query_node(&engine, "Person", "param-cap-node", &[], 1.0); + engine.reset_query_execution_counters_for_test(); + + let params = GqlParams::from([( + "ids".to_string(), + GqlParamValue::List(vec![ + GqlParamValue::UInt(1), + GqlParamValue::UInt(2), + GqlParamValue::UInt(3), + ]), + )]); + let err = engine + .execute_gql( + "MATCH (n:Person) WHERE id(n) IN $ids RETURN n.name LIMIT 1", + ¶ms, + &gql_param_cap_options(2, 8, 1_024), ) .unwrap_err(); - assert!(matches!(oversized_cursor, EngineError::InvalidCursor { .. })); + assert_gql_param_error(err, "ids", "exceeding max_literal_items"); + assert_eq!( + engine.query_execution_counter_snapshot_for_test(), + QueryExecutionCounterSnapshot::default() + ); } #[test] -fn gql_fixed_multi_hop_path_assignment_composes_after_fixed_matching() { +fn gql_referenced_param_nested_depth_cap_rejects_iteratively() { let (_dir, engine) = query_test_engine(); - let a = insert_query_node(&engine, "FixedPathStart", "gql-fixed-path-a", &[], 1.0); - let b = insert_query_node(&engine, "FixedPathMid", "gql-fixed-path-b", &[], 1.0); - let c = insert_query_node(&engine, "FixedPathEnd", "gql-fixed-path-c", &[], 1.0); - let ab = engine - .upsert_edge( - a, - b, - "GQL_FIXED_PATH_R", - UpsertEdgeOptions { - props: query_test_props(&[("kind", PropValue::String("first".to_string()))]), - ..UpsertEdgeOptions::default() - }, - ) - .unwrap(); - let cb = engine - .upsert_edge(c, b, "GQL_FIXED_PATH_S", UpsertEdgeOptions::default()) - .unwrap(); - engine - .upsert_edge(a, c, "GQL_FIXED_PATH_R", UpsertEdgeOptions::default()) - .unwrap(); + insert_query_node(&engine, "Person", "param-depth-node", &[], 1.0); + engine.reset_query_execution_counters_for_test(); - let source = format!( - "MATCH p = (a:FixedPathStart)-[:GQL_FIXED_PATH_R {{kind: 'first'}}]->(b)<-[s:GQL_FIXED_PATH_S]-(c) \ - WHERE id(a) = {a} \ - RETURN p, node_ids(p), edge_ids(p), length(p), id(s)" - ); - let result = execute_gql_ok(&engine, &source); - assert_eq!(result.rows.len(), 1); - let values = &result.rows[0].values; - let path = gql_single_path(&values[0]); - assert_eq!(path.node_ids, vec![a, b, c]); - assert_eq!(path.edge_ids, vec![ab, cb]); - assert_eq!( - values[1], - GqlValue::List(vec![GqlValue::UInt(a), GqlValue::UInt(b), GqlValue::UInt(c)]) - ); + let params = GqlParams::from([( + "payload".to_string(), + GqlParamValue::List(vec![GqlParamValue::List(vec![GqlParamValue::List(vec![ + GqlParamValue::Int(1), + ])])]), + )]); + let err = engine + .execute_gql( + "MATCH (n:Person) RETURN $payload LIMIT 1", + ¶ms, + &gql_param_cap_options(8, 2, 1_024), + ) + .unwrap_err(); + assert_gql_param_error(err, "payload", "nested list/map depth"); assert_eq!( - values[2], - GqlValue::List(vec![GqlValue::UInt(ab), GqlValue::UInt(cb)]) + engine.query_execution_counter_snapshot_for_test(), + QueryExecutionCounterSnapshot::default() ); - assert_eq!(values[3], GqlValue::UInt(2)); - assert_eq!(values[4], GqlValue::UInt(cb)); +} - let explain = engine - .explain_gql( - &source, - &GqlParams::new(), - &GqlExecutionOptions { - include_plan: true, - ..gql_opts() - }, +#[test] +fn gql_referenced_param_total_items_rejects_even_with_limit_zero() { + let (_dir, engine) = query_test_engine(); + insert_query_node(&engine, "Person", "param-total-node", &[], 1.0); + + let params = GqlParams::from([( + "payload".to_string(), + GqlParamValue::List(vec![ + GqlParamValue::List(vec![GqlParamValue::Int(1), GqlParamValue::Int(2)]), + GqlParamValue::Int(3), + ]), + )]); + let err = engine + .execute_gql( + "MATCH (n:Person) RETURN $payload LIMIT 0", + ¶ms, + &gql_param_cap_options(3, 8, 1_024), ) - .unwrap(); - let explain = gql_read_explain(&explain); - assert!(explain - .projection - .iter() - .any(|item| item.contains("FixedPathCompose"))); + .unwrap_err(); + assert_gql_param_error(err, "payload", "total list/map items"); } #[test] -fn gql_optional_fixed_multi_hop_path_assignment_null_extends_and_filters() { +fn gql_referenced_param_string_bytes_and_map_key_bytes_are_capped() { let (_dir, engine) = query_test_engine(); - let hit = insert_query_node(&engine, "FixedPathAnchor", "gql-fixed-path-hit", &[], 1.0); - let miss = insert_query_node(&engine, "FixedPathAnchor", "gql-fixed-path-miss", &[], 1.0); - let mid = insert_query_node(&engine, "FixedPathMid", "gql-fixed-path-mid", &[], 1.0); - let end = insert_query_node(&engine, "FixedPathEnd", "gql-fixed-path-end", &[], 1.0); - let hm = engine - .upsert_edge(hit, mid, "GQL_OPTIONAL_FIXED_R", UpsertEdgeOptions::default()) - .unwrap(); - let me = engine - .upsert_edge(mid, end, "GQL_OPTIONAL_FIXED_S", UpsertEdgeOptions::default()) - .unwrap(); + let string_source = "MATCH (n:Person) RETURN $p LIMIT 0"; + let string_err = engine + .execute_gql( + string_source, + &GqlParams::from([( + "p".to_string(), + GqlParamValue::String("x".repeat(5)), + )]), + &gql_param_cap_options(8, 8, 4), + ) + .unwrap_err(); + assert_gql_param_error(string_err, "p", "string is"); - let result = execute_gql_ok( - &engine, - "MATCH (a:FixedPathAnchor) \ - OPTIONAL MATCH p = (a)-[:GQL_OPTIONAL_FIXED_R]->(b)-[:GQL_OPTIONAL_FIXED_S]->(c) \ - WHERE length(p) = 2 \ - RETURN id(a), p, length(p) ORDER BY id(a)", - ); - assert_eq!(result.rows.len(), 2); - assert_eq!(result.rows[0].values[0], GqlValue::UInt(hit)); - let path = gql_single_path(&result.rows[0].values[1]); - assert_eq!(path.node_ids, vec![hit, mid, end]); - assert_eq!(path.edge_ids, vec![hm, me]); - assert_eq!(result.rows[0].values[2], GqlValue::UInt(2)); - assert_eq!(result.rows[1].values[0], GqlValue::UInt(miss)); - assert_eq!(result.rows[1].values[1], GqlValue::Null); - assert_eq!(result.rows[1].values[2], GqlValue::Null); + let bytes_source = "MATCH (n:Person) RETURN $b LIMIT 0"; + let bytes_err = engine + .execute_gql( + bytes_source, + &GqlParams::from([( + "b".to_string(), + GqlParamValue::Bytes(vec![7; 5]), + )]), + &gql_param_cap_options(8, 8, 4), + ) + .unwrap_err(); + assert_gql_param_error(bytes_err, "b", "bytes is"); + + let key_source = "MATCH (n:Person) RETURN $payload LIMIT 0"; + let key_err = engine + .execute_gql( + key_source, + &GqlParams::from([( + "payload".to_string(), + GqlParamValue::Map(BTreeMap::from([("k".repeat(5), GqlParamValue::Null)])), + )]), + &gql_param_cap_options(8, 8, 4), + ) + .unwrap_err(); + assert_gql_param_error(key_err, "payload", "map key is"); } #[test] -fn gql_fixed_multi_hop_path_assignment_uses_final_row_cursors() { +fn gql_boundary_sized_referenced_params_work_and_unused_oversized_params_are_ignored() { let (_dir, engine) = query_test_engine(); - let a1 = insert_query_node(&engine, "FixedPathPageStart", "gql-fixed-page-a1", &[], 1.0); - let b1 = insert_query_node(&engine, "FixedPathPageMid", "gql-fixed-page-b1", &[], 1.0); - let c1 = insert_query_node(&engine, "FixedPathPageEnd", "gql-fixed-page-c1", &[], 1.0); - let a2 = insert_query_node(&engine, "FixedPathPageStart", "gql-fixed-page-a2", &[], 1.0); - let b2 = insert_query_node(&engine, "FixedPathPageMid", "gql-fixed-page-b2", &[], 1.0); - let c2 = insert_query_node(&engine, "FixedPathPageEnd", "gql-fixed-page-c2", &[], 1.0); - let a1b1 = engine - .upsert_edge(a1, b1, "GQL_FIXED_PAGE_R", UpsertEdgeOptions::default()) - .unwrap(); - let b1c1 = engine - .upsert_edge(b1, c1, "GQL_FIXED_PAGE_S", UpsertEdgeOptions::default()) - .unwrap(); - let a2b2 = engine - .upsert_edge(a2, b2, "GQL_FIXED_PAGE_R", UpsertEdgeOptions::default()) - .unwrap(); - let b2c2 = engine - .upsert_edge(b2, c2, "GQL_FIXED_PAGE_S", UpsertEdgeOptions::default()) - .unwrap(); + let node = insert_query_node(&engine, "Person", "param-boundary-node", &[], 1.0); - let source = "MATCH p = (a:FixedPathPageStart)-[:GQL_FIXED_PAGE_R]->(b)-[:GQL_FIXED_PAGE_S]->(c) \ - RETURN p ORDER BY p"; - let mut options = GqlExecutionOptions { - max_rows: 1, - ..GqlExecutionOptions::default() - }; - let mut cursor = None; - let mut paths = Vec::new(); - loop { - options.cursor = cursor.take(); - let page = execute_gql_with_options(&engine, source, options.clone()); - paths.extend(page.rows.iter().map(|row| { - let path = gql_single_path(&row.values[0]); - (path.node_ids.clone(), path.edge_ids.clone()) - })); - cursor = page.next_cursor; - if cursor.is_none() { - break; - } - } + let source = "MATCH (n:Person) RETURN $payload LIMIT 1"; + let params = GqlParams::from([( + "payload".to_string(), + GqlParamValue::Map(BTreeMap::from([( + "key".to_string(), + GqlParamValue::List(vec![ + GqlParamValue::String("x".repeat(61)), + GqlParamValue::Null, + ]), + )])), + )]); + let result = engine + .execute_gql(source, ¶ms, &gql_param_cap_options(3, 2, 64)) + .unwrap(); assert_eq!( - paths, - vec![ - (vec![a1, b1, c1], vec![a1b1, b1c1]), - (vec![a2, b2, c2], vec![a2b2, b2c2]), - ] - ); - - let first_cursor = execute_gql_with_options( - &engine, - source, - GqlExecutionOptions { - max_rows: 1, - ..GqlExecutionOptions::default() - }, - ) - .next_cursor - .expect("first page should emit a cursor"); - let mismatch = engine - .execute_gql( - "MATCH p = (a:FixedPathPageStart)-[:GQL_FIXED_PAGE_R]->(b)-[:GQL_FIXED_PAGE_S]->(c) \ - RETURN edge_ids(p) ORDER BY p", - &GqlParams::new(), - &GqlExecutionOptions { - cursor: Some(first_cursor), - max_rows: 1, - ..GqlExecutionOptions::default() - }, + result.rows[0].values[0], + GqlValue::Map(BTreeMap::from([( + "key".to_string(), + GqlValue::List(vec![GqlValue::String("x".repeat(61)), GqlValue::Null]) + )])) + ); + + let unused = engine + .execute_gql( + "MATCH (n:Person) RETURN id(n) LIMIT 1", + &GqlParams::from([( + "unused".to_string(), + GqlParamValue::List(vec![ + GqlParamValue::Int(1), + GqlParamValue::Int(2), + GqlParamValue::Int(3), + ]), + )]), + &gql_param_cap_options(1, 8, 128), ) - .unwrap_err(); - assert!(matches!(mismatch, EngineError::InvalidCursor { .. })); + .unwrap(); + assert_eq!(unused.rows[0].values[0], GqlValue::UInt(node)); } #[test] -fn gql_vlp_direction_self_loop_and_parallel_edges_match_graph_row() { +fn gql_explain_enforces_referenced_param_caps_like_query() { let (_dir, engine) = query_test_engine(); - let a = insert_query_node(&engine, "DirectionPath", "gql-direction-a", &[], 1.0); - let b = insert_query_node(&engine, "DirectionPath", "gql-direction-b", &[], 1.0); - let incoming_edge = engine - .upsert_edge(b, a, "GQL_INCOMING_PATH", UpsertEdgeOptions::default()) - .unwrap(); - - let incoming_gql = execute_gql_ok( - &engine, - &format!( - "MATCH p = (a)<-[:GQL_INCOMING_PATH*1..1]-(b) \ - WHERE id(a) = {a} AND id(b) = {b} RETURN p" - ), - ); - let incoming_path = gql_single_path(&incoming_gql.rows[0].values[0]); - assert_eq!(incoming_path.node_ids, vec![a, b]); - assert_eq!(incoming_path.edge_ids, vec![incoming_edge]); + insert_query_node(&engine, "Person", "param-explain-node", &[], 1.0); - let mut incoming_native = graph_query( - &["a", "b"], - vec![graph_vlp(Some("p"), None, "a", "b", 1, 1)], - ); - if let GraphPatternPiece::VariableLength(path) = &mut incoming_native.pieces[0] { - path.direction = Direction::Incoming; - path.label_filter = vec!["GQL_INCOMING_PATH".to_string()]; - } - incoming_native.nodes[0].ids = vec![a]; - incoming_native.nodes[1].ids = vec![b]; - incoming_native.return_items = Some(vec![graph_return_binding( - "p", - GraphReturnProjection::Element(GraphElementProjection::Full), + let params = GqlParams::from([( + "ids".to_string(), + GqlParamValue::List(vec![ + GqlParamValue::UInt(1), + GqlParamValue::UInt(2), + GqlParamValue::UInt(3), + ]), )]); - assert_eq!( - vec![(incoming_path.node_ids.clone(), incoming_path.edge_ids.clone())], - graph_row_path_ids(engine.query_graph_rows(&incoming_native).unwrap()) - ); - - let loop_node = insert_query_node(&engine, "DirectionPath", "gql-direction-loop", &[], 1.0); - let loop_edge = engine - .upsert_edge( - loop_node, - loop_node, - "GQL_BOTH_PATH", - UpsertEdgeOptions::default(), + let err = engine + .explain_gql( + "MATCH (n:Person) WHERE id(n) IN $ids RETURN id(n)", + ¶ms, + &gql_param_cap_options(2, 8, 1_024), ) - .unwrap(); - let p1 = engine - .upsert_edge(a, b, "GQL_BOTH_PATH", UpsertEdgeOptions::default()) - .unwrap(); - let p2 = engine - .upsert_edge(a, b, "GQL_BOTH_PATH", UpsertEdgeOptions::default()) - .unwrap(); + .unwrap_err(); + assert_gql_param_error(err, "ids", "exceeding max_literal_items"); +} - let self_loop = execute_gql_ok( - &engine, - &format!( - "MATCH p = (n)-[:GQL_BOTH_PATH*1..1]-(n) WHERE id(n) = {loop_node} RETURN p" +#[test] +fn gql_beta_unsupported_features_are_rejected_by_execution_api() { + let (_dir, engine) = query_test_engine(); + let cases = [ + ( + "CREATE INDEX node_status FOR (n:User) ON (n.status)", + "schema/DDL", + "CREATE", ), - ); - let loop_path = gql_single_path(&self_loop.rows[0].values[0]); - assert_eq!(loop_path.node_ids, vec![loop_node, loop_node]); - assert_eq!(loop_path.edge_ids, vec![loop_edge]); - - let parallel = execute_gql_ok( - &engine, - &format!( - "MATCH p = (a)-[:GQL_BOTH_PATH*1..1]-(b) \ - WHERE id(a) = {a} AND id(b) = {b} RETURN p ORDER BY p" + ("DROP INDEX node_status", "schema/DDL", "DROP"), + ( + "MATCH (n:Person)-[*]->(m) RETURN n", + "unbounded VLP", + "*", ), - ); - let parallel_paths = parallel - .rows - .iter() - .map(|row| { - let path = gql_single_path(&row.values[0]); - (path.node_ids.clone(), path.edge_ids.clone()) - }) - .collect::>(); - assert_eq!(parallel_paths, vec![(vec![a, b], vec![p1]), (vec![a, b], vec![p2])]); + ( + "MATCH (n:Person) RETURN n UNION CREATE (m:Person {key: 'm'}) RETURN m", + "write clauses", + "CREATE", + ), + ("CALL db.labels()", "CALL", "CALL"), + ]; + + for (source, expected_feature, expected_span) in cases { + let err = engine + .execute_gql(source, &GqlParams::new(), &gql_opts()) + .unwrap_err(); + match err { + EngineError::GqlUnsupported { feature, span, .. } => { + assert_eq!(feature, expected_feature, "query: {source}"); + assert_eq!( + span.offset, + source.find(expected_span).unwrap(), + "query: {source}" + ); + } + other => panic!("expected unsupported {expected_feature} for {source}, got {other:?}"), + } + } } #[test] -fn gql_vlp_caps_surface_graph_row_errors() { +fn gql_deferred_features_remain_rejected_after_row_ops() { let (_dir, engine) = query_test_engine(); - let start = insert_query_node(&engine, "GqlVlpCap", "gql-vlp-cap-start", &[], 1.0); - let a = insert_query_node(&engine, "GqlVlpCap", "gql-vlp-cap-a", &[], 1.0); - let b = insert_query_node(&engine, "GqlVlpCap", "gql-vlp-cap-b", &[], 1.0); - engine - .upsert_edge(start, a, "GQL_VLP_CAP", UpsertEdgeOptions::default()) - .unwrap(); - engine - .upsert_edge(start, b, "GQL_VLP_CAP", UpsertEdgeOptions::default()) - .unwrap(); + { + let source = "MATCH (n:Person)-[*]->(m) RETURN n"; + let err = engine + .execute_gql(source, &GqlParams::new(), &gql_opts()) + .unwrap_err(); + assert!( + matches!(err, EngineError::GqlUnsupported { .. } | EngineError::GqlParse { .. }), + "expected unsupported/parse error for {source}, got {err:?}" + ); + } - let err = engine + let skip_offset = engine .execute_gql( - &format!( - "MATCH p = (a)-[:GQL_VLP_CAP*1..1]->(b) WHERE id(a) = {start} RETURN p" - ), + "MATCH (n:Person) RETURN n SKIP 1 OFFSET 1", &GqlParams::new(), - &GqlExecutionOptions { - max_intermediate_bindings: 1, - max_frontier: 1, - ..GqlExecutionOptions::default() - }, + &gql_opts(), ) .unwrap_err(); - let message = err.to_string(); - assert!(message.contains("max_frontier")); - assert!(message.contains("configured cap 1")); - assert!(message.contains("path=p")); + assert!(matches!(skip_offset, EngineError::GqlParse { .. })); } #[test] -fn gql_vlp_source_correctness_matches_graph_row_oracle() { +fn gql_read_only_exists_subqueries_execute_with_correlation_and_cache() { let (_dir, engine) = query_test_engine(); - let start = insert_query_node(&engine, "GqlVlpSource", "gql-vlp-source-start", &[], 1.0); - let keep_mid = insert_query_node(&engine, "GqlVlpSource", "gql-vlp-source-mid", &[], 1.0); - let keep_end = insert_query_node( + let a = insert_query_node( &engine, - "GqlVlpEnd", - "gql-vlp-source-keep", - &[("status", PropValue::String("keep".to_string()))], + "GqlSubExists", + "a", + &[("status", PropValue::String("active".to_string()))], 1.0, ); - let drop_end = insert_query_node( + let b = insert_query_node( &engine, - "GqlVlpEnd", - "gql-vlp-source-drop", - &[("status", PropValue::String("drop".to_string()))], + "GqlSubExists", + "b", + &[("status", PropValue::String("active".to_string()))], 1.0, ); - let deleted_end = insert_query_node( + insert_query_node( &engine, - "GqlVlpEnd", - "gql-vlp-source-deleted", - &[("status", PropValue::String("keep".to_string()))], + "GqlSubExists", + "c", + &[("status", PropValue::String("stale".to_string()))], 1.0, ); - let pruned_end = insert_query_node( - &engine, - "GqlVlpEnd", - "gql-vlp-source-pruned", - &[("status", PropValue::String("keep".to_string()))], - 0.1, + engine + .upsert_edge(a, b, "GQL_SUB_EXISTS_REL", UpsertEdgeOptions::default()) + .unwrap(); + + let options = GqlExecutionOptions { + allow_full_scan: true, + include_plan: true, + ..gql_opts() + }; + let correlated = engine + .execute_gql( + "MATCH (n:GqlSubExists) \ + WHERE EXISTS { MATCH (n)-[:GQL_SUB_EXISTS_REL]->(m) RETURN m } \ + RETURN n.key AS key ORDER BY key", + &GqlParams::new(), + &options, + ) + .unwrap(); + assert_eq!(gql_string_column(&correlated, 0), vec!["a"]); + let plan = gql_read_explain(correlated.plan.as_ref().expect("plan")); + assert_eq!(plan.target, GqlLoweringTarget::GraphPipelineQuery); + assert!(plan + .projection + .iter() + .any(|item| item.contains("exists_predicates=1"))); + + let pushed_conjunct = engine + .execute_gql( + "MATCH (n:GqlSubExists) \ + WHERE n.status = 'active' AND EXISTS { MATCH (n)-[:GQL_SUB_EXISTS_REL]->(m) RETURN m } \ + RETURN n.key AS key ORDER BY key", + &GqlParams::new(), + &options, + ) + .unwrap(); + assert_eq!(gql_string_column(&pushed_conjunct, 0), vec!["a"]); + let plan = gql_read_explain(pushed_conjunct.plan.as_ref().expect("plan")); + assert!( + plan.pushed_down.iter().any(|item| item.contains("n.status")), + "expected subquery-free conjunct to stay pushdown-capable, got {:?}", + plan.pushed_down + ); + + let zero_visible = engine + .execute_gql( + "MATCH (:GqlSubExists) \ + WHERE EXISTS { MATCH (m:GqlSubExists) RETURN m } \ + RETURN 1 AS one", + &GqlParams::new(), + &options, + ) + .unwrap(); + assert_eq!(zero_visible.rows.len(), 3); + assert!(zero_visible + .rows + .iter() + .all(|row| row.values == vec![GqlValue::Int(1)])); + + let repeated_key = engine + .execute_gql( + "MATCH (n:GqlSubExists) \ + WITH n.status AS status \ + WHERE EXISTS { MATCH (m:GqlSubExists) WHERE m.status = status RETURN m } \ + RETURN status ORDER BY status", + &GqlParams::new(), + &options, + ) + .unwrap(); + assert_eq!( + gql_string_column(&repeated_key, 0), + vec![ + "active".to_string(), + "active".to_string(), + "stale".to_string() + ] + ); + let plan = gql_read_explain(repeated_key.plan.as_ref().expect("plan")); + assert!(plan + .projection + .iter() + .any(|item| item.contains("subquery_invocations=2"))); + assert!(plan + .projection + .iter() + .any(|item| item.contains("subquery_cache_hits=1"))); + + let uncorrelated = engine + .execute_gql( + "MATCH (n:GqlSubExists) \ + WHERE EXISTS { MATCH (m:GqlSubExists) RETURN m } \ + RETURN n.key AS key ORDER BY key", + &GqlParams::new(), + &options, + ) + .unwrap(); + assert_eq!( + gql_string_column(&uncorrelated, 0), + vec!["a".to_string(), "b".to_string(), "c".to_string()] ); - let first = engine - .upsert_edge( - start, - keep_mid, - "GQL_VLP_SOURCE", - UpsertEdgeOptions { - props: query_test_props(&[("status", PropValue::String("open".to_string()))]), - ..UpsertEdgeOptions::default() - }, + let plan = gql_read_explain(uncorrelated.plan.as_ref().expect("plan")); + assert!(plan + .projection + .iter() + .any(|item| item.contains("subquery_invocations=1"))); + assert!(plan + .projection + .iter() + .any(|item| item.contains("subquery_cache_hits=2"))); + + insert_query_node(&engine, "GqlSubNullMarker", "marker", &[], 1.0); + let null_key = engine + .execute_gql( + "MATCH (n:GqlSubExists) \ + WITH n.missing AS missing \ + WHERE EXISTS { MATCH (marker:GqlSubNullMarker) WHERE missing IS NULL RETURN marker } \ + RETURN missing", + &GqlParams::new(), + &options, ) .unwrap(); - let second = engine + assert_eq!(null_key.rows.len(), 3); + assert!(null_key + .rows + .iter() + .all(|row| row.values == vec![GqlValue::Null])); + let plan = gql_read_explain(null_key.plan.as_ref().expect("plan")); + assert!(plan + .projection + .iter() + .any(|item| item.contains("subquery_invocations=1"))); + assert!(plan + .projection + .iter() + .any(|item| item.contains("subquery_cache_hits=2"))); + + let epoch_a = insert_query_node(&engine, "GqlSubEpoch", "a", &[], 1.0); + let epoch_b = insert_query_node(&engine, "GqlSubEpoch", "b", &[], 1.0); + let epoch_c = insert_query_node(&engine, "GqlSubEpoch", "c", &[], 1.0); + engine .upsert_edge( - keep_mid, - keep_end, - "GQL_VLP_SOURCE", - UpsertEdgeOptions { - props: query_test_props(&[("status", PropValue::String("open".to_string()))]), - ..UpsertEdgeOptions::default() - }, + epoch_a, + epoch_b, + "GQL_SUB_EPOCH_REL", + UpsertEdgeOptions::default(), ) .unwrap(); engine .upsert_edge( - start, - drop_end, - "GQL_VLP_SOURCE", - UpsertEdgeOptions { - props: query_test_props(&[("status", PropValue::String("open".to_string()))]), - ..UpsertEdgeOptions::default() - }, + epoch_b, + epoch_a, + "GQL_SUB_EPOCH_REL", + UpsertEdgeOptions::default(), ) .unwrap(); - let deleted_edge = engine - .upsert_edge( - start, - deleted_end, - "GQL_VLP_SOURCE", - UpsertEdgeOptions { - props: query_test_props(&[("status", PropValue::String("open".to_string()))]), - ..UpsertEdgeOptions::default() + let epoch_source = "MATCH (n:GqlSubEpoch) \ + WHERE EXISTS { MATCH (n)-[:GQL_SUB_EPOCH_REL]->(m) RETURN m } \ + RETURN n.key AS key ORDER BY key"; + let first_epoch_page = engine + .execute_gql( + epoch_source, + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_rows: 1, + ..gql_opts() }, ) .unwrap(); + assert_eq!(gql_string_column(&first_epoch_page, 0), vec!["a"]); + let cursor = first_epoch_page + .next_cursor + .clone() + .expect("first epoch page should return cursor"); + let cursor_epoch = + graph_pipeline_decode_logical_cursor(&cursor, GraphPipelineOptions::default().max_cursor_bytes) + .unwrap() + .effective_at_epoch; engine .upsert_edge( - start, - pruned_end, - "GQL_VLP_SOURCE", + epoch_c, + epoch_a, + "GQL_SUB_EPOCH_REL", UpsertEdgeOptions { - props: query_test_props(&[("status", PropValue::String("open".to_string()))]), + valid_from: Some(cursor_epoch.saturating_add(1)), ..UpsertEdgeOptions::default() }, ) .unwrap(); - engine.delete_node(deleted_end).unwrap(); - engine.delete_edge(deleted_edge).unwrap(); - engine - .set_prune_policy( - "gql-vlp-low-weight", - PrunePolicy { - max_age_ms: None, - max_weight: Some(0.5), - label: Some("GqlVlpEnd".to_string()), - }, - ) - .unwrap(); - - let source = format!( - "MATCH p = (a)-[:GQL_VLP_SOURCE*1..2 {{status: 'open'}}]->(b:GqlVlpEnd {{status: 'keep'}}) \ - WHERE id(a) = {start} RETURN p ORDER BY p" - ); - let gql = execute_gql_ok(&engine, &source); - let gql_paths = gql - .rows - .iter() - .map(|row| { - let path = gql_single_path(&row.values[0]); - (path.node_ids.clone(), path.edge_ids.clone()) - }) - .collect::>(); - - let mut native = graph_query( - &["a", "b"], - vec![graph_vlp(Some("p"), None, "a", "b", 1, 2)], - ); - native.nodes[0].ids = vec![start]; - native.nodes[1].label_filter = Some(NodeLabelFilter { - labels: vec!["GqlVlpEnd".to_string()], - mode: LabelMatchMode::All, - }); - native.nodes[1].filter = Some(NodeFilterExpr::PropertyEquals { - key: "status".to_string(), - value: PropValue::String("keep".to_string()), - }); - if let GraphPatternPiece::VariableLength(path) = &mut native.pieces[0] { - path.label_filter = vec!["GQL_VLP_SOURCE".to_string()]; - path.filter = Some(EdgeFilterExpr::PropertyEquals { - key: "status".to_string(), - value: PropValue::String("open".to_string()), - }); + while now_millis() <= cursor_epoch.saturating_add(1) { + std::thread::sleep(std::time::Duration::from_millis(1)); } - native.return_items = Some(vec![graph_return_binding( - "p", - GraphReturnProjection::Element(GraphElementProjection::Full), - )]); - native.order_by = vec![GraphOrderItem { - expr: GraphExpr::Binding("p".to_string()), - direction: GraphOrderDirection::Asc, - }]; - let native_paths = graph_row_path_ids(engine.query_graph_rows(&native).unwrap()); - assert_eq!(gql_paths, native_paths); - assert_eq!(native_paths, vec![(vec![start, keep_mid, keep_end], vec![first, second])]); -} - -#[test] -fn gql_path_outputs_hydrate_elements_and_respect_vector_policy() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("db"); - let engine = DatabaseEngine::open( - &db_path, - &DbOptions { - dense_vector: Some(DenseVectorConfig { - dimension: 3, - metric: DenseMetric::Cosine, - hnsw: HnswConfig::default(), - }), - ..DbOptions::default() - }, - ) - .unwrap(); - seed_query_test_catalog(&engine); - let a = engine - .upsert_node( - "PathVector", - "gql-path-vector-a", - UpsertNodeOptions { - dense_vector: Some(vec![0.1, 0.2, 0.3]), - sparse_vector: Some(vec![(1, 1.0)]), - ..UpsertNodeOptions::default() + let fresh_epoch_page = engine + .execute_gql( + epoch_source, + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_rows: 10, + ..gql_opts() }, ) .unwrap(); - let b = engine - .upsert_node( - "PathVector", - "gql-path-vector-b", - UpsertNodeOptions { - dense_vector: Some(vec![0.4, 0.5, 0.6]), - sparse_vector: Some(vec![(2, 2.0)]), - ..UpsertNodeOptions::default() + assert_eq!( + gql_string_column(&fresh_epoch_page, 0), + vec!["a".to_string(), "b".to_string(), "c".to_string()] + ); + let second_epoch_page = engine + .execute_gql( + epoch_source, + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_rows: 10, + cursor: Some(cursor), + ..gql_opts() }, ) .unwrap(); - let edge = engine - .upsert_edge(a, b, "GQL_PATH_VECTOR", UpsertEdgeOptions::default()) - .unwrap(); - - let source = format!("MATCH p = (a)-[:GQL_PATH_VECTOR*1..1]->(b) WHERE id(a) = {a} RETURN p"); - let default_path = gql_single_path(&execute_gql_ok(&engine, &source).rows[0].values[0]).clone(); - assert_eq!(default_path.node_ids, vec![a, b]); - assert_eq!(default_path.edge_ids, vec![edge]); - let nodes = default_path.nodes.as_ref().expect("direct path should hydrate nodes"); - let edges = default_path.edges.as_ref().expect("direct path should hydrate edges"); - assert_eq!(nodes.len(), 2); - assert_eq!(edges.len(), 1); - assert!(nodes.iter().all(|node| node.dense_vector.is_none())); - assert!(nodes.iter().all(|node| node.sparse_vector.is_none())); - - let vector_path = gql_single_path( - &execute_gql_with_options( - &engine, - &source, - GqlExecutionOptions { - include_vectors: true, - ..GqlExecutionOptions::default() - }, - ) - .rows[0] - .values[0], - ) - .clone(); - let nodes = vector_path.nodes.as_ref().unwrap(); - assert_eq!(nodes[0].dense_vector.as_deref(), Some([0.1, 0.2, 0.3].as_slice())); - assert_eq!(nodes[1].sparse_vector.as_deref(), Some([(2, 2.0)].as_slice())); + assert_eq!(gql_string_column(&second_epoch_page, 0), vec!["b"]); } #[test] -fn gql_optional_vlp_path_explain_surfaces_graph_row_root() { +fn gql_exists_subquery_uses_physical_probe_for_simple_matches() { let (_dir, engine) = query_test_engine(); - let a = insert_query_node(&engine, "Person", "gql-explain-path-a", &[], 1.0); - let b = insert_query_node(&engine, "Person", "gql-explain-path-b", &[], 1.0); - engine - .upsert_edge(a, b, "GQL_EXPLAIN_PATH", UpsertEdgeOptions::default()) + insert_query_node(&engine, "GqlSubExistsProbeOuter", "outer", &[], 1.0); + for index in 0..8 { + insert_query_node( + &engine, + "GqlSubExistsProbeInner", + &format!("inner-{index}"), + &[], + 1.0, + ); + } + + let probe_options = GqlExecutionOptions { + allow_full_scan: true, + include_plan: true, + max_intermediate_bindings: 1, + ..gql_opts() + }; + let broad_true = engine + .execute_gql( + "MATCH (outer:GqlSubExistsProbeOuter) \ + WHERE EXISTS { MATCH (inner:GqlSubExistsProbeInner) RETURN inner } \ + RETURN outer.key", + &GqlParams::new(), + &probe_options, + ) .unwrap(); + assert_eq!(gql_string_column(&broad_true, 0), vec!["outer"]); + let plan = gql_read_explain(broad_true.plan.as_ref().expect("plan")); + assert!(plan + .projection + .iter() + .any(|item| item.contains("physical_exists_probe=true"))); + assert!(plan + .projection + .iter() + .any(|item| item.contains("subquery_invocations=1"))); - let explain = engine - .explain_gql( - &format!( - "MATCH (a:Person) WHERE id(a) = {a} \ - OPTIONAL MATCH p = (a)-[:GQL_EXPLAIN_PATH*1..2]->(b) \ - RETURN p ORDER BY length(p) LIMIT 1" - ), + let broad_false = engine + .execute_gql( + "MATCH (outer:GqlSubExistsProbeOuter) \ + WHERE EXISTS { MATCH (missing:GqlSubExistsProbeMissing) RETURN missing } \ + RETURN outer.key", &GqlParams::new(), - &gql_opts(), + &probe_options, ) .unwrap(); - let explain = gql_read_explain(&explain); - assert_eq!(explain.target, GqlLoweringTarget::GraphRowQuery); - assert!(explain.native_plan.is_none()); - for expected in [ - "GraphRowPhysicalPlan", - "VariableLengthPath", - "Optional", - "path element p", + assert!(broad_false.rows.is_empty()); + + for projection in [ + "inner.key AS key", + "id(inner) AS inner_id", + "{key: inner.key, id: id(inner)} AS payload", + "[inner.key, id(inner)] AS payload", ] { + let projected_true = engine + .execute_gql( + &format!( + "MATCH (outer:GqlSubExistsProbeOuter) \ + WHERE EXISTS {{ MATCH (inner:GqlSubExistsProbeInner) RETURN {projection} }} \ + RETURN outer.key" + ), + &GqlParams::new(), + &probe_options, + ) + .unwrap(); + assert_eq!(gql_string_column(&projected_true, 0), vec!["outer"]); + let plan = gql_read_explain(projected_true.plan.as_ref().expect("plan")); assert!( - explain - .projection + plan.projection .iter() - .any(|item| item.contains(expected)), - "expected explain projection to contain {expected:?}, got {:?}", - explain.projection + .any(|item| item.contains("physical_exists_probe=true")), + "expected physical probe for projection {projection}, got {:?}", + plan.projection ); } + + let limit_zero = engine + .execute_gql( + "MATCH (outer:GqlSubExistsProbeOuter) \ + WHERE EXISTS { MATCH (inner:GqlSubExistsProbeInner) RETURN inner LIMIT 0 } \ + RETURN outer.key", + &GqlParams::new(), + &probe_options, + ) + .unwrap(); + assert!(limit_zero.rows.is_empty()); + + let unsafe_projection = engine + .execute_gql( + "MATCH (outer:GqlSubExistsProbeOuter) \ + WHERE EXISTS { MATCH (inner:GqlSubExistsProbeInner) RETURN 1 / 0 AS boom } \ + RETURN outer.key", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + include_plan: true, + max_intermediate_bindings: 64, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + unsafe_projection.to_string().contains("division by zero") + || unsafe_projection.to_string().contains("divide by zero"), + "unexpected unsafe projection error: {unsafe_projection:?}" + ); } #[test] -fn gql_fixed_pattern_explain_asserts_fanout_aware_physical_choice() { +fn gql_exists_subquery_probe_does_not_cap_raw_edge_candidates() { let (_dir, engine) = query_test_engine(); - let small = insert_query_node(&engine, "GQL_FANOUT_SMALL", "gql-fanout-small", &[], 1.0); - let bridge_hit = insert_query_node( - &engine, - "GQL_FANOUT_BRIDGE", - "gql-fanout-bridge-hit", - &[], - 1.0, - ); + let source = insert_query_node(&engine, "GqlSubExistsProbeEdgeSource", "source", &[], 1.0); + let miss = insert_query_node(&engine, "GqlSubExistsProbeEdgeMiss", "miss", &[], 1.0); + let hit = insert_query_node(&engine, "GqlSubExistsProbeEdgeHit", "hit", &[], 1.0); engine .upsert_edge( - small, - bridge_hit, - "GQL_FANOUT_HIGH", + source, + miss, + "GQL_SUB_EXISTS_PROBE_EDGE", UpsertEdgeOptions::default(), ) .unwrap(); - for index in 0..39 { - let bridge = insert_query_node( - &engine, - "GQL_FANOUT_BRIDGE", - &format!("gql-fanout-bridge-{index}"), - &[], - 1.0, - ); + engine + .upsert_edge( + source, + hit, + "GQL_SUB_EXISTS_PROBE_EDGE", + UpsertEdgeOptions::default(), + ) + .unwrap(); + + let result = engine + .execute_gql( + "MATCH (source:GqlSubExistsProbeEdgeSource) \ + WHERE EXISTS { \ + MATCH (source)-[:GQL_SUB_EXISTS_PROBE_EDGE]->(target:GqlSubExistsProbeEdgeHit) \ + RETURN target \ + } \ + RETURN source.key", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_intermediate_bindings: 1, + ..gql_opts() + }, + ) + .unwrap(); + assert_eq!(gql_string_column(&result, 0), vec!["source"]); +} + +#[test] +fn gql_optional_match_where_exists_preserves_left_outer_semantics() { + let (_dir, engine) = query_test_engine(); + let pass = insert_query_node(&engine, "GqlOptExistsOuter", "pass", &[], 1.0); + let pass_match = insert_query_node(&engine, "GqlOptExistsInner", "ok", &[], 1.0); + let fail = insert_query_node(&engine, "GqlOptExistsOuter", "fail", &[], 1.0); + let fail_match = insert_query_node(&engine, "GqlOptExistsInner", "bad", &[], 1.0); + let _miss = insert_query_node(&engine, "GqlOptExistsOuter", "miss", &[], 1.0); + let partial = insert_query_node(&engine, "GqlOptExistsOuter", "partial", &[], 1.0); + let partial_good = insert_query_node(&engine, "GqlOptExistsInner", "good", &[], 1.0); + let partial_bad = insert_query_node(&engine, "GqlOptExistsInner", "drop", &[], 1.0); + let marker = insert_query_node(&engine, "GqlOptExistsMarker", "marker", &[], 1.0); + + for (from, to) in [ + (pass, pass_match), + (fail, fail_match), + (partial, partial_good), + (partial, partial_bad), + ] { engine - .upsert_edge(small, bridge, "GQL_FANOUT_HIGH", UpsertEdgeOptions::default()) + .upsert_edge(from, to, "GQL_OPT_EXISTS_REL", UpsertEdgeOptions::default()) .unwrap(); } - let mut expected = Vec::new(); - for index in 0..5 { - let larger = insert_query_node( - &engine, - "GQL_FANOUT_LARGER", - &format!("gql-fanout-larger-{index}"), - &[], - 1.0, - ); - expected.push(larger); + for from in [pass_match, partial_good] { engine .upsert_edge( - larger, - bridge_hit, - "GQL_FANOUT_LOW", + from, + marker, + "GQL_OPT_EXISTS_MARK", UpsertEdgeOptions::default(), ) .unwrap(); } - engine.flush().unwrap(); - expected.sort_unstable(); - let source = "MATCH (small:GQL_FANOUT_SMALL)-[high_edge:GQL_FANOUT_HIGH]->\ - (bridge:GQL_FANOUT_BRIDGE)<-[low_edge:GQL_FANOUT_LOW]-\ - (larger:GQL_FANOUT_LARGER) \ - RETURN id(larger) ORDER BY id(larger)"; - let result = execute_gql_ok(&engine, source); - assert_eq!(gql_u64_column(&result, 0), expected); + let result = engine + .execute_gql( + "MATCH (n:GqlOptExistsOuter) \ + OPTIONAL MATCH (n)-[:GQL_OPT_EXISTS_REL]->(m:GqlOptExistsInner) \ + WHERE EXISTS { MATCH (m)-[:GQL_OPT_EXISTS_MARK]->(marker:GqlOptExistsMarker) RETURN marker } \ + RETURN n.key AS outer_key, m.key AS inner_key \ + ORDER BY outer_key", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + include_plan: true, + ..gql_opts() + }, + ) + .unwrap(); + let rows = result + .rows + .iter() + .map(|row| row.values.clone()) + .collect::>(); + assert_eq!( + rows, + vec![ + vec![GqlValue::String("fail".to_string()), GqlValue::Null], + vec![GqlValue::String("miss".to_string()), GqlValue::Null], + vec![ + GqlValue::String("partial".to_string()), + GqlValue::String("good".to_string()) + ], + vec![ + GqlValue::String("pass".to_string()), + GqlValue::String("ok".to_string()) + ], + ] + ); + let plan = gql_read_explain(result.plan.as_ref().expect("plan")); + assert!(plan.projection.iter().any(|item| { + item.contains("optional_candidate_filter=true") + && item.contains("optional_candidate_exists_predicates=1") + })); + assert!(plan + .projection + .iter() + .any(|item| item.contains("synthesized_miss_rows=1"))); - let explain = engine - .explain_gql(source, &GqlParams::new(), &gql_opts()) + let post_optional_filter = engine + .execute_gql( + "MATCH (n:GqlOptExistsOuter) \ + OPTIONAL MATCH (n)-[:GQL_OPT_EXISTS_REL]->(m:GqlOptExistsInner) \ + WITH n, m \ + WHERE EXISTS { MATCH (m)-[:GQL_OPT_EXISTS_MARK]->(marker:GqlOptExistsMarker) RETURN marker } \ + RETURN n.key AS outer_key, m.key AS inner_key \ + ORDER BY outer_key", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + ..gql_opts() + }, + ) .unwrap(); - let explain = gql_read_explain(&explain); - assert_eq!(explain.target, GqlLoweringTarget::GraphRowQuery); - assert!(explain.native_plan.is_none()); - for expected in [ - "graph row plan: GraphRowPhysicalPlan", - "physical_edge_order=[\"alias:low_edge\", \"alias:high_edge\"]", - "initial_driver=EdgeAnchor(edge=alias:low_edge", - "graph row plan: GraphRowPlanAlternative", - "chosen; kind=EdgeAnchor", - "source=EdgeCandidateSource", - ] { - assert!( - explain - .projection - .iter() - .any(|item| item.contains(expected)), - "expected GQL explain projection to contain {expected:?}, got {:?}", - explain.projection - ); - } + assert_eq!( + post_optional_filter + .rows + .iter() + .map(|row| row.values.clone()) + .collect::>(), + vec![ + vec![ + GqlValue::String("partial".to_string()), + GqlValue::String("good".to_string()) + ], + vec![ + GqlValue::String("pass".to_string()), + GqlValue::String("ok".to_string()) + ], + ] + ); } #[test] -fn gql_fixed_match_uses_graph_row_relaxed_distinctness_for_self_loops() { +fn gql_optional_match_where_exists_reuses_canonical_candidate_cache() { let (_dir, engine) = query_test_engine(); - let node = insert_query_node(&engine, "Person", "gql-self-loop", &[], 1.0); - let edge = engine - .upsert_edge(node, node, "LOOP", UpsertEdgeOptions::default()) - .unwrap(); - - let result = execute_gql_ok( + let outer = insert_query_node(&engine, "GqlOptExistsCacheOuter", "outer", &[], 1.0); + let first = insert_query_node( &engine, - "MATCH (a:Person)-[r:LOOP]->(b:Person) RETURN id(a), id(r), id(b)", + "GqlOptExistsCacheInner", + "first", + &[("bucket", PropValue::String("hit".to_string()))], + 1.0, + ); + let second = insert_query_node( + &engine, + "GqlOptExistsCacheInner", + "second", + &[("bucket", PropValue::String("hit".to_string()))], + 1.0, + ); + insert_query_node( + &engine, + "GqlOptExistsCacheMarker", + "marker", + &[("bucket", PropValue::String("hit".to_string()))], + 1.0, ); + for target in [first, second] { + engine + .upsert_edge( + outer, + target, + "GQL_OPT_EXISTS_CACHE_REL", + UpsertEdgeOptions::default(), + ) + .unwrap(); + } - assert_eq!(result.rows.len(), 1); + let result = engine + .execute_gql( + "MATCH (n:GqlOptExistsCacheOuter) \ + WITH n, 'hit' AS bucket \ + OPTIONAL MATCH (n)-[:GQL_OPT_EXISTS_CACHE_REL]->(m:GqlOptExistsCacheInner) \ + WHERE EXISTS { \ + MATCH (marker:GqlOptExistsCacheMarker) \ + WHERE marker.bucket = bucket \ + RETURN marker \ + } \ + RETURN m.key AS inner_key \ + ORDER BY inner_key", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + include_plan: true, + ..gql_opts() + }, + ) + .unwrap(); assert_eq!( - result.rows[0].values, - vec![GqlValue::UInt(node), GqlValue::UInt(edge), GqlValue::UInt(node)] - ); + gql_string_column(&result, 0), + vec!["first".to_string(), "second".to_string()] + ); + let plan = gql_read_explain(result.plan.as_ref().expect("plan")); + assert!(plan.projection.iter().any(|item| { + item.contains("optional candidate filter") + && item.contains("subquery_invocations=1") + && item.contains("subquery_cache_hits=1") + })); + assert!(plan + .projection + .iter() + .any(|item| item.contains("optional EXISTS subquery"))); } #[test] -fn gql_rich_graph_indexed_queries_match_native_oracles() { +fn gql_read_only_call_subqueries_inner_apply_and_cursor() { let (_dir, engine) = query_test_engine(); - let fixture = seed_rich_gql_graph(&engine); - engine.flush().unwrap(); - let _indexes = install_rich_gql_indexes(&engine); - - let node_query = "MATCH (n:Person:Employee) \ - WHERE n.status IN $statuses AND n.score >= $min_score \ - RETURN id(n) AS id, n.key AS key, labels(n) AS labels, n.weight AS weight, \ - n.created_at AS created_at, n.updated_at AS updated_at, \ - $payload AS payload, $shape AS shape \ - ORDER BY n.score ASC, n.key ASC"; - let node_params = GqlParams::from([ - ( - "statuses".to_string(), - GqlParamValue::List(vec![GqlParamValue::String("focus".to_string())]), - ), - ("min_score".to_string(), GqlParamValue::Int(70)), - ( - "payload".to_string(), - GqlParamValue::Bytes(vec![7, 8, 9]), - ), - ( - "shape".to_string(), - GqlParamValue::Map(BTreeMap::from([ - ( - "kind".to_string(), - GqlParamValue::String("employee-score".to_string()), - ), - ( - "thresholds".to_string(), - GqlParamValue::List(vec![ - GqlParamValue::Int(70), - GqlParamValue::String("focus".to_string()), - ]), - ), - ])), - ), - ]); - let node_result = execute_gql_with_params(&engine, node_query, node_params.clone()); - let native_node_ids = sorted_rich_employee_focus_score_oracle(&engine, 70); - assert_eq!( - node_result.columns, - vec!["id", "key", "labels", "weight", "created_at", "updated_at", "payload", "shape"] - ); - assert_eq!(gql_u64_column(&node_result, 0), native_node_ids); - assert_eq!(native_node_ids, vec![fixture.bob, fixture.alice]); - - let expected_payload = GqlValue::Bytes(vec![7, 8, 9]); - let expected_shape = GqlValue::Map(BTreeMap::from([ - ( - "kind".to_string(), - GqlValue::String("employee-score".to_string()), - ), - ( - "thresholds".to_string(), - GqlValue::List(vec![ - GqlValue::Int(70), - GqlValue::String("focus".to_string()), - ]), - ), - ])); - for (row, node_id) in node_result.rows.iter().zip(native_node_ids.iter().copied()) { - let node = engine.get_node(node_id).unwrap().unwrap(); - assert_eq!(row.values[1], GqlValue::String(node.key)); - assert_eq!( - row.values[2], - GqlValue::List(node.labels.into_iter().map(GqlValue::String).collect()) - ); - assert_eq!(row.values[3], GqlValue::Float(node.weight as f64)); - assert_eq!(row.values[4], GqlValue::Int(node.created_at)); - assert_eq!(row.values[5], GqlValue::Int(node.updated_at)); - assert_eq!(row.values[6], expected_payload); - assert_eq!(row.values[7], expected_shape); - } + let a = insert_query_node(&engine, "GqlSubCall", "a", &[], 1.0); + let b = insert_query_node(&engine, "GqlSubCall", "b", &[], 1.0); + let c = insert_query_node(&engine, "GqlSubCall", "c", &[], 1.0); + engine + .upsert_edge(a, b, "GQL_SUB_CALL_REL", UpsertEdgeOptions::default()) + .unwrap(); + engine + .upsert_edge(a, c, "GQL_SUB_CALL_REL", UpsertEdgeOptions::default()) + .unwrap(); - let alice_labels = node_result - .rows - .iter() - .find(|row| row.values[0] == GqlValue::UInt(fixture.alice)) - .map(|row| row.values[2].clone()) + let source = "MATCH (n:GqlSubCall) \ + CALL { MATCH (n)-[:GQL_SUB_CALL_REL]->(m) RETURN m, m.key AS friend } \ + RETURN n.key AS source, friend, id(m) AS mid \ + ORDER BY source, friend"; + let options = GqlExecutionOptions { + allow_full_scan: true, + include_plan: true, + ..gql_opts() + }; + let result = engine + .execute_gql(source, &GqlParams::new(), &options) .unwrap(); assert_eq!( - alice_labels, - GqlValue::List( - engine - .get_node(fixture.alice) - .unwrap() - .unwrap() - .labels - .into_iter() - .map(GqlValue::String) - .collect() - ) + gql_string_column(&result, 0), + vec!["a".to_string(), "a".to_string()] ); - - let node_explain = engine - .explain_gql(node_query, &node_params, &gql_opts()) - .unwrap(); - let node_explain = gql_read_explain(&node_explain); - assert_eq!(node_explain.target, GqlLoweringTarget::GraphRowQuery); - assert!(node_explain - .pushed_down + assert_eq!( + gql_string_column(&result, 1), + vec!["b".to_string(), "c".to_string()] + ); + assert_eq!(gql_u64_column(&result, 2), vec![b, c]); + let plan = gql_read_explain(result.plan.as_ref().expect("plan")); + assert!(plan + .projection .iter() - .any(|item| item.contains("n.status"))); - assert!(node_explain - .pushed_down + .any(|item| item.contains("graph pipeline stage") && item.contains("Call"))); + assert!(plan + .projection .iter() - .any(|item| item.contains("n.score"))); - assert!(node_explain.native_plan.is_none()); + .any(|item| item.contains("invocations=3"))); - let range_explain = engine - .explain_gql( - "MATCH (n:Person:Employee) WHERE n.score >= $min_score RETURN id(n)", - &GqlParams::from([("min_score".to_string(), GqlParamValue::Int(70))]), - &gql_opts(), + let no_order_source = "MATCH (n:GqlSubCall) WHERE n.key = 'a' \ + CALL { \ + MATCH (n)-[:GQL_SUB_CALL_REL]->(m) \ + RETURN m.key AS friend ORDER BY friend DESC \ + } \ + RETURN friend"; + let no_order = engine + .execute_gql(no_order_source, &GqlParams::new(), &options) + .unwrap(); + assert_eq!( + gql_string_column(&no_order, 0), + vec!["c".to_string(), "b".to_string()] + ); + let first_no_order = engine + .execute_gql( + no_order_source, + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_rows: 1, + ..gql_opts() + }, ) .unwrap(); - let range_explain = gql_read_explain(&range_explain); - assert_eq!(range_explain.target, GqlLoweringTarget::GraphRowQuery); - assert!(range_explain.native_plan.is_none()); - assert!(range_explain - .pushed_down - .iter() - .any(|item| item.contains("n.score"))); + assert_eq!(gql_string_column(&first_no_order, 0), vec!["c"]); + let no_order_cursor = first_no_order + .next_cursor + .expect("first no-order CALL page should return cursor"); + let second_no_order = engine + .execute_gql( + no_order_source, + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_rows: 1, + cursor: Some(no_order_cursor), + ..gql_opts() + }, + ) + .unwrap(); + assert_eq!(gql_string_column(&second_no_order, 0), vec!["b"]); - let fallback_result = execute_gql_ok( - &engine, - "MATCH (n:Person:Employee) WHERE n.department = 'platform' \ - RETURN id(n) ORDER BY id(n)", - ); - let mut fallback_native = engine - .query_node_ids(&NodeQuery { - label_filter: Some(node_label_filter( - &["Person", "Employee"], - LabelMatchMode::All, - )), - filter: Some(NodeFilterExpr::PropertyEquals { - key: "department".to_string(), - value: PropValue::String("platform".to_string()), - }), - ..NodeQuery::default() - }) - .unwrap() - .items; - fallback_native.sort_unstable(); - assert_eq!(gql_u64_column(&fallback_result, 0), fallback_native); - let fallback_explain = engine - .explain_gql( - "MATCH (n:Person:Employee) WHERE n.department = 'platform' RETURN id(n)", + let first = engine + .execute_gql( + source, &GqlParams::new(), - &gql_opts(), + &GqlExecutionOptions { + allow_full_scan: true, + max_rows: 1, + ..gql_opts() + }, ) .unwrap(); - let fallback_explain = gql_read_explain(&fallback_explain); - assert!(fallback_explain.native_plan.is_none()); + assert_eq!(gql_string_column(&first, 1), vec!["b"]); + let cursor = first.next_cursor.expect("first page should return cursor"); + let second = engine + .execute_gql( + source, + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_rows: 1, + cursor: Some(cursor), + ..gql_opts() + }, + ) + .unwrap(); + assert_eq!(gql_string_column(&second, 1), vec!["c"]); + assert!(second.next_cursor.is_none()); +} - let edge_query = "MATCH ()-[r:WORKS_ON]->() \ - WHERE r.role IN $roles AND r.hours >= $min_hours \ - RETURN id(r) AS id, r.from AS from, r.to AS to, type(r) AS label, \ - r.hours AS hours, r.weight AS weight, r.created_at AS created_at, \ - r.updated_at AS updated_at, r.valid_from AS valid_from, r.valid_to AS valid_to \ - ORDER BY r.hours ASC, id(r) ASC"; - let edge_params = GqlParams::from([ - ( - "roles".to_string(), - GqlParamValue::List(vec![ - GqlParamValue::String("lead".to_string()), - GqlParamValue::String("reviewer".to_string()), - ]), - ), - ("min_hours".to_string(), GqlParamValue::Int(30)), - ]); - let edge_result = execute_gql_with_params(&engine, edge_query, edge_params.clone()); - let native_edge_ids = sorted_rich_work_edge_oracle(&engine, 30); - assert_eq!(gql_u64_column(&edge_result, 0), native_edge_ids); - assert_eq!(native_edge_ids, vec![fixture.review_edge, fixture.lead_edge]); - for (row, edge_id) in edge_result.rows.iter().zip(native_edge_ids.iter().copied()) { - let edge = engine.get_edge(edge_id).unwrap().unwrap(); - assert_eq!(row.values[1], GqlValue::UInt(edge.from)); - assert_eq!(row.values[2], GqlValue::UInt(edge.to)); - assert_eq!(row.values[3], GqlValue::String(edge.label)); - assert_eq!(row.values[4], GqlValue::Int(edge_prop_i64(&engine, edge_id, "hours"))); - assert_eq!(row.values[5], GqlValue::Float(edge.weight as f64)); - assert_eq!(row.values[6], GqlValue::Int(edge.created_at)); - assert_eq!(row.values[7], GqlValue::Int(edge.updated_at)); - assert_eq!(row.values[8], GqlValue::Int(edge.valid_from)); - assert_eq!(row.values[9], GqlValue::Int(edge.valid_to)); +#[test] +fn gql_call_subquery_does_not_truncate_inner_rows_to_outer_page_cap() { + let (_dir, engine) = query_test_engine(); + let outer = insert_query_node(&engine, "GqlSubCallPageOuter", "outer", &[], 1.0); + for key in ["a", "b", "c"] { + let inner = insert_query_node(&engine, "GqlSubCallPageInner", key, &[], 1.0); + engine + .upsert_edge( + outer, + inner, + "GQL_SUB_CALL_PAGE_REL", + UpsertEdgeOptions::default(), + ) + .unwrap(); } - let edge_explain = engine - .explain_gql(edge_query, &edge_params, &gql_opts()) + let source = "MATCH (n:GqlSubCallPageOuter) WHERE n.key = 'outer' \ + CALL { \ + MATCH (n)-[:GQL_SUB_CALL_PAGE_REL]->(m:GqlSubCallPageInner) \ + RETURN m.key AS friend ORDER BY friend \ + } \ + RETURN friend ORDER BY friend"; + let first = engine + .execute_gql( + source, + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_rows: 2, + ..gql_opts() + }, + ) .unwrap(); - let edge_explain = gql_read_explain(&edge_explain); - assert_eq!(edge_explain.target, GqlLoweringTarget::GraphRowQuery); - assert!(edge_explain - .pushed_down - .iter() - .any(|item| item.contains("r.role"))); - assert!(edge_explain - .pushed_down - .iter() - .any(|item| item.contains("r.hours"))); - assert!(edge_explain.native_plan.is_none()); - let edge_range_explain = engine - .explain_gql( - "MATCH ()-[r:WORKS_ON]->() WHERE r.hours >= $min_hours RETURN id(r)", - &GqlParams::from([("min_hours".to_string(), GqlParamValue::Int(30))]), - &gql_opts(), + assert_eq!( + gql_string_column(&first, 0), + vec!["a".to_string(), "b".to_string()] + ); + let cursor = first + .next_cursor + .expect("CALL rows beyond the outer page cap should remain pageable"); + let second = engine + .execute_gql( + source, + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_rows: 2, + cursor: Some(cursor), + ..gql_opts() + }, ) .unwrap(); - let edge_range_explain = gql_read_explain(&edge_range_explain); - assert_eq!(edge_range_explain.target, GqlLoweringTarget::GraphRowQuery); - assert!(edge_range_explain.native_plan.is_none()); - assert!(edge_range_explain - .pushed_down - .iter() - .any(|item| item.contains("r.hours"))); + assert_eq!(gql_string_column(&second, 0), vec!["c".to_string()]); + assert!(second.next_cursor.is_none()); +} - let endpoint_result = execute_gql_with_params( - &engine, - "MATCH ()-[r:WORKS_ON]->() \ - WHERE r.from = $from AND r.to IN $targets RETURN id(r) ORDER BY id(r)", - GqlParams::from([ - ("from".to_string(), GqlParamValue::UInt(fixture.alice)), - ( - "targets".to_string(), - GqlParamValue::List(vec![ - GqlParamValue::UInt(fixture.acme), - GqlParamValue::UInt(fixture.globex), - ]), - ), - ]), +#[test] +fn gql_call_subquery_enforces_joined_cache_materialization_cap() { + let (_dir, engine) = query_test_engine(); + for outer_key in ["outer-a", "outer-b", "outer-c"] { + insert_query_node( + &engine, + "GqlSubCallCapOuter", + outer_key, + &[("bucket", PropValue::String("hit".to_string()))], + 1.0, + ); + } + for inner_key in ["inner-a", "inner-b"] { + insert_query_node( + &engine, + "GqlSubCallCapInner", + inner_key, + &[("bucket", PropValue::String("hit".to_string()))], + 1.0, + ); + } + + let err = engine + .execute_gql( + "MATCH (n:GqlSubCallCapOuter) \ + WITH n.bucket AS bucket, n.key AS source \ + CALL { \ + MATCH (m:GqlSubCallCapInner) \ + WHERE m.bucket = bucket \ + RETURN m.key AS friend ORDER BY friend \ + } \ + RETURN source, friend \ + ORDER BY source, friend", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_pipeline_rows: 5, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + err.to_string().contains("max_pipeline_rows"), + "unexpected CALL materialization cap error: {err:?}" + ); +} + +#[test] +fn gql_exists_subquery_union_branches_see_correlated_imports_and_cache() { + let (_dir, engine) = query_test_engine(); + for (key, bucket) in [ + ("left-a", "left"), + ("left-b", "left"), + ("right-a", "right"), + ("miss-a", "miss"), + ] { + insert_query_node( + &engine, + "GqlSubUnionExistsOuter", + key, + &[("bucket", PropValue::String(bucket.to_string()))], + 1.0, + ); + } + insert_query_node( + &engine, + "GqlSubUnionExistsLeft", + "left-marker", + &[("bucket", PropValue::String("left".to_string()))], + 1.0, + ); + insert_query_node( + &engine, + "GqlSubUnionExistsRight", + "right-marker", + &[("bucket", PropValue::String("right".to_string()))], + 1.0, ); - let mut endpoint_native = engine - .query_edge_ids(&EdgeQuery { - label: Some("WORKS_ON".to_string()), - from_ids: vec![fixture.alice], - to_ids: vec![fixture.acme, fixture.globex], - ..EdgeQuery::default() - }) - .unwrap() - .edge_ids; - endpoint_native.sort_unstable(); - assert_eq!(gql_u64_column(&endpoint_result, 0), endpoint_native); - assert_eq!(endpoint_native, vec![fixture.lead_edge, fixture.startup_edge]); - let pattern_query = "MATCH (p:Person:Employee)-[r:WORKS_ON]->(c:Company) \ - WHERE p.status = 'focus' AND r.role = 'lead' AND c.tier = 'enterprise' \ - RETURN id(p), id(r), id(c) ORDER BY p.key, id(r)"; - let pattern_result = execute_gql_ok(&engine, pattern_query); - let pattern_native = rich_pattern_oracle(&engine, "lead"); - let pattern_gql = pattern_result - .rows - .iter() - .map(|row| match (&row.values[0], &row.values[1], &row.values[2]) { - (GqlValue::UInt(p), GqlValue::UInt(r), GqlValue::UInt(c)) => (*p, *r, *c), - other => panic!("expected id tuple, got {other:?}"), - }) - .collect::>(); - assert_eq!(pattern_gql, pattern_native); - assert_eq!(pattern_native, vec![(fixture.alice, fixture.lead_edge, fixture.acme)]); - let pattern_explain = engine - .explain_gql(pattern_query, &GqlParams::new(), &gql_opts()) + let result = engine + .execute_gql( + "MATCH (n:GqlSubUnionExistsOuter) \ + WITH n.bucket AS bucket, n.key AS key \ + WHERE EXISTS { \ + MATCH (left:GqlSubUnionExistsLeft) WHERE left.bucket = bucket RETURN left AS hit \ + UNION \ + MATCH (right:GqlSubUnionExistsRight) WHERE right.bucket = bucket RETURN right AS hit \ + } \ + RETURN key ORDER BY key", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + include_plan: true, + ..gql_opts() + }, + ) .unwrap(); - let pattern_explain = gql_read_explain(&pattern_explain); - assert_eq!(pattern_explain.target, GqlLoweringTarget::GraphRowQuery); - assert!(pattern_explain.residual.is_empty()); - assert!(pattern_explain - .pushed_down + assert_eq!( + gql_string_column(&result, 0), + vec![ + "left-a".to_string(), + "left-b".to_string(), + "right-a".to_string() + ] + ); + let plan = gql_read_explain(result.plan.as_ref().expect("plan")); + assert!(plan + .projection .iter() - .any(|item| item.contains("p.status"))); - assert!(pattern_explain - .pushed_down + .any(|item| item.contains("subquery_invocations=3"))); + assert!(plan + .projection .iter() - .any(|item| item.contains("r.role"))); - assert!(pattern_explain - .pushed_down + .any(|item| item.contains("subquery_cache_hits=1"))); + assert!(plan + .projection .iter() - .any(|item| item.contains("c.tier"))); - assert!(pattern_explain.native_plan.is_none()); + .any(|item| item.contains("internal_limit=true"))); +} - let alt_result = execute_gql_ok( - &engine, - &format!( - "MATCH (p:Person)-[r:WORKS_ON|MENTORS]->(x) \ - WHERE id(p) = {} RETURN id(r) ORDER BY id(r)", - fixture.alice - ), - ); +#[test] +fn gql_call_subquery_union_branches_see_correlated_imports_and_dedupe() { + let (_dir, engine) = query_test_engine(); + let one = insert_query_node(&engine, "GqlSubUnionCallOuter", "one", &[], 1.0); + let two = insert_query_node(&engine, "GqlSubUnionCallOuter", "two", &[], 1.0); + insert_query_node(&engine, "GqlSubUnionCallOuter", "none", &[], 1.0); + let alpha = insert_query_node(&engine, "GqlSubUnionCallInner", "alpha", &[], 1.0); + let beta = insert_query_node(&engine, "GqlSubUnionCallInner", "beta", &[], 1.0); + let gamma = insert_query_node(&engine, "GqlSubUnionCallInner", "gamma", &[], 1.0); + + for (from, to, label) in [ + (one, alpha, "GQL_SUB_UNION_CALL_A"), + (one, alpha, "GQL_SUB_UNION_CALL_B"), + (one, beta, "GQL_SUB_UNION_CALL_B"), + (two, gamma, "GQL_SUB_UNION_CALL_B"), + ] { + engine + .upsert_edge(from, to, label, UpsertEdgeOptions::default()) + .unwrap(); + } + + let result = engine + .execute_gql( + "MATCH (n:GqlSubUnionCallOuter) \ + CALL { \ + MATCH (n)-[:GQL_SUB_UNION_CALL_A]->(m:GqlSubUnionCallInner) \ + RETURN m.key AS friend, m AS friend_node \ + UNION \ + MATCH (n)-[:GQL_SUB_UNION_CALL_B]->(m:GqlSubUnionCallInner) \ + RETURN m.key AS friend, m AS friend_node \ + } \ + RETURN n.key AS source, friend, id(friend_node) AS friend_id \ + ORDER BY source, friend", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + include_plan: true, + ..gql_opts() + }, + ) + .unwrap(); assert_eq!( - gql_u64_column(&alt_result, 0), - vec![fixture.lead_edge, fixture.startup_edge, fixture.mentor_edge] + result + .rows + .iter() + .map(|row| row.values.clone()) + .collect::>(), + vec![ + vec![ + GqlValue::String("one".to_string()), + GqlValue::String("alpha".to_string()), + GqlValue::UInt(alpha), + ], + vec![ + GqlValue::String("one".to_string()), + GqlValue::String("beta".to_string()), + GqlValue::UInt(beta), + ], + vec![ + GqlValue::String("two".to_string()), + GqlValue::String("gamma".to_string()), + GqlValue::UInt(gamma), + ], + ] ); + let plan = gql_read_explain(result.plan.as_ref().expect("plan")); + assert!(plan + .projection + .iter() + .any(|item| item.contains("graph pipeline stage") && item.contains("Call"))); + assert!(plan + .projection + .iter() + .any(|item| item.contains("Union"))); } #[test] -fn gql_residual_where_filters_with_null_semantics_after_pushdown() { +fn gql_call_subquery_mixed_union_output_cursor_resumes() { let (_dir, engine) = query_test_engine(); - let keep = insert_query_node( - &engine, - "Person", - "residual-keep", - &[("status", PropValue::String("active".to_string()))], - 1.0, + insert_query_node(&engine, "GqlSubUnionMixedCursorOuter", "outer", &[], 1.0); + let source = insert_query_node(&engine, "GqlSubUnionMixedCursor", "node", &[], 1.0); + let target = insert_query_node(&engine, "GqlSubUnionMixedCursor", "target", &[], 1.0); + let edge = engine + .upsert_edge( + source, + target, + "GQL_SUB_UNION_MIXED_CURSOR_REL", + UpsertEdgeOptions::default(), + ) + .unwrap(); + + for query in [ + "MATCH (outer:GqlSubUnionMixedCursorOuter) \ + CALL { \ + MATCH (m:GqlSubUnionMixedCursor) WHERE m.key = 'node' RETURN m AS mixed \ + UNION \ + MATCH (a:GqlSubUnionMixedCursor)-[r:GQL_SUB_UNION_MIXED_CURSOR_REL]->(b:GqlSubUnionMixedCursor) RETURN r AS mixed \ + } \ + RETURN mixed ORDER BY mixed", + "MATCH (outer:GqlSubUnionMixedCursorOuter) \ + CALL { \ + MATCH (a:GqlSubUnionMixedCursor)-[r:GQL_SUB_UNION_MIXED_CURSOR_REL]->(b:GqlSubUnionMixedCursor) RETURN r AS mixed \ + UNION \ + MATCH (m:GqlSubUnionMixedCursor) WHERE m.key = 'node' RETURN m AS mixed \ + } \ + RETURN mixed ORDER BY mixed", + ] { + let first = engine + .execute_gql( + query, + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_rows: 1, + ..gql_opts() + }, + ) + .unwrap(); + assert_eq!(first.rows.len(), 1); + assert_eq!(first.rows[0].values[0], GqlValue::UInt(source)); + let cursor = first + .next_cursor + .clone() + .expect("mixed CALL UNION first page should return cursor"); + let second = engine + .execute_gql( + query, + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_rows: 1, + cursor: Some(cursor), + ..gql_opts() + }, + ) + .unwrap(); + assert_eq!(second.rows.len(), 1); + assert_eq!(second.rows[0].values[0], GqlValue::UInt(edge)); + assert!(second.next_cursor.is_none()); + } +} + +#[test] +fn gql_read_only_subqueries_reject_mutation_collision_depth_and_caps() { + let (_dir, engine) = query_test_engine(); + insert_query_node(&engine, "GqlSubReject", "a", &[], 1.0); + insert_query_node(&engine, "GqlSubReject", "b", &[], 1.0); + + for source in [ + "MATCH (n:GqlSubReject) WHERE EXISTS { CREATE (m) RETURN m } RETURN n", + "MATCH (n:GqlSubReject) CALL { CREATE (m) RETURN m } RETURN n", + "CREATE (n:GqlSubReject {key: 'x'}) CALL { MATCH (m) RETURN m } RETURN n", + ] { + let err = engine + .execute_gql( + source, + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + matches!( + err, + EngineError::GqlUnsupported { .. } + | EngineError::GqlParse { .. } + | EngineError::GqlSemantic { .. } + ), + "expected subquery reject for {source}, got {err:?}" + ); + } + + let collision = engine + .execute_gql( + "MATCH (n:GqlSubReject) \ + CALL { MATCH (n) RETURN n } \ + RETURN n", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + collision.to_string().contains("collides"), + "unexpected collision error: {collision:?}" ); - insert_query_node( - &engine, - "Person", - "residual-drop", - &[ - ("status", PropValue::String("active".to_string())), - ("blocked", PropValue::Bool(true)), - ], - 1.0, + + let branch_local_leak = engine + .execute_gql( + "MATCH (n:GqlSubReject) \ + WHERE EXISTS { \ + MATCH (m:GqlSubReject) RETURN m AS item \ + UNION \ + MATCH (x:GqlSubReject) WHERE x.key = m.key RETURN x AS item \ + } \ + RETURN n", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + branch_local_leak.to_string().contains("unknown variable 'm'"), + "unexpected branch-local alias leak error: {branch_local_leak:?}" ); - insert_query_node( - &engine, - "Person", - "residual-inactive", - &[("status", PropValue::String("inactive".to_string()))], - 1.0, + + let left = insert_query_node(&engine, "GqlSubRejectMixedUnion", "left", &[], 1.0); + let right = insert_query_node(&engine, "GqlSubRejectMixedUnion", "right", &[], 1.0); + engine + .upsert_edge( + left, + right, + "GQL_SUB_REJECT_MIXED_UNION_REL", + UpsertEdgeOptions::default(), + ) + .unwrap(); + for source in [ + "MATCH (n:GqlSubReject) \ + CALL { \ + MATCH (m:GqlSubRejectMixedUnion) RETURN m AS mixed \ + UNION \ + MATCH (a:GqlSubRejectMixedUnion)-[r:GQL_SUB_REJECT_MIXED_UNION_REL]->(b:GqlSubRejectMixedUnion) RETURN r AS mixed \ + } \ + RETURN id(mixed)", + "MATCH (n:GqlSubReject) \ + CALL { \ + MATCH (a:GqlSubRejectMixedUnion)-[r:GQL_SUB_REJECT_MIXED_UNION_REL]->(b:GqlSubRejectMixedUnion) RETURN r AS mixed \ + UNION \ + MATCH (m:GqlSubRejectMixedUnion) RETURN m AS mixed \ + } \ + RETURN id(mixed)", + ] { + let mixed_union_err = engine + .execute_gql( + source, + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + mixed_union_err + .to_string() + .contains("expects a node or edge alias"), + "unexpected mixed union kind error: {mixed_union_err:?}" + ); + } + + let nested = "MATCH (n:GqlSubReject) \ + WHERE EXISTS { \ + MATCH (m:GqlSubReject) \ + WHERE EXISTS { MATCH (x:GqlSubReject) RETURN x } \ + RETURN m \ + } \ + RETURN n.key AS key ORDER BY key"; + let ok = engine + .execute_gql( + nested, + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_subquery_depth: 2, + ..gql_opts() + }, + ) + .unwrap(); + assert_eq!( + gql_string_column(&ok, 0), + vec!["a".to_string(), "b".to_string()] + ); + let depth_err = engine + .execute_gql( + nested, + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_subquery_depth: 1, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + depth_err.to_string().contains("max_subquery_depth"), + "unexpected depth error: {depth_err:?}" ); - let result = execute_gql_ok( - &engine, - "MATCH (n:Person) \ - WHERE n.status = 'active' AND n.blocked IS NULL AND n.missing <> 'x' \ - RETURN id(n)", + let cap_err = engine + .execute_gql( + "MATCH (n:GqlSubReject) \ + WHERE EXISTS { MATCH (n) RETURN n } \ + RETURN n.key", + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_subquery_invocations: 1, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + cap_err.to_string().contains("max_subquery_invocations"), + "unexpected cap error: {cap_err:?}" ); - assert_eq!(gql_u64_column(&result, 0), Vec::::new()); - let result = execute_gql_ok( - &engine, - "MATCH (n:Person) \ - WHERE n.status = 'active' AND n.blocked IS NULL \ - RETURN id(n)", + let nested_cap_err = engine + .execute_gql( + nested, + &GqlParams::new(), + &GqlExecutionOptions { + allow_full_scan: true, + max_subquery_depth: 2, + max_subquery_invocations: 1, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + nested_cap_err + .to_string() + .contains("max_subquery_invocations"), + "unexpected nested cap error: {nested_cap_err:?}" ); - assert_eq!(gql_u64_column(&result, 0), vec![keep]); } #[test] -fn gql_return_scalars_missing_null_params_and_duplicate_columns() { +fn gql_with_pipeline_executes_projection_and_seeded_match_stages() { let (_dir, engine) = query_test_engine(); - let node = insert_query_node_with_labels( + let ada = insert_query_node( &engine, - &["Person", "Topic"], - "scalar-node", + "WithPerson", + "with-pipeline-ada", &[ ("name", PropValue::String("Ada".to_string())), - ("optional", PropValue::Null), + ("rank", PropValue::Int(1)), ], 1.0, ); - let params = GqlParams::from([ - ("wanted".to_string(), GqlParamValue::String("Ada".to_string())), - ("answer".to_string(), GqlParamValue::Int(42)), - ]); - let result = execute_gql_with_params( + let bob = insert_query_node( &engine, - "MATCH (n:Person) WHERE n.name = $wanted \ - RETURN id(n) AS id, labels(n) AS labels, n.name AS x, n.missing AS missing, \ - n.optional AS opt, n.key AS x, $answer", - params, - ); - - assert_eq!(result.columns, vec!["id", "labels", "x", "missing", "opt", "x", "$answer"]); - assert_eq!(result.rows.len(), 1); - assert_eq!(result.rows[0].values[0], GqlValue::UInt(node)); - assert_eq!( - result.rows[0].values[1], - GqlValue::List(vec![ - GqlValue::String("Person".to_string()), - GqlValue::String("Topic".to_string()), - ]) + "WithPerson", + "with-pipeline-bob", + &[ + ("name", PropValue::String("Bob".to_string())), + ("rank", PropValue::Int(2)), + ], + 1.0, ); - assert_eq!(result.rows[0].values[2], GqlValue::String("Ada".to_string())); - assert_eq!(result.rows[0].values[3], GqlValue::Null); - assert_eq!(result.rows[0].values[4], GqlValue::Null); - assert_eq!(result.rows[0].values[5], GqlValue::String("scalar-node".to_string())); - assert_eq!(result.rows[0].values[6], GqlValue::Int(42)); - - let numeric_result = execute_gql_with_params( + let carol = insert_query_node( &engine, - &format!( - "MATCH (n:Person) WHERE n.name = $wanted \ - RETURN id(n) = {node}.0 AS eq, id(n) IN [{node}.0] AS in_id" - ), - GqlParams::from([( - "wanted".to_string(), - GqlParamValue::String("Ada".to_string()), - )]), + "WithPerson", + "with-pipeline-carol", + &[ + ("name", PropValue::String("Carol".to_string())), + ("rank", PropValue::Int(3)), + ], + 1.0, ); - assert_eq!( - numeric_result.rows[0].values, - vec![GqlValue::Bool(true), GqlValue::Bool(true)] + let ada_topic = insert_query_node( + &engine, + "WithTopic", + "with-pipeline-ada-topic", + &[("name", PropValue::String("Graph".to_string()))], + 1.0, ); - - let ambiguous_order = engine - .execute_gql( - "MATCH (n:Person) RETURN n.name AS x, n.key AS x ORDER BY x", - &GqlParams::new(), - &gql_opts(), - ) - .unwrap_err(); - assert!(matches!( - ambiguous_order, - EngineError::GqlSemantic { - code: GqlSemanticErrorCode::InvalidReturnExpression, - .. - } - )); - - let ambiguous_limit = engine - .execute_gql( - "MATCH (n:Person) RETURN 1 AS x, 2 AS x LIMIT x", - &GqlParams::new(), - &gql_opts(), - ) - .unwrap_err(); - assert!(matches!( - ambiguous_limit, - EngineError::GqlSemantic { - code: GqlSemanticErrorCode::InvalidReturnExpression, - .. - } - )); - - let bound_variable_takes_priority = execute_gql_ok( + let bob_topic = insert_query_node( &engine, - "MATCH (x:Person) RETURN 0 AS x ORDER BY x.name", + "WithTopic", + "with-pipeline-bob-topic", + &[("name", PropValue::String("Rust".to_string()))], + 1.0, ); - assert_eq!(bound_variable_takes_priority.rows.len(), 1); -} - -#[test] -fn gql_numeric_property_predicates_match_native_semantics_without_indexes() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("gql-numeric-semantics"); - let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); - - let mut expected_nodes = Vec::new(); - for (key, value) in [ - ("score-int", PropValue::Int(1)), - ("score-uint", PropValue::UInt(1)), - ("score-float", PropValue::Float(1.0)), - ] { - expected_nodes.push( - engine - .upsert_node( - "Person", - key, - UpsertNodeOptions { - props: query_test_props(&[("score", value)]), - ..Default::default() - }, - ) - .unwrap(), - ); - } engine - .upsert_node( - "Person", - "score-string", - UpsertNodeOptions { - props: query_test_props(&[("score", PropValue::String("1".to_string()))]), - ..Default::default() - }, - ) + .upsert_edge(ada, ada_topic, "WITH_PIPELINE_REL", UpsertEdgeOptions::default()) + .unwrap(); + engine + .upsert_edge(bob, bob_topic, "WITH_PIPELINE_REL", UpsertEdgeOptions::default()) .unwrap(); - let eq = execute_gql_ok( + let passthrough = execute_gql_ok( &engine, - "MATCH (n:Person) WHERE n.score = 1.0 RETURN id(n)", + "MATCH (n:WithPerson) WITH n RETURN n ORDER BY id(n)", ); - assert_eq!(gql_u64_column(&eq, 0), expected_nodes); + let passthrough_ids = passthrough + .rows + .iter() + .map(|row| gql_single_node(&row.values[0]).id.unwrap()) + .collect::>(); + assert_eq!(passthrough_ids, vec![ada, bob, carol]); - let in_result = execute_gql_ok( + let renamed = execute_gql_ok( &engine, - "MATCH (n:Person) WHERE n.score IN [1, 1.0] RETURN id(n)", + "MATCH (n:WithPerson) WITH n AS x RETURN id(x) AS id ORDER BY id", ); - assert_eq!(gql_u64_column(&in_result, 0), expected_nodes); + assert_eq!(gql_u64_column(&renamed, 0), vec![ada, bob, carol]); - let range_result = execute_gql_ok( + let scalar = execute_gql_ok( &engine, - "MATCH (n:Person) WHERE n.score >= -0.0 AND n.score <= 1.0 RETURN id(n)", + "MATCH (n:WithPerson) WITH n.name AS name \ + WHERE name STARTS WITH 'A' RETURN name ORDER BY name", ); - assert_eq!(gql_u64_column(&range_result, 0), expected_nodes); + assert_eq!(gql_string_column(&scalar, 0), vec!["Ada".to_string()]); - let a = expected_nodes[0]; - let b = expected_nodes[1]; - let mut expected_edges = Vec::new(); - for value in [PropValue::Int(1), PropValue::UInt(1), PropValue::Float(1.0)] { - expected_edges.push( - engine - .upsert_edge( - a, - b, - "LIKES", - UpsertEdgeOptions { - props: query_test_props(&[("score", value)]), - ..Default::default() - }, - ) - .unwrap(), - ); - } - let edge_eq = execute_gql_ok( + let repeated_star = execute_gql_ok( &engine, - "MATCH ()-[r:LIKES]->() WHERE r.score = 1.0 RETURN id(r)", + "MATCH (n:WithPerson) WITH n WITH * RETURN n.name AS name ORDER BY name", + ); + assert_eq!( + gql_string_column(&repeated_star, 0), + vec!["Ada".to_string(), "Bob".to_string(), "Carol".to_string()] ); - assert_eq!(gql_u64_column(&edge_eq, 0), expected_edges); - - engine.close().unwrap(); -} - -#[test] -fn gql_numeric_equality_uses_semantic_equality_indexes() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("gql-indexed-numeric-equality"); - let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); - - let node_index = engine - .ensure_node_property_index("Person", "score", SecondaryIndexKind::Equality) - .unwrap() - .index_id; - let edge_index = engine - .ensure_edge_property_index("LIKES", "score", SecondaryIndexKind::Equality) - .unwrap() - .index_id; - wait_for_property_index_state(&engine, node_index, SecondaryIndexState::Ready); - wait_for_edge_property_index_state(&engine, edge_index, SecondaryIndexState::Ready); - let mut expected_nodes = Vec::new(); - for (key, value) in [ - ("score-index-int", PropValue::Int(1)), - ("score-index-uint", PropValue::UInt(1)), - ("score-index-float", PropValue::Float(1.0)), - ] { - expected_nodes.push( - engine - .upsert_node( - "Person", - key, - UpsertNodeOptions { - props: query_test_props(&[("score", value)]), - ..Default::default() - }, - ) - .unwrap(), - ); - } - engine - .upsert_node( - "Person", - "score-index-string", - UpsertNodeOptions { - props: query_test_props(&[("score", PropValue::String("1".to_string()))]), - ..Default::default() - }, - ) - .unwrap(); + let seeded_required = execute_gql_ok( + &engine, + "MATCH (n:WithPerson) WITH n ORDER BY n.rank SKIP 1 LIMIT 1 \ + MATCH (n)-[:WITH_PIPELINE_REL]->(m:WithTopic) \ + RETURN n.name AS person, m.name AS topic", + ); + assert_eq!( + seeded_required.rows[0].values, + vec![ + GqlValue::String("Bob".to_string()), + GqlValue::String("Rust".to_string()) + ] + ); - let mut expected_edges = Vec::new(); - for value in [PropValue::Int(1), PropValue::UInt(1), PropValue::Float(1.0)] { - expected_edges.push( - engine - .upsert_edge( - expected_nodes[0], - expected_nodes[1], - "LIKES", - UpsertEdgeOptions { - props: query_test_props(&[("score", value)]), - ..Default::default() - }, - ) - .unwrap(), - ); - } - engine - .upsert_edge( - expected_nodes[0], - expected_nodes[2], - "LIKES", - UpsertEdgeOptions { - props: query_test_props(&[("score", PropValue::String("1".to_string()))]), - ..Default::default() - }, - ) - .unwrap(); - engine.flush().unwrap(); + let seeded_optional = execute_gql_ok( + &engine, + &format!( + "MATCH (n:WithPerson) WHERE id(n) = {carol} WITH n \ + OPTIONAL MATCH (n)-[r:WITH_PIPELINE_REL]->(m:WithTopic) \ + RETURN id(n) AS n, id(r) AS r, id(m) AS m" + ), + ); + assert_eq!( + seeded_optional.rows[0].values, + vec![GqlValue::UInt(carol), GqlValue::Null, GqlValue::Null] + ); - expected_nodes.sort_unstable(); - expected_edges.sort_unstable(); + let null_seeded_required = execute_gql_ok( + &engine, + &format!( + "MATCH (n:WithPerson) WHERE id(n) = {carol} \ + OPTIONAL MATCH (n)-[:WITH_PIPELINE_REL]->(m:WithTopic) \ + WITH m MATCH (m)-[:WITH_PIPELINE_REL]->(x) RETURN id(x) AS x" + ), + ); + assert!(null_seeded_required.rows.is_empty()); +} - let where_eq = execute_gql_ok( +#[test] +fn gql_with_where_filters_after_projection_row_ops() { + let (_dir, engine) = query_test_engine(); + insert_query_node( &engine, - "MATCH (n:Person) WHERE n.score = 1.0 RETURN id(n) ORDER BY id(n)", + "WithWhereBarrier", + "with-where-top-inactive", + &[ + ("name", PropValue::String("top-inactive".to_string())), + ("score", PropValue::Int(100)), + ("active", PropValue::Bool(false)), + ], + 1.0, ); - assert_eq!(gql_u64_column(&where_eq, 0), expected_nodes); - let map_eq = execute_gql_ok( + insert_query_node( &engine, - "MATCH (n:Person {score: 1.0}) RETURN id(n) ORDER BY id(n)", + "WithWhereBarrier", + "with-where-second-active", + &[ + ("name", PropValue::String("second-active".to_string())), + ("score", PropValue::Int(90)), + ("active", PropValue::Bool(true)), + ], + 1.0, ); - assert_eq!(gql_u64_column(&map_eq, 0), expected_nodes); - let in_eq = execute_gql_ok( + + let top_then_filter = execute_gql_ok( &engine, - "MATCH (n:Person) WHERE n.score IN [1, 1.0] RETURN id(n) ORDER BY id(n)", + "MATCH (n:WithWhereBarrier) \ + WITH n ORDER BY n.score DESC LIMIT 1 WHERE n.active \ + RETURN n.name AS name", ); - assert_eq!(gql_u64_column(&in_eq, 0), expected_nodes); + assert!(top_then_filter.rows.is_empty()); - let node_explain = engine - .explain_gql( - "MATCH (n:Person) WHERE n.score = 1.0 RETURN id(n)", + let filter_then_top = execute_gql_ok( + &engine, + "MATCH (n:WithWhereBarrier) WHERE n.active \ + WITH n ORDER BY n.score DESC LIMIT 1 \ + RETURN n.name AS name", + ); + assert_eq!( + gql_string_column(&filter_then_top, 0), + vec!["second-active".to_string()] + ); +} + +#[test] +fn gql_with_pipeline_explain_reports_native_match_and_project_stages() { + let (_dir, engine) = query_test_engine(); + let n = insert_query_node( + &engine, + "WithExplain", + "with-explain-n", + &[("name", PropValue::String("Ada".to_string()))], + 1.0, + ); + let result = engine + .execute_gql( + "MATCH (n:WithExplain) WITH n.name AS name RETURN name ORDER BY name", &GqlParams::new(), - &gql_opts(), + &GqlExecutionOptions { + include_plan: true, + profile: true, + ..gql_opts() + }, ) .unwrap(); - let node_explain = gql_read_explain(&node_explain); - assert_eq!(node_explain.target, GqlLoweringTarget::GraphRowQuery); - assert!(node_explain.native_plan.is_none()); - assert!(node_explain - .pushed_down + assert_eq!(result.rows[0].values, vec![GqlValue::String("Ada".to_string())]); + let plan = result.plan.as_ref().expect("include_plan should return plan"); + let read = gql_read_explain(plan); + assert_eq!(read.target, GqlLoweringTarget::GraphPipelineQuery); + assert!(read + .projection .iter() - .any(|item| item.contains("n.score"))); + .any(|item| item.contains("graph pipeline stage 0: Match"))); + assert!(read + .projection + .iter() + .any(|item| item.contains("Project(With)"))); + assert!(read + .projection + .iter() + .any(|item| item.contains("nested graph row plan"))); + assert_eq!(gql_string_column(&result, 0), vec!["Ada".to_string()]); - let edge_eq = execute_gql_ok( - &engine, - "MATCH ()-[r:LIKES]->() WHERE r.score = 1.0 RETURN id(r) ORDER BY id(r)", - ); - assert_eq!(gql_u64_column(&edge_eq, 0), expected_edges); - let edge_in = execute_gql_ok( - &engine, - "MATCH ()-[r:LIKES]->() WHERE r.score IN [1, 1.0] RETURN id(r) ORDER BY id(r)", - ); - assert_eq!(gql_u64_column(&edge_in, 0), expected_edges); - let edge_explain = engine + let explain = engine .explain_gql( - "MATCH ()-[r:LIKES]->() WHERE r.score = 1.0 RETURN id(r)", + &format!("MATCH (n:WithExplain) WHERE id(n) = {n} WITH n RETURN id(n) AS id"), &GqlParams::new(), - &gql_opts(), + &GqlExecutionOptions { + include_plan: true, + ..gql_opts() + }, ) .unwrap(); - let edge_explain = gql_read_explain(&edge_explain); - assert_eq!(edge_explain.target, GqlLoweringTarget::GraphRowQuery); - assert!(edge_explain.native_plan.is_none()); - assert!(edge_explain - .pushed_down + let read = gql_read_explain(&explain); + assert_eq!(read.target, GqlLoweringTarget::GraphPipelineQuery); + assert!(read + .projection .iter() - .any(|item| item.contains("r.score"))); - - engine.close().unwrap(); + .any(|item| item.contains("graph pipeline stage"))); } #[test] -fn gql_numeric_range_uses_domainless_indexes_for_mixed_numeric_values() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("gql-indexed-numeric-range"); - let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); - - let node_index = engine - .ensure_node_property_index("Person", "score", SecondaryIndexKind::Range) - .unwrap() - .index_id; - let edge_index = engine - .ensure_edge_property_index("LIKES", "score", SecondaryIndexKind::Range) - .unwrap() - .index_id; - wait_for_property_index_state(&engine, node_index, SecondaryIndexState::Ready); - wait_for_published_property_index_state(&engine, node_index, SecondaryIndexState::Ready); - wait_for_edge_property_index_state(&engine, edge_index, SecondaryIndexState::Ready); - wait_for_published_property_index_state(&engine, edge_index, SecondaryIndexState::Ready); +fn gql_distinct_projection_deduplicates_scalars_and_visible_star_rows() { + let (_dir, engine) = query_test_engine(); + insert_query_node( + &engine, + "DistinctScalar", + "int-one", + &[ + ("score", PropValue::Int(1)), + ("flag", PropValue::Bool(true)), + ("name", PropValue::String("Ada".to_string())), + ("bytes", PropValue::Bytes(vec![1, 2])), + ], + 1.0, + ); + insert_query_node( + &engine, + "DistinctScalar", + "uint-one", + &[("score", PropValue::UInt(1)), ("flag", PropValue::Bool(true))], + 1.0, + ); + insert_query_node( + &engine, + "DistinctScalar", + "float-one", + &[("score", PropValue::Float(1.0)), ("flag", PropValue::Bool(false))], + 1.0, + ); + insert_query_node( + &engine, + "DistinctScalar", + "two", + &[("score", PropValue::Int(2)), ("flag", PropValue::Bool(false))], + 1.0, + ); - fn assert_domainless_indexed_range_gql( - engine: &DatabaseEngine, - expected_nodes: &[u64], - expected_edges: &[u64], - ) { - let node_range = execute_gql_ok( - engine, - "MATCH (n:Person) WHERE n.score >= 1 AND n.score <= 1.0 \ - RETURN id(n) ORDER BY id(n)", - ); - assert_eq!(gql_u64_column(&node_range, 0), expected_nodes); - let node_range_explain = engine - .explain_gql( - "MATCH (n:Person) WHERE n.score >= 1 AND n.score <= 1.0 RETURN id(n)", - &GqlParams::new(), - &gql_opts(), - ) - .unwrap(); - let node_range_explain = gql_read_explain(&node_range_explain); - assert_eq!(node_range_explain.target, GqlLoweringTarget::GraphRowQuery); - assert!(node_range_explain.native_plan.is_none()); - assert!(node_range_explain - .pushed_down - .iter() - .any(|item| item.contains("n.score"))); + let exact_numeric = execute_gql_ok( + &engine, + "MATCH (n:DistinctScalar) RETURN DISTINCT n.score AS score ORDER BY score", + ); + assert_eq!( + gql_u64_or_i64_values(&exact_numeric, 0), + vec!["1".to_string(), "2".to_string()] + ); - let edge_range = execute_gql_ok( - engine, - "MATCH ()-[r:LIKES]->() WHERE r.score >= 1 AND r.score <= 1.0 \ - RETURN id(r) ORDER BY id(r)", - ); - assert_eq!(gql_u64_column(&edge_range, 0), expected_edges); - let edge_range_explain = engine - .explain_gql( - "MATCH ()-[r:LIKES]->() WHERE r.score >= 1 AND r.score <= 1.0 RETURN id(r)", - &GqlParams::new(), - &gql_opts(), - ) - .unwrap(); - let edge_range_explain = gql_read_explain(&edge_range_explain); - assert_eq!(edge_range_explain.target, GqlLoweringTarget::GraphRowQuery); - assert!(edge_range_explain.native_plan.is_none()); - assert!(edge_range_explain - .pushed_down + let scalar_domains = execute_gql_ok( + &engine, + "MATCH (n:DistinctScalar) RETURN DISTINCT n.flag AS flag ORDER BY flag", + ); + assert_eq!( + scalar_domains + .rows .iter() - .any(|item| item.contains("r.score"))); - } - - let mut expected_nodes = Vec::new(); - for (key, value) in [ - ("score-range-int", PropValue::Int(1)), - ("score-range-uint", PropValue::UInt(1)), - ("score-range-float", PropValue::Float(1.0)), - ] { - expected_nodes.push( - engine - .upsert_node( - "Person", - key, - UpsertNodeOptions { - props: query_test_props(&[("score", value)]), - ..Default::default() - }, - ) - .unwrap(), - ); - } - for (key, value) in [ - ("score-range-higher", PropValue::Float(2.5)), - ("score-range-string", PropValue::String("1".to_string())), - ("score-range-nan", PropValue::Float(f64::NAN)), - ] { - engine - .upsert_node( - "Person", - key, - UpsertNodeOptions { - props: query_test_props(&[("score", value)]), - ..Default::default() - }, - ) - .unwrap(); - } + .map(|row| row.values[0].clone()) + .collect::>(), + vec![GqlValue::Bool(false), GqlValue::Bool(true)] + ); + let bytes = execute_gql_ok( + &engine, + "MATCH (n:DistinctScalar) RETURN DISTINCT n.bytes AS bytes", + ); + assert!(bytes + .rows + .iter() + .any(|row| row.values[0] == GqlValue::Bytes(vec![1, 2]))); + assert!(bytes.rows.iter().any(|row| row.values[0] == GqlValue::Null)); - let mut expected_edges = Vec::new(); - for value in [PropValue::Int(1), PropValue::UInt(1), PropValue::Float(1.0)] { - expected_edges.push( - engine - .upsert_edge( - expected_nodes[0], - expected_nodes[1], - "LIKES", - UpsertEdgeOptions { - props: query_test_props(&[("score", value)]), - ..Default::default() - }, - ) - .unwrap(), - ); - } - for value in [ - PropValue::Float(2.5), - PropValue::String("1".to_string()), - PropValue::Float(f64::NAN), - ] { - engine - .upsert_edge( - expected_nodes[0], - expected_nodes[2], - "LIKES", - UpsertEdgeOptions { - props: query_test_props(&[("score", value)]), - ..Default::default() - }, - ) - .unwrap(); - } + let list_key = execute_gql_ok( + &engine, + "MATCH (n:DistinctScalar) RETURN DISTINCT [n.score] AS bucket", + ); + assert_eq!(list_key.rows.len(), 2); + let map_key = execute_gql_ok( + &engine, + "MATCH (n:DistinctScalar) RETURN DISTINCT {score: n.score} AS bucket", + ); + assert_eq!(map_key.rows.len(), 2); - expected_nodes.sort_unstable(); - expected_edges.sort_unstable(); - assert_domainless_indexed_range_gql(&engine, &expected_nodes, &expected_edges); + let a = insert_query_node(&engine, "DistinctStar", "a", &[], 1.0); + let b = insert_query_node(&engine, "DistinctStar", "b", &[], 1.0); + engine + .upsert_edge(a, b, "DISTINCT_STAR_A", UpsertEdgeOptions::default()) + .unwrap(); + engine + .upsert_edge(a, b, "DISTINCT_STAR_B", UpsertEdgeOptions::default()) + .unwrap(); - engine.flush().unwrap(); - assert_domainless_indexed_range_gql(&engine, &expected_nodes, &expected_edges); + let return_star = execute_gql_ok(&engine, "MATCH (a:DistinctStar)-[]->(b) RETURN DISTINCT *"); + assert_eq!(return_star.rows.len(), 1); + assert_eq!(return_star.rows, return_star_id_rows(a, b)); - engine.close().unwrap(); + let with_star = execute_gql_ok( + &engine, + "MATCH (a:DistinctStar)-[]->(b) WITH DISTINCT * RETURN id(a), id(b)", + ); + assert_eq!(with_star.rows, return_star_id_rows(a, b)); } #[test] -fn gql_empty_results_and_parameter_values_use_public_handler_path() { +fn gql_distinct_projection_handles_graph_identity_values_and_caps() { let (_dir, engine) = query_test_engine(); - let node = insert_query_node( + let a = insert_query_node(&engine, "DistinctGraph", "a", &[], 1.0); + let b = insert_query_node(&engine, "DistinctGraph", "b", &[], 1.0); + let c = insert_query_node(&engine, "DistinctGraph", "c", &[], 1.0); + let ab = engine + .upsert_edge(a, b, "DISTINCT_GRAPH_A", UpsertEdgeOptions::default()) + .unwrap(); + let ac = engine + .upsert_edge(a, c, "DISTINCT_GRAPH_B", UpsertEdgeOptions::default()) + .unwrap(); + + let graph_identity_options = GqlExecutionOptions { + allow_full_scan: true, + ..gql_opts() + }; + let nodes = execute_gql_with_options( &engine, - "Person", - "boundary-node", - &[("name", PropValue::String("Ada".to_string()))], - 1.0, + &format!("MATCH (a:DistinctGraph)-[]->(b) WHERE id(a) = {a} RETURN DISTINCT a"), + graph_identity_options.clone(), ); - let from = insert_query_node(&engine, "Person", "boundary-from", &[], 1.0); - let to = insert_query_node(&engine, "Person", "boundary-to", &[], 1.0); - let edge = engine - .upsert_edge(from, to, "KNOWS", UpsertEdgeOptions::default()) - .unwrap(); + assert_eq!(nodes.rows.len(), 1); + assert_eq!(gql_single_node(&nodes.rows[0].values[0]).id, Some(a)); - let unknown_nodes = execute_gql_ok(&engine, "MATCH (n:DefinitelyMissing) RETURN id(n)"); - assert!(unknown_nodes.rows.is_empty()); - assert_eq!(engine.get_node_label_id("DefinitelyMissing").unwrap(), None); + let edges = execute_gql_with_options( + &engine, + &format!( + "MATCH (a:DistinctGraph)-[r]->(b) WHERE id(a) = {a} RETURN DISTINCT r ORDER BY id(r)" + ), + graph_identity_options.clone(), + ); + assert_eq!( + edges + .rows + .iter() + .map(|row| gql_single_edge(&row.values[0]).id.unwrap()) + .collect::>(), + vec![ab, ac] + ); - let unknown_edges = execute_gql_ok(&engine, "MATCH ()-[r:DEFINITELY_MISSING]->() RETURN id(r)"); - assert!(unknown_edges.rows.is_empty()); - assert_eq!(engine.get_edge_label_id("DEFINITELY_MISSING").unwrap(), None); + let paths = execute_gql_with_options( + &engine, + &format!("MATCH p = (a:DistinctGraph)-[]->(b) WHERE id(a) = {a} RETURN DISTINCT p ORDER BY p"), + graph_identity_options, + ); + assert_eq!( + paths + .rows + .iter() + .map(|row| gql_single_path(&row.values[0]).edge_ids.clone()) + .collect::>(), + vec![vec![ab], vec![ac]] + ); - let missing_property = execute_gql_ok( + let err = engine + .execute_gql( + "MATCH (n:DistinctGraph) RETURN DISTINCT n", + &GqlParams::new(), + &GqlExecutionOptions { + max_groups: 1, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + err.to_string().contains("max_groups"), + "unexpected error: {err:?}" + ); +} + +#[test] +fn gql_aggregate_projection_executes_grouped_global_and_null_semantics() { + let (_dir, engine) = query_test_engine(); + for (key, kind, score, name) in [ + ("a", "x", PropValue::Int(1), "Ada"), + ("b", "x", PropValue::UInt(2), "Bob"), + ("c", "y", PropValue::Float(3.5), "Cy"), + ("d", "y", PropValue::Null, "Dee"), + ("e", "z", PropValue::Int(-1), "Eve"), + ] { + insert_query_node( + &engine, + "AggPerson", + key, + &[ + ("kind", PropValue::String(kind.to_string())), + ("score", score), + ("name", PropValue::String(name.to_string())), + ], + 1.0, + ); + } + + let grouped = execute_gql_ok( &engine, - "MATCH (n:Person) WHERE n.no_such_property = 'x' RETURN id(n)", + "MATCH (n:AggPerson) RETURN n.kind AS k, count(*) AS c ORDER BY k", + ); + assert_eq!( + grouped.rows, + vec![ + GqlRow { + values: vec![GqlValue::String("x".to_string()), GqlValue::UInt(2)] + }, + GqlRow { + values: vec![GqlValue::String("y".to_string()), GqlValue::UInt(2)] + }, + GqlRow { + values: vec![GqlValue::String("z".to_string()), GqlValue::UInt(1)] + }, + ] ); - assert!(missing_property.rows.is_empty()); - let impossible_node_id = execute_gql_ok( + let global = execute_gql_ok( &engine, - &format!("MATCH (n) WHERE id(n) = {}.5 RETURN id(n)", node), + "MATCH (n:AggPerson) RETURN count(*) AS rows, count(n.score) AS scored, sum(n.score) AS sum, avg(n.score) AS avg, min(n.score) AS min, max(n.score) AS max", ); - assert!(impossible_node_id.rows.is_empty()); - assert_eq!(impossible_node_id.stats.rows_matched, 0); + assert_eq!(global.rows.len(), 1); + assert_eq!(global.rows[0].values[0], GqlValue::UInt(5)); + assert_eq!(global.rows[0].values[1], GqlValue::UInt(4)); + assert_eq!(global.rows[0].values[2], GqlValue::Float(5.5)); + assert_eq!(global.rows[0].values[3], GqlValue::Float(1.375)); + assert_eq!(global.rows[0].values[4], GqlValue::Int(-1)); + assert_eq!(global.rows[0].values[5], GqlValue::Float(3.5)); - let impossible_edge_id = execute_gql_ok( + let zero_global = execute_gql_ok( &engine, - &format!("MATCH ()-[r]->() WHERE id(r) = {}.5 RETURN id(r)", edge), + "MATCH (n:AggMissing) RETURN count(*) AS rows, count(n.score) AS scored, sum(n.score) AS sum, avg(n.score) AS avg, min(n.score) AS min, max(n.score) AS max, collect(n.score) AS values", + ); + assert_eq!( + zero_global.rows[0].values, + vec![ + GqlValue::UInt(0), + GqlValue::UInt(0), + GqlValue::Null, + GqlValue::Null, + GqlValue::Null, + GqlValue::Null, + GqlValue::List(Vec::new()), + ] ); - assert!(impossible_edge_id.rows.is_empty()); - assert_eq!(impossible_edge_id.stats.rows_matched, 0); - let result = execute_gql_with_params( + let zero_grouped = execute_gql_ok( &engine, - "MATCH (n:Person) WHERE n.key = $key \ - RETURN $payload AS payload, $shape AS shape, $names AS names, n.name", - GqlParams::from([ - ( - "key".to_string(), - GqlParamValue::String("boundary-node".to_string()), - ), - ( - "payload".to_string(), - GqlParamValue::Bytes(vec![1, 2, 3, 4]), - ), - ( - "shape".to_string(), - GqlParamValue::Map(BTreeMap::from([ - ("enabled".to_string(), GqlParamValue::Bool(true)), - ("score".to_string(), GqlParamValue::Float(1.5)), - ])), - ), - ( - "names".to_string(), - GqlParamValue::List(vec![ - GqlParamValue::String("Ada".to_string()), - GqlParamValue::Null, - ]), - ), - ]), + "MATCH (n:AggMissing) RETURN n.kind AS k, count(*) AS rows", + ); + assert!(zero_grouped.rows.is_empty()); + + let only_nulls = execute_gql_ok( + &engine, + "MATCH (n:AggPerson) WHERE n.score IS NULL RETURN count(n.score), sum(n.score), avg(n.score), min(n.score), max(n.score), collect(n.score)", ); - assert_eq!(result.rows.len(), 1); - assert_eq!(result.rows[0].values[0], GqlValue::Bytes(vec![1, 2, 3, 4])); assert_eq!( - result.rows[0].values[1], - GqlValue::Map(BTreeMap::from([ - ("enabled".to_string(), GqlValue::Bool(true)), - ("score".to_string(), GqlValue::Float(1.5)), - ])) + only_nulls.rows[0].values, + vec![ + GqlValue::UInt(0), + GqlValue::Null, + GqlValue::Null, + GqlValue::Null, + GqlValue::Null, + GqlValue::List(Vec::new()), + ] + ); +} + +#[test] +fn gql_aggregate_projection_supports_distinct_collect_alias_filter_and_order() { + let (_dir, engine) = query_test_engine(); + for (key, kind, name) in [ + ("a", "x", "Ada"), + ("b", "x", "Bob"), + ("c", "x", "Ada"), + ("d", "y", "Cy"), + ("e", "y", "Cy"), + ("f", "z", "Zed"), + ] { + insert_query_node( + &engine, + "AggCollect", + key, + &[ + ("kind", PropValue::String(kind.to_string())), + ("name", PropValue::String(name.to_string())), + ], + 1.0, + ); + } + + let collect = execute_gql_ok( + &engine, + "MATCH (n:AggCollect) RETURN collect(n.name) AS names, collect(DISTINCT n.name) AS unique_names, count(DISTINCT n.name) AS unique_count", ); assert_eq!( - result.rows[0].values[2], - GqlValue::List(vec![GqlValue::String("Ada".to_string()), GqlValue::Null]) + collect.rows[0].values, + vec![ + GqlValue::List(vec![ + GqlValue::String("Ada".to_string()), + GqlValue::String("Bob".to_string()), + GqlValue::String("Ada".to_string()), + GqlValue::String("Cy".to_string()), + GqlValue::String("Cy".to_string()), + GqlValue::String("Zed".to_string()), + ]), + GqlValue::List(vec![ + GqlValue::String("Ada".to_string()), + GqlValue::String("Bob".to_string()), + GqlValue::String("Cy".to_string()), + GqlValue::String("Zed".to_string()), + ]), + GqlValue::UInt(4), + ] + ); + + let alias_filter = execute_gql_ok( + &engine, + "MATCH (n:AggCollect) WITH n.kind AS k, count(*) AS c WHERE c > 1 RETURN k, c ORDER BY k", + ); + assert_eq!( + alias_filter.rows, + vec![ + GqlRow { + values: vec![GqlValue::String("x".to_string()), GqlValue::UInt(3)] + }, + GqlRow { + values: vec![GqlValue::String("y".to_string()), GqlValue::UInt(2)] + }, + ] + ); + + let ordered = execute_gql_ok( + &engine, + "MATCH (n:AggCollect) RETURN n.kind AS k, count(*) AS c ORDER BY count(*) DESC, k ASC", + ); + assert_eq!( + ordered + .rows + .iter() + .map(|row| row.values.clone()) + .collect::>(), + vec![ + vec![GqlValue::String("x".to_string()), GqlValue::UInt(3)], + vec![GqlValue::String("y".to_string()), GqlValue::UInt(2)], + vec![GqlValue::String("z".to_string()), GqlValue::UInt(1)], + ] + ); + + let scalar_expr = execute_gql_ok( + &engine, + "MATCH (n:AggCollect) RETURN count(*) + 1 AS total", + ); + assert_eq!(scalar_expr.rows[0].values[0], GqlValue::Int(7)); + + let max_collect = engine + .execute_gql( + "MATCH (n:AggCollect) RETURN collect(n.name)", + &GqlParams::new(), + &GqlExecutionOptions { + max_collect_items: 2, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + max_collect.to_string().contains("max_collect_items"), + "unexpected error: {max_collect:?}" ); - assert_eq!(result.rows[0].values[3], GqlValue::String("Ada".to_string())); } -#[test] -fn gql_return_relationship_type_properties_and_elements() { - let (_dir, engine) = query_test_engine(); - let from = insert_query_node(&engine, "Person", "element-from", &[], 1.0); - let to = insert_query_node(&engine, "Article", "element-to", &[], 1.0); - let edge = engine - .upsert_edge( - from, - to, - "LIKES", - UpsertEdgeOptions { - props: query_test_props(&[("since", PropValue::Int(2025))]), - ..UpsertEdgeOptions::default() - }, +#[test] +fn gql_aggregate_projection_enforces_numeric_domain_and_group_caps() { + let (_dir, engine) = query_test_engine(); + insert_query_node( + &engine, + "AggOverflow", + "a", + &[("score", PropValue::Int(i64::MAX))], + 1.0, + ); + insert_query_node( + &engine, + "AggOverflow", + "b", + &[("score", PropValue::Int(1))], + 1.0, + ); + let overflow = engine + .execute_gql( + "MATCH (n:AggOverflow) RETURN sum(n.score)", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!(overflow.to_string().contains("overflow")); + + insert_query_node( + &engine, + "AggTooLargeUnsigned", + "a", + &[("score", PropValue::UInt(i64::MAX as u64 + 1))], + 1.0, + ); + let unsigned = engine + .execute_gql( + "MATCH (n:AggTooLargeUnsigned) RETURN sum(n.score)", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!(unsigned.to_string().contains("unsigned value")); + + insert_query_node( + &engine, + "AggTooLargeUnsignedAfterFloat", + "a", + &[("score", PropValue::Float(1.0))], + 1.0, + ); + insert_query_node( + &engine, + "AggTooLargeUnsignedAfterFloat", + "b", + &[("score", PropValue::UInt(i64::MAX as u64 + 1))], + 1.0, + ); + let unsigned_after_float = engine + .execute_gql( + "MATCH (n:AggTooLargeUnsignedAfterFloat) RETURN sum(n.score)", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!(unsigned_after_float + .to_string() + .contains("unsigned value")); + + insert_query_node( + &engine, + "AggTooLargeUnsignedBeforeFloat", + "a", + &[("score", PropValue::UInt(i64::MAX as u64 + 1))], + 1.0, + ); + insert_query_node( + &engine, + "AggTooLargeUnsignedBeforeFloat", + "b", + &[("score", PropValue::Float(1.0))], + 1.0, + ); + let unsigned_before_float = engine + .execute_gql( + "MATCH (n:AggTooLargeUnsignedBeforeFloat) RETURN sum(n.score)", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!(unsigned_before_float + .to_string() + .contains("unsigned value")); + + insert_query_node( + &engine, + "AggNonFinite", + "a", + &[("score", PropValue::Float(f64::NAN))], + 1.0, + ); + let non_finite = engine + .execute_gql( + "MATCH (n:AggNonFinite) RETURN avg(n.score)", + &GqlParams::new(), + &gql_opts(), ) - .unwrap(); + .unwrap_err(); + assert!(non_finite.to_string().contains("finite")); - let result = execute_gql_ok( + insert_query_node( &engine, - "MATCH ()-[r:LIKES]->() RETURN type(r) AS t, r.since AS since, r", + "AggNonExactAvg", + "a", + &[("score", PropValue::UInt(9_007_199_254_740_993))], + 1.0, ); - assert_eq!(result.columns, vec!["t", "since", "r"]); - assert_eq!(result.rows[0].values[0], GqlValue::String("LIKES".to_string())); - assert_eq!(result.rows[0].values[1], GqlValue::Int(2025)); - let projected = gql_single_edge(&result.rows[0].values[2]); - assert_eq!(projected.id, Some(edge)); - assert_eq!(projected.from, Some(from)); - assert_eq!(projected.to, Some(to)); - assert_eq!(projected.label.as_deref(), Some("LIKES")); - assert_eq!( - projected.props.as_ref().unwrap().get("since"), - Some(&GqlValue::Int(2025)) + let non_exact_avg = engine + .execute_gql( + "MATCH (n:AggNonExactAvg) RETURN avg(n.score)", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!( + non_exact_avg.to_string().contains("exactly as float"), + "unexpected error: {non_exact_avg:?}" ); -} -#[test] -fn gql_return_node_element_star_order_and_anonymous_alias_omission() { - let (_dir, engine) = query_test_engine(); - let a = insert_query_node( + insert_query_node( &engine, - "Person", - "star-a", - &[("name", PropValue::String("A".to_string()))], + "AggNonExactMixedSum", + "a", + &[("score", PropValue::Float(0.5))], 1.0, ); - let b = insert_query_node( + insert_query_node( &engine, - "Person", - "star-b", - &[("name", PropValue::String("B".to_string()))], + "AggNonExactMixedSum", + "b", + &[("score", PropValue::Int(9_007_199_254_740_993))], 1.0, ); - let edge = engine - .upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default()) - .unwrap(); - - let node_result = execute_gql_ok(&engine, "MATCH (n:Person) WHERE id(n) = 1 RETURN n"); - let node = gql_single_node(&node_result.rows[0].values[0]); - assert!(node.dense_vector.is_none()); - assert!(node.sparse_vector.is_none()); - assert!(node.props.as_ref().unwrap().contains_key("name")); - - let star = execute_gql_ok(&engine, "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN *"); - assert_eq!(star.columns, vec!["a", "r", "b"]); - assert_eq!(gql_single_node(&star.rows[0].values[0]).id, Some(a)); - assert_eq!(gql_single_edge(&star.rows[0].values[1]).id, Some(edge)); - assert_eq!(gql_single_node(&star.rows[0].values[2]).id, Some(b)); - - let anonymous = execute_gql_ok(&engine, "MATCH (:Person)-[r:KNOWS]->(:Person) RETURN *"); - assert_eq!(anonymous.columns, vec!["r"]); - assert_eq!(gql_single_edge(&anonymous.rows[0].values[0]).id, Some(edge)); -} + let non_exact_mixed_sum = engine + .execute_gql( + "MATCH (n:AggNonExactMixedSum) RETURN sum(n.score)", + &GqlParams::new(), + &gql_opts(), + ) + .unwrap_err(); + assert!( + non_exact_mixed_sum.to_string().contains("exactly as float"), + "unexpected error: {non_exact_mixed_sum:?}" + ); -#[test] -fn gql_parameter_and_deferred_feature_errors_are_clear() { - let (_dir, engine) = query_test_engine(); insert_query_node( &engine, - "Person", - "param-node", - &[("name", PropValue::String("Ada".to_string()))], + "AggMinMaxMixed", + "a", + &[("value", PropValue::Bool(true))], 1.0, ); - - let missing = engine + insert_query_node( + &engine, + "AggMinMaxMixed", + "b", + &[("value", PropValue::String("x".to_string()))], + 1.0, + ); + let mixed = engine .execute_gql( - "MATCH (n:Person) WHERE n.name = $name RETURN n.name", + "MATCH (n:AggMinMaxMixed) RETURN min(n.value)", &GqlParams::new(), &gql_opts(), ) .unwrap_err(); - assert!(matches!( - missing, - EngineError::GqlParameter { ref name, .. } if name == "name" - )); + assert!(mixed.to_string().contains("incompatible")); + + for (key, group) in [("a", "x"), ("b", "y")] { + insert_query_node( + &engine, + "AggGroupCap", + key, + &[("group", PropValue::String(group.to_string()))], + 1.0, + ); + } + let grouped_cap = engine + .execute_gql( + "MATCH (n:AggGroupCap) RETURN n.group, count(*)", + &GqlParams::new(), + &GqlExecutionOptions { + max_groups: 1, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!(grouped_cap.to_string().contains("max_groups")); } #[test] -fn gql_referenced_param_list_cap_rejects_before_native_execution() { +fn gql_union_all_and_union_execute_with_branch_order_and_explain() { let (_dir, engine) = query_test_engine(); - insert_query_node(&engine, "Person", "param-cap-node", &[], 1.0); - engine.reset_query_execution_counters_for_test(); + for (key, side, name) in [ + ("left-a", "left", "a"), + ("left-b", "left", "b"), + ("right-b", "right", "b"), + ("right-c", "right", "c"), + ("right-d", "right", "d"), + ] { + insert_query_node( + &engine, + "GqlUnionRows", + key, + &[ + ("side", PropValue::String(side.to_string())), + ("name", PropValue::String(name.to_string())), + ], + 1.0, + ); + } - let params = GqlParams::from([( - "ids".to_string(), - GqlParamValue::List(vec![ - GqlParamValue::UInt(1), - GqlParamValue::UInt(2), - GqlParamValue::UInt(3), - ]), - )]); - let err = engine + let all_query = "\ + MATCH (n:GqlUnionRows) WHERE n.side = 'left' RETURN n.name AS name ORDER BY name DESC \ + UNION ALL \ + MATCH (m:GqlUnionRows) WHERE m.side = 'right' RETURN m.name AS name ORDER BY name ASC SKIP 1 LIMIT 2"; + let all = engine .execute_gql( - "MATCH (n:Person) WHERE id(n) IN $ids RETURN n.name LIMIT 1", - ¶ms, - &gql_param_cap_options(2, 8, 1_024), + all_query, + &GqlParams::new(), + &GqlExecutionOptions { + include_plan: true, + ..gql_opts() + }, ) - .unwrap_err(); - assert_gql_param_error(err, "ids", "exceeding max_literal_items"); + .unwrap(); assert_eq!( - engine.query_execution_counter_snapshot_for_test(), - QueryExecutionCounterSnapshot::default() + gql_string_column(&all, 0), + vec!["b".to_string(), "a".to_string(), "c".to_string(), "d".to_string()] + ); + let read = all.plan.as_ref().map(gql_read_explain).unwrap(); + assert!(read + .projection + .iter() + .any(|entry| entry.contains("UnionAll") && entry.contains("branches=2"))); + assert!(read + .projection + .iter() + .any(|entry| entry.contains("branch 1 stages: Match"))); + assert!(read + .projection + .iter() + .any(|entry| entry.contains("branch 2 row op: Sort"))); + assert!(read + .projection + .iter() + .any(|entry| entry.contains("branch 2 row op: Skip"))); + assert!(read + .projection + .iter() + .any(|entry| entry.contains("branch 2 row op: Limit"))); + + let dedupe_query = "\ + MATCH (n:GqlUnionRows) WHERE n.side = 'left' RETURN n.name AS name ORDER BY name DESC \ + UNION \ + MATCH (m:GqlUnionRows) WHERE m.side = 'right' RETURN m.name AS name ORDER BY name ASC"; + let dedupe = execute_gql_ok(&engine, dedupe_query); + assert_eq!( + gql_string_column(&dedupe, 0), + vec!["b".to_string(), "a".to_string(), "c".to_string(), "d".to_string()] + ); + + let mixed_node = insert_query_node( + &engine, + "GqlUnionMixed", + "node", + &[("name", PropValue::String("node".to_string()))], + 1.0, + ); + let mixed_node_two = insert_query_node( + &engine, + "GqlUnionMixed", + "node-two", + &[("name", PropValue::String("node-two".to_string()))], + 1.0, ); + let mixed = execute_gql_ok( + &engine, + "\ + MATCH (n:GqlUnionMixed) RETURN 'literal' AS value LIMIT 1 \ + UNION ALL \ + MATCH (m:GqlUnionMixed) RETURN m AS value", + ); + assert_eq!(mixed.rows[0].values[0], GqlValue::String("literal".to_string())); + assert_eq!(gql_single_node(&mixed.rows[1].values[0]).id, Some(mixed_node)); + + for mixed_query in [ + "\ + MATCH (n:GqlUnionMixed) RETURN 'literal' AS value LIMIT 1 \ + UNION ALL \ + MATCH (m:GqlUnionMixed) RETURN m AS value", + "\ + MATCH (n:GqlUnionMixed) RETURN 'literal' AS value LIMIT 1 \ + UNION \ + MATCH (m:GqlUnionMixed) RETURN m AS value", + ] { + let first = engine + .execute_gql( + mixed_query, + &GqlParams::new(), + &GqlExecutionOptions { + max_rows: 2, + ..gql_opts() + }, + ) + .unwrap(); + assert_eq!(first.rows.len(), 2); + assert!(first.next_cursor.is_some()); + assert_eq!(gql_single_node(&first.rows[1].values[0]).id, Some(mixed_node)); + let second = engine + .execute_gql( + mixed_query, + &GqlParams::new(), + &GqlExecutionOptions { + max_rows: 2, + cursor: first.next_cursor.clone(), + ..gql_opts() + }, + ) + .unwrap(); + assert_eq!(second.rows.len(), 1); + assert_eq!( + gql_single_node(&second.rows[0].values[0]).id, + Some(mixed_node_two) + ); + } } #[test] -fn gql_referenced_param_nested_depth_cap_rejects_iteratively() { +fn gql_union_caps_cursors_snapshot_and_branch_failure_are_deterministic() { let (_dir, engine) = query_test_engine(); - insert_query_node(&engine, "Person", "param-depth-node", &[], 1.0); - engine.reset_query_execution_counters_for_test(); + for (key, label, name) in [ + ("left-a", "GqlUnionCursorLeft", "a"), + ("left-b", "GqlUnionCursorLeft", "b"), + ("right-b", "GqlUnionCursorRight", "b"), + ("right-c", "GqlUnionCursorRight", "c"), + ] { + insert_query_node( + &engine, + label, + key, + &[("name", PropValue::String(name.to_string()))], + 1.0, + ); + } - let params = GqlParams::from([( - "payload".to_string(), - GqlParamValue::List(vec![GqlParamValue::List(vec![GqlParamValue::List(vec![ - GqlParamValue::Int(1), - ])])]), - )]); - let err = engine + let all_query = "\ + MATCH (n:GqlUnionCursorLeft) RETURN n.name AS name ORDER BY name \ + UNION ALL \ + MATCH (m:GqlUnionCursorRight) RETURN m.name AS name ORDER BY name"; + let first = engine .execute_gql( - "MATCH (n:Person) RETURN $payload LIMIT 1", - ¶ms, - &gql_param_cap_options(8, 2, 1_024), + all_query, + &GqlParams::new(), + &GqlExecutionOptions { + max_rows: 2, + ..gql_opts() + }, + ) + .unwrap(); + assert_eq!( + gql_string_column(&first, 0), + vec!["a".to_string(), "b".to_string()] + ); + assert!(first.next_cursor.is_some()); + let second = engine + .execute_gql( + all_query, + &GqlParams::new(), + &GqlExecutionOptions { + max_rows: 2, + cursor: first.next_cursor.clone(), + ..gql_opts() + }, + ) + .unwrap(); + assert_eq!( + gql_string_column(&second, 0), + vec!["b".to_string(), "c".to_string()] + ); + let union_all_cursor = first.next_cursor.clone().unwrap(); + for changed_query in [ + "\ + MATCH (n:GqlUnionCursorLeft) RETURN n.name AS name ORDER BY name \ + UNION \ + MATCH (m:GqlUnionCursorRight) RETURN m.name AS name ORDER BY name", + "\ + MATCH (m:GqlUnionCursorRight) RETURN m.name AS name ORDER BY name \ + UNION ALL \ + MATCH (n:GqlUnionCursorLeft) RETURN n.name AS name ORDER BY name", + "\ + MATCH (n:GqlUnionCursorLeft) RETURN n.name AS other ORDER BY other \ + UNION ALL \ + MATCH (m:GqlUnionCursorRight) RETURN m.name AS other ORDER BY other", + "\ + MATCH (n:GqlUnionCursorLeft) WHERE n.name <> 'z' RETURN n.name AS name ORDER BY name \ + UNION ALL \ + MATCH (m:GqlUnionCursorRight) RETURN m.name AS name ORDER BY name", + ] { + let err = engine + .execute_gql( + changed_query, + &GqlParams::new(), + &GqlExecutionOptions { + max_rows: 2, + cursor: Some(union_all_cursor.clone()), + ..gql_opts() + }, + ) + .unwrap_err(); + assert!( + matches!(err, EngineError::InvalidCursor { .. }), + "expected invalid cursor for changed union query, got {err:?}" + ); + } + + let param_query = "\ + MATCH (n:GqlUnionCursorLeft) WHERE n.name >= $min RETURN n.name AS name ORDER BY name \ + UNION ALL \ + MATCH (m:GqlUnionCursorRight) WHERE m.name >= $min RETURN m.name AS name ORDER BY name"; + let param_first = engine + .execute_gql( + param_query, + &GqlParams::from([("min".to_string(), GqlParamValue::String("a".to_string()))]), + &GqlExecutionOptions { + max_rows: 2, + ..gql_opts() + }, + ) + .unwrap(); + let param_err = engine + .execute_gql( + param_query, + &GqlParams::from([("min".to_string(), GqlParamValue::String("b".to_string()))]), + &GqlExecutionOptions { + max_rows: 2, + cursor: param_first.next_cursor.clone(), + ..gql_opts() + }, ) .unwrap_err(); - assert_gql_param_error(err, "payload", "nested list/map depth"); - assert_eq!( - engine.query_execution_counter_snapshot_for_test(), - QueryExecutionCounterSnapshot::default() + assert!( + matches!(param_err, EngineError::InvalidCursor { .. }), + "expected invalid cursor for changed union params, got {param_err:?}" ); -} - -#[test] -fn gql_referenced_param_total_items_rejects_even_with_limit_zero() { - let (_dir, engine) = query_test_engine(); - insert_query_node(&engine, "Person", "param-total-node", &[], 1.0); - let params = GqlParams::from([( - "payload".to_string(), - GqlParamValue::List(vec![ - GqlParamValue::List(vec![GqlParamValue::Int(1), GqlParamValue::Int(2)]), - GqlParamValue::Int(3), - ]), - )]); - let err = engine + let dedupe_query = "\ + MATCH (n:GqlUnionCursorLeft) RETURN n.name AS name ORDER BY name \ + UNION \ + MATCH (m:GqlUnionCursorRight) RETURN m.name AS name ORDER BY name"; + let dedupe_first = engine .execute_gql( - "MATCH (n:Person) RETURN $payload LIMIT 0", - ¶ms, - &gql_param_cap_options(3, 8, 1_024), + dedupe_query, + &GqlParams::new(), + &GqlExecutionOptions { + max_rows: 2, + ..gql_opts() + }, ) - .unwrap_err(); - assert_gql_param_error(err, "payload", "total list/map items"); -} + .unwrap(); + assert_eq!( + gql_string_column(&dedupe_first, 0), + vec!["a".to_string(), "b".to_string()] + ); + assert!(dedupe_first.next_cursor.is_some()); + let dedupe_second = engine + .execute_gql( + dedupe_query, + &GqlParams::new(), + &GqlExecutionOptions { + max_rows: 2, + cursor: dedupe_first.next_cursor.clone(), + ..gql_opts() + }, + ) + .unwrap(); + assert_eq!(gql_string_column(&dedupe_second, 0), vec!["c".to_string()]); -#[test] -fn gql_referenced_param_string_bytes_and_map_key_bytes_are_capped() { - let (_dir, engine) = query_test_engine(); - let string_source = "MATCH (n:Person) RETURN $p LIMIT 0"; - let string_err = engine + let branch_cap = engine .execute_gql( - string_source, - &GqlParams::from([( - "p".to_string(), - GqlParamValue::String("x".repeat(5)), - )]), - &gql_param_cap_options(8, 8, 4), + "\ + MATCH (n:GqlUnionCursorLeft) RETURN n.name AS name \ + UNION ALL MATCH (m:GqlUnionCursorRight) RETURN m.name AS name \ + UNION ALL MATCH (x:GqlUnionCursorLeft) RETURN x.name AS name", + &GqlParams::new(), + &GqlExecutionOptions { + max_union_branches: 2, + ..gql_opts() + }, ) .unwrap_err(); - assert_gql_param_error(string_err, "p", "string is"); + assert!(branch_cap.to_string().contains("max_union_branches")); - let bytes_source = "MATCH (n:Person) RETURN $b LIMIT 0"; - let bytes_err = engine + let dedupe_cap = engine .execute_gql( - bytes_source, - &GqlParams::from([( - "b".to_string(), - GqlParamValue::Bytes(vec![7; 5]), - )]), - &gql_param_cap_options(8, 8, 4), + dedupe_query, + &GqlParams::new(), + &GqlExecutionOptions { + max_groups: 1, + ..gql_opts() + }, ) .unwrap_err(); - assert_gql_param_error(bytes_err, "b", "bytes is"); + assert!(dedupe_cap.to_string().contains("max_groups")); - let key_source = "MATCH (n:Person) RETURN $payload LIMIT 0"; - let key_err = engine + let failure = engine .execute_gql( - key_source, - &GqlParams::from([( - "payload".to_string(), - GqlParamValue::Map(BTreeMap::from([("k".repeat(5), GqlParamValue::Null)])), - )]), - &gql_param_cap_options(8, 8, 4), + "\ + MATCH (n:GqlUnionCursorLeft) RETURN n.name AS name \ + UNION ALL \ + MATCH (m:GqlUnionCursorRight) RETURN 1 / 0 AS name", + &GqlParams::new(), + &gql_opts(), ) .unwrap_err(); - assert_gql_param_error(key_err, "payload", "map key is"); + assert!(failure.to_string().contains("division by zero")); } #[test] -fn gql_boundary_sized_referenced_params_work_and_unused_oversized_params_are_ignored() { +fn gql_distinct_and_aggregate_cursors_are_shape_checked() { let (_dir, engine) = query_test_engine(); - let node = insert_query_node(&engine, "Person", "param-boundary-node", &[], 1.0); + for (key, group) in [("a", "a"), ("b", "b"), ("c", "c")] { + insert_query_node( + &engine, + "AggCursor", + key, + &[("group", PropValue::String(group.to_string()))], + 1.0, + ); + } - let source = "MATCH (n:Person) RETURN $payload LIMIT 1"; - let params = GqlParams::from([( - "payload".to_string(), - GqlParamValue::Map(BTreeMap::from([( - "key".to_string(), - GqlParamValue::List(vec![ - GqlParamValue::String("x".repeat(61)), - GqlParamValue::Null, - ]), - )])), - )]); - let result = engine - .execute_gql(source, ¶ms, &gql_param_cap_options(3, 2, 64)) + let distinct_query = "MATCH (n:AggCursor) RETURN DISTINCT n.group AS g ORDER BY g"; + let distinct_first = engine + .execute_gql( + distinct_query, + &GqlParams::new(), + &GqlExecutionOptions { + max_rows: 1, + include_plan: true, + ..gql_opts() + }, + ) .unwrap(); - assert_eq!( - result.rows[0].values[0], - GqlValue::Map(BTreeMap::from([( - "key".to_string(), - GqlValue::List(vec![GqlValue::String("x".repeat(61)), GqlValue::Null]) - )])) - ); + assert_eq!(distinct_first.rows[0].values[0], GqlValue::String("a".to_string())); + let distinct_cursor = distinct_first.next_cursor.clone().unwrap(); + let distinct_second = engine + .execute_gql( + distinct_query, + &GqlParams::new(), + &GqlExecutionOptions { + cursor: Some(distinct_cursor.clone()), + max_rows: 1, + ..gql_opts() + }, + ) + .unwrap(); + assert_eq!(distinct_second.rows[0].values[0], GqlValue::String("b".to_string())); + assert!(distinct_first + .plan + .as_ref() + .map(gql_read_explain) + .unwrap() + .projection + .iter() + .any(|item| item.contains("distinct=true"))); + let distinct_shape_err = engine + .execute_gql( + "MATCH (n:AggCursor) RETURN DISTINCT n.group AS g ORDER BY g DESC", + &GqlParams::new(), + &GqlExecutionOptions { + cursor: Some(distinct_cursor), + max_rows: 1, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!(matches!(distinct_shape_err, EngineError::InvalidCursor { .. })); - let unused = engine + let aggregate_query = + "MATCH (n:AggCursor) RETURN n.group AS g, count(*) AS c ORDER BY g"; + let aggregate_first = engine .execute_gql( - "MATCH (n:Person) RETURN id(n) LIMIT 1", - &GqlParams::from([( - "unused".to_string(), - GqlParamValue::List(vec![ - GqlParamValue::Int(1), - GqlParamValue::Int(2), - GqlParamValue::Int(3), - ]), - )]), - &gql_param_cap_options(1, 8, 128), + aggregate_query, + &GqlParams::new(), + &GqlExecutionOptions { + max_rows: 1, + include_plan: true, + ..gql_opts() + }, ) .unwrap(); - assert_eq!(unused.rows[0].values[0], GqlValue::UInt(node)); + assert_eq!(aggregate_first.rows[0].values[0], GqlValue::String("a".to_string())); + let aggregate_cursor = aggregate_first.next_cursor.clone().unwrap(); + let aggregate_second = engine + .execute_gql( + aggregate_query, + &GqlParams::new(), + &GqlExecutionOptions { + cursor: Some(aggregate_cursor.clone()), + max_rows: 1, + ..gql_opts() + }, + ) + .unwrap(); + assert_eq!(aggregate_second.rows[0].values[0], GqlValue::String("b".to_string())); + let read = aggregate_first + .plan + .as_ref() + .map(gql_read_explain) + .unwrap(); + assert!(read + .projection + .iter() + .any(|item| item.contains("aggregate=true"))); + assert!(read + .projection + .iter() + .any(|item| item.contains("aggregate calls"))); + let aggregate_shape_err = engine + .execute_gql( + "MATCH (n:AggCursor) RETURN n.group AS g, count(*) AS c ORDER BY c", + &GqlParams::new(), + &GqlExecutionOptions { + cursor: Some(aggregate_cursor), + max_rows: 1, + ..gql_opts() + }, + ) + .unwrap_err(); + assert!(matches!(aggregate_shape_err, EngineError::InvalidCursor { .. })); } #[test] -fn gql_explain_enforces_referenced_param_caps_like_query() { +fn gql_mutation_return_aggregation_is_rejected() { let (_dir, engine) = query_test_engine(); - insert_query_node(&engine, "Person", "param-explain-node", &[], 1.0); - - let params = GqlParams::from([( - "ids".to_string(), - GqlParamValue::List(vec![ - GqlParamValue::UInt(1), - GqlParamValue::UInt(2), - GqlParamValue::UInt(3), - ]), - )]); let err = engine - .explain_gql( - "MATCH (n:Person) WHERE id(n) IN $ids RETURN id(n)", - ¶ms, - &gql_param_cap_options(2, 8, 1_024), + .execute_gql( + "CREATE (n:GqlAggregationRejected {key: 'n'}) RETURN count(*)", + &GqlParams::new(), + &gql_opts(), ) .unwrap_err(); - assert_gql_param_error(err, "ids", "exceeding max_literal_items"); -} - -#[test] -fn gql_beta_unsupported_features_are_rejected_by_execution_api() { - let (_dir, engine) = query_test_engine(); - let cases = [ - ("MERGE (n:Person {key: 'ada'}) RETURN n", "write clauses", "MERGE"), - ( - "CREATE INDEX node_status FOR (n:User) ON (n.status)", - "schema/DDL", - "CREATE", - ), - ("DROP INDEX node_status", "schema/DDL", "DROP"), - ( - "MATCH (n:Person)-[*]->(m) RETURN n", - "unbounded VLP", - "*", - ), - ("MATCH (n:Person) RETURN DISTINCT n", "DISTINCT", "DISTINCT"), - ("MATCH (n:Person) RETURN count(n)", "aggregation", "count"), - ("MATCH (n:Person) WITH n RETURN n", "WITH", "WITH"), - ( - "MATCH (n:Person) RETURN n UNION MATCH (m:Person) RETURN m", - "UNION", - "UNION", - ), - ("CALL db.labels()", "CALL", "CALL"), - ]; - - for (source, expected_feature, expected_span) in cases { - let err = engine - .execute_gql(source, &GqlParams::new(), &gql_opts()) - .unwrap_err(); - match err { - EngineError::GqlUnsupported { feature, span, .. } => { - assert_eq!(feature, expected_feature, "query: {source}"); - assert_eq!( - span.offset, - source.find(expected_span).unwrap(), - "query: {source}" - ); - } - other => panic!("expected unsupported {expected_feature} for {source}, got {other:?}"), - } - } + assert!( + matches!(err, EngineError::GqlSemantic { .. }), + "expected semantic rejection, got {err:?}" + ); } #[test] -fn gql_deferred_features_remain_rejected_after_row_ops() { +fn gql_mutation_read_after_write_stages_remain_rejected() { let (_dir, engine) = query_test_engine(); for source in [ - "MATCH (n:Person)-[*]->(m) RETURN n", - "MATCH (n:Person) RETURN DISTINCT n", - "MATCH (n:Person) RETURN count(n)", - "MATCH (n:Person) WITH n RETURN n", + "CREATE (n:Person {key: 'with-after-write'}) WITH n RETURN n", + "CREATE (n:Person {key: 'match-after-write'}) MATCH (n) RETURN n", + "CREATE (n:Person {key: 'call-after-write'}) CALL { MATCH (m) RETURN m } RETURN n", ] { let err = engine .execute_gql(source, &GqlParams::new(), &gql_opts()) .unwrap_err(); assert!( - matches!(err, EngineError::GqlUnsupported { .. } | EngineError::GqlParse { .. }), - "expected unsupported/parse error for {source}, got {err:?}" + matches!(err, EngineError::GqlUnsupported { .. }), + "expected unsupported read-after-write mutation form for {source}, got {err:?}" ); } - - let skip_offset = engine - .execute_gql( - "MATCH (n:Person) RETURN n SKIP 1 OFFSET 1", - &GqlParams::new(), - &gql_opts(), - ) - .unwrap_err(); - assert!(matches!(skip_offset, EngineError::GqlParse { .. })); } #[test] @@ -7365,6 +11133,13 @@ fn gql_explain_reports_targets_row_ops_caps_and_does_not_execute_rows() { let cap_options = GqlExecutionOptions { max_rows: 7, + max_pipeline_rows: 11, + max_groups: 13, + max_collect_items: 15, + max_union_branches: 3, + max_subquery_invocations: 23, + max_subquery_depth: 2, + max_shortest_path_pairs: 29, max_intermediate_bindings: 17, max_skip: 19, max_query_bytes: 1_024, @@ -7382,6 +11157,13 @@ fn gql_explain_reports_targets_row_ops_caps_and_does_not_execute_rows() { .unwrap() .caps; assert_eq!(cap_summary.max_rows, 7); + assert_eq!(cap_summary.max_pipeline_rows, 11); + assert_eq!(cap_summary.max_groups, 13); + assert_eq!(cap_summary.max_collect_items, 15); + assert_eq!(cap_summary.max_union_branches, 3); + assert_eq!(cap_summary.max_subquery_invocations, 23); + assert_eq!(cap_summary.max_subquery_depth, 2); + assert_eq!(cap_summary.max_shortest_path_pairs, 29); assert_eq!(cap_summary.max_intermediate_bindings, 17); assert_eq!(cap_summary.max_skip, 19); assert_eq!(cap_summary.max_query_bytes, 1_024); diff --git a/src/engine/tests/graph_rows.rs b/src/engine/tests/graph_rows.rs index 3edf9c6..caba3fa 100644 --- a/src/engine/tests/graph_rows.rs +++ b/src/engine/tests/graph_rows.rs @@ -219,6 +219,2693 @@ fn decoded_cursor_payload_len(cursor: &str) -> usize { base64url_no_pad_decode(encoded).unwrap().len() } +fn graph_pipeline_from_row_query(query: &GraphRowQuery) -> GraphPipelineQuery { + let items = match query.return_items.clone() { + Some(items) => GraphProjectionItems::Items( + items + .into_iter() + .map(|item| GraphProjectItem { + expr: item.expr, + alias: item.alias, + projection: item.projection, + }) + .collect(), + ), + None => GraphProjectionItems::Star, + }; + GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: query.nodes.clone(), + pieces: query.pieces.clone(), + optional_candidate_where: None, + where_: query.where_.clone(), + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items, + distinct: false, + where_: None, + order_by: query.order_by.clone(), + skip: None, + limit: None, + }), + ], + params: query.params.clone(), + at_epoch: query.at_epoch, + page: query.page.clone(), + output: query.output.clone(), + options: GraphPipelineOptions { + allow_full_scan: query.options.allow_full_scan, + max_rows: query.options.max_page_limit, + max_intermediate_bindings: query.options.max_intermediate_bindings, + max_frontier: query.options.max_frontier, + max_path_hops: query.options.max_path_hops, + max_paths_per_start: query.options.max_paths_per_start, + max_order_materialization: query.options.max_order_materialization, + max_cursor_bytes: query.options.max_cursor_bytes, + max_query_bytes: query.options.max_query_bytes, + include_plan: query.options.include_plan, + profile: query.options.profile, + ..GraphPipelineOptions::default() + }, + } +} + +fn assert_graph_pipeline_invalid( + engine: &DatabaseEngine, + query: &GraphPipelineQuery, + expected: &str, +) { + let err = engine.query_graph_pipeline(query).unwrap_err(); + let message = err.to_string(); + assert!( + message.contains(expected), + "expected error containing {expected:?}, got {message:?}" + ); +} + +#[test] +fn graph_pipeline_stats_merge_preserves_owner_row_count() { + let mut owner = empty_graph_pipeline_stats(7); + owner.rows_after_filter = 5; + owner.intermediate_rows = 3; + let mut nested = empty_graph_pipeline_stats(7); + nested.rows_after_filter = 99; + nested.intermediate_rows = 11; + nested.pipeline_rows_materialized = 13; + nested.groups = 2; + nested.subquery_invocations = 1; + + owner.merge_from(&nested); + + assert_eq!(owner.rows_after_filter, 5); + assert_eq!(owner.intermediate_rows, 11); + assert_eq!(owner.pipeline_rows_materialized, 13); + assert_eq!(owner.groups, 2); + assert_eq!(owner.subquery_invocations, 1); +} + +#[test] +fn graph_pipeline_options_default_matches_spec() { + let options = GraphPipelineOptions::default(); + assert!(!options.allow_full_scan); + assert_eq!(options.max_rows, 10_000); + assert_eq!(options.max_pipeline_rows, 65_536); + assert_eq!(options.max_groups, 65_536); + assert_eq!(options.max_collect_items, 65_536); + assert_eq!(options.max_union_branches, 16); + assert_eq!(options.max_subquery_invocations, 4_096); + assert_eq!(options.max_subquery_depth, 2); + assert_eq!(options.max_shortest_path_pairs, 4_096); + assert_eq!(options.max_intermediate_bindings, 65_536); + assert_eq!(options.max_frontier, 65_536); + assert_eq!(options.max_path_hops, 16); + assert_eq!(options.max_paths_per_start, 4_096); + assert_eq!(options.max_order_materialization, 65_536); + assert_eq!(options.max_skip, 100_000); + assert_eq!(options.max_cursor_bytes, 16 * 1024); + assert_eq!(options.max_query_bytes, 1_048_576); + assert_eq!(options.max_param_bytes, 1_048_576); + assert_eq!(options.max_ast_depth, 256); + assert_eq!(options.max_literal_items, 10_000); + assert!(!options.include_plan); + assert!(!options.profile); +} + +#[test] +fn graph_pipeline_one_stage_matches_graph_row_result_and_cursor() { + let (_dir, engine) = graph_row_test_engine(); + insert_graph_row_node( + &engine, + "PipelinePerson", + "ada", + &[("name", PropValue::String("Ada".to_string()))], + ); + insert_graph_row_node( + &engine, + "PipelinePerson", + "ben", + &[("name", PropValue::String("Ben".to_string()))], + ); + let epoch = now_millis(); + let mut graph_query = GraphRowQuery { + nodes: vec![graph_node_with_label("n", "PipelinePerson")], + pieces: Vec::new(), + where_: None, + return_items: Some(vec![graph_return_expr(graph_prop("n", "name"), "name")]), + order_by: Vec::new(), + page: GraphPageRequest { + skip: 0, + limit: 1, + cursor: None, + }, + at_epoch: Some(epoch), + params: BTreeMap::new(), + output: GraphOutputOptions::default(), + options: GraphQueryOptions { + allow_full_scan: false, + include_plan: true, + ..GraphQueryOptions::default() + }, + }; + let mut pipeline_query = graph_pipeline_from_row_query(&graph_query); + + let graph_first = engine.query_graph_rows(&graph_query).unwrap(); + let pipeline_first = engine.query_graph_pipeline(&pipeline_query).unwrap(); + assert_eq!(pipeline_first.columns, graph_first.columns); + assert_eq!(pipeline_first.rows, graph_first.rows); + assert!(graph_first.next_cursor.is_some()); + assert!(pipeline_first.next_cursor.is_some()); + assert_ne!(pipeline_first.next_cursor, graph_first.next_cursor); + assert!(pipeline_first + .next_cursor + .as_ref() + .is_some_and(|cursor| cursor.starts_with(GRAPH_PIPELINE_CURSOR_PREFIX))); + assert_eq!(pipeline_first.stats.rows_returned, graph_first.stats.rows_returned); + assert_eq!(pipeline_first.stats.rows_after_filter, graph_first.stats.rows_after_filter); + assert!(pipeline_first.plan.is_some()); + + let raw_graph_cursor = graph_first.next_cursor.clone().unwrap(); + let pipeline_cursor = pipeline_first.next_cursor.clone().unwrap(); + pipeline_query.page.cursor = Some(raw_graph_cursor.clone()); + assert_graph_pipeline_invalid( + &engine, + &pipeline_query, + "invalid graph pipeline cursor prefix", + ); + graph_query.page.cursor = Some(pipeline_cursor.clone()); + let graph_cursor_err = engine.query_graph_rows(&graph_query).unwrap_err(); + assert!( + graph_cursor_err + .to_string() + .contains("invalid graph row cursor prefix"), + "unexpected graph-row cursor error: {graph_cursor_err:?}" + ); + + graph_query.page.cursor = Some(raw_graph_cursor); + pipeline_query.page.cursor = Some(pipeline_cursor); + let graph_second = engine.query_graph_rows(&graph_query).unwrap(); + let pipeline_second = engine.query_graph_pipeline(&pipeline_query).unwrap(); + assert_eq!(pipeline_second.columns, graph_second.columns); + assert_eq!(pipeline_second.rows, graph_second.rows); + assert_eq!(pipeline_second.next_cursor, None); + assert_eq!(graph_second.next_cursor, None); +} + +#[test] +fn graph_pipeline_multistage_caps_and_cursor_namespaces_are_enforced() { + let (_dir, engine) = graph_row_test_engine(); + for key in ["a", "b", "c"] { + insert_graph_row_node( + &engine, + "PipelineWithCaps", + key, + &[("name", PropValue::String(key.to_string()))], + ); + } + let mut query = GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node_with_label("n", "PipelineWithCaps")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::With, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: graph_prop("n", "name"), + alias: Some("name".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("name".to_string()), + alias: Some("name".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: vec![GraphOrderItem { + expr: GraphExpr::Binding("name".to_string()), + direction: GraphOrderDirection::Asc, + }], + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: Some(now_millis()), + page: GraphPageRequest { + skip: 0, + limit: 1, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + allow_full_scan: false, + ..GraphPipelineOptions::default() + }, + }; + + let first = engine.query_graph_pipeline(&query).unwrap(); + assert_eq!( + graph_pipeline_value_rows(first.clone()), + vec![vec![GraphValue::String("a".to_string())]] + ); + assert!(first.next_cursor.is_some()); + + let pipeline_cursor = first.next_cursor.clone(); + query.page.cursor = pipeline_cursor.clone(); + let second = engine.query_graph_pipeline(&query).unwrap(); + assert_eq!( + graph_pipeline_value_rows(second), + vec![vec![GraphValue::String("b".to_string())]] + ); + + let graph_query = GraphRowQuery { + nodes: vec![graph_node_with_label("n", "PipelineWithCaps")], + pieces: Vec::new(), + where_: None, + return_items: Some(vec![graph_return_expr(graph_prop("n", "name"), "name")]), + order_by: Vec::new(), + page: GraphPageRequest { + skip: 0, + limit: 1, + cursor: None, + }, + at_epoch: query.at_epoch, + params: BTreeMap::new(), + output: GraphOutputOptions::default(), + options: GraphQueryOptions { + allow_full_scan: false, + ..GraphQueryOptions::default() + }, + }; + let raw_graph_cursor = engine + .query_graph_rows(&graph_query) + .unwrap() + .next_cursor + .unwrap(); + query.page.cursor = Some(raw_graph_cursor); + assert_graph_pipeline_invalid(&engine, &query, "invalid graph pipeline cursor prefix"); + + query.page.cursor = pipeline_cursor; + let mut tiny_cursor_cap = query.clone(); + tiny_cursor_cap.options.max_cursor_bytes = 4; + assert_graph_pipeline_invalid(&engine, &tiny_cursor_cap, "max_cursor_bytes 4"); + + let mut order_cap = query.clone(); + order_cap.page.cursor = None; + order_cap.options.max_order_materialization = 1; + assert_graph_pipeline_invalid(&engine, &order_cap, "max_order_materialization"); + + let mut row_cap = query.clone(); + row_cap.page.cursor = None; + row_cap.options.max_pipeline_rows = 1; + assert_graph_pipeline_invalid(&engine, &row_cap, "max_intermediate_bindings"); + + let mut max_rows = query.clone(); + max_rows.page.cursor = None; + max_rows.page.limit = 2; + max_rows.options.max_rows = 1; + assert_graph_pipeline_invalid(&engine, &max_rows, "max_rows"); + + let mut max_skip = query; + max_skip.page.cursor = None; + max_skip.page.skip = 2; + max_skip.options.max_skip = 1; + assert_graph_pipeline_invalid(&engine, &max_skip, "max_skip"); +} + +#[test] +fn graph_pipeline_terminal_projection_uses_final_row_cap() { + let (_dir, engine) = graph_row_test_engine(); + for key in ["a", "b", "c"] { + insert_graph_row_node( + &engine, + "PipelineTerminalCap", + key, + &[("name", PropValue::String(key.to_string()))], + ); + } + + let query = GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node_with_label("n", "PipelineTerminalCap")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: graph_prop("n", "name"), + alias: Some("name".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: vec![GraphOrderItem { + expr: graph_prop("n", "name"), + direction: GraphOrderDirection::Asc, + }], + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: Some(now_millis()), + page: GraphPageRequest { + skip: 0, + limit: 1, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + allow_full_scan: false, + max_pipeline_rows: 3, + max_rows: 1, + ..GraphPipelineOptions::default() + }, + }; + + let result = engine.query_graph_pipeline(&query).unwrap(); + assert_eq!( + graph_pipeline_value_rows(result.clone()), + vec![vec![GraphValue::String("a".to_string())]] + ); + assert_eq!(result.stats.rows_after_filter, 3); + assert_eq!(result.stats.rows_returned, 1); + assert!(result.next_cursor.is_some()); + + let mut low_pipeline_cap = query; + low_pipeline_cap.options.max_pipeline_rows = 2; + assert_graph_pipeline_invalid(&engine, &low_pipeline_cap, "max_intermediate_bindings"); +} + +#[test] +fn graph_pipeline_terminal_aggregate_uses_group_and_final_row_caps() { + let (_dir, engine) = graph_row_test_engine(); + for key in ["a", "b", "c"] { + insert_graph_row_node( + &engine, + "PipelineTerminalAggCap", + key, + &[("group", PropValue::String(key.to_string()))], + ); + } + + let query = GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node_with_label("n", "PipelineTerminalAggCap")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![ + GraphProjectItem { + expr: graph_prop("n", "group"), + alias: Some("group".to_string()), + projection: GraphReturnProjection::Auto, + }, + GraphProjectItem { + expr: GraphExpr::AggregateCall { + function: GraphAggregateFunction::Count, + distinct: false, + arg: None, + }, + alias: Some("count".to_string()), + projection: GraphReturnProjection::Auto, + }, + ]), + distinct: false, + where_: None, + order_by: vec![GraphOrderItem { + expr: GraphExpr::Binding("group".to_string()), + direction: GraphOrderDirection::Asc, + }], + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: Some(now_millis()), + page: GraphPageRequest { + skip: 0, + limit: 1, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + allow_full_scan: false, + max_pipeline_rows: 3, + max_groups: 3, + max_rows: 1, + ..GraphPipelineOptions::default() + }, + }; + + let result = engine.query_graph_pipeline(&query).unwrap(); + assert_eq!( + graph_pipeline_value_rows(result.clone()), + vec![vec![GraphValue::String("a".to_string()), GraphValue::UInt(1)]] + ); + assert_eq!(result.stats.groups, 3); + assert_eq!(result.stats.rows_after_filter, 3); + assert_eq!(result.stats.rows_returned, 1); + assert!(result.next_cursor.is_some()); + + let mut low_group_cap = query; + low_group_cap.options.max_groups = 2; + assert_graph_pipeline_invalid(&engine, &low_group_cap, "max_groups"); +} + +#[test] +fn graph_pipeline_executes_distinct_and_aggregate_project_stages() { + let (_dir, engine) = graph_row_test_engine(); + for (key, group, score) in [ + ("a", "x", PropValue::Int(1)), + ("b", "x", PropValue::Int(2)), + ("c", "y", PropValue::Int(3)), + ] { + insert_graph_row_node( + &engine, + "PipelineAgg", + key, + &[ + ("group", PropValue::String(group.to_string())), + ("score", score), + ], + ); + } + + let distinct = GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node_with_label("n", "PipelineAgg")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: graph_prop("n", "group"), + alias: Some("group".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: true, + where_: None, + order_by: vec![GraphOrderItem { + expr: GraphExpr::Binding("group".to_string()), + direction: GraphOrderDirection::Asc, + }], + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: Some(now_millis()), + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + allow_full_scan: false, + include_plan: true, + ..GraphPipelineOptions::default() + }, + }; + let distinct_result = engine.query_graph_pipeline(&distinct).unwrap(); + assert_eq!( + graph_pipeline_value_rows(distinct_result.clone()), + vec![ + vec![GraphValue::String("x".to_string())], + vec![GraphValue::String("y".to_string())], + ] + ); + assert!(distinct_result + .plan + .unwrap() + .row_ops + .iter() + .any(|op| op.kind == "Distinct")); + + let aggregate = GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node_with_label("n", "PipelineAgg")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![ + GraphProjectItem { + expr: graph_prop("n", "group"), + alias: Some("group".to_string()), + projection: GraphReturnProjection::Auto, + }, + GraphProjectItem { + expr: GraphExpr::AggregateCall { + function: GraphAggregateFunction::Count, + distinct: false, + arg: None, + }, + alias: Some("count".to_string()), + projection: GraphReturnProjection::Auto, + }, + GraphProjectItem { + expr: GraphExpr::AggregateCall { + function: GraphAggregateFunction::Sum, + distinct: false, + arg: Some(Box::new(graph_prop("n", "score"))), + }, + alias: Some("sum".to_string()), + projection: GraphReturnProjection::Auto, + }, + GraphProjectItem { + expr: GraphExpr::AggregateCall { + function: GraphAggregateFunction::Count, + distinct: true, + arg: Some(Box::new(graph_prop("n", "score"))), + }, + alias: Some("distinct_scores".to_string()), + projection: GraphReturnProjection::Auto, + }, + ]), + distinct: false, + where_: None, + order_by: vec![GraphOrderItem { + expr: GraphExpr::Binding("group".to_string()), + direction: GraphOrderDirection::Asc, + }], + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: Some(now_millis()), + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + allow_full_scan: false, + include_plan: true, + ..GraphPipelineOptions::default() + }, + }; + let aggregate_result = engine.query_graph_pipeline(&aggregate).unwrap(); + assert_eq!( + graph_pipeline_value_rows(aggregate_result.clone()), + vec![ + vec![ + GraphValue::String("x".to_string()), + GraphValue::UInt(2), + GraphValue::Int(3), + GraphValue::UInt(2), + ], + vec![ + GraphValue::String("y".to_string()), + GraphValue::UInt(1), + GraphValue::Int(3), + GraphValue::UInt(1), + ], + ] + ); + assert_eq!(aggregate_result.stats.groups, 2); + let aggregate_plan = aggregate_result.plan.unwrap(); + assert!(aggregate_plan + .row_ops + .iter() + .any(|op| op.kind == "Aggregate")); + assert!(aggregate_plan.stages.iter().any(|stage| { + stage.detail.contains("aggregate_distinct_keys=3") + && stage + .notes + .iter() + .any(|note| note.contains("aggregate DISTINCT")) + })); + + let count_distinct_star = GraphPipelineQuery { + stages: vec![GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::AggregateCall { + function: GraphAggregateFunction::Count, + distinct: true, + arg: None, + }, + alias: Some("bad".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + })], + params: BTreeMap::new(), + at_epoch: Some(now_millis()), + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions::default(), + }; + assert_graph_pipeline_invalid( + &engine, + &count_distinct_star, + "DISTINCT requires an argument", + ); + + let sum_star = GraphPipelineQuery { + stages: vec![GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::AggregateCall { + function: GraphAggregateFunction::Sum, + distinct: false, + arg: None, + }, + alias: Some("bad".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + })], + params: BTreeMap::new(), + at_epoch: Some(now_millis()), + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions::default(), + }; + assert_graph_pipeline_invalid(&engine, &sum_star, "sum aggregate requires an argument"); + + let zero_groups = GraphPipelineQuery { + options: GraphPipelineOptions { + max_groups: 0, + ..GraphPipelineOptions::default() + }, + ..sum_star + }; + assert_graph_pipeline_invalid(&engine, &zero_groups, "greater than zero"); + + let reserved_project_alias = GraphPipelineQuery { + stages: vec![GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Int(1), + alias: Some("__gql_bad".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + })], + params: BTreeMap::new(), + at_epoch: Some(now_millis()), + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions::default(), + }; + assert_graph_pipeline_invalid(&engine, &reserved_project_alias, "reserved internal"); + + let reserved_match_alias = GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node_with_label("__gql_bad", "PipelineAgg")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Star, + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: Some(now_millis()), + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + allow_full_scan: false, + ..GraphPipelineOptions::default() + }, + }; + assert_graph_pipeline_invalid(&engine, &reserved_match_alias, "reserved internal"); +} + +#[test] +fn graph_pipeline_aggregate_collect_hydrates_nested_graph_values_at_output() { + let (_dir, engine) = graph_row_test_engine(); + let a = insert_graph_row_node( + &engine, + "PipelineCollectElement", + "a", + &[("name", PropValue::String("a".to_string()))], + ); + let b = insert_graph_row_node( + &engine, + "PipelineCollectElement", + "b", + &[("name", PropValue::String("b".to_string()))], + ); + let edge = insert_graph_row_edge( + &engine, + a, + b, + "PIPELINE_COLLECT_ELEMENT", + &[("rank", PropValue::Int(1))], + ); + + let mut start = graph_node_with_label("a", "PipelineCollectElement"); + start.ids = vec![a]; + let mut end = graph_node_with_label("b", "PipelineCollectElement"); + end.ids = vec![b]; + let mut path = graph_vlp(Some("p"), Some("r"), "a", "b", 1, 1); + if let GraphPatternPiece::VariableLength(path) = &mut path { + path.label_filter = vec!["PIPELINE_COLLECT_ELEMENT".to_string()]; + } + let query = GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![start, end], + pieces: vec![path], + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![ + GraphProjectItem { + expr: GraphExpr::AggregateCall { + function: GraphAggregateFunction::Collect, + distinct: false, + arg: Some(Box::new(GraphExpr::Binding("a".to_string()))), + }, + alias: Some("nodes".to_string()), + projection: GraphReturnProjection::Auto, + }, + GraphProjectItem { + expr: GraphExpr::AggregateCall { + function: GraphAggregateFunction::Collect, + distinct: false, + arg: Some(Box::new(GraphExpr::Binding("r".to_string()))), + }, + alias: Some("edges".to_string()), + projection: GraphReturnProjection::Auto, + }, + GraphProjectItem { + expr: GraphExpr::AggregateCall { + function: GraphAggregateFunction::Collect, + distinct: false, + arg: Some(Box::new(GraphExpr::Binding("p".to_string()))), + }, + alias: Some("paths".to_string()), + projection: GraphReturnProjection::Auto, + }, + ]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: Some(now_millis()), + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions { + mode: GraphOutputMode::Elements, + include_vectors: false, + compact_rows: false, + }, + options: GraphPipelineOptions { + allow_full_scan: false, + ..GraphPipelineOptions::default() + }, + }; + + let result = engine.query_graph_pipeline(&query).unwrap(); + assert_eq!(result.rows.len(), 1); + let row = &result.rows[0].values; + + let GraphValue::List(nodes) = &row[0] else { + panic!("expected collected nodes"); + }; + let GraphValue::Node(node) = &nodes[0] else { + panic!("expected collected node element"); + }; + assert_eq!(node.id, Some(a)); + assert_eq!(node.key.as_deref(), Some("a")); + assert_eq!( + node.props.as_ref().unwrap().get("name"), + Some(&GraphValue::String("a".to_string())) + ); + + let GraphValue::List(edges) = &row[1] else { + panic!("expected collected edges"); + }; + let GraphValue::Edge(collected_edge) = &edges[0] else { + panic!("expected collected edge element"); + }; + assert_eq!(collected_edge.id, Some(edge)); + assert_eq!( + collected_edge.label.as_deref(), + Some("PIPELINE_COLLECT_ELEMENT") + ); + assert_eq!( + collected_edge.props.as_ref().unwrap().get("rank"), + Some(&GraphValue::Int(1)) + ); + + let GraphValue::List(paths) = &row[2] else { + panic!("expected collected paths"); + }; + let GraphValue::Path(path) = &paths[0] else { + panic!("expected collected path element"); + }; + assert_eq!(path.node_ids, vec![a, b]); + assert_eq!(path.edge_ids, vec![edge]); + assert_eq!(path.nodes.as_ref().unwrap()[0].key.as_deref(), Some("a")); + assert_eq!( + path.edges.as_ref().unwrap()[0].label.as_deref(), + Some("PIPELINE_COLLECT_ELEMENT") + ); +} + +#[test] +fn graph_pipeline_seeded_bound_node_alias_verifies_later_match_constraints() { + let (_dir, engine) = graph_row_test_engine(); + let active = insert_graph_row_node_with_labels( + &engine, + &["PipelineSeedSource", "PipelineSeedRequired"], + "active", + &[("status", PropValue::String("active".to_string()))], + ); + let inactive = insert_graph_row_node_with_labels( + &engine, + &["PipelineSeedSource"], + "inactive", + &[("status", PropValue::String("inactive".to_string()))], + ); + let active_target = insert_graph_row_node(&engine, "PipelineSeedTarget", "active-target", &[]); + let inactive_target = + insert_graph_row_node(&engine, "PipelineSeedTarget", "inactive-target", &[]); + insert_graph_row_edge( + &engine, + active, + active_target, + "PIPELINE_SEED_REQUIRED_REL", + &[], + ); + insert_graph_row_edge( + &engine, + inactive, + inactive_target, + "PIPELINE_SEED_REQUIRED_REL", + &[], + ); + + let query = GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node_with_label("n", "PipelineSeedSource")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::With, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("n".to_string()), + alias: Some("n".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![GraphNodePattern { + alias: "n".to_string(), + label_filter: Some(NodeLabelFilter { + labels: vec!["PipelineSeedRequired".to_string()], + mode: LabelMatchMode::All, + }), + ids: Vec::new(), + keys: Vec::new(), + filter: Some(NodeFilterExpr::PropertyEquals { + key: "status".to_string(), + value: PropValue::String("active".to_string()), + }), + }, graph_node_with_label("m", "PipelineSeedTarget")], + pieces: vec![graph_edge_with_label( + Some("r"), + "n", + "m", + "PIPELINE_SEED_REQUIRED_REL", + )], + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("n".to_string()), + alias: Some("n".to_string()), + projection: GraphReturnProjection::IdOnly, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: Some(now_millis()), + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + allow_full_scan: false, + ..GraphPipelineOptions::default() + }, + }; + + assert_eq!( + graph_pipeline_value_rows(engine.query_graph_pipeline(&query).unwrap()), + vec![vec![GraphValue::NodeId(active)]] + ); + + let mut optional_query = query; + if let GraphPipelineStage::Match(stage) = &mut optional_query.stages[2] { + stage.optional = true; + } + if let GraphPipelineStage::Project(stage) = &mut optional_query.stages[3] { + stage.items = GraphProjectionItems::Items(vec![ + GraphProjectItem { + expr: GraphExpr::Binding("n".to_string()), + alias: Some("n".to_string()), + projection: GraphReturnProjection::IdOnly, + }, + GraphProjectItem { + expr: GraphExpr::Binding("m".to_string()), + alias: Some("m".to_string()), + projection: GraphReturnProjection::IdOnly, + }, + ]); + stage.order_by = vec![GraphOrderItem { + expr: GraphExpr::NodeField { + alias: "n".to_string(), + field: GraphNodeField::Id, + }, + direction: GraphOrderDirection::Asc, + }]; + } + assert_eq!( + graph_pipeline_value_rows(engine.query_graph_pipeline(&optional_query).unwrap()), + vec![ + vec![GraphValue::NodeId(active), GraphValue::NodeId(active_target)], + vec![GraphValue::NodeId(inactive), GraphValue::Null] + ] + ); +} + +#[test] +fn graph_pipeline_cursor_preserves_scalar_only_duplicate_rows() { + let (_dir, engine) = graph_row_test_engine(); + for key in ["a", "b", "c"] { + insert_graph_row_node( + &engine, + "PipelineCursorDup", + key, + &[("name", PropValue::String("same".to_string()))], + ); + } + let mut query = GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node_with_label("n", "PipelineCursorDup")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::With, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: graph_prop("n", "name"), + alias: Some("name".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Int(1), + alias: Some("one".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: vec![GraphOrderItem { + expr: GraphExpr::Binding("one".to_string()), + direction: GraphOrderDirection::Asc, + }], + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: Some(now_millis()), + page: GraphPageRequest { + skip: 1, + limit: 1, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + allow_full_scan: false, + max_skip: 1, + ..GraphPipelineOptions::default() + }, + }; + + let first = engine.query_graph_pipeline(&query).unwrap(); + assert_eq!(graph_pipeline_value_rows(first.clone()), vec![vec![GraphValue::Int(1)]]); + let cursor = first.next_cursor.expect("duplicate scalar page should continue"); + + query.page.skip = 0; + query.page.cursor = Some(cursor.clone()); + let second = engine.query_graph_pipeline(&query).unwrap(); + assert_eq!( + graph_pipeline_value_rows(second.clone()), + vec![vec![GraphValue::Int(1)]] + ); + assert!(second.next_cursor.is_none()); + + let mut lowered_skip_cap = query.clone(); + lowered_skip_cap.options.max_skip = 0; + assert_graph_pipeline_invalid( + &engine, + &lowered_skip_cap, + "original skip 1 exceeds max_skip 0", + ); + + let mut wrong_sort_shape = query.clone(); + wrong_sort_shape.page.cursor = Some(tampered_pipeline_cursor_sort_key(cursor.clone())); + assert_graph_pipeline_invalid(&engine, &wrong_sort_shape, "cursor sort key has"); + + let mut wrong_logical_shape = query.clone(); + wrong_logical_shape.page.cursor = Some(tampered_pipeline_cursor_logical_key(cursor.clone())); + assert_graph_pipeline_invalid(&engine, &wrong_logical_shape, "cursor logical row key has"); + + let mut wrong_internal_key_shape = query; + wrong_internal_key_shape.page.cursor = Some(tampered_pipeline_cursor_internal_key_atom(cursor)); + engine.reset_query_execution_counters_for_test(); + assert_graph_pipeline_invalid( + &engine, + &wrong_internal_key_shape, + "internal cursor key atom", + ); + let counters = engine.query_execution_counter_snapshot_for_test(); + assert_eq!(counters.graph_row_query_calls, 0); +} + +#[test] +fn graph_pipeline_enforces_pipeline_rows_and_cursor_skip_caps() { + let (_dir, engine) = graph_row_test_engine(); + for key in ["a", "b", "c", "d"] { + insert_graph_row_node( + &engine, + "PipelineCaps", + key, + &[("name", PropValue::String(key.to_string()))], + ); + } + let graph_query = GraphRowQuery { + nodes: vec![graph_node_with_label("n", "PipelineCaps")], + pieces: Vec::new(), + where_: None, + return_items: Some(vec![graph_return_expr(graph_prop("n", "name"), "name")]), + order_by: Vec::new(), + page: GraphPageRequest { + skip: 0, + limit: 2, + cursor: None, + }, + at_epoch: Some(now_millis()), + params: BTreeMap::new(), + output: GraphOutputOptions::default(), + options: GraphQueryOptions { + allow_full_scan: false, + ..GraphQueryOptions::default() + }, + }; + let mut capped = graph_pipeline_from_row_query(&graph_query); + capped.options.max_pipeline_rows = 1; + assert_graph_pipeline_invalid(&engine, &capped, "max_pipeline_rows"); + + let mut first_page = graph_pipeline_from_row_query(&graph_query); + first_page.page.skip = 2; + first_page.page.limit = 1; + first_page.options.max_skip = 2; + let first = engine.query_graph_pipeline(&first_page).unwrap(); + assert!(first.next_cursor.is_some()); + + let mut resume = first_page; + resume.page.skip = 0; + resume.page.cursor = first.next_cursor; + resume.options.max_skip = 1; + assert_graph_pipeline_invalid(&engine, &resume, "original skip 2 exceeds max_skip 1"); + + let mut oversized_cursor = graph_pipeline_from_row_query(&graph_query); + oversized_cursor.options.max_cursor_bytes = 4; + oversized_cursor.page.cursor = Some(format!( + "{GRAPH_PIPELINE_CURSOR_PREFIX}{}", + "A".repeat(32) + )); + let err = engine.query_graph_pipeline(&oversized_cursor).unwrap_err(); + assert!(matches!(err, EngineError::InvalidCursor { .. })); + assert!( + err.to_string() + .contains("too large to decode within max_cursor_bytes 4"), + "unexpected error: {err}" + ); +} + +#[test] +fn graph_pipeline_validates_referenced_param_byte_caps() { + let (_dir, engine) = graph_row_test_engine(); + let graph_query = GraphRowQuery { + nodes: vec![graph_node_with_label("n", "PipelineParamCaps")], + pieces: Vec::new(), + where_: None, + return_items: Some(vec![graph_return_binding( + "n", + GraphReturnProjection::IdOnly, + )]), + order_by: Vec::new(), + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + at_epoch: Some(now_millis()), + params: BTreeMap::new(), + output: GraphOutputOptions::default(), + options: GraphQueryOptions { + allow_full_scan: false, + ..GraphQueryOptions::default() + }, + }; + let mut query = graph_pipeline_from_row_query(&graph_query); + if let GraphPipelineStage::Project(project) = &mut query.stages[1] { + project.items = GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Param("needle".to_string()), + alias: Some("needle".to_string()), + projection: GraphReturnProjection::Auto, + }]); + } + query.options.max_param_bytes = 4; + query + .params + .insert("needle".to_string(), GraphParamValue::String("too-long".to_string())); + query.params.insert( + "unused".to_string(), + GraphParamValue::String("also-too-long-but-unreferenced".to_string()), + ); + assert_graph_pipeline_invalid(&engine, &query, "exceeding max_param_bytes 4"); + + query + .params + .insert("needle".to_string(), GraphParamValue::String("ok".to_string())); + let result = engine.query_graph_pipeline(&query).unwrap(); + assert!(result.rows.is_empty()); +} + +#[test] +fn graph_pipeline_explain_reports_stage_shell_and_caps() { + let (_dir, engine) = graph_row_test_engine(); + insert_graph_row_node( + &engine, + "PipelineExplain", + "ada", + &[("name", PropValue::String("Ada".to_string()))], + ); + let mut graph_query = GraphRowQuery { + nodes: vec![graph_node_with_label("n", "PipelineExplain")], + pieces: Vec::new(), + where_: None, + return_items: Some(vec![graph_return_expr(graph_prop("n", "name"), "name")]), + order_by: Vec::new(), + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + at_epoch: Some(now_millis()), + params: BTreeMap::new(), + output: GraphOutputOptions::default(), + options: GraphQueryOptions { + allow_full_scan: false, + ..GraphQueryOptions::default() + }, + }; + graph_query.options.include_plan = true; + let mut pipeline_query = graph_pipeline_from_row_query(&graph_query); + pipeline_query.options.max_pipeline_rows = 123; + pipeline_query.options.max_groups = 45; + pipeline_query.options.max_collect_items = 67; + pipeline_query.options.max_union_branches = 3; + pipeline_query.options.max_subquery_invocations = 89; + pipeline_query.options.max_subquery_depth = 1; + pipeline_query.options.max_shortest_path_pairs = 21; + + let explain = engine.explain_graph_pipeline(&pipeline_query).unwrap(); + assert_eq!(explain.columns, vec!["name"]); + assert_eq!(explain.stages.len(), 2); + assert_eq!(explain.stages[0].kind, "Match"); + assert!(explain.stages[0].graph_row.is_some()); + assert_eq!(explain.stages[1].kind, "Project(Return)"); + assert_eq!(explain.stages[1].columns, vec!["name"]); + assert_eq!(explain.caps.max_pipeline_rows, 123); + assert_eq!(explain.caps.max_groups, 45); + assert_eq!(explain.caps.max_collect_items, 67); + assert_eq!(explain.caps.max_union_branches, 3); + assert_eq!(explain.caps.max_subquery_invocations, 89); + assert_eq!(explain.caps.max_subquery_depth, 1); + assert_eq!(explain.caps.max_shortest_path_pairs, 21); + assert_eq!(explain.stats.rows_entered_pipeline, 1); + assert!(!explain + .notes + .iter() + .any(|note| note.contains("CP34.1 supports only"))); + + let native_pipeline = GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node_with_label("n", "PipelineExplain")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::With, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: graph_prop("n", "name"), + alias: Some("name".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("name".to_string()), + alias: Some("name".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: graph_query.at_epoch, + page: graph_query.page.clone(), + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + include_plan: true, + allow_full_scan: false, + ..GraphPipelineOptions::default() + }, + }; + engine.reset_query_execution_counters_for_test(); + let native_explain = engine.explain_graph_pipeline(&native_pipeline).unwrap(); + let counters = engine.query_execution_counter_snapshot_for_test(); + assert_eq!(counters.graph_row_query_calls, 0); + assert_eq!( + native_explain + .stages + .iter() + .map(|stage| stage.kind.as_str()) + .collect::>(), + vec!["Match", "Project(With)", "Project(Return)"] + ); + assert!(native_explain.stages[0].graph_row.is_some()); + assert!(native_explain.stages[0] + .detail + .contains("seeded_node_aliases=")); + assert!(!native_explain.stages[0].detail.contains("seeded_aliases=")); + assert!(native_explain.stages[1] + .notes + .iter() + .any(|note| note.contains("created scalar aliases: name"))); + assert!(native_explain.stages[1] + .notes + .iter() + .any(|note| note.contains("scalar expressions: name :="))); + + let carried_pipeline = GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node_with_label("n", "PipelineExplain")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::With, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("n".to_string()), + alias: Some("n".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node_with_label("m", "PipelineExplain")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("m".to_string()), + alias: Some("m".to_string()), + projection: GraphReturnProjection::IdOnly, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: graph_query.at_epoch, + page: graph_query.page.clone(), + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + include_plan: true, + allow_full_scan: false, + ..GraphPipelineOptions::default() + }, + }; + let carried_explain = engine.explain_graph_pipeline(&carried_pipeline).unwrap(); + assert!(carried_explain.stages[2] + .detail + .contains("seeded_node_aliases=; carried_aliases=n")); + + let seeded_pipeline = GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node_with_label("n", "PipelineExplain")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::With, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("n".to_string()), + alias: Some("n".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![ + graph_node("n"), + graph_node_with_label("m", "PipelineExplain"), + ], + pieces: vec![graph_edge_with_label( + Some("r"), + "n", + "m", + "PIPELINE_EXPLAIN_REL", + )], + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("m".to_string()), + alias: Some("m".to_string()), + projection: GraphReturnProjection::IdOnly, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: graph_query.at_epoch, + page: graph_query.page.clone(), + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + include_plan: true, + allow_full_scan: false, + ..GraphPipelineOptions::default() + }, + }; + let seeded_explain = engine.explain_graph_pipeline(&seeded_pipeline).unwrap(); + assert!(seeded_explain.stages[2] + .detail + .contains("seeded_node_aliases=n; carried_aliases=")); +} + +#[test] +fn graph_pipeline_shortest_path_stage_executes_and_reports_stats() { + let (_dir, engine) = graph_row_test_engine(); + let a = insert_graph_row_node(&engine, "PipelineShortest", "a", &[]); + let b = insert_graph_row_node(&engine, "PipelineShortest", "b", &[]); + let c = insert_graph_row_node(&engine, "PipelineShortest", "c", &[]); + let ab = engine + .upsert_edge(a, b, "PIPELINE_SHORTEST", UpsertEdgeOptions::default()) + .unwrap(); + let bc = engine + .upsert_edge(b, c, "PIPELINE_SHORTEST", UpsertEdgeOptions::default()) + .unwrap(); + + let query = GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::ShortestPath(GraphShortestPathStage { + optional: false, + output_path_alias: "p".to_string(), + mode: GraphShortestPathMode::One, + from: GraphShortestPathEndpoint::NodeId(a), + to: GraphShortestPathEndpoint::NodeId(c), + direction: Direction::Outgoing, + edge_label_filter: vec!["PIPELINE_SHORTEST".to_string()], + min_hops: 1, + max_hops: 4, + weight_field: None, + max_cost: None, + max_paths: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("p".to_string()), + alias: Some("p".to_string()), + projection: GraphReturnProjection::Element(GraphElementProjection::Full), + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + include_plan: true, + ..GraphPipelineOptions::default() + }, + }; + + let result = engine.query_graph_pipeline(&query).unwrap(); + assert_eq!(result.stats.shortest_path_pairs, 1); + assert_eq!(result.stats.shortest_path_cache_hits, 0); + let rows = graph_pipeline_value_rows(result.clone()); + let GraphValue::Path(path) = &rows[0][0] else { + panic!("expected path output"); + }; + assert_eq!(path.node_ids, vec![a, b, c]); + assert_eq!(path.edge_ids, vec![ab, bc]); + let plan = result.plan.expect("include_plan should attach explain"); + assert!(plan.stages.iter().any(|stage| { + stage.kind == "ShortestPath" + && stage.detail.contains("algorithm=bidirectional_bfs") + && stage.detail.contains("distinct_pair_count=1") + && stage.detail.contains("emitted_path_count=1") + })); +} + +#[test] +fn graph_pipeline_shortest_path_node_key_endpoints_use_cached_id_resolution() { + let (_dir, engine) = graph_row_test_engine(); + let a = insert_graph_row_node(&engine, "PipelineShortestKey", "a", &[]); + let b = insert_graph_row_node(&engine, "PipelineShortestKey", "b", &[]); + let c = insert_graph_row_node(&engine, "PipelineShortestKey", "c", &[]); + insert_graph_row_node(&engine, "PipelineShortestKeyDup", "dup-1", &[]); + insert_graph_row_node(&engine, "PipelineShortestKeyDup", "dup-2", &[]); + let ab = engine + .upsert_edge(a, b, "PIPELINE_SHORTEST_KEY", UpsertEdgeOptions::default()) + .unwrap(); + let bc = engine + .upsert_edge(b, c, "PIPELINE_SHORTEST_KEY", UpsertEdgeOptions::default()) + .unwrap(); + + let query = GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node_with_label("d", "PipelineShortestKeyDup")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::ShortestPath(GraphShortestPathStage { + optional: false, + output_path_alias: "p".to_string(), + mode: GraphShortestPathMode::One, + from: GraphShortestPathEndpoint::NodeKey { + label: "PipelineShortestKey".to_string(), + key: "a".to_string(), + }, + to: GraphShortestPathEndpoint::NodeKey { + label: "PipelineShortestKey".to_string(), + key: "c".to_string(), + }, + direction: Direction::Outgoing, + edge_label_filter: vec!["PIPELINE_SHORTEST_KEY".to_string()], + min_hops: 1, + max_hops: 4, + weight_field: None, + max_cost: None, + max_paths: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("p".to_string()), + alias: Some("p".to_string()), + projection: GraphReturnProjection::IdOnly, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + include_plan: true, + allow_full_scan: true, + ..GraphPipelineOptions::default() + }, + }; + + engine.reset_query_execution_counters_for_test(); + let result = engine.query_graph_pipeline(&query).unwrap(); + assert_eq!(result.stats.shortest_path_pairs, 1); + assert_eq!(result.stats.shortest_path_cache_hits, 1); + let counters = engine.query_execution_counter_snapshot_for_test(); + assert_eq!(counters.node_record_hydration_reads, 0); + + let rows = graph_pipeline_value_rows(result.clone()); + assert_eq!(rows.len(), 2); + for row in rows { + let GraphValue::Path(path) = &row[0] else { + panic!("expected path output"); + }; + assert_eq!(path.node_ids, vec![a, b, c]); + assert_eq!(path.edge_ids, vec![ab, bc]); + } +} + +#[test] +fn graph_pipeline_union_executes_all_and_distinct_with_stats() { + let (_dir, engine) = graph_row_test_engine(); + for (key, side, name) in [ + ("a", "left", "a"), + ("b", "left", "b"), + ("b2", "right", "b"), + ("c", "right", "c"), + ] { + insert_graph_row_node( + &engine, + "PipelineUnion", + key, + &[ + ("side", PropValue::String(side.to_string())), + ("name", PropValue::String(name.to_string())), + ], + ); + } + + fn branch(side: &str, desc: bool) -> GraphPipelineQuery { + let direction = if desc { + GraphOrderDirection::Desc + } else { + GraphOrderDirection::Asc + }; + GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node_with_label("n", "PipelineUnion")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: Some(GraphExpr::Binary { + left: Box::new(graph_prop("n", "side")), + op: GraphBinaryOp::Eq, + right: Box::new(GraphExpr::String(side.to_string())), + }), + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: graph_prop("n", "name"), + alias: Some("name".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: vec![GraphOrderItem { + expr: GraphExpr::Binding("name".to_string()), + direction, + }], + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + allow_full_scan: true, + include_plan: true, + ..GraphPipelineOptions::default() + }, + } + } + + let union_all = GraphPipelineQuery { + stages: vec![GraphPipelineStage::Union(GraphUnionStage { + branches: vec![branch("left", true), branch("right", false)], + all: true, + })], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + allow_full_scan: true, + include_plan: true, + ..GraphPipelineOptions::default() + }, + }; + let all = engine.query_graph_pipeline(&union_all).unwrap(); + assert_eq!( + all.rows + .iter() + .map(|row| row.values[0].clone()) + .collect::>(), + vec![ + GraphValue::String("b".to_string()), + GraphValue::String("a".to_string()), + GraphValue::String("b".to_string()), + GraphValue::String("c".to_string()), + ] + ); + assert_eq!(all.stats.union_branches, 2); + assert_eq!(all.stats.union_dedup_keys, 0); + let plan = all.plan.as_ref().unwrap(); + assert_eq!(plan.stages[0].kind, "UnionAll"); + assert!(plan.stages[0].detail.contains("branches=2")); + assert!(plan.stages[0] + .notes + .iter() + .any(|note| note.contains("branch 1 stages: Match"))); + assert!(plan.stages[0] + .notes + .iter() + .any(|note| note.contains("branch 2 row op: Sort"))); + + let mut dedupe = union_all.clone(); + if let GraphPipelineStage::Union(stage) = &mut dedupe.stages[0] { + stage.all = false; + } + let distinct = engine.query_graph_pipeline(&dedupe).unwrap(); + assert_eq!( + distinct + .rows + .iter() + .map(|row| row.values[0].clone()) + .collect::>(), + vec![ + GraphValue::String("b".to_string()), + GraphValue::String("a".to_string()), + GraphValue::String("c".to_string()), + ] + ); + assert_eq!(distinct.stats.union_branches, 2); + assert_eq!(distinct.stats.union_dedup_keys, 3); + + let source = insert_graph_row_node(&engine, "PipelineUnionEpochNode", "source", &[]); + let past = insert_graph_row_node(&engine, "PipelineUnionEpochNode", "past", &[]); + let future = insert_graph_row_node(&engine, "PipelineUnionEpochNode", "future", &[]); + engine + .upsert_edge( + source, + past, + "PipelineUnionEpochEdge", + UpsertEdgeOptions { + props: graph_row_props(&[ + ("side", PropValue::String("past".to_string())), + ("name", PropValue::String("past".to_string())), + ]), + valid_from: Some(100), + valid_to: Some(200), + ..Default::default() + }, + ) + .unwrap(); + engine + .upsert_edge( + source, + future, + "PipelineUnionEpochEdge", + UpsertEdgeOptions { + props: graph_row_props(&[ + ("side", PropValue::String("future".to_string())), + ("name", PropValue::String("future".to_string())), + ]), + valid_from: Some(300), + valid_to: None, + ..Default::default() + }, + ) + .unwrap(); + fn epoch_branch(side: &str) -> GraphPipelineQuery { + GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node("source"), graph_node("target")], + pieces: vec![GraphPatternPiece::Edge(GraphEdgePattern { + alias: Some("r".to_string()), + from_alias: "source".to_string(), + to_alias: "target".to_string(), + direction: Direction::Outgoing, + label_filter: vec!["PipelineUnionEpochEdge".to_string()], + filter: None, + })], + optional_candidate_where: None, + where_: Some(GraphExpr::Binary { + left: Box::new(graph_prop("r", "side")), + op: GraphBinaryOp::Eq, + right: Box::new(GraphExpr::String(side.to_string())), + }), + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: graph_prop("r", "name"), + alias: Some("name".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + allow_full_scan: true, + ..GraphPipelineOptions::default() + }, + } + } + let epoch_union = GraphPipelineQuery { + stages: vec![GraphPipelineStage::Union(GraphUnionStage { + branches: vec![epoch_branch("past"), epoch_branch("future")], + all: true, + })], + params: BTreeMap::new(), + at_epoch: Some(150), + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions::default(), + }; + let snapshot = engine.query_graph_pipeline(&epoch_union).unwrap(); + assert_eq!( + snapshot + .rows + .iter() + .map(|row| row.values[0].clone()) + .collect::>(), + vec![GraphValue::String("past".to_string())] + ); + + let nullable_source = insert_graph_row_node(&engine, "PipelineUnionNullable", "source", &[]); + let nullable_missing = + insert_graph_row_node(&engine, "PipelineUnionNullable", "missing", &[]); + let nullable_target = insert_graph_row_node(&engine, "PipelineUnionNullable", "target", &[]); + insert_graph_row_edge( + &engine, + nullable_source, + nullable_target, + "PipelineUnionNullableEdge", + &[], + ); + fn nullable_branch(source_id: u64, optional: bool) -> GraphPipelineQuery { + let mut source = graph_node_with_label("source", "PipelineUnionNullable"); + source.ids = vec![source_id]; + let edge = graph_edge_with_label( + Some("r"), + "source", + "item", + "PipelineUnionNullableEdge", + ); + let pieces = if optional { + vec![graph_optional(vec![edge], None)] + } else { + vec![edge] + }; + GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![ + source, + graph_node_with_label("item", "PipelineUnionNullable"), + ], + pieces, + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("item".to_string()), + alias: Some("item".to_string()), + projection: GraphReturnProjection::IdOnly, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions::default(), + } + } + let nullable_union = GraphPipelineQuery { + stages: vec![GraphPipelineStage::Union(GraphUnionStage { + branches: vec![ + nullable_branch(nullable_source, false), + nullable_branch(nullable_missing, true), + ], + all: true, + })], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions::default(), + }; + let nullable = engine.query_graph_pipeline(&nullable_union).unwrap(); + assert_eq!( + nullable + .rows + .iter() + .map(|row| row.values[0].clone()) + .collect::>(), + vec![GraphValue::NodeId(nullable_target), GraphValue::Null] + ); + + let mixed_node = insert_graph_row_node( + &engine, + "PipelineUnionMixed", + "node", + &[("name", PropValue::String("node".to_string()))], + ); + let mixed_node_two = insert_graph_row_node( + &engine, + "PipelineUnionMixed", + "node-two", + &[("name", PropValue::String("node-two".to_string()))], + ); + let mixed_scalar_branch = GraphPipelineQuery { + stages: vec![GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::String("literal".to_string()), + alias: Some("value".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + })], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions::default(), + }; + let mixed_node_branch = GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![GraphNodePattern { + alias: "n".to_string(), + label_filter: Some(NodeLabelFilter { + labels: vec!["PipelineUnionMixed".to_string()], + mode: LabelMatchMode::All, + }), + ids: vec![mixed_node, mixed_node_two], + keys: Vec::new(), + filter: None, + }], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("n".to_string()), + alias: Some("value".to_string()), + projection: GraphReturnProjection::Element(GraphElementProjection::Full), + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions::default(), + }; + let mixed_union = GraphPipelineQuery { + stages: vec![GraphPipelineStage::Union(GraphUnionStage { + branches: vec![mixed_scalar_branch.clone(), mixed_node_branch], + all: true, + })], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions::default(), + }; + let mixed = engine.query_graph_pipeline(&mixed_union).unwrap(); + assert_eq!(mixed.rows[0].values[0], GraphValue::String("literal".to_string())); + match &mixed.rows[1].values[0] { + GraphValue::Node(node) => assert_eq!(node.id, Some(mixed_node)), + other => panic!("expected mixed union node output, got {other:?}"), + } + let mut paged_mixed_all = mixed_union.clone(); + paged_mixed_all.page.limit = 2; + paged_mixed_all.options.max_rows = 2; + let paged_all_first = engine.query_graph_pipeline(&paged_mixed_all).unwrap(); + assert_eq!(paged_all_first.rows.len(), 2); + assert!(paged_all_first.next_cursor.is_some()); + let paged_all_second = engine + .query_graph_pipeline(&GraphPipelineQuery { + page: GraphPageRequest { + cursor: paged_all_first.next_cursor.clone(), + ..paged_mixed_all.page.clone() + }, + ..paged_mixed_all.clone() + }) + .unwrap(); + assert_eq!(paged_all_second.rows.len(), 1); + match &paged_all_second.rows[0].values[0] { + GraphValue::Node(node) => assert_eq!(node.id, Some(mixed_node_two)), + other => panic!("expected second mixed cursor page node output, got {other:?}"), + } + let mut paged_mixed_dedupe = paged_mixed_all.clone(); + if let GraphPipelineStage::Union(stage) = &mut paged_mixed_dedupe.stages[0] { + stage.all = false; + } + let paged_dedupe_first = engine.query_graph_pipeline(&paged_mixed_dedupe).unwrap(); + assert_eq!(paged_dedupe_first.rows.len(), 2); + assert!(paged_dedupe_first.next_cursor.is_some()); + let paged_dedupe_second = engine + .query_graph_pipeline(&GraphPipelineQuery { + page: GraphPageRequest { + cursor: paged_dedupe_first.next_cursor.clone(), + ..paged_mixed_dedupe.page.clone() + }, + ..paged_mixed_dedupe.clone() + }) + .unwrap(); + assert_eq!(paged_dedupe_second.rows.len(), 1); + match &paged_dedupe_second.rows[0].values[0] { + GraphValue::Node(node) => assert_eq!(node.id, Some(mixed_node_two)), + other => panic!("expected second mixed dedupe cursor page node output, got {other:?}"), + } + + let selected_node_id = insert_graph_row_node( + &engine, + "PipelineUnionProjection", + "selected", + &[ + ("visible", PropValue::String("yes".to_string())), + ("hidden", PropValue::String("no".to_string())), + ], + ); + let full_node_id = insert_graph_row_node( + &engine, + "PipelineUnionProjection", + "full", + &[ + ("visible", PropValue::String("full".to_string())), + ("hidden", PropValue::String("full-hidden".to_string())), + ], + ); + let compact_node_id = + insert_graph_row_node(&engine, "PipelineUnionProjection", "compact", &[]); + let selected_projection = GraphReturnProjection::Selected(GraphSelectedProjection::Node( + GraphSelectedNodeProjection { + id: true, + labels: false, + key: false, + props: GraphPropertySelection::Keys(vec!["visible".to_string()]), + weight: false, + created_at: false, + updated_at: false, + vectors: GraphVectorSelection::None, + }, + )); + fn projection_branch(node_id: u64, projection: GraphReturnProjection) -> GraphPipelineQuery { + GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![GraphNodePattern { + alias: "n".to_string(), + label_filter: Some(NodeLabelFilter { + labels: vec!["PipelineUnionProjection".to_string()], + mode: LabelMatchMode::All, + }), + ids: vec![node_id], + keys: Vec::new(), + filter: None, + }], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("n".to_string()), + alias: Some("value".to_string()), + projection, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions::default(), + } + } + let selected_scalar_union = GraphPipelineQuery { + stages: vec![GraphPipelineStage::Union(GraphUnionStage { + branches: vec![ + projection_branch(selected_node_id, selected_projection.clone()), + mixed_scalar_branch, + ], + all: true, + })], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions::default(), + }; + let selected_scalar = engine.query_graph_pipeline(&selected_scalar_union).unwrap(); + match &selected_scalar.rows[0].values[0] { + GraphValue::Node(node) => { + assert_eq!(node.id, Some(selected_node_id)); + assert!(node.labels.is_none()); + assert!(node.key.is_none()); + assert_eq!( + node.props.as_ref().and_then(|props| props.get("visible")), + Some(&GraphValue::String("yes".to_string())) + ); + assert!(!node + .props + .as_ref() + .is_some_and(|props| props.contains_key("hidden"))); + } + other => panic!("expected selected node output, got {other:?}"), + } + assert_eq!( + selected_scalar.rows[1].values[0], + GraphValue::String("literal".to_string()) + ); + + let selected_full_union = GraphPipelineQuery { + stages: vec![GraphPipelineStage::Union(GraphUnionStage { + branches: vec![ + projection_branch(selected_node_id, selected_projection.clone()), + projection_branch( + full_node_id, + GraphReturnProjection::Element(GraphElementProjection::Full), + ), + ], + all: true, + })], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions::default(), + }; + engine.reset_query_execution_counters_for_test(); + let selected_full = engine.query_graph_pipeline(&selected_full_union).unwrap(); + let selected_full_counters = engine.query_execution_counter_snapshot_for_test(); + assert_eq!(selected_full_counters.node_selected_field_batches, 2); + assert_eq!(selected_full_counters.node_selected_field_ids, 2); + match &selected_full.rows[0].values[0] { + GraphValue::Node(node) => { + assert_eq!(node.id, Some(selected_node_id)); + assert!(node.labels.is_none()); + assert!(node.key.is_none()); + assert!(!node + .props + .as_ref() + .is_some_and(|props| props.contains_key("hidden"))); + } + other => panic!("expected selected node output, got {other:?}"), + } + match &selected_full.rows[1].values[0] { + GraphValue::Node(node) => { + assert_eq!(node.id, Some(full_node_id)); + assert!(node.labels.is_some()); + assert!(node.key.is_some()); + assert!(node + .props + .as_ref() + .is_some_and(|props| props.contains_key("hidden"))); + } + other => panic!("expected full node output, got {other:?}"), + } + + let selected_compact_union = GraphPipelineQuery { + stages: vec![GraphPipelineStage::Union(GraphUnionStage { + branches: vec![ + projection_branch(selected_node_id, selected_projection), + projection_branch( + compact_node_id, + GraphReturnProjection::Element(GraphElementProjection::Compact), + ), + ], + all: true, + })], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions::default(), + }; + let selected_compact = engine.query_graph_pipeline(&selected_compact_union).unwrap(); + match &selected_compact.rows[1].values[0] { + GraphValue::Node(node) => { + assert_eq!(node.id, Some(compact_node_id)); + assert!(node.labels.is_some()); + assert!(node.key.is_some()); + assert!(node.props.is_none()); + } + other => panic!("expected compact node output, got {other:?}"), + } + + fn full_scan_branch() -> GraphPipelineQuery { + GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node("n")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("n".to_string()), + alias: Some("id".to_string()), + projection: GraphReturnProjection::IdOnly, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: Some(GraphExpr::UInt(1)), + }), + ], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions::default(), + } + } + let full_scan_union = GraphPipelineQuery { + stages: vec![GraphPipelineStage::Union(GraphUnionStage { + branches: vec![full_scan_branch(), full_scan_branch()], + all: true, + })], + params: BTreeMap::new(), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + allow_full_scan: true, + ..GraphPipelineOptions::default() + }, + }; + let full_scan_explain = engine.explain_graph_pipeline(&full_scan_union).unwrap(); + assert!(full_scan_explain + .warnings + .iter() + .any(|warning| warning.contains("FullScanExplicitlyAllowed"))); + assert!(full_scan_explain.stages[0] + .warnings + .iter() + .any(|warning| warning.contains("FullScanExplicitlyAllowed"))); + assert!(full_scan_explain.stages[0] + .notes + .iter() + .any(|note| note.contains("branch 1 warning: FullScanExplicitlyAllowed"))); +} + +#[test] +fn graph_pipeline_rejects_cp34_1_deferred_shapes() { + let (_dir, engine) = graph_row_test_engine(); + let base_graph = GraphRowQuery { + nodes: vec![graph_node_with_label("n", "PipelineReject")], + pieces: Vec::new(), + where_: None, + return_items: Some(vec![graph_return_binding( + "n", + GraphReturnProjection::IdOnly, + )]), + order_by: Vec::new(), + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + at_epoch: Some(now_millis()), + params: BTreeMap::new(), + output: GraphOutputOptions::default(), + options: GraphQueryOptions { + allow_full_scan: true, + ..GraphQueryOptions::default() + }, + }; + let base = graph_pipeline_from_row_query(&base_graph); + + let mut only_match = base.clone(); + only_match.stages.truncate(1); + assert_graph_pipeline_invalid(&engine, &only_match, "terminal Project(Return)"); + + let mut only_project = base.clone(); + only_project.stages.remove(0); + assert_graph_pipeline_invalid(&engine, &only_project, "unknown binding"); + + let mut union = base.clone(); + union.stages = vec![GraphPipelineStage::Union(GraphUnionStage { + branches: vec![base.clone()], + all: false, + })]; + assert_graph_pipeline_invalid(&engine, &union, "at least two"); + + let mut union_branch_base = base.clone(); + union_branch_base.at_epoch = None; + + let mut column_count_mismatch = base.clone(); + let mut two_columns = union_branch_base.clone(); + if let GraphPipelineStage::Project(project) = &mut two_columns.stages[1] { + project.items = GraphProjectionItems::Items(vec![ + GraphProjectItem { + expr: GraphExpr::Binding("n".to_string()), + alias: Some("n".to_string()), + projection: GraphReturnProjection::IdOnly, + }, + GraphProjectItem { + expr: GraphExpr::UInt(1), + alias: Some("extra".to_string()), + projection: GraphReturnProjection::Auto, + }, + ]); + } + column_count_mismatch.stages = vec![GraphPipelineStage::Union(GraphUnionStage { + branches: vec![union_branch_base.clone(), two_columns], + all: false, + })]; + assert_graph_pipeline_invalid(&engine, &column_count_mismatch, "returns 2 column"); + + let mut column_name_mismatch = base.clone(); + let mut renamed = union_branch_base.clone(); + if let GraphPipelineStage::Project(project) = &mut renamed.stages[1] { + project.items = GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("n".to_string()), + alias: Some("other".to_string()), + projection: GraphReturnProjection::IdOnly, + }]); + } + column_name_mismatch.stages = vec![GraphPipelineStage::Union(GraphUnionStage { + branches: vec![union_branch_base.clone(), renamed], + all: false, + })]; + assert_graph_pipeline_invalid(&engine, &column_name_mismatch, "columns"); + + let mut branch_cap = base.clone(); + branch_cap.options.max_union_branches = 1; + branch_cap.stages = vec![GraphPipelineStage::Union(GraphUnionStage { + branches: vec![base.clone(), base.clone()], + all: true, + })]; + assert_graph_pipeline_invalid(&engine, &branch_cap, "max_union_branches"); + + let mut branch_cursor = base.clone(); + branch_cursor.at_epoch = None; + branch_cursor.page.cursor = Some("raw-branch-cursor".to_string()); + let mut cursor_union = base.clone(); + cursor_union.stages = vec![GraphPipelineStage::Union(GraphUnionStage { + branches: vec![union_branch_base.clone(), branch_cursor], + all: true, + })]; + assert_graph_pipeline_invalid(&engine, &cursor_union, "raw cursor"); + + let mut branch_skip = union_branch_base.clone(); + branch_skip.page.skip = 1; + let mut skip_union = base.clone(); + skip_union.stages = vec![GraphPipelineStage::Union(GraphUnionStage { + branches: vec![union_branch_base.clone(), branch_skip], + all: true, + })]; + assert_graph_pipeline_invalid(&engine, &skip_union, "public page skip"); + + let mut reserved_alias = union_branch_base.clone(); + if let GraphPipelineStage::Project(project) = &mut reserved_alias.stages[1] { + project.items = GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::UInt(1), + alias: Some("__og_union_order".to_string()), + projection: GraphReturnProjection::Auto, + }]); + } + assert_graph_pipeline_invalid(&engine, &reserved_alias, "reserved internal alias"); + + let mut call_collision = base.clone(); + call_collision.stages = vec![ + base.stages[0].clone(), + GraphPipelineStage::Call(GraphSubqueryStage { + query: Box::new(base.clone()), + import_aliases: vec!["n".to_string()], + }), + base.stages[1].clone(), + ]; + assert_graph_pipeline_invalid(&engine, &call_collision, "collides"); + + let mut shortest_path = base.clone(); + let shortest_path_match = shortest_path.stages[0].clone(); + let shortest_path_return = shortest_path.stages[1].clone(); + shortest_path.stages = vec![ + shortest_path_match, + GraphPipelineStage::ShortestPath(GraphShortestPathStage { + optional: false, + output_path_alias: "p".to_string(), + mode: GraphShortestPathMode::One, + from: GraphShortestPathEndpoint::Alias("a".to_string()), + to: GraphShortestPathEndpoint::Alias("b".to_string()), + direction: Direction::Outgoing, + edge_label_filter: Vec::new(), + min_hops: 1, + max_hops: 2, + weight_field: None, + max_cost: None, + max_paths: None, + }), + shortest_path_return, + ]; + assert_graph_pipeline_invalid(&engine, &shortest_path, "endpoint alias"); + + let mut extra_stage = base.clone(); + extra_stage.stages.push(extra_stage.stages[1].clone()); + assert_graph_pipeline_invalid(&engine, &extra_stage, "must be the final"); + + let mut with_project = base.clone(); + if let GraphPipelineStage::Project(stage) = &mut with_project.stages[1] { + stage.kind = GraphProjectKind::With; + } + assert_graph_pipeline_invalid(&engine, &with_project, "terminal Project(Return)"); + + let alias_kind_conflict = GraphPipelineQuery { + stages: vec![ + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node_with_label("n", "PipelineReject")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::With, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: graph_prop("n", "name"), + alias: Some("n".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + GraphPipelineStage::Match(GraphPipelineMatchStage { + optional: false, + nodes: vec![graph_node_with_label("n", "PipelineReject")], + pieces: Vec::new(), + optional_candidate_where: None, + where_: None, + }), + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + items: GraphProjectionItems::Items(vec![GraphProjectItem { + expr: GraphExpr::Binding("n".to_string()), + alias: Some("n".to_string()), + projection: GraphReturnProjection::Auto, + }]), + distinct: false, + where_: None, + order_by: Vec::new(), + skip: None, + limit: None, + }), + ], + params: BTreeMap::new(), + at_epoch: Some(now_millis()), + page: GraphPageRequest { + skip: 0, + limit: 10, + cursor: None, + }, + output: GraphOutputOptions::default(), + options: GraphPipelineOptions { + allow_full_scan: true, + ..GraphPipelineOptions::default() + }, + }; + assert_graph_pipeline_invalid( + &engine, + &alias_kind_conflict, + "collides with an existing non-node alias", + ); +} + fn tampered_cursor_checksum(cursor: &str) -> String { let encoded = cursor.strip_prefix(GRAPH_ROW_CURSOR_PREFIX).unwrap(); let mut bytes = base64url_no_pad_decode(encoded).unwrap(); @@ -269,10 +2956,46 @@ fn tampered_cursor_logical_key_atom( graph_row_encode_cursor(&payload, GraphQueryOptions::default().max_cursor_bytes).unwrap() } +fn tampered_pipeline_cursor_sort_key(cursor: String) -> String { + let mut payload = + graph_pipeline_decode_logical_cursor(&cursor, GraphPipelineOptions::default().max_cursor_bytes) + .unwrap(); + payload.last_sort_key.push(GraphSortAtom::Null); + graph_pipeline_encode_logical_cursor(&payload, GraphPipelineOptions::default().max_cursor_bytes) + .unwrap() +} + +fn tampered_pipeline_cursor_logical_key(cursor: String) -> String { + let mut payload = + graph_pipeline_decode_logical_cursor(&cursor, GraphPipelineOptions::default().max_cursor_bytes) + .unwrap(); + payload.last_logical_row_key.pop(); + graph_pipeline_encode_logical_cursor(&payload, GraphPipelineOptions::default().max_cursor_bytes) + .unwrap() +} + +fn tampered_pipeline_cursor_internal_key_atom(cursor: String) -> String { + let mut payload = + graph_pipeline_decode_logical_cursor(&cursor, GraphPipelineOptions::default().max_cursor_bytes) + .unwrap(); + let atom = payload + .last_logical_row_key + .iter_mut() + .find(|atom| matches!(atom, GraphSortAtom::Bytes(_))) + .expect("pipeline cursor logical key should include internal bytes atom"); + *atom = GraphSortAtom::String(b"not-bytes".to_vec()); + graph_pipeline_encode_logical_cursor(&payload, GraphPipelineOptions::default().max_cursor_bytes) + .unwrap() +} + fn graph_row_value_rows(result: GraphRowResult) -> Vec> { result.rows.into_iter().map(|row| row.values).collect() } +fn graph_pipeline_value_rows(result: GraphPipelineResult) -> Vec> { + result.rows.into_iter().map(|row| row.values).collect() +} + fn graph_row_single_u64_column(result: GraphRowResult) -> Vec { result .rows @@ -468,12 +3191,31 @@ fn expr_contains_param(expr: &GraphExpr) -> bool { GraphExpr::List(items) => items.iter().any(expr_contains_param), GraphExpr::Map(items) => items.values().any(expr_contains_param), GraphExpr::Function { args, .. } => args.iter().any(expr_contains_param), + GraphExpr::AggregateCall { arg, .. } => { + arg.as_deref().is_some_and(expr_contains_param) + } + GraphExpr::ExistsSubquery(stage) => stage + .query + .stages + .iter() + .any(graph_pipeline_stage_contains_param_for_test), GraphExpr::Unary { expr, .. } | GraphExpr::IsNull(expr) | GraphExpr::IsNotNull(expr) => { expr_contains_param(expr) } GraphExpr::Binary { left, right, .. } => { expr_contains_param(left) || expr_contains_param(right) } + GraphExpr::Case { + operand, + branches, + else_expr, + } => { + operand.as_deref().is_some_and(expr_contains_param) + || branches + .iter() + .any(|branch| expr_contains_param(&branch.when) || expr_contains_param(&branch.then)) + || else_expr.as_deref().is_some_and(expr_contains_param) + } GraphExpr::Null | GraphExpr::Bool(_) | GraphExpr::Int(_) @@ -489,6 +3231,43 @@ fn expr_contains_param(expr: &GraphExpr) -> bool { } } +fn graph_pipeline_stage_contains_param_for_test(stage: &GraphPipelineStage) -> bool { + match stage { + GraphPipelineStage::Match(stage) => stage + .where_ + .as_ref() + .is_some_and(expr_contains_param), + GraphPipelineStage::Project(stage) => { + let items = match &stage.items { + GraphProjectionItems::Star => false, + GraphProjectionItems::Items(items) => { + items.iter().any(|item| expr_contains_param(&item.expr)) + } + }; + items + || stage.where_.as_ref().is_some_and(expr_contains_param) + || stage.order_by.iter().any(|item| expr_contains_param(&item.expr)) + || stage.skip.as_ref().is_some_and(expr_contains_param) + || stage.limit.as_ref().is_some_and(expr_contains_param) + } + GraphPipelineStage::Call(stage) => stage + .query + .stages + .iter() + .any(graph_pipeline_stage_contains_param_for_test), + GraphPipelineStage::Union(stage) => stage.branches.iter().any(|branch| { + branch + .stages + .iter() + .any(graph_pipeline_stage_contains_param_for_test) + }), + GraphPipelineStage::ShortestPath(stage) => { + matches!(&stage.from, GraphShortestPathEndpoint::Expr(expr) if expr_contains_param(expr)) + || matches!(&stage.to, GraphShortestPathEndpoint::Expr(expr) if expr_contains_param(expr)) + } + } +} + #[test] fn graph_row_binding_schema_slot_lookup_covers_all_slot_kinds() { let mut schema = GraphBindingSchema::new(); @@ -1004,7 +3783,7 @@ fn graph_row_property_field_and_function_evaluation_uses_synthetic_bindings() { }, ) .unwrap(), - GraphEvalValue::Node(GraphBoundNode::id_only(1)) + GraphEvalValue::Node(synthetic_node(1)) ); assert_eq!( eval_with_row( @@ -1016,7 +3795,7 @@ fn graph_row_property_field_and_function_evaluation_uses_synthetic_bindings() { }, ) .unwrap(), - GraphEvalValue::Node(GraphBoundNode::id_only(3)) + GraphEvalValue::Node(synthetic_node(3)) ); assert_eq!( eval_with_row( @@ -1029,9 +3808,9 @@ fn graph_row_property_field_and_function_evaluation_uses_synthetic_bindings() { ) .unwrap(), GraphEvalValue::List(vec![ - GraphEvalValue::Node(GraphBoundNode::id_only(1)), - GraphEvalValue::Node(GraphBoundNode::id_only(2)), - GraphEvalValue::Node(GraphBoundNode::id_only(3)), + GraphEvalValue::Node(synthetic_node(1)), + GraphEvalValue::Node(synthetic_node(2)), + GraphEvalValue::Node(synthetic_node(3)), ]) ); assert_eq!( @@ -1045,8 +3824,8 @@ fn graph_row_property_field_and_function_evaluation_uses_synthetic_bindings() { ) .unwrap(), GraphEvalValue::List(vec![ - GraphEvalValue::Edge(GraphBoundEdge::id_only(10)), - GraphEvalValue::Edge(GraphBoundEdge::id_only(11)), + GraphEvalValue::Edge(synthetic_edge(10, 1, 2)), + GraphEvalValue::Edge(synthetic_edge(11, 2, 3)), ]) ); } @@ -1112,7 +3891,7 @@ fn graph_row_path_derived_endpoint_functions_compose_with_loaded_path_payloads() .unwrap(); assert_eq!( eval_bound_graph_expr(&direct_start, &bound_context).unwrap(), - GraphEvalValue::Node(GraphBoundNode::id_only(1)) + GraphEvalValue::Node(synthetic_node(1)) ); let mut id_only_row = schema.empty_row(); @@ -1621,6 +4400,92 @@ fn graph_row_path_list_functions_support_selected_output() { assert!(first_edge.props.as_ref().unwrap().contains_key("since")); } +#[test] +fn graph_row_rich_path_function_outputs_preserve_hydrated_elements() { + let return_items = vec![ + GraphReturnItem { + expr: GraphExpr::Case { + operand: None, + branches: vec![GraphCaseBranch { + when: GraphExpr::Bool(true), + then: GraphExpr::Function { + name: GraphFunction::Nodes, + args: vec![GraphExpr::Binding("p".to_string())], + }, + }], + else_expr: Some(Box::new(GraphExpr::List(Vec::new()))), + }, + alias: Some("nodes".to_string()), + projection: GraphReturnProjection::Selected(GraphSelectedProjection::Node( + selected_node( + GraphPropertySelection::Keys(vec!["name".to_string()]), + GraphVectorSelection::None, + ), + )), + }, + GraphReturnItem { + expr: GraphExpr::Case { + operand: None, + branches: vec![GraphCaseBranch { + when: GraphExpr::Bool(true), + then: GraphExpr::Function { + name: GraphFunction::Relationships, + args: vec![GraphExpr::Binding("p".to_string())], + }, + }], + else_expr: Some(Box::new(GraphExpr::List(Vec::new()))), + }, + alias: Some("relationships".to_string()), + projection: GraphReturnProjection::Selected(GraphSelectedProjection::Edge( + selected_edge(GraphPropertySelection::Keys(vec!["since".to_string()])), + )), + }, + ]; + let mut query = graph_query( + &["a", "b"], + vec![graph_vlp(Some("p"), None, "a", "b", 1, 2)], + ); + query.output = GraphOutputOptions { + mode: GraphOutputMode::Projected, + compact_rows: false, + include_vectors: false, + }; + query.return_items = Some(return_items.clone()); + let normalized = normalize_graph_row_query(&query).unwrap(); + let path_needs = normalized.projection_needs.output.paths.get("p").unwrap(); + assert!(path_needs.nodes.is_some()); + assert!(path_needs.edges.is_some()); + + let mut schema = GraphBindingSchema::new(); + let path = schema.add_path_alias("p", false).unwrap(); + let mut row = schema.empty_row(); + row.bind_path(path, synthetic_path(&[1, 2, 3], &[10, 11])) + .unwrap(); + let values = + project_graph_row_values(&schema, &row, &return_items, &query.output, &BTreeMap::new()) + .unwrap(); + + let GraphValue::List(nodes) = &values[0] else { + panic!("expected selected node list"); + }; + let GraphValue::Node(first_node) = &nodes[0] else { + panic!("expected selected node"); + }; + assert_eq!(first_node.id, Some(1)); + assert_eq!(first_node.props.as_ref().unwrap().len(), 1); + assert!(first_node.props.as_ref().unwrap().contains_key("name")); + + let GraphValue::List(edges) = &values[1] else { + panic!("expected selected edge list"); + }; + let GraphValue::Edge(first_edge) = &edges[0] else { + panic!("expected selected edge"); + }; + assert_eq!(first_edge.id, Some(10)); + assert_eq!(first_edge.props.as_ref().unwrap().len(), 1); + assert!(first_edge.props.as_ref().unwrap().contains_key("since")); +} + #[test] fn graph_row_synthetic_output_conversion_covers_modes_paths_vectors_and_nulls() { let mut schema = GraphBindingSchema::new(); @@ -3261,6 +6126,33 @@ fn graph_row_vlp_path_output_hydrates_after_page_and_dedupes_elements() { }, "relationships", ), + graph_return_expr( + GraphExpr::Function { + name: GraphFunction::Size, + args: vec![GraphExpr::Function { + name: GraphFunction::Nodes, + args: vec![GraphExpr::Binding("p".to_string())], + }], + }, + "node_count", + ), + graph_return_expr( + GraphExpr::Function { + name: GraphFunction::Size, + args: vec![GraphExpr::Function { + name: GraphFunction::Relationships, + args: vec![GraphExpr::Binding("p".to_string())], + }], + }, + "edge_count", + ), + graph_return_expr( + GraphExpr::Function { + name: GraphFunction::Size, + args: vec![GraphExpr::List(vec![GraphExpr::Binding("a".to_string())])], + }, + "literal_node_list_count", + ), ]); let function_values = &engine.query_graph_rows(&function_query).unwrap().rows[0].values; assert_eq!(function_values[0], GraphValue::UInt(2)); @@ -3268,6 +6160,9 @@ fn graph_row_vlp_path_output_hydrates_after_page_and_dedupes_elements() { assert!(matches!(function_values[2], GraphValue::Node(_))); assert!(matches!(function_values[3], GraphValue::List(_))); assert!(matches!(function_values[4], GraphValue::List(_))); + assert_eq!(function_values[5], GraphValue::UInt(3)); + assert_eq!(function_values[6], GraphValue::UInt(2)); + assert_eq!(function_values[7], GraphValue::UInt(1)); } #[test] @@ -3777,6 +6672,70 @@ fn graph_row_order_over_obvious_list_or_map_is_rejected() { direction: GraphOrderDirection::Asc, }]; assert_graph_row_invalid(&labels, "order expression must not be a list or map value"); + + let mut case_list = graph_query(&["a"], Vec::new()); + case_list.order_by = vec![GraphOrderItem { + expr: GraphExpr::Case { + operand: None, + branches: vec![GraphCaseBranch { + when: GraphExpr::Bool(true), + then: GraphExpr::List(vec![GraphExpr::Int(1)]), + }], + else_expr: Some(Box::new(GraphExpr::Int(2))), + }, + direction: GraphOrderDirection::Asc, + }]; + assert_graph_row_invalid(&case_list, "order expression must not be a list or map value"); +} + +#[test] +fn graph_row_scalar_operators_reject_obvious_graph_element_operands() { + let mut neg_node = graph_query(&["a"], Vec::new()); + neg_node.return_items = Some(vec![graph_return_expr( + GraphExpr::Unary { + op: GraphUnaryOp::Neg, + expr: Box::new(GraphExpr::Binding("a".to_string())), + }, + "bad", + )]); + assert_graph_row_invalid( + &neg_node, + "operator - expects scalar operands, got a node", + ); + + let mut string_predicate_node = graph_query(&["a"], Vec::new()); + string_predicate_node.where_ = Some(GraphExpr::Binary { + left: Box::new(GraphExpr::Binding("a".to_string())), + op: GraphBinaryOp::StartsWith, + right: Box::new(GraphExpr::String("a".to_string())), + }); + assert_graph_row_invalid( + &string_predicate_node, + "operator STARTS WITH expects scalar operands, got a node", + ); + + let mut coalesce_case_node = graph_query(&["a"], Vec::new()); + coalesce_case_node.return_items = Some(vec![graph_return_expr( + GraphExpr::Function { + name: GraphFunction::Coalesce, + args: vec![ + GraphExpr::Case { + operand: None, + branches: vec![GraphCaseBranch { + when: GraphExpr::Bool(true), + then: GraphExpr::Binding("a".to_string()), + }], + else_expr: Some(Box::new(GraphExpr::Null)), + }, + GraphExpr::String("fallback".to_string()), + ], + }, + "bad", + )]); + assert_graph_row_invalid( + &coalesce_case_node, + "function coalesce expects scalar, list, map, or null input, got a node", + ); } #[test] @@ -6435,7 +9394,7 @@ fn graph_row_cursor_pages_concatenate_and_validate_replay_fields() { let cursor = page1.next_cursor.clone().expect("expected continuation"); let decoded_cursor_len = decoded_cursor_payload_len(&cursor); assert!( - cursor.as_bytes().len() > decoded_cursor_len, + cursor.len() > decoded_cursor_len, "encoded cursor should include prefix/base64 overhead" ); assert_eq!(graph_row_single_u64_column(page1), vec![ids[1], ids[2]]); diff --git a/src/engine/tests/txn.rs b/src/engine/tests/txn.rs index 7e6166b..aa57360 100644 --- a/src/engine/tests/txn.rs +++ b/src/engine/tests/txn.rs @@ -102,6 +102,206 @@ fn test_write_txn_unknown_read_only_lookups_do_not_create_tokens() { engine.close().unwrap(); } +#[test] +fn test_write_txn_merge_batch_planner_coalesces_node_keys() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("testdb"); + let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap(); + let existing_id = engine + .upsert_node( + "TxnMergePlanNode", + "existing", + UpsertNodeOptions::default(), + ) + .unwrap(); + + let txn = engine.begin_write_txn().unwrap(); + let mut overlay = TxnMergeOverlay::default(); + let batch = txn + .plan_keyed_node_merge_batch( + &mut overlay, + &[ + ("TxnMergePlanNode".to_string(), "new".to_string()), + ("TxnMergePlanNode".to_string(), "existing".to_string()), + ("TxnMergePlanNode".to_string(), "new".to_string()), + ], + ) + .unwrap(); + + assert_eq!(batch.snapshot_lookup_count, 2); + assert_eq!(batch.existing_ids, BTreeSet::from([existing_id])); + let local = match &batch.rows[0] { + TxnKeyedNodeMergeRowOutcome::Create(local) => *local, + other => panic!("expected first row to create, got {other:?}"), + }; + assert_eq!(batch.rows[1], TxnKeyedNodeMergeRowOutcome::Existing(existing_id)); + assert_eq!(batch.rows[2], TxnKeyedNodeMergeRowOutcome::MatchedLocal(local)); + + let repeat = txn + .plan_keyed_node_merge_batch( + &mut overlay, + &[("TxnMergePlanNode".to_string(), "new".to_string())], + ) + .unwrap(); + assert_eq!(repeat.snapshot_lookup_count, 0); + assert_eq!(repeat.rows, vec![TxnKeyedNodeMergeRowOutcome::MatchedLocal(local)]); + assert!(engine + .get_node_by_key("TxnMergePlanNode", "new") + .unwrap() + .is_none()); + engine.close().unwrap(); +} + +#[test] +fn test_write_txn_merge_batch_planner_coalesces_unique_edge_triples() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("testdb"); + let engine = DatabaseEngine::open( + &db_path, + &DbOptions { + edge_uniqueness: true, + ..Default::default() + }, + ) + .unwrap(); + let a = engine + .upsert_node("TxnMergePlanEndpoint", "a", UpsertNodeOptions::default()) + .unwrap(); + let b = engine + .upsert_node("TxnMergePlanEndpoint", "b", UpsertNodeOptions::default()) + .unwrap(); + let existing_edge = engine + .upsert_edge(a, b, "TXN_MERGE_PLAN_EDGE", UpsertEdgeOptions::default()) + .unwrap(); + + let txn = engine.begin_write_txn().unwrap(); + let mut overlay = TxnMergeOverlay::default(); + let local_endpoint = TxnNodeRef::Local(TxnLocalRef::Alias("planned-local".to_string())); + let batch = txn + .plan_unique_edge_merge_batch( + &mut overlay, + &[ + Some(TxnUniqueEdgeMergeInput { + from: TxnNodeRef::Id(a), + to: TxnNodeRef::Id(b), + label: "TXN_MERGE_PLAN_EDGE".to_string(), + }), + Some(TxnUniqueEdgeMergeInput { + from: TxnNodeRef::Id(b), + to: TxnNodeRef::Id(a), + label: "TXN_MERGE_PLAN_EDGE".to_string(), + }), + Some(TxnUniqueEdgeMergeInput { + from: TxnNodeRef::Id(b), + to: TxnNodeRef::Id(a), + label: "TXN_MERGE_PLAN_EDGE".to_string(), + }), + Some(TxnUniqueEdgeMergeInput { + from: local_endpoint.clone(), + to: TxnNodeRef::Id(b), + label: "TXN_MERGE_PLAN_EDGE".to_string(), + }), + Some(TxnUniqueEdgeMergeInput { + from: local_endpoint, + to: TxnNodeRef::Id(b), + label: "TXN_MERGE_PLAN_EDGE".to_string(), + }), + None, + ], + ) + .unwrap(); + + assert_eq!(batch.snapshot_lookup_count, 2); + assert_eq!(batch.existing_ids, BTreeSet::from([existing_edge])); + assert_eq!( + batch.missing_committed_triples, + BTreeSet::from([(b, a, "TXN_MERGE_PLAN_EDGE".to_string())]) + ); + assert_eq!( + batch.rows[0], + TxnUniqueEdgeMergeRowOutcome::Existing(existing_edge) + ); + let committed_local = match &batch.rows[1] { + TxnUniqueEdgeMergeRowOutcome::Create { local, .. } => *local, + other => panic!("expected missing committed triple to create, got {other:?}"), + }; + assert_eq!( + batch.rows[2], + TxnUniqueEdgeMergeRowOutcome::MatchedLocal(committed_local) + ); + let staged_endpoint_local = match &batch.rows[3] { + TxnUniqueEdgeMergeRowOutcome::Create { local, .. } => *local, + other => panic!("expected local endpoint triple to create, got {other:?}"), + }; + assert_eq!( + batch.rows[4], + TxnUniqueEdgeMergeRowOutcome::MatchedLocal(staged_endpoint_local) + ); + assert_eq!(batch.rows[5], TxnUniqueEdgeMergeRowOutcome::SkippedNull); + + let repeat_existing = txn + .plan_unique_edge_merge_batch( + &mut overlay, + &[Some(TxnUniqueEdgeMergeInput { + from: TxnNodeRef::Id(a), + to: TxnNodeRef::Id(b), + label: "TXN_MERGE_PLAN_EDGE".to_string(), + })], + ) + .unwrap(); + assert_eq!(repeat_existing.snapshot_lookup_count, 0); + assert_eq!(repeat_existing.existing_ids, BTreeSet::from([existing_edge])); + assert_eq!( + repeat_existing.rows, + vec![TxnUniqueEdgeMergeRowOutcome::Existing(existing_edge)] + ); + + let repeat_created = txn + .plan_unique_edge_merge_batch( + &mut overlay, + &[Some(TxnUniqueEdgeMergeInput { + from: TxnNodeRef::Id(b), + to: TxnNodeRef::Id(a), + label: "TXN_MERGE_PLAN_EDGE".to_string(), + })], + ) + .unwrap(); + assert_eq!(repeat_created.snapshot_lookup_count, 0); + assert_eq!( + repeat_created.rows, + vec![TxnUniqueEdgeMergeRowOutcome::MatchedLocal(committed_local)] + ); + + let keyed_endpoint = txn + .plan_unique_edge_merge_batch( + &mut overlay, + &[Some(TxnUniqueEdgeMergeInput { + from: TxnNodeRef::Key { + label: "TxnMergePlanEndpoint".to_string(), + key: "a".to_string(), + }, + to: TxnNodeRef::Id(b), + label: "TXN_MERGE_PLAN_EDGE".to_string(), + })], + ) + .unwrap_err(); + assert!(matches!( + keyed_endpoint, + EngineError::InvalidOperation(message) + if message.contains("resolved node IDs or local refs") + )); + + let non_unique = DatabaseEngine::open(&dir.path().join("non_unique"), &DbOptions::default()) + .unwrap(); + let non_unique_txn = non_unique.begin_write_txn().unwrap(); + assert!(matches!( + non_unique_txn.plan_unique_edge_merge_batch(&mut TxnMergeOverlay::default(), &[]), + Err(EngineError::InvalidOperation(message)) if message.contains("edge_uniqueness=true") + )); + non_unique.close().unwrap(); + engine.close().unwrap(); +} + #[test] fn test_write_txn_lifecycle_closed_db_and_finished_txn_rules() { let dir = TempDir::new().unwrap(); diff --git a/src/engine/txn.rs b/src/engine/txn.rs index 76f0104..d26d9fb 100644 --- a/src/engine/txn.rs +++ b/src/engine/txn.rs @@ -52,6 +52,81 @@ pub(crate) struct TxnReturnReadSet { pub(crate) edge_ids: BTreeSet, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct TxnMergeLocalNodeRef(pub(crate) usize); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct TxnMergeLocalEdgeRef(pub(crate) usize); + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum TxnMergeEndpointKey { + Id(u64), + Local(TxnLocalRef), + Key(String, String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TxnKeyedNodeMergeRowOutcome { + Existing(u64), + MatchedLocal(TxnMergeLocalNodeRef), + Create(TxnMergeLocalNodeRef), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TxnKeyedNodeMergeBatchOutcome { + pub(crate) rows: Vec, + pub(crate) existing_ids: BTreeSet, + pub(crate) snapshot_lookup_count: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TxnUniqueEdgeMergeInput { + pub(crate) from: TxnNodeRef, + pub(crate) to: TxnNodeRef, + pub(crate) label: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TxnUniqueEdgeMergeRowOutcome { + SkippedNull, + Existing(u64), + MatchedLocal(TxnMergeLocalEdgeRef), + Create { + local: TxnMergeLocalEdgeRef, + from: TxnNodeRef, + to: TxnNodeRef, + label: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TxnUniqueEdgeMergeBatchOutcome { + pub(crate) rows: Vec, + pub(crate) existing_ids: BTreeSet, + pub(crate) snapshot_lookup_count: usize, + pub(crate) missing_committed_triples: BTreeSet<(u64, u64, String)>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum TxnMergeNodeTarget { + Existing(u64), + Created(TxnMergeLocalNodeRef), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum TxnMergeEdgeTarget { + Existing(u64), + Created(TxnMergeLocalEdgeRef), +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct TxnMergeOverlay { + node_keys: BTreeMap<(String, String), TxnMergeNodeTarget>, + edge_triples: BTreeMap<(TxnMergeEndpointKey, TxnMergeEndpointKey, String), TxnMergeEdgeTarget>, + next_node: usize, + next_edge: usize, +} + #[derive(Clone, Copy)] pub(crate) struct TxnGraphOpBudget { scope: &'static str, @@ -204,6 +279,39 @@ fn txn_graph_op_cap_error(budget: TxnGraphOpBudget, actual: usize) -> EngineErro )) } +impl TxnMergeOverlay { + fn allocate_node(&mut self) -> TxnMergeLocalNodeRef { + let local = TxnMergeLocalNodeRef(self.next_node); + self.next_node = self.next_node.saturating_add(1); + local + } + + fn allocate_edge(&mut self) -> TxnMergeLocalEdgeRef { + let local = TxnMergeLocalEdgeRef(self.next_edge); + self.next_edge = self.next_edge.saturating_add(1); + local + } +} + +fn txn_merge_endpoint_key(node: &TxnNodeRef) -> TxnMergeEndpointKey { + match node { + TxnNodeRef::Id(id) => TxnMergeEndpointKey::Id(*id), + TxnNodeRef::Key { label, key } => TxnMergeEndpointKey::Key(label.clone(), key.clone()), + TxnNodeRef::Local(local) => TxnMergeEndpointKey::Local(local.clone()), + } +} + +fn txn_committed_edge_merge_triple( + from: &TxnNodeRef, + to: &TxnNodeRef, + label: &str, +) -> Option<(u64, u64, String)> { + match (from, to) { + (TxnNodeRef::Id(from), TxnNodeRef::Id(to)) => Some((*from, *to, label.to_string())), + _ => None, + } +} + impl WriteTxn { #[allow(dead_code)] pub(crate) fn gql_snapshot(&self) -> Result, EngineError> { @@ -222,6 +330,158 @@ impl WriteTxn { Ok(self.edge_uniqueness) } + pub(crate) fn plan_keyed_node_merge_batch( + &self, + overlay: &mut TxnMergeOverlay, + keys: &[(String, String)], + ) -> Result { + self.ensure_open()?; + let lookup_keys = keys + .iter() + .filter(|key| !overlay.node_keys.contains_key(*key)) + .cloned() + .collect::>(); + let snapshot_matches = self.gql_lookup_node_merge_keys(&lookup_keys)?; + let existing_ids = snapshot_matches.values().copied().collect::>(); + let mut existing_ids = existing_ids; + let mut rows = Vec::with_capacity(keys.len()); + + for merge_key in keys { + if let Some(target) = overlay.node_keys.get(merge_key).cloned() { + rows.push(match target { + TxnMergeNodeTarget::Existing(id) => { + existing_ids.insert(id); + TxnKeyedNodeMergeRowOutcome::Existing(id) + } + TxnMergeNodeTarget::Created(local) => { + TxnKeyedNodeMergeRowOutcome::MatchedLocal(local) + } + }); + continue; + } + + if let Some(&id) = snapshot_matches.get(merge_key) { + overlay + .node_keys + .insert(merge_key.clone(), TxnMergeNodeTarget::Existing(id)); + rows.push(TxnKeyedNodeMergeRowOutcome::Existing(id)); + continue; + } + + let local = overlay.allocate_node(); + overlay + .node_keys + .insert(merge_key.clone(), TxnMergeNodeTarget::Created(local)); + rows.push(TxnKeyedNodeMergeRowOutcome::Create(local)); + } + + Ok(TxnKeyedNodeMergeBatchOutcome { + rows, + existing_ids, + snapshot_lookup_count: lookup_keys.len(), + }) + } + + pub(crate) fn plan_unique_edge_merge_batch( + &self, + overlay: &mut TxnMergeOverlay, + inputs: &[Option], + ) -> Result { + self.ensure_open()?; + if !self.edge_uniqueness { + return Err(EngineError::InvalidOperation( + "GQL relationship MERGE requires edge_uniqueness=true".to_string(), + )); + } + for input in inputs.iter().flatten() { + if matches!(&input.from, TxnNodeRef::Key { .. }) + || matches!(&input.to, TxnNodeRef::Key { .. }) + { + return Err(EngineError::InvalidOperation( + "transaction relationship MERGE planner requires resolved node IDs or local refs" + .to_string(), + )); + } + } + + let committed_triples = inputs + .iter() + .filter_map(|input| { + let input = input.as_ref()?; + let merge_key = ( + txn_merge_endpoint_key(&input.from), + txn_merge_endpoint_key(&input.to), + input.label.clone(), + ); + if overlay.edge_triples.contains_key(&merge_key) { + return None; + } + txn_committed_edge_merge_triple(&input.from, &input.to, &input.label) + }) + .collect::>(); + let snapshot_matches = self.gql_lookup_edge_merge_triples(&committed_triples)?; + let existing_ids = snapshot_matches.values().copied().collect::>(); + let mut existing_ids = existing_ids; + let mut missing_committed_triples = BTreeSet::new(); + let mut rows = Vec::with_capacity(inputs.len()); + + for input in inputs { + let Some(input) = input else { + rows.push(TxnUniqueEdgeMergeRowOutcome::SkippedNull); + continue; + }; + let merge_key = ( + txn_merge_endpoint_key(&input.from), + txn_merge_endpoint_key(&input.to), + input.label.clone(), + ); + + if let Some(target) = overlay.edge_triples.get(&merge_key).cloned() { + rows.push(match target { + TxnMergeEdgeTarget::Existing(id) => { + existing_ids.insert(id); + TxnUniqueEdgeMergeRowOutcome::Existing(id) + } + TxnMergeEdgeTarget::Created(local) => { + TxnUniqueEdgeMergeRowOutcome::MatchedLocal(local) + } + }); + continue; + } + + if let Some(triple) = + txn_committed_edge_merge_triple(&input.from, &input.to, &input.label) + { + if let Some(&id) = snapshot_matches.get(&triple) { + overlay + .edge_triples + .insert(merge_key, TxnMergeEdgeTarget::Existing(id)); + rows.push(TxnUniqueEdgeMergeRowOutcome::Existing(id)); + continue; + } + missing_committed_triples.insert(triple); + } + + let local = overlay.allocate_edge(); + overlay + .edge_triples + .insert(merge_key, TxnMergeEdgeTarget::Created(local)); + rows.push(TxnUniqueEdgeMergeRowOutcome::Create { + local, + from: input.from.clone(), + to: input.to.clone(), + label: input.label.clone(), + }); + } + + Ok(TxnUniqueEdgeMergeBatchOutcome { + rows, + existing_ids, + snapshot_lookup_count: committed_triples.len(), + missing_committed_triples, + }) + } + pub(crate) fn gql_first_existing_node_key( &self, keys: &BTreeSet<(String, String)>, @@ -251,6 +511,36 @@ impl WriteTxn { Ok(None) } + pub(crate) fn gql_lookup_node_merge_keys( + &self, + keys: &BTreeSet<(String, String)>, + ) -> Result, EngineError> { + self.ensure_open()?; + let mut resolved = Vec::with_capacity(keys.len()); + for (label, key) in keys { + let Some(label_id) = self.snapshot.label_catalog.resolve_node_label_for_read(label)? + else { + continue; + }; + resolved.push((label.clone(), key.clone(), label_id)); + } + if resolved.is_empty() { + return Ok(BTreeMap::new()); + } + let key_refs: Vec<(u32, &str)> = resolved + .iter() + .map(|(_, key, label_id)| (*label_id, key.as_str())) + .collect(); + let nodes = self.snapshot.get_nodes_by_label_keys_raw(&key_refs)?; + let mut out = BTreeMap::new(); + for ((label, key, _), node) in resolved.into_iter().zip(nodes) { + if let Some(node) = node { + out.insert((label, key), node.id); + } + } + Ok(out) + } + pub(crate) fn gql_first_existing_edge_triple( &self, triples: &BTreeSet<(u64, u64, String)>, @@ -280,6 +570,36 @@ impl WriteTxn { Ok(None) } + pub(crate) fn gql_lookup_edge_merge_triples( + &self, + triples: &BTreeSet<(u64, u64, String)>, + ) -> Result, EngineError> { + self.ensure_open()?; + let mut resolved = Vec::with_capacity(triples.len()); + for (from, to, label) in triples { + let Some(label_id) = self.snapshot.label_catalog.resolve_edge_label_for_read(label)? + else { + continue; + }; + resolved.push((*from, *to, label.clone(), label_id)); + } + if resolved.is_empty() { + return Ok(BTreeMap::new()); + } + let triple_refs: Vec<(u64, u64, u32)> = resolved + .iter() + .map(|(from, to, _, label_id)| (*from, *to, *label_id)) + .collect(); + let edges = self.snapshot.get_edges_by_triples_raw(&triple_refs)?; + let mut out = BTreeMap::new(); + for ((from, to, label, _), edge) in resolved.into_iter().zip(edges) { + if let Some(edge) = edge { + out.insert((from, to, label), edge.id); + } + } + Ok(out) + } + pub fn upsert_node( &mut self, labels: L, diff --git a/src/engine/write.rs b/src/engine/write.rs index d9d62a8..7615e85 100644 --- a/src/engine/write.rs +++ b/src/engine/write.rs @@ -576,7 +576,7 @@ impl EngineCore { for ((input, &label_set), (dense_vector, sparse_vector)) in inputs .iter() .zip(label_sets.iter()) - .zip(normalized_vectors.into_iter()) + .zip(normalized_vectors) { let mut winner: Option<(u64, i64)> = None; for &label_id in label_set.as_slice() { diff --git a/src/gql/ast.rs b/src/gql/ast.rs index 3df2c2b..5c4ccf1 100644 --- a/src/gql/ast.rs +++ b/src/gql/ast.rs @@ -15,9 +15,123 @@ pub(crate) struct GqlQuery { pub(crate) order_by: Vec, pub(crate) skip: Option, pub(crate) limit: Option, + pub(crate) pipeline: GqlReadPipeline, pub(crate) span: SourceSpan, } +impl GqlQuery { + pub(crate) fn is_legacy_single_block(&self) -> bool { + self.pipeline.union_branches.is_empty() + && self.pipeline.is_legacy_single_block() + && !self.return_clause.distinct + && self.match_clauses + == self + .pipeline + .leading_match_clauses() + .cloned() + .unwrap_or_default() + } + + pub(crate) fn requires_deferred_pipeline_execution(&self) -> bool { + !self.is_legacy_single_block() + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GqlReadPipeline { + pub(crate) clauses: Vec, + pub(crate) union_branches: Vec, + pub(crate) span: SourceSpan, +} + +impl GqlReadPipeline { + fn leading_match_clauses(&self) -> Option<&Vec> { + match self.clauses.first()? { + GqlPipelineClause::Match(clauses) => Some(clauses), + GqlPipelineClause::ShortestPath(_) => None, + GqlPipelineClause::Call(_) => None, + GqlPipelineClause::Projection(_) => None, + } + } + + fn is_legacy_single_block(&self) -> bool { + matches!( + self.clauses.as_slice(), + [ + GqlPipelineClause::Match(_), + GqlPipelineClause::Projection(GqlProjectionClause { + kind: GqlProjectionKind::Return, + distinct: false, + where_clause: None, + .. + }) + ] + ) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GqlUnionBranch { + pub(crate) modifier: GqlUnionModifier, + pub(crate) clauses: Vec, + pub(crate) span: SourceSpan, + pub(crate) union_span: SourceSpan, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum GqlUnionModifier { + Distinct, + All, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum GqlPipelineClause { + Match(Vec), + ShortestPath(GqlShortestPathClause), + Call(GqlCallSubquery), + Projection(GqlProjectionClause), +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GqlCallSubquery { + pub(crate) pipeline: Box, + pub(crate) span: SourceSpan, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GqlShortestPathClause { + pub(crate) optional: bool, + pub(crate) output_path_alias: Ident, + pub(crate) mode: GqlShortestPathMode, + pub(crate) pattern: Pattern, + pub(crate) span: SourceSpan, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum GqlShortestPathMode { + One, + All, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GqlProjectionClause { + pub(crate) kind: GqlProjectionKind, + pub(crate) distinct: bool, + pub(crate) distinct_span: Option, + pub(crate) body: ReturnBody, + pub(crate) where_clause: Option, + pub(crate) order_by: Vec, + pub(crate) skip: Option, + pub(crate) limit: Option, + pub(crate) span: SourceSpan, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum GqlProjectionKind { + With, + Return, +} + #[derive(Clone, Debug, PartialEq)] pub(crate) struct GqlStatement { pub(crate) kind: GqlStatementKind, @@ -34,14 +148,17 @@ pub(crate) enum GqlStatementBody { #[derive(Clone, Debug, PartialEq)] pub(crate) struct GqlMutationStatement { pub(crate) read_prefix: Vec, + pub(crate) read_prefix_pipeline: Option, pub(crate) mutation_clauses: Vec, pub(crate) return_tail: Option, pub(crate) span: SourceSpan, } +#[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, PartialEq)] pub(crate) enum MutationClause { Create(CreateClause), + Merge(MergeClause), Set(SetClause), Remove(RemoveClause), Delete(DeleteClause), @@ -53,6 +170,14 @@ pub(crate) struct CreateClause { pub(crate) span: SourceSpan, } +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct MergeClause { + pub(crate) pattern: Pattern, + pub(crate) on_create: Option, + pub(crate) on_match: Option, + pub(crate) span: SourceSpan, +} + #[derive(Clone, Debug, PartialEq)] pub(crate) struct SetClause { pub(crate) items: Vec, @@ -172,12 +297,18 @@ pub(crate) enum RelationshipDirection { #[derive(Clone, Debug, PartialEq)] pub(crate) struct ReturnClause { pub(crate) body: ReturnBody, + pub(crate) distinct: bool, + pub(crate) distinct_span: Option, pub(crate) span: SourceSpan, } #[derive(Clone, Debug, PartialEq)] pub(crate) enum ReturnBody { All(SourceSpan), + AllAndItems { + star_span: SourceSpan, + items: Vec, + }, Items(Vec), } @@ -233,10 +364,28 @@ pub(crate) enum ExprKind { name: Ident, args: Vec, }, + AggregateCall { + function: AggregateFunction, + distinct: bool, + arg: Option>, + name_span: SourceSpan, + }, + ExistsSubquery(Box), + Case { + operand: Option>, + branches: Vec, + else_expr: Option>, + }, List(Vec), Map(MapLiteral), } +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct CaseBranch { + pub(crate) when: Expr, + pub(crate) then: Expr, +} + #[derive(Clone, Debug, PartialEq)] pub(crate) enum Literal { Null, @@ -249,12 +398,17 @@ pub(crate) enum Literal { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum UnaryOp { Not, + Neg, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum BinaryOp { Or, And, + Add, + Sub, + Mul, + Div, Eq, Neq, Lt, @@ -262,6 +416,19 @@ pub(crate) enum BinaryOp { Gt, Ge, In, + StartsWith, + EndsWith, + Contains, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AggregateFunction { + Count, + Sum, + Avg, + Min, + Max, + Collect, } #[derive(Clone, Debug, PartialEq)] diff --git a/src/gql/eval.rs b/src/gql/eval.rs index 3310b6b..912a612 100644 --- a/src/gql/eval.rs +++ b/src/gql/eval.rs @@ -1,6 +1,10 @@ use crate::error::EngineError; use crate::gql::ast::{BinaryOp, Expr, ExprKind, Literal, MapLiteral, UnaryOp}; use crate::gql::semantic::{gql_semantic_error, GqlAliasKind, GqlReturnPlan, GqlSemanticPlan}; +use crate::graph_row::{ + eval_graph_binary_values, eval_graph_scalar_function_values, eval_graph_unary_value, + GraphEvalValue, +}; use crate::property_value_semantics::{ compare_numeric_keys, numeric_key_from_f64, numeric_key_from_i64, numeric_key_from_u64, NumericScalarKey, @@ -11,7 +15,9 @@ use crate::row_projection::{ EdgeOutputProjection, EdgeProjectionField, NodeOutputProjection, NodeProjectionField, ProjectedRow, ProjectedValue, ProjectionColumn, ProjectionNeedClass, RowProjectionPlan, }; -use crate::types::{GqlParamValue, GqlParams, GqlSemanticErrorCode}; +use crate::types::{ + GqlParamValue, GqlParams, GqlSemanticErrorCode, GraphBinaryOp, GraphFunction, GraphUnaryOp, +}; use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; @@ -425,6 +431,18 @@ fn collect_expr_refs( )?; } } + ExprKind::AggregateCall { arg, .. } => { + if let Some(arg) = arg.as_ref() { + collect_expr_refs( + arg, + plan, + alias_projection, + include_variable_elements, + include_vectors, + refs, + )?; + } + } ExprKind::Unary { expr, .. } | ExprKind::IsNull { expr, .. } => collect_expr_refs( expr, plan, @@ -451,6 +469,50 @@ fn collect_expr_refs( refs, )?; } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand { + collect_expr_refs( + operand, + plan, + alias_projection, + include_variable_elements, + include_vectors, + refs, + )?; + } + for branch in branches { + collect_expr_refs( + &branch.when, + plan, + alias_projection, + include_variable_elements, + include_vectors, + refs, + )?; + collect_expr_refs( + &branch.then, + plan, + alias_projection, + include_variable_elements, + include_vectors, + refs, + )?; + } + if let Some(else_expr) = else_expr { + collect_expr_refs( + else_expr, + plan, + alias_projection, + include_variable_elements, + include_vectors, + refs, + )?; + } + } ExprKind::List(items) => { for item in items { collect_expr_refs( @@ -475,6 +537,7 @@ fn collect_expr_refs( )?; } } + ExprKind::ExistsSubquery(_) => {} ExprKind::Literal(_) | ExprKind::Parameter(_) => {} } Ok(()) @@ -515,7 +578,7 @@ fn add_element_ref( output_name: internal_output_name(alias, "edge"), }); } - GqlAliasKind::Path => {} + GqlAliasKind::Path | GqlAliasKind::Scalar => {} } Ok(()) } @@ -561,7 +624,7 @@ fn add_property_ref( }); } } - GqlAliasKind::Path => {} + GqlAliasKind::Path | GqlAliasKind::Scalar => {} } Ok(()) } @@ -582,7 +645,7 @@ fn add_function_ref( GqlAliasKind::Edge => { add_edge_metadata_ref(alias, projection_alias, EdgeProjectionField::Id, refs) } - GqlAliasKind::Path => {} + GqlAliasKind::Path | GqlAliasKind::Scalar => {} }, "labels" => { add_node_metadata_ref(alias, projection_alias, NodeProjectionField::Labels, refs) @@ -653,7 +716,7 @@ fn eval_expr(expr: &Expr, context: &GqlEvalContext<'_>) -> Result GqlRuntimeValueKey::EdgeElement { alias: alias.clone(), }, - GqlAliasKind::Path => { + GqlAliasKind::Path | GqlAliasKind::Scalar => { return Ok(RuntimeValue::Binding { alias: alias.clone(), kind, @@ -701,21 +764,20 @@ fn eval_expr(expr: &Expr, context: &GqlEvalContext<'_>) -> Result match eval_expr(expr, context)? { - RuntimeValue::Value(ProjectedValue::Bool(value)) => { - Ok(RuntimeValue::Value(ProjectedValue::Bool(!value))) - } - RuntimeValue::Value(ProjectedValue::Null) => { - Ok(RuntimeValue::Value(ProjectedValue::Null)) - } - RuntimeValue::Value(_) | RuntimeValue::Binding { .. } => Err(invalid_expression_error( - expr, - "NOT requires a boolean or null operand", - )), - }, + ExprKind::Unary { op, expr } => { + let value = value_only(expr, eval_expr(expr, context)?)?; + let graph_value = projected_value_to_graph_eval_scalar(&value)?.ok_or_else(|| { + invalid_expression_error(expr, "unary expression requires scalar or null input") + })?; + let graph_op = match op { + UnaryOp::Not => GraphUnaryOp::Not, + UnaryOp::Neg => GraphUnaryOp::Neg, + }; + Ok(RuntimeValue::Value(graph_eval_to_projected_scalar( + eval_graph_unary_value(graph_op, &graph_value)?, + &expr.span, + )?)) + } ExprKind::Binary { op, left, right } => eval_binary(*op, left, right, context), ExprKind::IsNull { expr, negated } => { let value = eval_expr(expr, context)?; @@ -727,6 +789,10 @@ fn eval_expr(expr: &Expr, context: &GqlEvalContext<'_>) -> Result { + let lower = name.name.to_ascii_lowercase(); + if let Some(function) = gql_eval_scalar_function_name(&lower) { + return eval_scalar_function(function, &name.name, args, context, &expr.span); + } if args.len() != 1 { return Err(invalid_expression_error( expr, @@ -761,6 +827,12 @@ fn eval_expr(expr: &Expr, context: &GqlEvalContext<'_>) -> Result { + return Err(invalid_expression_error( + expr, + "id() expects a node or edge alias", + )); + } }, "labels" => context.value(GqlRuntimeValueKey::NodeMetadata { alias: alias.clone(), @@ -779,6 +851,25 @@ fn eval_expr(expr: &Expr, context: &GqlEvalContext<'_>) -> Result Err(invalid_expression_error( + expr, + "aggregate functions require projection pipeline evaluation", + )), + ExprKind::ExistsSubquery(_) => Err(invalid_expression_error( + expr, + "EXISTS subqueries require native pipeline evaluation", + )), + ExprKind::Case { + operand, + branches, + else_expr, + } => eval_case( + operand.as_deref(), + branches, + else_expr.as_deref(), + context, + &expr.span, + ), ExprKind::List(items) => { let mut values = Vec::with_capacity(items.len()); for item in items { @@ -805,9 +896,37 @@ fn eval_binary( | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge + | BinaryOp::Add + | BinaryOp::Sub + | BinaryOp::Mul + | BinaryOp::Div + | BinaryOp::StartsWith + | BinaryOp::EndsWith + | BinaryOp::Contains | BinaryOp::In => { let left_value = value_only(left, eval_expr(left, context)?)?; let right_value = value_only(right, eval_expr(right, context)?)?; + if matches!( + op, + BinaryOp::Add + | BinaryOp::Sub + | BinaryOp::Mul + | BinaryOp::Div + | BinaryOp::StartsWith + | BinaryOp::EndsWith + | BinaryOp::Contains + ) { + return Ok(RuntimeValue::Value(eval_shared_binary( + op, + &left_value, + &right_value, + &left.span, + )?)); + } + if let Some(value) = try_eval_shared_binary(op, &left_value, &right_value, &left.span)? + { + return Ok(RuntimeValue::Value(value)); + } Ok(RuntimeValue::Value(compare_values( op, left_value, @@ -851,6 +970,275 @@ fn eval_or( })) } +fn eval_case( + operand: Option<&Expr>, + branches: &[crate::gql::ast::CaseBranch], + else_expr: Option<&Expr>, + context: &GqlEvalContext<'_>, + span: &crate::types::SourceSpan, +) -> Result { + if let Some(operand) = operand { + let operand_value = value_only(operand, eval_expr(operand, context)?)?; + for branch in branches { + let when_value = value_only(&branch.when, eval_expr(&branch.when, context)?)?; + if let Some(value) = + try_eval_shared_binary(BinaryOp::Eq, &operand_value, &when_value, span)? + { + match value { + ProjectedValue::Bool(true) => return eval_expr(&branch.then, context), + ProjectedValue::Bool(false) | ProjectedValue::Null => {} + _ => unreachable!("equality returns bool or null"), + } + } else if matches!( + compare_values(BinaryOp::Eq, operand_value.clone(), when_value), + ProjectedValue::Bool(true) + ) { + return eval_expr(&branch.then, context); + } + } + } else { + for branch in branches { + if let Some(true) = bool_or_null(&branch.when, eval_expr(&branch.when, context)?)? { + return eval_expr(&branch.then, context); + } + } + } + else_expr + .map(|expr| eval_expr(expr, context)) + .unwrap_or(Ok(RuntimeValue::Value(ProjectedValue::Null))) +} + +fn eval_scalar_function( + function: GraphFunction, + display: &str, + args: &[Expr], + context: &GqlEvalContext<'_>, + span: &crate::types::SourceSpan, +) -> Result { + validate_eval_scalar_function_arity(&display.to_ascii_lowercase(), display, args.len(), span)?; + if function == GraphFunction::Coalesce { + for arg in args { + let value = value_only(arg, eval_expr(arg, context)?)?; + let graph_value = projected_value_to_graph_eval_scalar(&value)?.ok_or_else(|| { + invalid_expression_error( + arg, + "scalar function expects scalar, list, map, or null input", + ) + })?; + if !graph_value.is_null() { + let checked = eval_graph_scalar_function_values( + GraphFunction::Coalesce, + std::slice::from_ref(&graph_value), + )?; + return Ok(RuntimeValue::Value(graph_eval_to_projected_scalar( + checked, &arg.span, + )?)); + } + } + return Ok(RuntimeValue::Value(ProjectedValue::Null)); + } + let values = args + .iter() + .map(|arg| { + let value = value_only(arg, eval_expr(arg, context)?)?; + projected_value_to_graph_eval_scalar(&value)?.ok_or_else(|| { + invalid_expression_error( + arg, + "scalar function expects scalar, list, map, or null input", + ) + }) + }) + .collect::, EngineError>>()?; + Ok(RuntimeValue::Value(graph_eval_to_projected_scalar( + eval_graph_scalar_function_values(function, &values)?, + span, + )?)) +} + +fn eval_shared_binary( + op: BinaryOp, + left: &ProjectedValue, + right: &ProjectedValue, + span: &crate::types::SourceSpan, +) -> Result { + let left = projected_value_to_graph_eval_scalar(left)?.ok_or_else(|| { + gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "scalar operator requires scalar, list, map, or null operands".to_string(), + span.clone(), + ) + })?; + let right = projected_value_to_graph_eval_scalar(right)?.ok_or_else(|| { + gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "scalar operator requires scalar, list, map, or null operands".to_string(), + span.clone(), + ) + })?; + graph_eval_to_projected_scalar( + eval_graph_binary_values(gql_eval_binary_op_to_graph_op(op), &left, &right)?, + span, + ) +} + +fn try_eval_shared_binary( + op: BinaryOp, + left: &ProjectedValue, + right: &ProjectedValue, + span: &crate::types::SourceSpan, +) -> Result, EngineError> { + let Some(left) = projected_value_to_graph_eval_scalar(left)? else { + return Ok(None); + }; + let Some(right) = projected_value_to_graph_eval_scalar(right)? else { + return Ok(None); + }; + graph_eval_to_projected_scalar( + eval_graph_binary_values(gql_eval_binary_op_to_graph_op(op), &left, &right)?, + span, + ) + .map(Some) +} + +fn gql_eval_binary_op_to_graph_op(op: BinaryOp) -> GraphBinaryOp { + match op { + BinaryOp::Or => GraphBinaryOp::Or, + BinaryOp::And => GraphBinaryOp::And, + BinaryOp::Add => GraphBinaryOp::Add, + BinaryOp::Sub => GraphBinaryOp::Sub, + BinaryOp::Mul => GraphBinaryOp::Mul, + BinaryOp::Div => GraphBinaryOp::Div, + BinaryOp::Eq => GraphBinaryOp::Eq, + BinaryOp::Neq => GraphBinaryOp::Neq, + BinaryOp::Lt => GraphBinaryOp::Lt, + BinaryOp::Le => GraphBinaryOp::Le, + BinaryOp::Gt => GraphBinaryOp::Gt, + BinaryOp::Ge => GraphBinaryOp::Ge, + BinaryOp::In => GraphBinaryOp::In, + BinaryOp::StartsWith => GraphBinaryOp::StartsWith, + BinaryOp::EndsWith => GraphBinaryOp::EndsWith, + BinaryOp::Contains => GraphBinaryOp::Contains, + } +} + +fn gql_eval_scalar_function_name(lower: &str) -> Option { + match lower { + "coalesce" => Some(GraphFunction::Coalesce), + "to_string" => Some(GraphFunction::ToString), + "to_integer" => Some(GraphFunction::ToInteger), + "to_float" => Some(GraphFunction::ToFloat), + "abs" => Some(GraphFunction::Abs), + "floor" => Some(GraphFunction::Floor), + "ceil" => Some(GraphFunction::Ceil), + "round" => Some(GraphFunction::Round), + "lower" => Some(GraphFunction::Lower), + "upper" => Some(GraphFunction::Upper), + "trim" => Some(GraphFunction::Trim), + "substring" => Some(GraphFunction::Substring), + "size" => Some(GraphFunction::Size), + "head" => Some(GraphFunction::Head), + "last" => Some(GraphFunction::Last), + _ => None, + } +} + +fn validate_eval_scalar_function_arity( + lower: &str, + display: &str, + arg_count: usize, + span: &crate::types::SourceSpan, +) -> Result<(), EngineError> { + let valid = match lower { + "coalesce" => arg_count >= 1, + "substring" => matches!(arg_count, 2 | 3), + "to_string" | "to_integer" | "to_float" | "abs" | "floor" | "ceil" | "round" | "lower" + | "upper" | "trim" | "size" | "head" | "last" => arg_count == 1, + _ => false, + }; + if valid { + return Ok(()); + } + let expected = match lower { + "coalesce" => "at least one argument", + "substring" => "two or three arguments", + _ => "exactly one argument", + }; + Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + format!("function '{display}' expects {expected}"), + span.clone(), + )) +} + +fn projected_value_to_graph_eval_scalar( + value: &ProjectedValue, +) -> Result, EngineError> { + Ok(match value { + ProjectedValue::Null => Some(GraphEvalValue::Null), + ProjectedValue::Bool(value) => Some(GraphEvalValue::Bool(*value)), + ProjectedValue::Int(value) => Some(GraphEvalValue::Int(*value)), + ProjectedValue::UInt(value) => Some(GraphEvalValue::UInt(*value)), + ProjectedValue::Float(value) => Some(GraphEvalValue::Float(*value)), + ProjectedValue::String(value) => Some(GraphEvalValue::String(value.clone())), + ProjectedValue::Bytes(value) => Some(GraphEvalValue::Bytes(value.clone())), + ProjectedValue::List(values) => { + let mut out = Vec::with_capacity(values.len()); + for value in values { + let Some(value) = projected_value_to_graph_eval_scalar(value)? else { + return Ok(None); + }; + out.push(value); + } + Some(GraphEvalValue::List(out)) + } + ProjectedValue::Map(values) => { + let mut out = BTreeMap::new(); + for (key, value) in values { + let Some(value) = projected_value_to_graph_eval_scalar(value)? else { + return Ok(None); + }; + out.insert(key.clone(), value); + } + Some(GraphEvalValue::Map(out)) + } + ProjectedValue::Node(_) | ProjectedValue::Edge(_) | ProjectedValue::Path(_) => None, + }) +} + +fn graph_eval_to_projected_scalar( + value: GraphEvalValue, + span: &crate::types::SourceSpan, +) -> Result { + Ok(match value { + GraphEvalValue::Null => ProjectedValue::Null, + GraphEvalValue::Bool(value) => ProjectedValue::Bool(value), + GraphEvalValue::Int(value) => ProjectedValue::Int(value), + GraphEvalValue::UInt(value) => ProjectedValue::UInt(value), + GraphEvalValue::Float(value) => ProjectedValue::Float(value), + GraphEvalValue::String(value) => ProjectedValue::String(value), + GraphEvalValue::Bytes(value) => ProjectedValue::Bytes(value), + GraphEvalValue::List(values) => ProjectedValue::List( + values + .into_iter() + .map(|value| graph_eval_to_projected_scalar(value, span)) + .collect::, _>>()?, + ), + GraphEvalValue::Map(values) => ProjectedValue::Map( + values + .into_iter() + .map(|(key, value)| Ok((key, graph_eval_to_projected_scalar(value, span)?))) + .collect::, EngineError>>()?, + ), + GraphEvalValue::Node(_) | GraphEvalValue::Edge(_) | GraphEvalValue::Path(_) => { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "scalar expression produced a graph element value".to_string(), + span.clone(), + )); + } + }) +} + fn eval_map(map: &MapLiteral, context: &GqlEvalContext<'_>) -> Result { let mut values = BTreeMap::new(); for entry in &map.entries { @@ -895,7 +1283,7 @@ fn property_value_for_alias( }) } } - GqlAliasKind::Path => ProjectedValue::Null, + GqlAliasKind::Path | GqlAliasKind::Scalar => ProjectedValue::Null, } } @@ -936,7 +1324,15 @@ fn compare_values(op: BinaryOp, left: ProjectedValue, right: ProjectedValue) -> } _ => ProjectedValue::Null, }, - BinaryOp::And | BinaryOp::Or => unreachable!(), + BinaryOp::And + | BinaryOp::Or + | BinaryOp::Add + | BinaryOp::Sub + | BinaryOp::Mul + | BinaryOp::Div + | BinaryOp::StartsWith + | BinaryOp::EndsWith + | BinaryOp::Contains => unreachable!(), } } @@ -1229,4 +1625,36 @@ mod tests { ProjectedValue::Bool(false) ); } + + #[test] + fn lazy_coalesce_uses_shared_scalar_domain_validation() { + let params = GqlParams::from([("bad".to_string(), GqlParamValue::Float(f64::NAN))]); + let plan = bind_query( + parse_query( + "MATCH (n:Person) RETURN coalesce($bad, 1)", + &GqlParseOptions::default(), + ) + .unwrap(), + ¶ms, + ) + .unwrap(); + let expr = return_exprs(&plan).pop().unwrap().expr; + let projection = build_runtime_projection( + std::slice::from_ref(&expr), + &plan, + &BTreeMap::new(), + false, + false, + ) + .unwrap(); + let row = ProjectedRow { values: Vec::new() }; + let context = GqlEvalContext::new(&projection, &row, &plan, ¶ms); + + let err = eval_expr_against_context(&expr, &context).unwrap_err(); + assert!(matches!( + err, + EngineError::InvalidOperation(message) + if message.contains("scalar function result must be finite") + )); + } } diff --git a/src/gql/lexer.rs b/src/gql/lexer.rs index f5d2c61..9ce1a1d 100644 --- a/src/gql/lexer.rs +++ b/src/gql/lexer.rs @@ -11,6 +11,8 @@ pub(crate) enum Keyword { Asc, By, Call, + Case, + Contains, Constraint, Create, Delete, @@ -18,6 +20,9 @@ pub(crate) enum Keyword { Detach, Distinct, Drop, + Else, + End, + Ends, Exists, False, Foreach, @@ -32,6 +37,7 @@ pub(crate) enum Keyword { Not, Null, Offset, + On, Optional, Or, Order, @@ -40,11 +46,14 @@ pub(crate) enum Keyword { Set, Show, Skip, + Starts, True, + Then, Union, Unwind, Use, Where, + When, With, } @@ -534,6 +543,10 @@ fn keyword(raw: &str) -> Option { Keyword::By } else if raw.eq_ignore_ascii_case("CALL") { Keyword::Call + } else if raw.eq_ignore_ascii_case("CASE") { + Keyword::Case + } else if raw.eq_ignore_ascii_case("CONTAINS") { + Keyword::Contains } else if raw.eq_ignore_ascii_case("CONSTRAINT") { Keyword::Constraint } else if raw.eq_ignore_ascii_case("CREATE") { @@ -548,6 +561,12 @@ fn keyword(raw: &str) -> Option { Keyword::Distinct } else if raw.eq_ignore_ascii_case("DROP") { Keyword::Drop + } else if raw.eq_ignore_ascii_case("ELSE") { + Keyword::Else + } else if raw.eq_ignore_ascii_case("END") { + Keyword::End + } else if raw.eq_ignore_ascii_case("ENDS") { + Keyword::Ends } else if raw.eq_ignore_ascii_case("EXISTS") { Keyword::Exists } else if raw.eq_ignore_ascii_case("FALSE") { @@ -576,6 +595,8 @@ fn keyword(raw: &str) -> Option { Keyword::Null } else if raw.eq_ignore_ascii_case("OFFSET") { Keyword::Offset + } else if raw.eq_ignore_ascii_case("ON") { + Keyword::On } else if raw.eq_ignore_ascii_case("OPTIONAL") { Keyword::Optional } else if raw.eq_ignore_ascii_case("OR") { @@ -592,8 +613,12 @@ fn keyword(raw: &str) -> Option { Keyword::Show } else if raw.eq_ignore_ascii_case("SKIP") { Keyword::Skip + } else if raw.eq_ignore_ascii_case("STARTS") { + Keyword::Starts } else if raw.eq_ignore_ascii_case("TRUE") { Keyword::True + } else if raw.eq_ignore_ascii_case("THEN") { + Keyword::Then } else if raw.eq_ignore_ascii_case("UNION") { Keyword::Union } else if raw.eq_ignore_ascii_case("UNWIND") { @@ -602,6 +627,8 @@ fn keyword(raw: &str) -> Option { Keyword::Use } else if raw.eq_ignore_ascii_case("WHERE") { Keyword::Where + } else if raw.eq_ignore_ascii_case("WHEN") { + Keyword::When } else if raw.eq_ignore_ascii_case("WITH") { Keyword::With } else { diff --git a/src/gql/lower.rs b/src/gql/lower.rs index 7f48243..c360706 100644 --- a/src/gql/lower.rs +++ b/src/gql/lower.rs @@ -5,18 +5,25 @@ use crate::error::EngineError; use crate::gql::ast::*; use crate::gql::params::{validate_referenced_gql_mutation_params, validate_referenced_gql_params}; use crate::gql::semantic::{ - bind_mutation, bind_query, expression_output_name, gql_semantic_error, variable_name, - GqlAliasKind, GqlAliasOrigin, GqlBoundCreateEdge, GqlBoundCreateNode, GqlBoundEdgePattern, - GqlBoundMutationClause, GqlBoundNodePattern, GqlBoundPattern, GqlBoundRemoveItem, - GqlBoundSetItem, GqlMutationSemanticPlan, GqlReturnPlan, GqlSemanticPlan, + bind_mutation, bind_query, bind_subquery_pipeline_for_outer_aliases, expression_output_name, + gql_semantic_error, variable_name, GqlAliasBinding, GqlAliasKind, GqlAliasOrigin, + GqlAliasTable, GqlBoundCallSubquery, GqlBoundCreateEdge, GqlBoundCreateNode, + GqlBoundEdgePattern, GqlBoundMatchClause, GqlBoundMergeClause, GqlBoundMergePattern, + GqlBoundMutationClause, GqlBoundNodePattern, GqlBoundPattern, GqlBoundPipelineClause, + GqlBoundProjectionClause, GqlBoundRemoveItem, GqlBoundSetItem, GqlBoundShortestPathClause, + GqlMutationSemanticPlan, GqlReturnPlan, GqlSemanticPlan, }; use crate::row_projection::{DIRECT_EDGE_ALIAS, DIRECT_NODE_ALIAS}; use crate::types::{ Direction, EdgeFilterExpr, GqlExecutionOptions, GqlParamValue, GqlParams, GqlSemanticErrorCode, - GraphBinaryOp, GraphEdgeField, GraphEdgePattern, GraphElementProjection, GraphExpr, - GraphFunction, GraphNodeField, GraphNodePattern, GraphOptionalGroup, GraphOrderDirection, - GraphOutputMode, GraphOutputOptions, GraphPageRequest, GraphParamValue, GraphPathField, - GraphPatternPiece, GraphQueryOptions, GraphReturnItem, GraphReturnProjection, GraphRowQuery, + GraphAggregateFunction, GraphBinaryOp, GraphCaseBranch, GraphEdgeField, GraphEdgePattern, + GraphElementProjection, GraphExpr, GraphFunction, GraphNodeField, GraphNodePattern, + GraphOptionalGroup, GraphOrderDirection, GraphOrderItem, GraphOutputMode, GraphOutputOptions, + GraphPageRequest, GraphParamValue, GraphPathField, GraphPatternPiece, GraphPipelineMatchStage, + GraphPipelineOptions, GraphPipelineQuery, GraphPipelineStage, GraphProjectItem, + GraphProjectKind, GraphProjectStage, GraphProjectionItems, GraphQueryOptions, GraphReturnItem, + GraphReturnProjection, GraphRowQuery, GraphShortestPathEndpoint, GraphShortestPathMode, + GraphShortestPathStage, GraphSubqueryStage, GraphUnaryOp, GraphUnionStage, GraphVariableLengthPattern, LabelMatchMode, NodeFilterExpr, NodeKeyQuery, NodeLabelFilter, PropValue, PropertyRangeBound, SourceSpan, }; @@ -25,17 +32,20 @@ use std::collections::{BTreeMap, BTreeSet}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum GqlNativeTargetKind { GraphRows, + GraphPipeline, } #[derive(Clone, Debug, PartialEq)] pub(crate) enum GqlNativeTarget { GraphRows { query: GraphRowQueryTarget }, + GraphPipeline { query: GraphPipelineQuery }, } impl GqlNativeTarget { pub(crate) fn kind(&self) -> GqlNativeTargetKind { match self { Self::GraphRows { .. } => GqlNativeTargetKind::GraphRows, + Self::GraphPipeline { .. } => GqlNativeTargetKind::GraphPipeline, } } } @@ -81,7 +91,7 @@ pub(crate) struct GqlMutationPlan { #[derive(Clone, Debug, PartialEq)] pub(crate) struct GqlMutationReadPrefixPlan { - pub(crate) graph_row: GraphRowQueryTarget, + pub(crate) graph_row: Option, pub(crate) lowered: Box, pub(crate) internal_columns: Vec, } @@ -90,6 +100,7 @@ pub(crate) struct GqlMutationReadPrefixPlan { pub(crate) enum GqlMutationInternalColumn { TargetId { alias: String, kind: GqlAliasKind }, TargetPath { alias: String }, + ScalarValue { alias: String, expr: GraphExpr }, ExprValue { id: usize, expr: GraphExpr }, } @@ -97,6 +108,14 @@ pub(crate) enum GqlMutationInternalColumn { pub(crate) struct GqlMutationExprPlan { pub(crate) id: usize, pub(crate) expr: GraphExpr, + pub(crate) source: Expr, + pub(crate) late: bool, +} + +#[derive(Clone, Debug, PartialEq)] +struct GqlMutationOperationExpr { + expr: Expr, + late: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -107,6 +126,7 @@ pub(crate) struct GqlMutationExprRef { #[derive(Clone, Debug, PartialEq)] pub(crate) enum GqlMutationClausePlan { Create(Vec), + Merge(GqlMergePlan), Set(Vec), Remove(Vec), Delete { @@ -115,6 +135,28 @@ pub(crate) enum GqlMutationClausePlan { }, } +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GqlMergePlan { + pub(crate) pattern: GqlMergePatternPlan, + pub(crate) on_create: Vec, + pub(crate) on_match: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum GqlMergePatternPlan { + Node { + alias: String, + label: String, + key: GqlMutationExprRef, + }, + Relationship { + alias: String, + from_alias: String, + to_alias: String, + label: String, + }, +} + #[derive(Clone, Debug, PartialEq)] pub(crate) struct GqlCreatePatternPlan { pub(crate) nodes: Vec, @@ -181,6 +223,7 @@ pub(crate) struct GqlDeleteTargetPlan { #[derive(Clone, Debug, PartialEq)] pub(crate) struct GqlMutationReturnPlan { pub(crate) columns: Vec, + pub(crate) distinct: bool, pub(crate) order_items: usize, pub(crate) skip: Option, pub(crate) limit: Option, @@ -211,6 +254,13 @@ pub(crate) fn lower_semantic_plan( params: &GqlParams, options: &GqlExecutionOptions, ) -> Result { + if semantic.query.requires_deferred_pipeline_execution() + || gql_query_contains_aggregate(&semantic.query) + || gql_query_contains_subquery(&semantic.query) + { + return lower_read_pipeline_semantic_plan(semantic, params, options); + } + let mut state = LoweringState::new(params, &semantic); let mut graph_nodes = Vec::new(); let mut node_indexes = BTreeMap::new(); @@ -227,7 +277,7 @@ pub(crate) fn lower_semantic_plan( }); } let pattern = &clause.patterns[0]; - reject_unsupported_pure_edge_label_or(&semantic, pattern, params)?; + reject_unsupported_pure_edge_label_or(&semantic.clauses, pattern, params)?; let reused_node_constraints = state.collect_graph_nodes(pattern, &mut graph_nodes, &mut node_indexes)?; let materialize_node_only = @@ -299,17 +349,1033 @@ pub(crate) fn lower_semantic_plan( let skip = semantic.query.skip.clone(); let limit = semantic.query.limit.clone(); - Ok(GqlLoweredPlan { - semantic, - native_target, - residual_predicates: state.residual_predicates, - order_by, - skip, - limit, - pushed_down: state.pushed_down, - warnings: state.warnings, - notes: state.notes, - }) + Ok(GqlLoweredPlan { + semantic, + native_target, + residual_predicates: state.residual_predicates, + order_by, + skip, + limit, + pushed_down: state.pushed_down, + warnings: state.warnings, + notes: state.notes, + }) +} + +fn lower_read_pipeline_semantic_plan( + semantic: GqlSemanticPlan, + params: &GqlParams, + options: &GqlExecutionOptions, +) -> Result { + let mut lowered = lower_bound_read_pipeline(&semantic.pipeline, params, options, 0)?; + + lowered.warnings.sort(); + lowered.warnings.dedup(); + lowered.notes.sort(); + lowered.notes.dedup(); + + Ok(GqlLoweredPlan { + semantic, + native_target: GqlNativeTarget::GraphPipeline { + query: GraphPipelineQuery { + stages: lowered.stages, + params: gql_params_to_graph_params(params), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: options.max_rows.max(1), + cursor: options.cursor.clone(), + }, + output: GraphOutputOptions { + mode: GraphOutputMode::Ids, + compact_rows: options.compact_rows, + include_vectors: options.include_vectors, + }, + options: gql_pipeline_options(options), + }, + }, + residual_predicates: lowered.residual_predicates, + order_by: Vec::new(), + skip: None, + limit: None, + pushed_down: lowered.pushed_down, + warnings: lowered.warnings, + notes: lowered.notes, + }) +} + +#[derive(Default)] +struct LoweredReadPipelineStages { + stages: Vec, + residual_predicates: Vec, + pushed_down: Vec, + warnings: Vec, + notes: Vec, +} + +fn lower_bound_read_pipeline( + pipeline: &crate::gql::semantic::GqlBoundReadPipeline, + params: &GqlParams, + options: &GqlExecutionOptions, + subquery_depth: usize, +) -> Result { + lower_bound_read_pipeline_with_alias_kinds( + pipeline, + params, + options, + subquery_depth, + BTreeMap::new(), + ) +} + +fn lower_bound_read_pipeline_with_alias_kinds( + pipeline: &crate::gql::semantic::GqlBoundReadPipeline, + params: &GqlParams, + options: &GqlExecutionOptions, + subquery_depth: usize, + initial_alias_kinds: BTreeMap, +) -> Result { + if pipeline.union_branches.is_empty() { + lower_bound_read_pipeline_clauses( + &pipeline.clauses, + params, + options, + subquery_depth, + initial_alias_kinds, + ) + } else { + lower_union_read_pipeline( + pipeline, + params, + options, + subquery_depth, + initial_alias_kinds, + ) + } +} + +fn lower_union_read_pipeline( + pipeline: &crate::gql::semantic::GqlBoundReadPipeline, + params: &GqlParams, + options: &GqlExecutionOptions, + subquery_depth: usize, + initial_alias_kinds: BTreeMap, +) -> Result { + let branch_count = 1 + pipeline.union_branches.len(); + if branch_count > options.max_union_branches { + return Err(EngineError::InvalidOperation(format!( + "GQL UNION has {branch_count} branch(es), exceeding max_union_branches {}", + options.max_union_branches + ))); + } + let union_modifier = pipeline + .union_branches + .first() + .map(|branch| branch.modifier) + .expect("union branch exists"); + if pipeline + .union_branches + .iter() + .any(|branch| branch.modifier != union_modifier) + { + return Err(EngineError::GqlUnsupported { + feature: "mixed UNION modifiers".to_string(), + message: + "mixing UNION and UNION ALL in one statement is not supported in this checkpoint" + .to_string(), + span: pipeline + .union_branches + .iter() + .find(|branch| branch.modifier != union_modifier) + .map(|branch| branch.union_span.clone()) + .unwrap_or_else(|| SourceSpan::new(0, 0, 1, 1)), + }); + } + + let mut combined = LoweredReadPipelineStages::default(); + let mut branches = Vec::with_capacity(branch_count); + let first = lower_bound_read_pipeline_clauses( + &pipeline.clauses, + params, + options, + subquery_depth, + initial_alias_kinds.clone(), + )?; + combined.merge_from(&first); + branches.push(lowered_branch_query(first, params, options)); + for branch in &pipeline.union_branches { + let lowered = lower_bound_read_pipeline_clauses( + &branch.clauses, + params, + options, + subquery_depth, + initial_alias_kinds.clone(), + )?; + combined.merge_from(&lowered); + branches.push(lowered_branch_query(lowered, params, options)); + } + combined.stages = vec![GraphPipelineStage::Union(GraphUnionStage { + branches, + all: union_modifier == GqlUnionModifier::All, + })]; + Ok(combined) +} + +impl LoweredReadPipelineStages { + fn merge_from(&mut self, other: &LoweredReadPipelineStages) { + self.residual_predicates + .extend(other.residual_predicates.iter().cloned()); + self.pushed_down.extend(other.pushed_down.iter().cloned()); + self.warnings.extend(other.warnings.iter().cloned()); + self.notes.extend(other.notes.iter().cloned()); + } +} + +fn lowered_branch_query( + lowered: LoweredReadPipelineStages, + params: &GqlParams, + options: &GqlExecutionOptions, +) -> GraphPipelineQuery { + GraphPipelineQuery { + stages: lowered.stages, + params: gql_params_to_graph_params(params), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: options.max_rows.max(1), + cursor: None, + }, + output: GraphOutputOptions { + mode: GraphOutputMode::Ids, + compact_rows: options.compact_rows, + include_vectors: options.include_vectors, + }, + options: gql_pipeline_options(options), + } +} + +fn lower_bound_read_pipeline_clauses( + clauses: &[GqlBoundPipelineClause], + params: &GqlParams, + options: &GqlExecutionOptions, + subquery_depth: usize, + initial_alias_kinds: BTreeMap, +) -> Result { + let mut lowered = LoweredReadPipelineStages::default(); + let mut current_alias_kinds = initial_alias_kinds; + let mut saw_terminal_return = false; + let branch_match_clauses = clauses + .iter() + .flat_map(|clause| match clause { + GqlBoundPipelineClause::Match(clauses) => clauses.clone(), + GqlBoundPipelineClause::ShortestPath(_) => Vec::new(), + GqlBoundPipelineClause::Call(_) => Vec::new(), + GqlBoundPipelineClause::Projection(_) => Vec::new(), + }) + .collect::>(); + + for clause in clauses { + if saw_terminal_return { + return Err(EngineError::InvalidOperation( + "GQL read pipeline has stages after terminal RETURN".to_string(), + )); + } + match clause { + GqlBoundPipelineClause::Match(clauses) => { + for match_clause in clauses { + let mut stage_clause = match_clause.clone(); + let (match_filter, subquery_filter) = + split_match_where_for_subquery_filter(stage_clause.where_clause.take()); + stage_clause.where_clause = match_filter; + let (mut stage, stage_state, next_alias_kinds) = lower_pipeline_match_clause( + &branch_match_clauses, + &stage_clause, + params, + ¤t_alias_kinds, + )?; + lowered + .residual_predicates + .extend(stage_state.residual_predicates); + lowered.pushed_down.extend(stage_state.pushed_down); + lowered.warnings.extend(stage_state.warnings); + lowered.notes.extend(stage_state.notes); + let subquery_filter = if let Some(filter) = subquery_filter { + if match_clause.optional { + stage.optional_candidate_where = + Some(gql_expr_to_graph_expr_for_pipeline( + &filter, + &next_alias_kinds, + params, + options, + subquery_depth, + )?); + None + } else { + Some(filter) + } + } else { + None + }; + current_alias_kinds = next_alias_kinds; + lowered.stages.push(GraphPipelineStage::Match(stage)); + if let Some(filter) = subquery_filter { + lowered + .stages + .push(GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::With, + items: GraphProjectionItems::Star, + distinct: false, + where_: Some(gql_expr_to_graph_expr_for_pipeline( + &filter, + ¤t_alias_kinds, + params, + options, + subquery_depth, + )?), + order_by: Vec::new(), + skip: None, + limit: None, + })); + } + } + } + GqlBoundPipelineClause::ShortestPath(shortest) => { + let stage = lower_pipeline_shortest_path_clause(shortest)?; + current_alias_kinds.insert(shortest.output_path_alias.clone(), GqlAliasKind::Path); + lowered.stages.push(GraphPipelineStage::ShortestPath(stage)); + } + GqlBoundPipelineClause::Call(call) => { + let stage = lower_pipeline_call_subquery( + call, + params, + options, + subquery_depth, + ¤t_alias_kinds, + )?; + for output in &call.output_aliases { + current_alias_kinds.insert(output.name.clone(), output.kind); + } + lowered.stages.push(GraphPipelineStage::Call(stage)); + } + GqlBoundPipelineClause::Projection(projection) => { + let output_alias_kinds = projection_alias_kinds(projection); + let stage = lower_pipeline_projection_clause( + projection, + ¤t_alias_kinds, + &output_alias_kinds, + params, + options, + subquery_depth, + )?; + if projection.kind == GqlProjectionKind::Return { + saw_terminal_return = true; + } else { + current_alias_kinds = output_alias_kinds; + } + lowered.stages.push(GraphPipelineStage::Project(stage)); + } + } + } + + if !saw_terminal_return { + return Err(EngineError::InvalidOperation( + "GQL read pipeline must end in RETURN".to_string(), + )); + } + Ok(lowered) +} + +fn gql_query_contains_aggregate(query: &GqlQuery) -> bool { + gql_read_pipeline_contains_aggregate(&query.pipeline) +} + +fn gql_read_pipeline_contains_aggregate(pipeline: &GqlReadPipeline) -> bool { + pipeline + .clauses + .iter() + .any(gql_pipeline_clause_contains_aggregate) + || pipeline.union_branches.iter().any(|branch| { + branch + .clauses + .iter() + .any(gql_pipeline_clause_contains_aggregate) + }) +} + +fn gql_pipeline_clause_contains_aggregate(clause: &GqlPipelineClause) -> bool { + match clause { + GqlPipelineClause::Match(clauses) => clauses.iter().any(|clause| { + clause + .where_clause + .as_ref() + .is_some_and(gql_expr_contains_aggregate) + || clause.patterns.iter().any(gql_pattern_contains_aggregate) + }), + GqlPipelineClause::ShortestPath(_) => false, + GqlPipelineClause::Call(call) => gql_read_pipeline_contains_aggregate(&call.pipeline), + GqlPipelineClause::Projection(projection) => { + gql_return_body_contains_aggregate(&projection.body) + || projection + .where_clause + .as_ref() + .is_some_and(gql_expr_contains_aggregate) + || projection + .order_by + .iter() + .any(|item| gql_expr_contains_aggregate(&item.expr)) + || projection + .skip + .as_ref() + .is_some_and(gql_expr_contains_aggregate) + || projection + .limit + .as_ref() + .is_some_and(gql_expr_contains_aggregate) + } + } +} + +fn gql_query_contains_subquery(query: &GqlQuery) -> bool { + query + .pipeline + .clauses + .iter() + .any(gql_pipeline_clause_contains_subquery) + || query.pipeline.union_branches.iter().any(|branch| { + branch + .clauses + .iter() + .any(gql_pipeline_clause_contains_subquery) + }) +} + +fn gql_pipeline_clause_contains_subquery(clause: &GqlPipelineClause) -> bool { + match clause { + GqlPipelineClause::Match(clauses) => clauses.iter().any(|clause| { + clause + .where_clause + .as_ref() + .is_some_and(gql_expr_contains_subquery) + || clause.patterns.iter().any(gql_pattern_contains_subquery) + }), + GqlPipelineClause::ShortestPath(_) => false, + GqlPipelineClause::Call(_) => true, + GqlPipelineClause::Projection(projection) => { + gql_return_body_contains_subquery(&projection.body) + || projection + .where_clause + .as_ref() + .is_some_and(gql_expr_contains_subquery) + || projection + .order_by + .iter() + .any(|item| gql_expr_contains_subquery(&item.expr)) + || projection + .skip + .as_ref() + .is_some_and(gql_expr_contains_subquery) + || projection + .limit + .as_ref() + .is_some_and(gql_expr_contains_subquery) + } + } +} + +fn gql_return_body_contains_subquery(body: &ReturnBody) -> bool { + match body { + ReturnBody::All(_) => false, + ReturnBody::AllAndItems { items, .. } | ReturnBody::Items(items) => items + .iter() + .any(|item| gql_expr_contains_subquery(&item.expr)), + } +} + +fn gql_pattern_contains_subquery(pattern: &Pattern) -> bool { + pattern + .start + .properties + .as_ref() + .is_some_and(gql_map_contains_subquery) + || pattern.chains.iter().any(|chain| { + chain + .relationship + .properties + .as_ref() + .is_some_and(gql_map_contains_subquery) + || chain + .node + .properties + .as_ref() + .is_some_and(gql_map_contains_subquery) + }) +} + +fn gql_map_contains_subquery(map: &MapLiteral) -> bool { + map.entries + .iter() + .any(|entry| gql_expr_contains_subquery(&entry.value)) +} + +fn gql_expr_contains_subquery(expr: &Expr) -> bool { + match &expr.kind { + ExprKind::ExistsSubquery(_) => true, + ExprKind::PropertyAccess { object, .. } => gql_expr_contains_subquery(object), + ExprKind::Unary { expr, .. } | ExprKind::IsNull { expr, .. } => { + gql_expr_contains_subquery(expr) + } + ExprKind::Binary { left, right, .. } => { + gql_expr_contains_subquery(left) || gql_expr_contains_subquery(right) + } + ExprKind::FunctionCall { args, .. } | ExprKind::List(args) => { + args.iter().any(gql_expr_contains_subquery) + } + ExprKind::AggregateCall { arg, .. } => arg + .as_ref() + .is_some_and(|arg| gql_expr_contains_subquery(arg)), + ExprKind::Case { + operand, + branches, + else_expr, + } => { + operand + .as_ref() + .is_some_and(|expr| gql_expr_contains_subquery(expr)) + || branches.iter().any(|branch| { + gql_expr_contains_subquery(&branch.when) + || gql_expr_contains_subquery(&branch.then) + }) + || else_expr + .as_ref() + .is_some_and(|expr| gql_expr_contains_subquery(expr)) + } + ExprKind::Map(map) => gql_map_contains_subquery(map), + ExprKind::Literal(_) | ExprKind::Parameter(_) | ExprKind::Variable(_) => false, + } +} + +fn split_match_where_for_subquery_filter( + where_clause: Option, +) -> (Option, Option) { + let Some(where_clause) = where_clause else { + return (None, None); + }; + if !gql_expr_contains_subquery(&where_clause) { + return (Some(where_clause), None); + } + + let mut conjuncts = Vec::new(); + flatten_gql_and_conjuncts(where_clause, &mut conjuncts); + let mut match_conjuncts = Vec::new(); + let mut subquery_conjuncts = Vec::new(); + for conjunct in conjuncts { + if gql_expr_contains_subquery(&conjunct) { + subquery_conjuncts.push(conjunct); + } else { + match_conjuncts.push(conjunct); + } + } + ( + combine_gql_and_conjuncts(match_conjuncts), + combine_gql_and_conjuncts(subquery_conjuncts), + ) +} + +fn flatten_gql_and_conjuncts(expr: Expr, out: &mut Vec) { + match expr.kind { + ExprKind::Binary { + op: BinaryOp::And, + left, + right, + } => { + flatten_gql_and_conjuncts(*left, out); + flatten_gql_and_conjuncts(*right, out); + } + _ => out.push(expr), + } +} + +fn combine_gql_and_conjuncts(mut conjuncts: Vec) -> Option { + if conjuncts.is_empty() { + return None; + } + let mut combined = conjuncts.remove(0); + for conjunct in conjuncts { + let span = combine_expr_spans(&combined.span, &conjunct.span); + combined = Expr { + kind: ExprKind::Binary { + op: BinaryOp::And, + left: Box::new(combined), + right: Box::new(conjunct), + }, + span, + }; + } + Some(combined) +} + +fn combine_expr_spans(left: &SourceSpan, right: &SourceSpan) -> SourceSpan { + let start = left.offset.min(right.offset); + let end = left.end_offset().max(right.end_offset()); + let (line, column) = if left.offset <= right.offset { + (left.line, left.column) + } else { + (right.line, right.column) + }; + SourceSpan::new(start, end.saturating_sub(start), line, column) +} + +fn gql_return_body_contains_aggregate(body: &ReturnBody) -> bool { + match body { + ReturnBody::All(_) => false, + ReturnBody::AllAndItems { items, .. } | ReturnBody::Items(items) => items + .iter() + .any(|item| gql_expr_contains_aggregate(&item.expr)), + } +} + +fn gql_pattern_contains_aggregate(pattern: &Pattern) -> bool { + pattern + .start + .properties + .as_ref() + .is_some_and(gql_map_contains_aggregate) + || pattern.chains.iter().any(|chain| { + chain + .relationship + .properties + .as_ref() + .is_some_and(gql_map_contains_aggregate) + || chain + .node + .properties + .as_ref() + .is_some_and(gql_map_contains_aggregate) + }) +} + +fn gql_map_contains_aggregate(map: &MapLiteral) -> bool { + map.entries + .iter() + .any(|entry| gql_expr_contains_aggregate(&entry.value)) +} + +fn gql_expr_contains_aggregate(expr: &Expr) -> bool { + match &expr.kind { + ExprKind::AggregateCall { .. } => true, + ExprKind::PropertyAccess { object, .. } => gql_expr_contains_aggregate(object), + ExprKind::Unary { expr, .. } | ExprKind::IsNull { expr, .. } => { + gql_expr_contains_aggregate(expr) + } + ExprKind::Binary { left, right, .. } => { + gql_expr_contains_aggregate(left) || gql_expr_contains_aggregate(right) + } + ExprKind::FunctionCall { args, .. } | ExprKind::List(args) => { + args.iter().any(gql_expr_contains_aggregate) + } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + operand + .as_ref() + .is_some_and(|expr| gql_expr_contains_aggregate(expr)) + || branches.iter().any(|branch| { + gql_expr_contains_aggregate(&branch.when) + || gql_expr_contains_aggregate(&branch.then) + }) + || else_expr + .as_ref() + .is_some_and(|expr| gql_expr_contains_aggregate(expr)) + } + ExprKind::Map(map) => gql_map_contains_aggregate(map), + ExprKind::ExistsSubquery(pipeline) => gql_read_pipeline_contains_aggregate(pipeline), + ExprKind::Literal(_) | ExprKind::Parameter(_) | ExprKind::Variable(_) => false, + } +} + +fn lower_pipeline_match_clause<'a>( + branch_clauses: &[GqlBoundMatchClause], + clause: &GqlBoundMatchClause, + params: &'a GqlParams, + input_alias_kinds: &BTreeMap, +) -> Result< + ( + GraphPipelineMatchStage, + LoweringState<'a>, + BTreeMap, + ), + EngineError, +> { + if clause.patterns.len() != 1 { + return Err(EngineError::GqlUnsupported { + feature: "multiple MATCH patterns".to_string(), + message: "multiple comma-separated MATCH patterns are not supported".to_string(), + span: clause.span.clone(), + }); + } + let pattern = &clause.patterns[0]; + reject_unsupported_pure_edge_label_or(branch_clauses, pattern, params)?; + if pattern.path_alias.is_some() + && pattern.edges.len() > 1 + && pattern.edges.iter().all(|edge| edge.quantifier.is_none()) + { + return Err(EngineError::GqlUnsupported { + feature: "path assignment in WITH pipelines".to_string(), + message: "path assignment over multiple fixed relationship patterns in WITH pipelines is deferred".to_string(), + span: pattern + .path_span + .clone() + .unwrap_or_else(|| pattern.span.clone()), + }); + } + + let mut state = LoweringState::new_with_alias_kinds(params, input_alias_kinds.clone()); + add_pipeline_pattern_alias_kinds(pattern, &mut state.alias_kinds); + let mut graph_nodes = Vec::new(); + let mut node_indexes = BTreeMap::new(); + let mut pieces = Vec::new(); + let mut required_where = + collect_pipeline_graph_nodes(&mut state, pattern, &mut graph_nodes, &mut node_indexes)?; + pieces.extend(state.lower_pattern_pieces(pattern, true)?); + if let Some(where_clause) = clause.where_clause.as_ref() { + required_where.push(where_clause.clone()); + } + let fixed_edge_indexes = graph_fixed_edge_indexes(&pieces); + for where_clause in &required_where { + state.apply_where_to_graph_pattern( + where_clause, + &mut graph_nodes, + &mut pieces, + &node_indexes, + &fixed_edge_indexes, + )?; + } + let where_ = combine_pipeline_match_where_with_edge_id_constraints( + state.graph_residual_expr()?, + take_edge_id_constraint_residuals(&mut state), + ); + + let mut output_alias_kinds = input_alias_kinds.clone(); + add_pipeline_pattern_alias_kinds(pattern, &mut output_alias_kinds); + Ok(( + GraphPipelineMatchStage { + optional: clause.optional, + nodes: graph_nodes, + pieces, + where_, + optional_candidate_where: None, + }, + state, + output_alias_kinds, + )) +} + +fn lower_pipeline_projection_clause( + projection: &GqlBoundProjectionClause, + input_alias_kinds: &BTreeMap, + output_alias_kinds: &BTreeMap, + params: &GqlParams, + options: &GqlExecutionOptions, + subquery_depth: usize, +) -> Result { + let items = match &projection.returns { + GqlReturnPlan::Star { .. } => GraphProjectionItems::Star, + GqlReturnPlan::Items(items) => GraphProjectionItems::Items( + items + .iter() + .map(|item| { + Ok(GraphProjectItem { + expr: gql_expr_to_graph_expr_for_pipeline( + &item.expr, + input_alias_kinds, + params, + options, + subquery_depth, + )?, + alias: Some(item.output_name.clone()), + projection: gql_projection_for_expr(&item.expr, input_alias_kinds), + }) + }) + .collect::, EngineError>>()?, + ), + }; + Ok(GraphProjectStage { + kind: match projection.kind { + GqlProjectionKind::With => GraphProjectKind::With, + GqlProjectionKind::Return => GraphProjectKind::Return, + }, + items, + distinct: projection.distinct, + where_: projection + .where_clause + .as_ref() + .map(|expr| { + gql_expr_to_graph_expr_for_pipeline( + expr, + output_alias_kinds, + params, + options, + subquery_depth, + ) + }) + .transpose()?, + order_by: projection + .order_by + .iter() + .map(|item| { + Ok(GraphOrderItem { + expr: gql_expr_to_graph_expr_for_pipeline( + &item.expr, + output_alias_kinds, + params, + options, + subquery_depth, + )?, + direction: gql_order_direction_to_graph(item.direction), + }) + }) + .collect::, EngineError>>()?, + skip: projection + .skip + .as_ref() + .map(|expr| { + gql_expr_to_graph_expr_for_pipeline( + expr, + output_alias_kinds, + params, + options, + subquery_depth, + ) + }) + .transpose()?, + limit: projection + .limit + .as_ref() + .map(|expr| { + gql_expr_to_graph_expr_for_pipeline( + expr, + output_alias_kinds, + params, + options, + subquery_depth, + ) + }) + .transpose()?, + }) +} + +fn lower_pipeline_shortest_path_clause( + clause: &GqlBoundShortestPathClause, +) -> Result { + Ok(GraphShortestPathStage { + optional: clause.optional, + output_path_alias: clause.output_path_alias.clone(), + mode: match clause.mode { + GqlShortestPathMode::One => GraphShortestPathMode::One, + GqlShortestPathMode::All => GraphShortestPathMode::All, + }, + from: GraphShortestPathEndpoint::Alias(clause.from_alias.clone()), + to: GraphShortestPathEndpoint::Alias(clause.to_alias.clone()), + direction: native_direction(clause.direction), + edge_label_filter: clause + .rel_types + .iter() + .map(|label| label.name.clone()) + .collect(), + min_hops: clause.min_hops, + max_hops: clause.max_hops, + weight_field: None, + max_cost: None, + max_paths: None, + }) +} + +fn lower_pipeline_call_subquery( + call: &GqlBoundCallSubquery, + params: &GqlParams, + options: &GqlExecutionOptions, + subquery_depth: usize, + input_alias_kinds: &BTreeMap, +) -> Result { + let next_depth = subquery_depth.saturating_add(1); + if next_depth > options.max_subquery_depth { + return Err(EngineError::InvalidOperation(format!( + "GQL subquery depth {next_depth} exceeds max_subquery_depth {}", + options.max_subquery_depth + ))); + } + let import_alias_kinds = call + .import_aliases + .iter() + .filter_map(|alias| { + input_alias_kinds + .get(alias) + .copied() + .map(|kind| (alias.clone(), kind)) + }) + .collect::>(); + let lowered = lower_bound_read_pipeline_with_alias_kinds( + &call.pipeline, + params, + options, + next_depth, + import_alias_kinds, + )?; + Ok(GraphSubqueryStage { + query: Box::new(GraphPipelineQuery { + stages: lowered.stages, + params: gql_params_to_graph_params(params), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: options.max_rows.max(1), + cursor: None, + }, + output: GraphOutputOptions { + mode: GraphOutputMode::Ids, + compact_rows: false, + include_vectors: false, + }, + options: gql_pipeline_options(options), + }), + import_aliases: call.import_aliases.clone(), + }) +} + +fn collect_pipeline_graph_nodes( + state: &mut LoweringState<'_>, + pattern: &GqlBoundPattern, + nodes: &mut Vec, + node_indexes: &mut BTreeMap, +) -> Result, EngineError> { + let mut reused_constraints = Vec::new(); + for node in &pattern.nodes { + if node_indexes.contains_key(&node.alias) { + reused_constraints.extend(reused_node_constraint_exprs(node)); + } else { + let index = nodes.len(); + node_indexes.insert(node.alias.clone(), index); + nodes.push(state.lower_graph_node_pattern(node)?); + } + } + Ok(reused_constraints) +} + +fn projection_alias_kinds(projection: &GqlBoundProjectionClause) -> BTreeMap { + projection + .output_aliases + .iter() + .map(|alias| (alias.name.clone(), alias.kind)) + .collect() +} + +fn add_pipeline_pattern_alias_kinds( + pattern: &GqlBoundPattern, + alias_kinds: &mut BTreeMap, +) { + for node in &pattern.nodes { + alias_kinds.insert(node.alias.clone(), GqlAliasKind::Node); + } + for edge in &pattern.edges { + if let Some(alias) = edge.alias.as_ref() { + alias_kinds.insert(alias.clone(), GqlAliasKind::Edge); + } + } + if let Some(alias) = pattern.path_alias.as_ref() { + alias_kinds.insert(alias.clone(), GqlAliasKind::Path); + } +} + +fn take_edge_id_constraint_residuals(state: &mut LoweringState<'_>) -> Vec { + let constraints = std::mem::take(&mut state.edge_id_constraints); + constraints + .into_iter() + .map(|(alias, ids)| edge_id_constraint_graph_expr(&alias, &ids)) + .collect() +} + +fn combine_pipeline_match_where_with_edge_id_constraints( + mut where_: Option, + constraints: Vec, +) -> Option { + for constraint in constraints { + where_ = Some(match where_ { + Some(existing) => GraphExpr::Binary { + left: Box::new(existing), + op: GraphBinaryOp::And, + right: Box::new(constraint), + }, + None => constraint, + }); + } + where_ +} + +fn edge_id_constraint_graph_expr(alias: &str, ids: &[u64]) -> GraphExpr { + let id_expr = GraphExpr::Function { + name: GraphFunction::Id, + args: vec![GraphExpr::Binding(alias.to_string())], + }; + if let [id] = ids { + return GraphExpr::Binary { + left: Box::new(id_expr), + op: GraphBinaryOp::Eq, + right: Box::new(GraphExpr::UInt(*id)), + }; + } + GraphExpr::Binary { + left: Box::new(id_expr), + op: GraphBinaryOp::In, + right: Box::new(GraphExpr::List( + ids.iter().copied().map(GraphExpr::UInt).collect(), + )), + } +} + +fn gql_projection_for_expr( + expr: &Expr, + alias_kinds: &BTreeMap, +) -> GraphReturnProjection { + match &expr.kind { + ExprKind::Variable(alias) + if matches!( + alias_kinds.get(alias), + Some(GqlAliasKind::Node | GqlAliasKind::Edge | GqlAliasKind::Path) + ) => + { + GraphReturnProjection::Element(GraphElementProjection::Full) + } + _ => GraphReturnProjection::Auto, + } +} + +fn gql_pipeline_options(options: &GqlExecutionOptions) -> GraphPipelineOptions { + GraphPipelineOptions { + allow_full_scan: options.allow_full_scan, + max_rows: options.max_rows, + max_pipeline_rows: options.max_pipeline_rows, + max_groups: options.max_groups, + max_collect_items: options.max_collect_items, + max_union_branches: options.max_union_branches, + max_subquery_invocations: options.max_subquery_invocations, + max_subquery_depth: options.max_subquery_depth, + max_shortest_path_pairs: options.max_shortest_path_pairs, + max_intermediate_bindings: options.max_intermediate_bindings, + max_frontier: options.max_frontier, + max_path_hops: options.max_path_hops, + max_paths_per_start: options.max_paths_per_start, + max_order_materialization: options.max_order_materialization, + max_skip: options.max_skip, + max_cursor_bytes: options.max_cursor_bytes, + max_query_bytes: options.max_query_bytes, + max_param_bytes: options.max_param_bytes, + max_ast_depth: options.max_ast_depth, + max_literal_items: options.max_literal_items, + include_plan: options.include_plan, + profile: options.profile, + } } pub(crate) fn lower_mutation_semantic_plan( @@ -326,10 +1392,12 @@ pub(crate) fn lower_mutation_semantic_plan( let operation_expr_plans = operation_exprs .iter() .enumerate() - .map(|(id, expr)| { + .map(|(id, operation_expr)| { Ok(GqlMutationExprPlan { id, - expr: gql_expr_to_graph_expr(expr, &alias_kinds)?, + expr: gql_expr_to_graph_expr(&operation_expr.expr, &alias_kinds)?, + source: operation_expr.expr.clone(), + late: operation_expr.late, }) }) .collect::, EngineError>>()?; @@ -356,6 +1424,7 @@ pub(crate) fn lower_mutation_semantic_plan( .as_ref() .map(|tail| GqlMutationReturnPlan { columns: mutation_return_columns(&semantic), + distinct: tail.return_clause.distinct, order_items: tail.order_by.len(), skip: tail.skip.clone(), limit: tail.limit.clone(), @@ -379,12 +1448,12 @@ pub(crate) fn lower_mutation_semantic_plan( fn lower_mutation_read_prefix( semantic: &GqlMutationSemanticPlan, - operation_exprs: &[Expr], + operation_exprs: &[GqlMutationOperationExpr], internal_columns: Vec, params: &GqlParams, options: &GqlExecutionOptions, ) -> Result, EngineError> { - if semantic.statement.read_prefix.is_empty() { + if !mutation_statement_has_read_prefix(&semantic.statement) { return Ok(None); } let read_query = mutation_read_prefix_query(semantic, operation_exprs, &internal_columns); @@ -394,9 +1463,12 @@ fn lower_mutation_read_prefix( read_options.cursor = None; read_options.max_rows = options.max_mutation_rows.saturating_add(1).max(1); let lowered = lower_semantic_plan(read_semantic, params, &read_options)?; - let GqlNativeTarget::GraphRows { query } = &lowered.native_target; + let graph_row = match &lowered.native_target { + GqlNativeTarget::GraphRows { query } => Some(query.clone()), + GqlNativeTarget::GraphPipeline { .. } => None, + }; Ok(Some(GqlMutationReadPrefixPlan { - graph_row: query.clone(), + graph_row, lowered: Box::new(lowered), internal_columns, })) @@ -404,7 +1476,7 @@ fn lower_mutation_read_prefix( fn mutation_read_prefix_query( semantic: &GqlMutationSemanticPlan, - operation_exprs: &[Expr], + operation_exprs: &[GqlMutationOperationExpr], internal_columns: &[GqlMutationInternalColumn], ) -> GqlQuery { let mut items = Vec::new(); @@ -435,8 +1507,21 @@ fn mutation_read_prefix_query( &binding.span, )); } + GqlMutationInternalColumn::ScalarValue { alias, .. } => { + let Some(binding) = semantic.aliases.get(alias) else { + continue; + }; + items.push(internal_return_item( + Expr { + kind: ExprKind::Variable(alias.clone()), + span: binding.span.clone(), + }, + format!("_gql_mut_scalar_{alias}"), + &binding.span, + )); + } GqlMutationInternalColumn::ExprValue { id, .. } => { - let expr = &operation_exprs[*id]; + let expr = &operation_exprs[*id].expr; items.push(internal_return_item( expr.clone(), format!("_gql_mut_expr_{id}"), @@ -455,19 +1540,55 @@ fn mutation_read_prefix_query( &semantic.statement.span, )); } + let return_clause = ReturnClause { + body: ReturnBody::Items(items.clone()), + distinct: false, + distinct_span: None, + span: semantic.statement.span.clone(), + }; + let return_projection = GqlProjectionClause { + kind: GqlProjectionKind::Return, + distinct: false, + distinct_span: None, + body: ReturnBody::Items(items), + where_clause: None, + order_by: Vec::new(), + skip: None, + limit: None, + span: semantic.statement.span.clone(), + }; + let pipeline = if let Some(prefix) = semantic.statement.read_prefix_pipeline.as_ref() { + let mut pipeline = prefix.clone(); + pipeline + .clauses + .push(GqlPipelineClause::Projection(return_projection)); + pipeline.span = semantic.statement.span.clone(); + pipeline + } else { + GqlReadPipeline { + clauses: vec![ + GqlPipelineClause::Match(semantic.statement.read_prefix.clone()), + GqlPipelineClause::Projection(return_projection), + ], + union_branches: Vec::new(), + span: semantic.statement.span.clone(), + } + }; GqlQuery { match_clauses: semantic.statement.read_prefix.clone(), - return_clause: ReturnClause { - body: ReturnBody::Items(items), - span: semantic.statement.span.clone(), - }, + return_clause, order_by: Vec::new(), skip: None, limit: None, + pipeline, span: semantic.statement.span.clone(), } } +fn mutation_statement_has_read_prefix(statement: &GqlMutationStatement) -> bool { + statement.read_prefix_pipeline.is_some() || !statement.read_prefix.is_empty() +} + fn internal_return_item(expr: Expr, alias: String, span: &SourceSpan) -> ReturnItem { ReturnItem { span: expr.span.clone(), @@ -513,12 +1634,13 @@ fn path_function_expr(function: &str, alias: &str, span: &SourceSpan) -> Expr { fn mutation_internal_columns( semantic: &GqlMutationSemanticPlan, - operation_exprs: &[Expr], + operation_exprs: &[GqlMutationOperationExpr], lowered_exprs: &[GqlMutationExprPlan], ) -> Vec { let mut required_aliases = BTreeSet::new(); collect_mutation_target_aliases(semantic, &mut required_aliases); collect_return_identity_aliases(semantic, &mut required_aliases); + collect_late_expr_scalar_aliases(semantic, operation_exprs, &mut required_aliases); let mut columns = Vec::new(); for alias in semantic.user_order.iter() { @@ -543,11 +1665,17 @@ fn mutation_internal_columns( alias: alias.clone(), }); } + GqlAliasKind::Scalar => { + columns.push(GqlMutationInternalColumn::ScalarValue { + alias: alias.clone(), + expr: GraphExpr::Binding(alias.clone()), + }); + } } } for (id, expr) in operation_exprs.iter().enumerate() { - if expr_references_read_prefix_alias(expr, semantic) { + if !expr.late && expr_references_read_prefix_alias(&expr.expr, semantic) { columns.push(GqlMutationInternalColumn::ExprValue { id, expr: lowered_exprs[id].expr.clone(), @@ -573,16 +1701,25 @@ fn collect_mutation_target_aliases( } } } - GqlBoundMutationClause::Set(set) => { - for item in &set.items { - match item { - GqlBoundSetItem::Property { alias, .. } - | GqlBoundSetItem::MapMerge { alias, .. } - | GqlBoundSetItem::NodeLabel { alias, .. } => { - maybe_insert_read_prefix_alias(semantic, alias, required_aliases); - } + GqlBoundMutationClause::Merge(merge) => { + match &merge.pattern { + GqlBoundMergePattern::Node(node) => { + collect_read_prefix_aliases_from_expr( + semantic, + &node.key, + required_aliases, + ); + } + GqlBoundMergePattern::Relationship(rel) => { + maybe_insert_read_prefix_alias(semantic, &rel.from_alias, required_aliases); + maybe_insert_read_prefix_alias(semantic, &rel.to_alias, required_aliases); } } + collect_set_target_aliases(semantic, &merge.on_create.items, required_aliases); + collect_set_target_aliases(semantic, &merge.on_match.items, required_aliases); + } + GqlBoundMutationClause::Set(set) => { + collect_set_target_aliases(semantic, &set.items, required_aliases); } GqlBoundMutationClause::Remove(remove) => { for item in &remove.items { @@ -603,6 +1740,92 @@ fn collect_mutation_target_aliases( } } +fn collect_late_expr_scalar_aliases( + semantic: &GqlMutationSemanticPlan, + operation_exprs: &[GqlMutationOperationExpr], + required_aliases: &mut BTreeSet, +) { + for expr in operation_exprs.iter().filter(|expr| expr.late) { + collect_read_prefix_scalar_aliases_in_expr(semantic, &expr.expr, required_aliases); + } +} + +fn collect_read_prefix_scalar_aliases_in_expr( + semantic: &GqlMutationSemanticPlan, + expr: &Expr, + aliases: &mut BTreeSet, +) { + match &expr.kind { + ExprKind::Variable(name) => { + if semantic.aliases.get(name).is_some_and(|binding| { + binding.origin == GqlAliasOrigin::ReadPrefix && binding.kind == GqlAliasKind::Scalar + }) { + aliases.insert(name.clone()); + } + } + ExprKind::PropertyAccess { object, .. } + | ExprKind::Unary { expr: object, .. } + | ExprKind::IsNull { expr: object, .. } => { + collect_read_prefix_scalar_aliases_in_expr(semantic, object, aliases); + } + ExprKind::Binary { left, right, .. } => { + collect_read_prefix_scalar_aliases_in_expr(semantic, left, aliases); + collect_read_prefix_scalar_aliases_in_expr(semantic, right, aliases); + } + ExprKind::FunctionCall { args, .. } | ExprKind::List(args) => { + for arg in args { + collect_read_prefix_scalar_aliases_in_expr(semantic, arg, aliases); + } + } + ExprKind::AggregateCall { arg, .. } => { + if let Some(arg) = arg.as_ref() { + collect_read_prefix_scalar_aliases_in_expr(semantic, arg, aliases); + } + } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand.as_ref() { + collect_read_prefix_scalar_aliases_in_expr(semantic, operand, aliases); + } + for branch in branches { + collect_read_prefix_scalar_aliases_in_expr(semantic, &branch.when, aliases); + collect_read_prefix_scalar_aliases_in_expr(semantic, &branch.then, aliases); + } + if let Some(else_expr) = else_expr.as_ref() { + collect_read_prefix_scalar_aliases_in_expr(semantic, else_expr, aliases); + } + } + ExprKind::Map(map) => { + for entry in &map.entries { + collect_read_prefix_scalar_aliases_in_expr(semantic, &entry.value, aliases); + } + } + ExprKind::ExistsSubquery(_) | ExprKind::Literal(_) | ExprKind::Parameter(_) => {} + } +} + +fn collect_set_target_aliases( + semantic: &GqlMutationSemanticPlan, + items: &[GqlBoundSetItem], + required_aliases: &mut BTreeSet, +) { + for item in items { + match item { + GqlBoundSetItem::Property { alias, value, .. } + | GqlBoundSetItem::MapMerge { alias, value, .. } => { + maybe_insert_read_prefix_alias(semantic, alias, required_aliases); + collect_read_prefix_aliases_from_expr(semantic, value, required_aliases); + } + GqlBoundSetItem::NodeLabel { alias, .. } => { + maybe_insert_read_prefix_alias(semantic, alias, required_aliases); + } + } + } +} + fn collect_return_identity_aliases( semantic: &GqlMutationSemanticPlan, required_aliases: &mut BTreeSet, @@ -660,11 +1883,33 @@ fn collect_read_prefix_aliases_from_expr( collect_read_prefix_aliases_from_expr(semantic, arg, aliases); } } + ExprKind::AggregateCall { arg, .. } => { + if let Some(arg) = arg.as_ref() { + collect_read_prefix_aliases_from_expr(semantic, arg, aliases); + } + } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand.as_ref() { + collect_read_prefix_aliases_from_expr(semantic, operand, aliases); + } + for branch in branches { + collect_read_prefix_aliases_from_expr(semantic, &branch.when, aliases); + collect_read_prefix_aliases_from_expr(semantic, &branch.then, aliases); + } + if let Some(else_expr) = else_expr.as_ref() { + collect_read_prefix_aliases_from_expr(semantic, else_expr, aliases); + } + } ExprKind::Map(map) => { for entry in &map.entries { collect_read_prefix_aliases_from_expr(semantic, &entry.value, aliases); } } + ExprKind::ExistsSubquery(_) => {} ExprKind::Literal(_) | ExprKind::Parameter(_) => {} } } @@ -689,28 +1934,32 @@ fn expr_references_read_prefix_alias(expr: &Expr, semantic: &GqlMutationSemantic !aliases.is_empty() } -fn mutation_operation_exprs(semantic: &GqlMutationSemanticPlan) -> Vec { +fn mutation_operation_exprs(semantic: &GqlMutationSemanticPlan) -> Vec { let mut exprs = Vec::new(); for clause in &semantic.clauses { match clause { GqlBoundMutationClause::Create(create) => { for pattern in &create.patterns { for node in &pattern.nodes { - collect_map_value_exprs(node.properties.as_ref(), &mut exprs); + collect_map_value_exprs(node.properties.as_ref(), &mut exprs, false); } for edge in &pattern.edges { - collect_map_value_exprs(edge.properties.as_ref(), &mut exprs); + collect_map_value_exprs(edge.properties.as_ref(), &mut exprs, false); } } } - GqlBoundMutationClause::Set(set) => { - for item in &set.items { - match item { - GqlBoundSetItem::Property { value, .. } - | GqlBoundSetItem::MapMerge { value, .. } => exprs.push(value.clone()), - GqlBoundSetItem::NodeLabel { .. } => {} - } + GqlBoundMutationClause::Merge(merge) => { + if let GqlBoundMergePattern::Node(node) = &merge.pattern { + exprs.push(GqlMutationOperationExpr { + expr: node.key.clone(), + late: false, + }); } + collect_set_value_exprs(&merge.on_create.items, &mut exprs, semantic, true); + collect_set_value_exprs(&merge.on_match.items, &mut exprs, semantic, true); + } + GqlBoundMutationClause::Set(set) => { + collect_set_value_exprs(&set.items, &mut exprs, semantic, false); } GqlBoundMutationClause::Remove(_) | GqlBoundMutationClause::Delete(_) => {} } @@ -718,9 +1967,85 @@ fn mutation_operation_exprs(semantic: &GqlMutationSemanticPlan) -> Vec { exprs } -fn collect_map_value_exprs(map: Option<&MapLiteral>, exprs: &mut Vec) { +fn collect_set_value_exprs( + items: &[GqlBoundSetItem], + exprs: &mut Vec, + semantic: &GqlMutationSemanticPlan, + allow_late: bool, +) { + for item in items { + match item { + GqlBoundSetItem::Property { value, .. } | GqlBoundSetItem::MapMerge { value, .. } => { + exprs.push(GqlMutationOperationExpr { + expr: value.clone(), + late: allow_late && expr_references_created_or_merged_alias(value, semantic), + }) + } + GqlBoundSetItem::NodeLabel { .. } => {} + } + } +} + +fn collect_map_value_exprs( + map: Option<&MapLiteral>, + exprs: &mut Vec, + late: bool, +) { if let Some(map) = map { - exprs.extend(map.entries.iter().map(|entry| entry.value.clone())); + exprs.extend(map.entries.iter().map(|entry| GqlMutationOperationExpr { + expr: entry.value.clone(), + late, + })); + } +} + +fn expr_references_created_or_merged_alias( + expr: &Expr, + semantic: &GqlMutationSemanticPlan, +) -> bool { + match &expr.kind { + ExprKind::Variable(name) => semantic.aliases.get(name).is_some_and(|binding| { + matches!( + binding.origin, + GqlAliasOrigin::Created | GqlAliasOrigin::Merged + ) + }), + ExprKind::PropertyAccess { object, .. } + | ExprKind::Unary { expr: object, .. } + | ExprKind::IsNull { expr: object, .. } => { + expr_references_created_or_merged_alias(object, semantic) + } + ExprKind::Binary { left, right, .. } => { + expr_references_created_or_merged_alias(left, semantic) + || expr_references_created_or_merged_alias(right, semantic) + } + ExprKind::FunctionCall { args, .. } | ExprKind::List(args) => args + .iter() + .any(|arg| expr_references_created_or_merged_alias(arg, semantic)), + ExprKind::AggregateCall { arg, .. } => arg + .as_ref() + .is_some_and(|arg| expr_references_created_or_merged_alias(arg, semantic)), + ExprKind::Case { + operand, + branches, + else_expr, + } => { + operand + .as_ref() + .is_some_and(|expr| expr_references_created_or_merged_alias(expr, semantic)) + || branches.iter().any(|branch| { + expr_references_created_or_merged_alias(&branch.when, semantic) + || expr_references_created_or_merged_alias(&branch.then, semantic) + }) + || else_expr + .as_ref() + .is_some_and(|expr| expr_references_created_or_merged_alias(expr, semantic)) + } + ExprKind::Map(map) => map + .entries + .iter() + .any(|entry| expr_references_created_or_merged_alias(&entry.value, semantic)), + ExprKind::ExistsSubquery(_) | ExprKind::Literal(_) | ExprKind::Parameter(_) => false, } } @@ -747,6 +2072,9 @@ fn lower_mutation_clause( }) .collect(), ), + GqlBoundMutationClause::Merge(merge) => { + GqlMutationClausePlan::Merge(lower_merge_clause(merge, expr_cursor)) + } GqlBoundMutationClause::Set(set) => GqlMutationClausePlan::Set( set.items .iter() @@ -770,6 +2098,37 @@ fn lower_mutation_clause( } } +fn lower_merge_clause(merge: &GqlBoundMergeClause, expr_cursor: &mut usize) -> GqlMergePlan { + let pattern = match &merge.pattern { + GqlBoundMergePattern::Node(node) => GqlMergePatternPlan::Node { + alias: node.alias.clone(), + label: node.label.name.clone(), + key: next_expr_ref(expr_cursor), + }, + GqlBoundMergePattern::Relationship(rel) => GqlMergePatternPlan::Relationship { + alias: rel.alias.clone(), + from_alias: rel.from_alias.clone(), + to_alias: rel.to_alias.clone(), + label: rel.rel_type.name.clone(), + }, + }; + GqlMergePlan { + pattern, + on_create: merge + .on_create + .items + .iter() + .map(|item| lower_set_item(item, expr_cursor)) + .collect(), + on_match: merge + .on_match + .items + .iter() + .map(|item| lower_set_item(item, expr_cursor)) + .collect(), + } +} + fn lower_create_node(node: &GqlBoundCreateNode, expr_cursor: &mut usize) -> GqlCreateNodePlan { GqlCreateNodePlan { alias: node.alias.clone(), @@ -911,6 +2270,21 @@ impl<'a> LoweringState<'a> { } } + fn new_with_alias_kinds( + params: &'a GqlParams, + alias_kinds: BTreeMap, + ) -> Self { + Self { + params, + alias_kinds, + edge_id_constraints: BTreeMap::new(), + residual_predicates: Vec::new(), + pushed_down: Vec::new(), + warnings: Vec::new(), + notes: Vec::new(), + } + } + fn collect_graph_nodes( &mut self, pattern: &GqlBoundPattern, @@ -1181,7 +2555,11 @@ impl<'a> LoweringState<'a> { options: &GqlExecutionOptions, target: &mut GqlNativeTarget, ) -> Result<(), EngineError> { - let GqlNativeTarget::GraphRows { query } = target; + let GqlNativeTarget::GraphRows { query } = target else { + return Err(EngineError::InvalidOperation( + "GQL graph-row finalization received a non-graph-row target".to_string(), + )); + }; query.query.where_ = self.graph_residual_expr()?; query.query.return_items = Some(gql_graph_return_items(semantic)?); query.query.options.include_plan = options.include_plan; @@ -1332,10 +2710,11 @@ impl<'a> LoweringState<'a> { return Ok(()); } + let pushed_before = self.pushed_down.len(); match self.try_push_predicate(expr)? { Some(PushFilter::Node { alias, filter }) => { let Some(index) = node_indexes.get(&alias).copied() else { - self.residual_predicates.push(expr.clone()); + self.record_residual_after_failed_push(expr, pushed_before); return Ok(()); }; let node = &mut nodes[index]; @@ -1343,7 +2722,7 @@ impl<'a> LoweringState<'a> { } Some(PushFilter::NodeIds { alias, ids }) => { let Some(index) = node_indexes.get(&alias).copied() else { - self.residual_predicates.push(expr.clone()); + self.record_residual_after_failed_push(expr, pushed_before); return Ok(()); }; let node = &mut nodes[index]; @@ -1351,7 +2730,7 @@ impl<'a> LoweringState<'a> { } Some(PushFilter::NodeKeys { alias, keys }) => { let Some(index) = node_indexes.get(&alias).copied() else { - self.residual_predicates.push(expr.clone()); + self.record_residual_after_failed_push(expr, pushed_before); return Ok(()); }; let node = &mut nodes[index]; @@ -1370,16 +2749,16 @@ impl<'a> LoweringState<'a> { } })); } else { - self.residual_predicates.push(expr.clone()); + self.record_residual_after_failed_push(expr, pushed_before); } } Some(PushFilter::Edge { alias, filter }) => { let Some(index) = edge_indexes.get(&alias).copied() else { - self.residual_predicates.push(expr.clone()); + self.record_residual_after_failed_push(expr, pushed_before); return Ok(()); }; let Some(edge) = graph_fixed_edge_mut(pieces, index) else { - self.residual_predicates.push(expr.clone()); + self.record_residual_after_failed_push(expr, pushed_before); return Ok(()); }; edge.filter = merge_edge_filter(edge.filter.take(), Some(filter)); @@ -1390,30 +2769,30 @@ impl<'a> LoweringState<'a> { summary, }) => { let Some(index) = edge_indexes.get(&alias).copied() else { - self.residual_predicates.push(expr.clone()); + self.record_residual_after_failed_push(expr, pushed_before); return Ok(()); }; let Some(edge) = graph_fixed_edge_mut(pieces, index) else { - self.residual_predicates.push(expr.clone()); + self.record_residual_after_failed_push(expr, pushed_before); return Ok(()); }; if merge_edge_label_filter(&mut edge.label_filter, &labels) { self.record_edge_push(alias, summary); } else { - self.residual_predicates.push(expr.clone()); + self.record_residual_after_failed_push(expr, pushed_before); } } Some(PushFilter::EdgeIds { alias, ids }) => { if edge_indexes.contains_key(&alias) { if self.edge_id_constraints.contains_key(&alias) { - self.residual_predicates.push(expr.clone()); + self.record_residual_after_failed_push(expr, pushed_before); } else { let summary = edge_id_summary(&alias, &ids); self.edge_id_constraints.insert(alias.clone(), ids); self.record_edge_push(alias, summary); } } else { - self.residual_predicates.push(expr.clone()); + self.record_residual_after_failed_push(expr, pushed_before); } } Some(PushFilter::EdgeEndpointIds { alias, field, ids }) => { @@ -1429,11 +2808,11 @@ impl<'a> LoweringState<'a> { ) { self.record_edge_push(alias, summary); } else { - self.residual_predicates.push(expr.clone()); + self.record_residual_after_failed_push(expr, pushed_before); } } - Some(PushFilter::Noop) => self.residual_predicates.push(expr.clone()), - None => self.residual_predicates.push(expr.clone()), + Some(PushFilter::Noop) => self.record_residual_after_failed_push(expr, pushed_before), + None => self.record_residual_after_failed_push(expr, pushed_before), } Ok(()) } @@ -1506,9 +2885,18 @@ impl<'a> LoweringState<'a> { reverse_range_op(*op).and_then(|op| self.try_push_range(op, right, left)) }) .transpose(), - BinaryOp::And | BinaryOp::Or | BinaryOp::Neq => Ok(None), + BinaryOp::And + | BinaryOp::Or + | BinaryOp::Add + | BinaryOp::Sub + | BinaryOp::Mul + | BinaryOp::Div + | BinaryOp::Neq + | BinaryOp::StartsWith + | BinaryOp::EndsWith + | BinaryOp::Contains => Ok(None), }, - ExprKind::IsNull { .. } | ExprKind::Unary { .. } => Ok(None), + ExprKind::IsNull { .. } | ExprKind::Unary { .. } | ExprKind::Case { .. } => Ok(None), _ => Ok(None), } } @@ -1924,6 +3312,11 @@ impl<'a> LoweringState<'a> { summary, }); } + + fn record_residual_after_failed_push(&mut self, expr: &Expr, pushed_before: usize) { + self.pushed_down.truncate(pushed_before); + self.residual_predicates.push(expr.clone()); + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -2054,7 +3447,7 @@ enum PushFilter { } fn reject_unsupported_pure_edge_label_or( - semantic: &GqlSemanticPlan, + clauses: &[GqlBoundMatchClause], pattern: &GqlBoundPattern, params: &GqlParams, ) -> Result<(), EngineError> { @@ -2073,8 +3466,7 @@ fn reject_unsupported_pure_edge_label_or( }; if edge.rel_types.is_empty() && where_has_multi_label_constraint_for_edge( - semantic - .clauses + clauses .iter() .find_map(|clause| clause.where_clause.as_ref()), edge_alias, @@ -2082,8 +3474,7 @@ fn reject_unsupported_pure_edge_label_or( )? { return Err(unsupported_pure_edge_label_or( - semantic - .clauses + clauses .iter() .find_map(|clause| clause.where_clause.as_ref()) .map(|expr| expr.span.clone()) @@ -2212,11 +3603,33 @@ fn collect_expr_pattern_variables( collect_expr_pattern_variables(semantic, return_aliases, arg, out); } } + ExprKind::AggregateCall { arg, .. } => { + if let Some(arg) = arg.as_ref() { + collect_expr_pattern_variables(semantic, return_aliases, arg, out); + } + } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand.as_ref() { + collect_expr_pattern_variables(semantic, return_aliases, operand, out); + } + for branch in branches { + collect_expr_pattern_variables(semantic, return_aliases, &branch.when, out); + collect_expr_pattern_variables(semantic, return_aliases, &branch.then, out); + } + if let Some(else_expr) = else_expr.as_ref() { + collect_expr_pattern_variables(semantic, return_aliases, else_expr, out); + } + } ExprKind::Map(map) => { for entry in &map.entries { collect_expr_pattern_variables(semantic, return_aliases, &entry.value, out); } } + ExprKind::ExistsSubquery(_) => {} ExprKind::Literal(_) | ExprKind::Parameter(_) => {} } } @@ -2239,11 +3652,33 @@ fn collect_expr_variables(expr: &Expr, out: &mut BTreeSet) { collect_expr_variables(arg, out); } } + ExprKind::AggregateCall { arg, .. } => { + if let Some(arg) = arg.as_ref() { + collect_expr_variables(arg, out); + } + } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand.as_ref() { + collect_expr_variables(operand, out); + } + for branch in branches { + collect_expr_variables(&branch.when, out); + collect_expr_variables(&branch.then, out); + } + if let Some(else_expr) = else_expr.as_ref() { + collect_expr_variables(else_expr, out); + } + } ExprKind::Map(map) => { for entry in &map.entries { collect_expr_variables(&entry.value, out); } } + ExprKind::ExistsSubquery(_) => {} ExprKind::Literal(_) | ExprKind::Parameter(_) => {} } } @@ -2351,31 +3786,170 @@ fn gql_return_item_from_expr( ExprKind::Variable(alias) if semantic.aliases.contains(alias) => { GraphReturnProjection::Element(GraphElementProjection::Full) } - _ => GraphReturnProjection::Auto, - }; - Ok(GraphReturnItem { - expr: gql_expr_to_graph_expr( - expr, - &semantic - .aliases - .by_name + _ => GraphReturnProjection::Auto, + }; + Ok(GraphReturnItem { + expr: gql_expr_to_graph_expr( + expr, + &semantic + .aliases + .by_name + .iter() + .map(|(alias, binding)| (alias.clone(), binding.kind)) + .collect(), + )?, + alias: Some(output_name), + projection, + }) +} + +pub(crate) fn gql_expr_to_graph_expr( + expr: &Expr, + alias_kinds: &BTreeMap, +) -> Result { + Ok(match &expr.kind { + ExprKind::Literal(literal) => gql_literal_to_graph_expr(literal), + ExprKind::Parameter(name) => GraphExpr::Param(name.clone()), + ExprKind::Variable(alias) => GraphExpr::Binding(alias.clone()), + ExprKind::PropertyAccess { object, property } => { + if let ExprKind::Variable(alias) = &object.kind { + if let Some(kind) = alias_kinds.get(alias).copied() { + return gql_alias_property_to_graph_expr(alias, kind, property); + } + } + GraphExpr::Property { + alias: gql_property_object_alias(object)?, + key: property.name.clone(), + } + } + ExprKind::Unary { op, expr } => GraphExpr::Unary { + op: gql_unary_op_to_graph_op(*op), + expr: Box::new(gql_expr_to_graph_expr(expr, alias_kinds)?), + }, + ExprKind::Binary { op, left, right } => GraphExpr::Binary { + left: Box::new(gql_expr_to_graph_expr(left, alias_kinds)?), + op: gql_binary_op_to_graph_op(*op), + right: Box::new(gql_expr_to_graph_expr(right, alias_kinds)?), + }, + ExprKind::IsNull { expr, negated } => { + let inner = Box::new(gql_expr_to_graph_expr(expr, alias_kinds)?); + if *negated { + GraphExpr::IsNotNull(inner) + } else { + GraphExpr::IsNull(inner) + } + } + ExprKind::FunctionCall { name, args } => { + if name.name.eq_ignore_ascii_case("node_ids") + || name.name.eq_ignore_ascii_case("edge_ids") + { + if args.len() != 1 { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + format!("function '{}' expects exactly one argument", name.name), + name.span.clone(), + )); + } + let alias = variable_name(&args[0]).ok_or_else(|| { + gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + format!("function '{}' expects a path alias argument", name.name), + args[0].span.clone(), + ) + })?; + GraphExpr::PathField { + alias: alias.to_string(), + field: if name.name.eq_ignore_ascii_case("node_ids") { + GraphPathField::NodeIds + } else { + GraphPathField::EdgeIds + }, + } + } else { + GraphExpr::Function { + name: gql_function_to_graph_function(&name.name, &name.span)?, + args: args + .iter() + .map(|arg| gql_expr_to_graph_expr(arg, alias_kinds)) + .collect::, _>>()?, + } + } + } + ExprKind::AggregateCall { + function, + distinct, + arg, + .. + } => GraphExpr::AggregateCall { + function: gql_aggregate_function_to_graph(*function), + distinct: *distinct, + arg: arg + .as_ref() + .map(|arg| gql_expr_to_graph_expr(arg, alias_kinds).map(Box::new)) + .transpose()?, + }, + ExprKind::ExistsSubquery(_) => { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "EXISTS subqueries are supported only in graph pipeline predicate execution" + .to_string(), + expr.span.clone(), + )); + } + ExprKind::Case { + operand, + branches, + else_expr, + } => GraphExpr::Case { + operand: operand + .as_ref() + .map(|operand| gql_expr_to_graph_expr(operand, alias_kinds).map(Box::new)) + .transpose()?, + branches: branches .iter() - .map(|(alias, binding)| (alias.clone(), binding.kind)) - .collect(), - )?, - alias: Some(output_name), - projection, + .map(|branch| { + Ok(GraphCaseBranch { + when: gql_expr_to_graph_expr(&branch.when, alias_kinds)?, + then: gql_expr_to_graph_expr(&branch.then, alias_kinds)?, + }) + }) + .collect::, EngineError>>()?, + else_expr: else_expr + .as_ref() + .map(|else_expr| gql_expr_to_graph_expr(else_expr, alias_kinds).map(Box::new)) + .transpose()?, + }, + ExprKind::List(items) => GraphExpr::List( + items + .iter() + .map(|item| gql_expr_to_graph_expr(item, alias_kinds)) + .collect::, _>>()?, + ), + ExprKind::Map(map) => GraphExpr::Map( + map.entries + .iter() + .map(|entry| { + Ok(( + entry.key.name.clone(), + gql_expr_to_graph_expr(&entry.value, alias_kinds)?, + )) + }) + .collect::, EngineError>>()?, + ), }) } -pub(crate) fn gql_expr_to_graph_expr( +fn gql_expr_to_graph_expr_for_pipeline( expr: &Expr, alias_kinds: &BTreeMap, + params: &GqlParams, + options: &GqlExecutionOptions, + subquery_depth: usize, ) -> Result { Ok(match &expr.kind { - ExprKind::Literal(literal) => gql_literal_to_graph_expr(literal), - ExprKind::Parameter(name) => GraphExpr::Param(name.clone()), - ExprKind::Variable(alias) => GraphExpr::Binding(alias.clone()), + ExprKind::ExistsSubquery(pipeline) => { + lower_exists_subquery_expr(pipeline, alias_kinds, params, options, subquery_depth)? + } ExprKind::PropertyAccess { object, property } => { if let ExprKind::Variable(alias) = &object.kind { if let Some(kind) = alias_kinds.get(alias).copied() { @@ -2387,20 +3961,41 @@ pub(crate) fn gql_expr_to_graph_expr( key: property.name.clone(), } } - ExprKind::Unary { - op: UnaryOp::Not, - expr, - } => GraphExpr::Unary { - op: crate::types::GraphUnaryOp::Not, - expr: Box::new(gql_expr_to_graph_expr(expr, alias_kinds)?), + ExprKind::Unary { op, expr } => GraphExpr::Unary { + op: gql_unary_op_to_graph_op(*op), + expr: Box::new(gql_expr_to_graph_expr_for_pipeline( + expr, + alias_kinds, + params, + options, + subquery_depth, + )?), }, ExprKind::Binary { op, left, right } => GraphExpr::Binary { - left: Box::new(gql_expr_to_graph_expr(left, alias_kinds)?), + left: Box::new(gql_expr_to_graph_expr_for_pipeline( + left, + alias_kinds, + params, + options, + subquery_depth, + )?), op: gql_binary_op_to_graph_op(*op), - right: Box::new(gql_expr_to_graph_expr(right, alias_kinds)?), + right: Box::new(gql_expr_to_graph_expr_for_pipeline( + right, + alias_kinds, + params, + options, + subquery_depth, + )?), }, ExprKind::IsNull { expr, negated } => { - let inner = Box::new(gql_expr_to_graph_expr(expr, alias_kinds)?); + let inner = Box::new(gql_expr_to_graph_expr_for_pipeline( + expr, + alias_kinds, + params, + options, + subquery_depth, + )?); if *negated { GraphExpr::IsNotNull(inner) } else { @@ -2438,15 +4033,106 @@ pub(crate) fn gql_expr_to_graph_expr( name: gql_function_to_graph_function(&name.name, &name.span)?, args: args .iter() - .map(|arg| gql_expr_to_graph_expr(arg, alias_kinds)) + .map(|arg| { + gql_expr_to_graph_expr_for_pipeline( + arg, + alias_kinds, + params, + options, + subquery_depth, + ) + }) .collect::, _>>()?, } } } + ExprKind::AggregateCall { + function, + distinct, + arg, + .. + } => GraphExpr::AggregateCall { + function: gql_aggregate_function_to_graph(*function), + distinct: *distinct, + arg: arg + .as_ref() + .map(|arg| { + gql_expr_to_graph_expr_for_pipeline( + arg, + alias_kinds, + params, + options, + subquery_depth, + ) + .map(Box::new) + }) + .transpose()?, + }, + ExprKind::Case { + operand, + branches, + else_expr, + } => GraphExpr::Case { + operand: operand + .as_ref() + .map(|operand| { + gql_expr_to_graph_expr_for_pipeline( + operand, + alias_kinds, + params, + options, + subquery_depth, + ) + .map(Box::new) + }) + .transpose()?, + branches: branches + .iter() + .map(|branch| { + Ok(GraphCaseBranch { + when: gql_expr_to_graph_expr_for_pipeline( + &branch.when, + alias_kinds, + params, + options, + subquery_depth, + )?, + then: gql_expr_to_graph_expr_for_pipeline( + &branch.then, + alias_kinds, + params, + options, + subquery_depth, + )?, + }) + }) + .collect::, EngineError>>()?, + else_expr: else_expr + .as_ref() + .map(|else_expr| { + gql_expr_to_graph_expr_for_pipeline( + else_expr, + alias_kinds, + params, + options, + subquery_depth, + ) + .map(Box::new) + }) + .transpose()?, + }, ExprKind::List(items) => GraphExpr::List( items .iter() - .map(|item| gql_expr_to_graph_expr(item, alias_kinds)) + .map(|item| { + gql_expr_to_graph_expr_for_pipeline( + item, + alias_kinds, + params, + options, + subquery_depth, + ) + }) .collect::, _>>()?, ), ExprKind::Map(map) => GraphExpr::Map( @@ -2455,14 +4141,126 @@ pub(crate) fn gql_expr_to_graph_expr( .map(|entry| { Ok(( entry.key.name.clone(), - gql_expr_to_graph_expr(&entry.value, alias_kinds)?, + gql_expr_to_graph_expr_for_pipeline( + &entry.value, + alias_kinds, + params, + options, + subquery_depth, + )?, )) }) .collect::, EngineError>>()?, ), + ExprKind::Literal(_) | ExprKind::Parameter(_) | ExprKind::Variable(_) => { + gql_expr_to_graph_expr(expr, alias_kinds)? + } }) } +fn lower_exists_subquery_expr( + pipeline: &GqlReadPipeline, + alias_kinds: &BTreeMap, + params: &GqlParams, + options: &GqlExecutionOptions, + subquery_depth: usize, +) -> Result { + let next_depth = subquery_depth.saturating_add(1); + if next_depth > options.max_subquery_depth { + return Err(EngineError::InvalidOperation(format!( + "GQL subquery depth {next_depth} exceeds max_subquery_depth {}", + options.max_subquery_depth + ))); + } + let outer_aliases = alias_table_from_kinds(alias_kinds); + let (bound, import_aliases, _) = + bind_subquery_pipeline_for_outer_aliases(pipeline, &outer_aliases, params)?; + let import_alias_kinds = import_aliases + .iter() + .filter_map(|alias| { + alias_kinds + .get(alias) + .copied() + .map(|kind| (alias.clone(), kind)) + }) + .collect::>(); + let lowered = lower_bound_read_pipeline_with_alias_kinds( + &bound, + params, + options, + next_depth, + import_alias_kinds, + )?; + let mut stages = lowered.stages; + inject_exists_internal_limit(&mut stages); + Ok(GraphExpr::ExistsSubquery(GraphSubqueryStage { + query: Box::new(GraphPipelineQuery { + stages, + params: gql_params_to_graph_params(params), + at_epoch: None, + page: GraphPageRequest { + skip: 0, + limit: 1, + cursor: None, + }, + output: GraphOutputOptions { + mode: GraphOutputMode::Ids, + compact_rows: false, + include_vectors: false, + }, + options: gql_pipeline_options(options), + }), + import_aliases, + })) +} + +fn inject_exists_internal_limit(stages: &mut [GraphPipelineStage]) { + for stage in stages.iter_mut() { + if let GraphPipelineStage::Union(union) = stage { + for branch in &mut union.branches { + inject_exists_internal_limit(&mut branch.stages); + } + } + } + if let Some(GraphPipelineStage::Project(project)) = stages.iter_mut().rev().find(|stage| { + matches!( + stage, + GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + .. + }) + ) + }) { + if project.limit.is_none() { + project.limit = Some(GraphExpr::UInt(1)); + } + } +} + +fn alias_table_from_kinds(alias_kinds: &BTreeMap) -> GqlAliasTable { + let mut table = GqlAliasTable::default(); + for (name, kind) in alias_kinds { + table.by_name.insert( + name.clone(), + GqlAliasBinding { + name: name.clone(), + kind: *kind, + span: SourceSpan::new(0, 0, 1, 1), + user_visible: true, + }, + ); + table.user_order.push(name.clone()); + } + table +} + +fn gql_unary_op_to_graph_op(op: UnaryOp) -> GraphUnaryOp { + match op { + UnaryOp::Not => GraphUnaryOp::Not, + UnaryOp::Neg => GraphUnaryOp::Neg, + } +} + fn gql_property_object_alias(object: &Expr) -> Result { if let ExprKind::Variable(alias) = &object.kind { return Ok(alias.clone()); @@ -2565,6 +4363,7 @@ fn gql_alias_property_to_graph_expr( )); } }), + GqlAliasKind::Scalar => Ok(GraphExpr::Binding(alias.to_string())), } } @@ -2582,6 +4381,10 @@ fn gql_binary_op_to_graph_op(op: BinaryOp) -> GraphBinaryOp { match op { BinaryOp::Or => GraphBinaryOp::Or, BinaryOp::And => GraphBinaryOp::And, + BinaryOp::Add => GraphBinaryOp::Add, + BinaryOp::Sub => GraphBinaryOp::Sub, + BinaryOp::Mul => GraphBinaryOp::Mul, + BinaryOp::Div => GraphBinaryOp::Div, BinaryOp::Eq => GraphBinaryOp::Eq, BinaryOp::Neq => GraphBinaryOp::Neq, BinaryOp::Lt => GraphBinaryOp::Lt, @@ -2589,6 +4392,9 @@ fn gql_binary_op_to_graph_op(op: BinaryOp) -> GraphBinaryOp { BinaryOp::Gt => GraphBinaryOp::Gt, BinaryOp::Ge => GraphBinaryOp::Ge, BinaryOp::In => GraphBinaryOp::In, + BinaryOp::StartsWith => GraphBinaryOp::StartsWith, + BinaryOp::EndsWith => GraphBinaryOp::EndsWith, + BinaryOp::Contains => GraphBinaryOp::Contains, } } @@ -2605,6 +4411,21 @@ fn gql_function_to_graph_function( "end_node" => Ok(GraphFunction::EndNode), "nodes" => Ok(GraphFunction::Nodes), "relationships" => Ok(GraphFunction::Relationships), + "coalesce" => Ok(GraphFunction::Coalesce), + "to_string" => Ok(GraphFunction::ToString), + "to_integer" => Ok(GraphFunction::ToInteger), + "to_float" => Ok(GraphFunction::ToFloat), + "abs" => Ok(GraphFunction::Abs), + "floor" => Ok(GraphFunction::Floor), + "ceil" => Ok(GraphFunction::Ceil), + "round" => Ok(GraphFunction::Round), + "lower" => Ok(GraphFunction::Lower), + "upper" => Ok(GraphFunction::Upper), + "trim" => Ok(GraphFunction::Trim), + "substring" => Ok(GraphFunction::Substring), + "size" => Ok(GraphFunction::Size), + "head" => Ok(GraphFunction::Head), + "last" => Ok(GraphFunction::Last), _ => Err(gql_semantic_error( GqlSemanticErrorCode::InvalidReturnExpression, "unsupported GQL scalar function".to_string(), @@ -2613,6 +4434,17 @@ fn gql_function_to_graph_function( } } +fn gql_aggregate_function_to_graph(function: AggregateFunction) -> GraphAggregateFunction { + match function { + AggregateFunction::Count => GraphAggregateFunction::Count, + AggregateFunction::Sum => GraphAggregateFunction::Sum, + AggregateFunction::Avg => GraphAggregateFunction::Avg, + AggregateFunction::Min => GraphAggregateFunction::Min, + AggregateFunction::Max => GraphAggregateFunction::Max, + AggregateFunction::Collect => GraphAggregateFunction::Collect, + } +} + pub(crate) fn gql_order_direction_to_graph(direction: OrderDirection) -> GraphOrderDirection { match direction { OrderDirection::Asc => GraphOrderDirection::Asc, @@ -2840,7 +4672,7 @@ fn entity_value_ref( key: property.name.clone(), }) }), - GqlAliasKind::Path => None, + GqlAliasKind::Path | GqlAliasKind::Scalar => None, } } ExprKind::FunctionCall { name, args } if args.len() == 1 => { @@ -2849,7 +4681,7 @@ fn entity_value_ref( "id" => match alias_kinds.get(&alias).copied() { Some(GqlAliasKind::Node) => Some(EntityValueRef::NodeId { alias }), Some(GqlAliasKind::Edge) => Some(EntityValueRef::EdgeId { alias }), - Some(GqlAliasKind::Path) | None => None, + Some(GqlAliasKind::Path | GqlAliasKind::Scalar) | None => None, }, "type" => Some(EntityValueRef::RelationshipLabelFunction { alias }), _ => None, @@ -3688,7 +5520,22 @@ mod tests { } fn graph_target(plan: &GqlLoweredPlan) -> &GraphRowQueryTarget { - let GqlNativeTarget::GraphRows { query } = &plan.native_target; + let GqlNativeTarget::GraphRows { query } = &plan.native_target else { + panic!( + "expected graph-row target, got {:?}", + plan.native_target.kind() + ); + }; + query + } + + fn pipeline_target(plan: &GqlLoweredPlan) -> &GraphPipelineQuery { + let GqlNativeTarget::GraphPipeline { query } = &plan.native_target else { + panic!( + "expected graph-pipeline target, got {:?}", + plan.native_target.kind() + ); + }; query } @@ -3747,7 +5594,17 @@ mod tests { .internal_columns .iter() .any(|column| { matches!(column, GqlMutationInternalColumn::ExprValue { .. }) })); - assert_eq!(read.graph_row.query.return_items.as_ref().unwrap().len(), 4); + assert_eq!( + read.graph_row + .as_ref() + .unwrap() + .query + .return_items + .as_ref() + .unwrap() + .len(), + 5 + ); assert_eq!(plan.operation_exprs.len(), 1); let [GqlMutationClausePlan::Set(items)] = plan.clauses.as_slice() else { panic!("expected SET plan"); @@ -3763,6 +5620,73 @@ mod tests { )); } + #[test] + fn lowers_keyed_node_merge_actions_and_expr_order() { + let plan = lower_mut( + "MERGE (n:Person {key: 'ada'}) ON CREATE SET n.status = 'new' ON MATCH SET n.status = 'seen' RETURN n", + ) + .unwrap(); + assert!(plan.read_prefix.is_none()); + assert_eq!(plan.operation_exprs.len(), 3); + let [GqlMutationClausePlan::Merge(merge)] = plan.clauses.as_slice() else { + panic!("expected MERGE plan"); + }; + assert!(matches!( + &merge.pattern, + GqlMergePatternPlan::Node { alias, label, key } + if alias == "n" && label == "Person" && key.id == 0 + )); + assert!(matches!( + &merge.on_create[0], + GqlSetItemPlan::Property { alias, property, value, .. } + if alias == "n" && property == "status" && value.id == 1 + )); + assert!(matches!( + &merge.on_match[0], + GqlSetItemPlan::Property { alias, property, value, .. } + if alias == "n" && property == "status" && value.id == 2 + )); + + let late = lower_mut( + "MERGE (n:Person {key: 'ada'}) ON MATCH SET n.count = coalesce(n.count, 0) + 1", + ) + .unwrap(); + assert!(late.operation_exprs[1].late); + } + + #[test] + fn lowers_relationship_merge_and_with_read_prefix_to_pipeline() { + let relationship = + lower_mut("MATCH (a:Person) MATCH (b:Person) MERGE (a)-[r:KNOWS]->(b) RETURN r") + .unwrap(); + let read = relationship + .read_prefix + .as_ref() + .expect("relationship endpoints require read prefix"); + assert!(read.graph_row.is_some()); + let [GqlMutationClausePlan::Merge(merge)] = relationship.clauses.as_slice() else { + panic!("expected relationship MERGE plan"); + }; + assert!(matches!( + &merge.pattern, + GqlMergePatternPlan::Relationship { alias, from_alias, to_alias, label } + if alias == "r" && from_alias == "a" && to_alias == "b" && label == "KNOWS" + )); + + let with_prefix = + lower_mut("MATCH (s:Person) WITH s MERGE (n:GqlMergeWith {key: s.key}) RETURN n") + .unwrap(); + let read = with_prefix + .read_prefix + .as_ref() + .expect("WITH prefix should lower"); + assert!(read.graph_row.is_none()); + assert!(matches!( + read.lowered.native_target, + GqlNativeTarget::GraphPipeline { .. } + )); + } + #[test] fn rejects_duplicate_and_reserved_aliases() { let duplicate = @@ -3936,6 +5860,289 @@ mod tests { assert!(plan.residual_predicates.is_empty()); } + #[test] + fn with_pipeline_later_match_pushes_predicates_onto_carried_nodes() { + let plan = lower( + "MATCH (n:SeedSource) \ + WITH n \ + MATCH (n)-[:SEEDED_REL]->(m:SeedTarget) \ + WHERE n.status = 'active' \ + RETURN id(m) AS id", + ) + .unwrap(); + let pipeline = pipeline_target(&plan); + let GraphPipelineStage::Match(stage) = &pipeline.stages[2] else { + panic!("expected later MATCH stage, got {:?}", pipeline.stages[2]); + }; + let carried = stage + .nodes + .iter() + .find(|node| node.alias == "n") + .expect("carried node alias should be present in graph-row stage"); + assert!(node_filter_contains( + &carried.filter, + &NodeFilterExpr::PropertyEquals { + key: "status".to_string(), + value: PropValue::String("active".to_string()), + } + )); + assert!(stage.where_.is_none()); + assert!(plan.residual_predicates.is_empty()); + assert!(plan.pushed_down.iter().any(|push| { + push.alias == "n" + && push.target_kind == GqlAliasKind::Node + && push.summary.contains("n.status") + })); + } + + #[test] + fn lowers_shortest_path_pipeline_stage_to_native_stage() { + let plan = lower( + "MATCH (a) WITH a MATCH (b) WITH a, b \ + MATCH p = allShortestPaths((a)-[:R*2..4]-(b)) \ + RETURN p", + ) + .unwrap(); + let pipeline = pipeline_target(&plan); + let GraphPipelineStage::ShortestPath(stage) = &pipeline.stages[4] else { + panic!("expected shortest-path stage, got {:?}", pipeline.stages[4]); + }; + assert!(!stage.optional); + assert_eq!(stage.output_path_alias, "p"); + assert_eq!(stage.mode, GraphShortestPathMode::All); + assert_eq!( + stage.from, + GraphShortestPathEndpoint::Alias("a".to_string()) + ); + assert_eq!(stage.to, GraphShortestPathEndpoint::Alias("b".to_string())); + assert_eq!(stage.direction, Direction::Both); + assert_eq!(stage.edge_label_filter, vec!["R"]); + assert_eq!(stage.min_hops, 2); + assert_eq!(stage.max_hops, 4); + assert_eq!(stage.weight_field, None); + assert_eq!(stage.max_cost, None); + } + + #[test] + fn union_pipeline_lowers_to_native_union_stage() { + let plan = lower( + "MATCH (n:UnionLower) RETURN n.name AS name \ + UNION ALL \ + MATCH (m:UnionLower) RETURN m.name AS name", + ) + .unwrap(); + let pipeline = pipeline_target(&plan); + assert_eq!(pipeline.stages.len(), 1); + let GraphPipelineStage::Union(union) = &pipeline.stages[0] else { + panic!("expected union stage, got {:?}", pipeline.stages[0]); + }; + assert!(union.all); + assert_eq!(union.branches.len(), 2); + for branch in &union.branches { + assert_eq!(branch.page.skip, 0); + assert!(branch.page.cursor.is_none()); + assert!(matches!( + branch.stages.last(), + Some(GraphPipelineStage::Project(GraphProjectStage { + kind: GraphProjectKind::Return, + .. + })) + )); + } + } + + #[test] + fn union_lowers_caps_and_rejects_mixed_modifiers() { + let capped = lower_result_with_options( + "MATCH (n:UnionLower) RETURN n.name AS name \ + UNION ALL MATCH (m:UnionLower) RETURN m.name AS name \ + UNION ALL MATCH (x:UnionLower) RETURN x.name AS name", + GqlExecutionOptions { + max_union_branches: 2, + ..GqlExecutionOptions::default() + }, + ); + assert!(matches!( + capped, + Err(EngineError::InvalidOperation(message)) if message.contains("max_union_branches") + )); + + let mixed = lower( + "MATCH (n:UnionLower) RETURN n.name AS name \ + UNION ALL MATCH (m:UnionLower) RETURN m.name AS name \ + UNION MATCH (x:UnionLower) RETURN x.name AS name", + ); + assert!( + matches!(mixed, Err(EngineError::GqlUnsupported { feature, .. }) if feature == "mixed UNION modifiers") + ); + } + + #[test] + fn distinct_and_aggregate_pipeline_shape_is_preserved_in_lowered_ir() { + fn has_aggregate( + expr: &GraphExpr, + function: GraphAggregateFunction, + distinct: bool, + arg_present: bool, + ) -> bool { + match expr { + GraphExpr::AggregateCall { + function: actual_function, + distinct: actual_distinct, + arg, + } => { + *actual_function == function + && *actual_distinct == distinct + && arg.is_some() == arg_present + } + GraphExpr::ExistsSubquery(stage) => stage.query.stages.iter().any(|stage| { + graph_pipeline_stage_has_aggregate(stage, function, distinct, arg_present) + }), + GraphExpr::List(items) => items + .iter() + .any(|expr| has_aggregate(expr, function, distinct, arg_present)), + GraphExpr::Map(items) => items + .values() + .any(|expr| has_aggregate(expr, function, distinct, arg_present)), + GraphExpr::Function { args, .. } => args + .iter() + .any(|expr| has_aggregate(expr, function, distinct, arg_present)), + GraphExpr::Unary { expr, .. } + | GraphExpr::IsNull(expr) + | GraphExpr::IsNotNull(expr) => { + has_aggregate(expr, function, distinct, arg_present) + } + GraphExpr::Binary { left, right, .. } => { + has_aggregate(left, function, distinct, arg_present) + || has_aggregate(right, function, distinct, arg_present) + } + GraphExpr::Case { + operand, + branches, + else_expr, + } => { + operand + .as_deref() + .is_some_and(|expr| has_aggregate(expr, function, distinct, arg_present)) + || branches.iter().any(|branch| { + has_aggregate(&branch.when, function, distinct, arg_present) + || has_aggregate(&branch.then, function, distinct, arg_present) + }) + || else_expr.as_deref().is_some_and(|expr| { + has_aggregate(expr, function, distinct, arg_present) + }) + } + GraphExpr::Null + | GraphExpr::Bool(_) + | GraphExpr::Int(_) + | GraphExpr::UInt(_) + | GraphExpr::Float(_) + | GraphExpr::String(_) + | GraphExpr::Bytes(_) + | GraphExpr::Param(_) + | GraphExpr::Binding(_) + | GraphExpr::Property { .. } + | GraphExpr::NodeField { .. } + | GraphExpr::EdgeField { .. } + | GraphExpr::PathField { .. } => false, + } + } + + fn graph_pipeline_stage_has_aggregate( + stage: &GraphPipelineStage, + function: GraphAggregateFunction, + distinct: bool, + arg_present: bool, + ) -> bool { + match stage { + GraphPipelineStage::Match(stage) => stage + .where_ + .as_ref() + .is_some_and(|expr| has_aggregate(expr, function, distinct, arg_present)), + GraphPipelineStage::Project(stage) => { + let items = match &stage.items { + GraphProjectionItems::Star => false, + GraphProjectionItems::Items(items) => items + .iter() + .any(|item| has_aggregate(&item.expr, function, distinct, arg_present)), + }; + items + || stage.where_.as_ref().is_some_and(|expr| { + has_aggregate(expr, function, distinct, arg_present) + }) + || stage + .order_by + .iter() + .any(|item| has_aggregate(&item.expr, function, distinct, arg_present)) + || stage.skip.as_ref().is_some_and(|expr| { + has_aggregate(expr, function, distinct, arg_present) + }) + || stage.limit.as_ref().is_some_and(|expr| { + has_aggregate(expr, function, distinct, arg_present) + }) + } + GraphPipelineStage::Call(stage) => stage.query.stages.iter().any(|stage| { + graph_pipeline_stage_has_aggregate(stage, function, distinct, arg_present) + }), + GraphPipelineStage::Union(stage) => stage.branches.iter().any(|branch| { + branch.stages.iter().any(|stage| { + graph_pipeline_stage_has_aggregate(stage, function, distinct, arg_present) + }) + }), + GraphPipelineStage::ShortestPath(_) => false, + } + } + + let with = lower("MATCH (n:Person) WITH DISTINCT n.kind AS k RETURN k").unwrap(); + let with_pipeline = pipeline_target(&with); + let GraphPipelineStage::Project(with_stage) = &with_pipeline.stages[1] else { + panic!("expected WITH project stage"); + }; + assert_eq!(with_stage.kind, GraphProjectKind::With); + assert!(with_stage.distinct); + + let return_distinct = lower( + "MATCH (n:Person) RETURN DISTINCT n.kind AS k, count(*) + 1 AS total ORDER BY count(*) DESC", + ) + .unwrap(); + let return_pipeline = pipeline_target(&return_distinct); + let GraphPipelineStage::Project(return_stage) = &return_pipeline.stages[1] else { + panic!("expected RETURN project stage"); + }; + assert_eq!(return_stage.kind, GraphProjectKind::Return); + assert!(return_stage.distinct); + let GraphProjectionItems::Items(items) = &return_stage.items else { + panic!("expected explicit RETURN items"); + }; + assert!(has_aggregate( + &items[1].expr, + GraphAggregateFunction::Count, + false, + false + )); + assert!(has_aggregate( + &return_stage.order_by[0].expr, + GraphAggregateFunction::Count, + false, + false + )); + + let collect = lower("MATCH (n:Person) RETURN collect(DISTINCT n.kind) AS kinds").unwrap(); + let collect_pipeline = pipeline_target(&collect); + let GraphPipelineStage::Project(collect_stage) = &collect_pipeline.stages[1] else { + panic!("expected collect RETURN stage"); + }; + let GraphProjectionItems::Items(items) = &collect_stage.items else { + panic!("expected explicit collect items"); + }; + assert!(has_aggregate( + &items[0].expr, + GraphAggregateFunction::Collect, + true, + true + )); + } + #[test] fn node_metadata_predicates_push_down_only_when_native_semantics_match() { let plan = lower( diff --git a/src/gql/params.rs b/src/gql/params.rs index d5e391b..092e1ac 100644 --- a/src/gql/params.rs +++ b/src/gql/params.rs @@ -1,7 +1,7 @@ use crate::error::EngineError; use crate::gql::ast::{ - Expr, ExprKind, GqlMutationStatement, GqlQuery, GqlStatementBody, MapLiteral, MutationClause, - Pattern, RemoveItem, ReturnBody, SetItem, + Expr, ExprKind, GqlMutationStatement, GqlPipelineClause, GqlQuery, GqlReadPipeline, + GqlStatementBody, MapLiteral, MutationClause, Pattern, RemoveItem, ReturnBody, SetItem, }; use crate::gql::parser::{parse_statement, GqlParseOptions}; use crate::gql::semantic::{GqlMutationSemanticPlan, GqlSemanticPlan}; @@ -86,41 +86,65 @@ fn validate_referenced_param_set( fn collect_query_parameter_spans(query: &GqlQuery) -> BTreeMap { let mut spans = BTreeMap::new(); - for clause in &query.match_clauses { - for pattern in &clause.patterns { - collect_pattern_parameter_spans(pattern, &mut spans); - } - if let Some(where_clause) = clause.where_clause.as_ref() { - collect_expr_parameter_spans(where_clause, &mut spans); - } - } - if let ReturnBody::Items(items) = &query.return_clause.body { - for item in items { - collect_expr_parameter_spans(&item.expr, &mut spans); + collect_read_pipeline_parameter_spans(&query.pipeline, &mut spans); + spans +} + +fn collect_read_pipeline_parameter_spans( + pipeline: &GqlReadPipeline, + spans: &mut BTreeMap, +) { + for clause in &pipeline.clauses { + match clause { + GqlPipelineClause::Match(match_clauses) => { + for clause in match_clauses { + for pattern in &clause.patterns { + collect_pattern_parameter_spans(pattern, spans); + } + if let Some(where_clause) = clause.where_clause.as_ref() { + collect_expr_parameter_spans(where_clause, spans); + } + } + } + GqlPipelineClause::ShortestPath(shortest) => { + collect_pattern_parameter_spans(&shortest.pattern, spans); + } + GqlPipelineClause::Call(call) => { + collect_read_pipeline_parameter_spans(&call.pipeline, spans); + } + GqlPipelineClause::Projection(projection) => { + collect_return_body_parameter_spans(&projection.body, spans); + if let Some(where_clause) = projection.where_clause.as_ref() { + collect_expr_parameter_spans(where_clause, spans); + } + for item in &projection.order_by { + collect_expr_parameter_spans(&item.expr, spans); + } + if let Some(skip) = projection.skip.as_ref() { + collect_expr_parameter_spans(skip, spans); + } + if let Some(limit) = projection.limit.as_ref() { + collect_expr_parameter_spans(limit, spans); + } + } } } - for item in &query.order_by { - collect_expr_parameter_spans(&item.expr, &mut spans); - } - if let Some(skip) = query.skip.as_ref() { - collect_expr_parameter_spans(skip, &mut spans); - } - if let Some(limit) = query.limit.as_ref() { - collect_expr_parameter_spans(limit, &mut spans); - } - spans } fn collect_mutation_parameter_spans( mutation: &GqlMutationStatement, ) -> BTreeMap { let mut spans = BTreeMap::new(); - for clause in &mutation.read_prefix { - for pattern in &clause.patterns { - collect_pattern_parameter_spans(pattern, &mut spans); - } - if let Some(where_clause) = clause.where_clause.as_ref() { - collect_expr_parameter_spans(where_clause, &mut spans); + if let Some(pipeline) = mutation.read_prefix_pipeline.as_ref() { + collect_read_pipeline_parameter_spans(pipeline, &mut spans); + } else { + for clause in &mutation.read_prefix { + for pattern in &clause.patterns { + collect_pattern_parameter_spans(pattern, &mut spans); + } + if let Some(where_clause) = clause.where_clause.as_ref() { + collect_expr_parameter_spans(where_clause, &mut spans); + } } } for clause in &mutation.mutation_clauses { @@ -130,16 +154,18 @@ fn collect_mutation_parameter_spans( collect_pattern_parameter_spans(pattern, &mut spans); } } - MutationClause::Set(set) => { - for item in &set.items { - match item { - SetItem::Property { value, .. } | SetItem::MapMerge { value, .. } => { - collect_expr_parameter_spans(value, &mut spans); - } - SetItem::NodeLabel { .. } => {} - } + MutationClause::Merge(merge) => { + collect_pattern_parameter_spans(&merge.pattern, &mut spans); + if let Some(on_create) = merge.on_create.as_ref() { + collect_set_parameter_spans(on_create, &mut spans); + } + if let Some(on_match) = merge.on_match.as_ref() { + collect_set_parameter_spans(on_match, &mut spans); } } + MutationClause::Set(set) => { + collect_set_parameter_spans(set, &mut spans); + } MutationClause::Remove(remove) => { for item in &remove.items { match item { @@ -155,11 +181,7 @@ fn collect_mutation_parameter_spans( } } if let Some(tail) = mutation.return_tail.as_ref() { - if let ReturnBody::Items(items) = &tail.return_clause.body { - for item in items { - collect_expr_parameter_spans(&item.expr, &mut spans); - } - } + collect_return_body_parameter_spans(&tail.return_clause.body, &mut spans); for item in &tail.order_by { collect_expr_parameter_spans(&item.expr, &mut spans); } @@ -173,6 +195,34 @@ fn collect_mutation_parameter_spans( spans } +fn collect_set_parameter_spans( + set: &crate::gql::ast::SetClause, + spans: &mut BTreeMap, +) { + for item in &set.items { + match item { + SetItem::Property { value, .. } | SetItem::MapMerge { value, .. } => { + collect_expr_parameter_spans(value, spans); + } + SetItem::NodeLabel { .. } => {} + } + } +} + +fn collect_return_body_parameter_spans( + body: &ReturnBody, + spans: &mut BTreeMap, +) { + match body { + ReturnBody::All(_) => {} + ReturnBody::AllAndItems { items, .. } | ReturnBody::Items(items) => { + for item in items { + collect_expr_parameter_spans(&item.expr, spans); + } + } + } +} + fn collect_pattern_parameter_spans(pattern: &Pattern, spans: &mut BTreeMap) { if let Some(properties) = pattern.start.properties.as_ref() { collect_map_parameter_spans(properties, spans); @@ -209,11 +259,35 @@ fn collect_expr_parameter_spans(expr: &Expr, spans: &mut BTreeMap { + if let Some(else_expr) = else_expr { + stack.push(else_expr); + } + for branch in branches.iter().rev() { + stack.push(&branch.then); + stack.push(&branch.when); + } + if let Some(operand) = operand { + stack.push(operand); + } + } ExprKind::FunctionCall { args, .. } | ExprKind::List(args) => { for arg in args.iter().rev() { stack.push(arg); } } + ExprKind::AggregateCall { arg, .. } => { + if let Some(arg) = arg.as_ref() { + stack.push(arg); + } + } + ExprKind::ExistsSubquery(pipeline) => { + collect_read_pipeline_parameter_spans(pipeline, spans); + } ExprKind::Map(map) => { for entry in map.entries.iter().rev() { stack.push(&entry.value); diff --git a/src/gql/parser.rs b/src/gql/parser.rs index ae4cc38..fade450 100644 --- a/src/gql/parser.rs +++ b/src/gql/parser.rs @@ -92,6 +92,15 @@ struct ParsedMapLiteral { ast_depth: usize, } +struct ParsedReadBranch { + clauses: Vec, + return_clause: ReturnClause, + order_by: Vec, + skip: Option, + limit: Option, + span: SourceSpan, +} + type RelationshipDetail = ( Option, Vec, @@ -111,17 +120,21 @@ impl<'a> Parser<'a> { fn parse_statement(&mut self) -> Result { if self.at_mutation_clause_start() { - return self.parse_mutation_statement(Vec::new()); + return self.parse_mutation_statement(Vec::new(), None); + } + + if self.at_mutation_read_prefix_start() && self.has_top_level_mutation_before_return() { + let (read_prefix, read_prefix_pipeline) = self.parse_mutation_read_prefix_pipeline()?; + return self.parse_mutation_statement(read_prefix, Some(read_prefix_pipeline)); } if self.at_match_clause_start() { let mut match_clauses = Vec::new(); - match_clauses.push(self.parse_match_clause()?); - while self.at_match_clause_start() { + while self.at_regular_match_clause_start() { match_clauses.push(self.parse_match_clause()?); } if self.at_mutation_clause_start() { - return self.parse_mutation_statement(match_clauses); + return self.parse_mutation_statement(match_clauses, None); } let query = self.parse_query_tail(match_clauses)?; return Ok(GqlStatement { @@ -131,6 +144,15 @@ impl<'a> Parser<'a> { }); } + if self.at_call_subquery_start() { + let query = self.parse_query_tail(Vec::new())?; + return Ok(GqlStatement { + kind: GqlStatementKind::Query, + span: query.span.clone(), + body: GqlStatementBody::Query(query), + }); + } + self.reject_unsupported_clause()?; let query = self.parse_query()?; Ok(GqlStatement { @@ -143,8 +165,10 @@ impl<'a> Parser<'a> { fn parse_query(&mut self) -> Result { self.reject_unsupported_clause()?; let mut match_clauses = Vec::new(); - match_clauses.push(self.parse_match_clause()?); - while self.at_match_clause_start() { + if !self.at_match_clause_start() { + return Err(self.parse_error_current("expected MATCH clause")); + } + while self.at_regular_match_clause_start() { match_clauses.push(self.parse_match_clause()?); } @@ -155,25 +179,32 @@ impl<'a> Parser<'a> { &mut self, match_clauses: Vec, ) -> Result { - self.reject_unsupported_clause()?; - let return_clause = self.parse_return_clause()?; - let order_by = if self.at_keyword(Keyword::Order) { - self.parse_order_by()? - } else { - Vec::new() - }; - let mut skip = None; - if self.at_keyword(Keyword::Skip) || self.at_keyword(Keyword::Offset) { - skip = Some(self.parse_skip_or_offset()?); - } - if self.at_keyword(Keyword::Skip) || self.at_keyword(Keyword::Offset) { - return Err(self.parse_error_current("SKIP and OFFSET cannot both be specified")); + let first_branch = self.parse_read_branch_tail(match_clauses.clone())?; + let mut union_branches = Vec::new(); + while self.at_keyword(Keyword::Union) { + let union = self.advance(); + let modifier = if self.token_word_eq(self.pos, "all") { + self.advance(); + GqlUnionModifier::All + } else { + GqlUnionModifier::Distinct + }; + self.reject_unsupported_clause()?; + if !self.at_match_clause_start() { + return Err(self.parse_error_current("expected MATCH after UNION")); + } + let mut branch_matches = Vec::new(); + while self.at_regular_match_clause_start() { + branch_matches.push(self.parse_match_clause()?); + } + let branch = self.parse_read_branch_tail(branch_matches)?; + union_branches.push(GqlUnionBranch { + modifier, + clauses: branch.clauses, + span: branch.span, + union_span: union.span, + }); } - let limit = if self.consume_keyword(Keyword::Limit).is_some() { - Some(self.parse_expression(0)?.expr) - } else { - None - }; if let Some(semicolon) = self.consume_if(|kind| matches!(kind, TokenKind::Semicolon)) { if !self.at_eof() { @@ -190,14 +221,67 @@ impl<'a> Parser<'a> { } let span = self.span_between( - &match_clauses + &first_branch + .clauses .first() - .map(|clause| clause.span.clone()) - .unwrap_or_else(|| return_clause.span.clone()), + .map(gql_pipeline_clause_span) + .unwrap_or_else(|| first_branch.return_clause.span.clone()), &self.previous_non_eof_span(), ); + let pipeline = GqlReadPipeline { + clauses: first_branch.clauses, + union_branches, + span: span.clone(), + }; Ok(GqlQuery { match_clauses, + return_clause: first_branch.return_clause, + order_by: first_branch.order_by, + skip: first_branch.skip, + limit: first_branch.limit, + pipeline, + span, + }) + } + + fn parse_read_branch_tail( + &mut self, + match_clauses: Vec, + ) -> Result { + let mut clauses = Vec::new(); + if !match_clauses.is_empty() { + clauses.push(GqlPipelineClause::Match(match_clauses.clone())); + } + self.parse_read_stage_sequence(&mut clauses)?; + while self.at_keyword(Keyword::With) { + clauses.push(GqlPipelineClause::Projection( + self.parse_projection_clause(GqlProjectionKind::With)?, + )); + self.parse_read_stage_sequence(&mut clauses)?; + } + + self.reject_unsupported_clause()?; + let return_projection = self.parse_projection_clause(GqlProjectionKind::Return)?; + let return_clause = ReturnClause { + body: return_projection.body.clone(), + distinct: return_projection.distinct, + distinct_span: return_projection.distinct_span.clone(), + span: return_projection.span.clone(), + }; + let order_by = return_projection.order_by.clone(); + let skip = return_projection.skip.clone(); + let limit = return_projection.limit.clone(); + clauses.push(GqlPipelineClause::Projection(return_projection)); + + let span = self.span_between( + &clauses + .first() + .map(gql_pipeline_clause_span) + .unwrap_or_else(|| return_clause.span.clone()), + &self.previous_non_eof_span(), + ); + Ok(ParsedReadBranch { + clauses, return_clause, order_by, skip, @@ -206,9 +290,92 @@ impl<'a> Parser<'a> { }) } + fn parse_read_stage_sequence( + &mut self, + clauses: &mut Vec, + ) -> Result<(), EngineError> { + loop { + if self.at_call_subquery_start() { + clauses.push(GqlPipelineClause::Call(self.parse_call_subquery()?)); + continue; + } + if self.at_shortest_path_match_clause_start() { + clauses.push(GqlPipelineClause::ShortestPath( + self.parse_shortest_path_clause()?, + )); + continue; + } + if self.at_regular_match_clause_start() { + let mut matches = Vec::new(); + while self.at_regular_match_clause_start() { + matches.push(self.parse_match_clause()?); + } + if !matches.is_empty() { + clauses.push(GqlPipelineClause::Match(matches)); + } + continue; + } + break; + } + Ok(()) + } + + fn parse_call_subquery(&mut self) -> Result { + let start = self.expect_keyword(Keyword::Call, "expected CALL")?; + self.expect_kind( + |kind| matches!(kind, TokenKind::LBrace), + "expected '{' after CALL", + )?; + let pipeline = self.parse_nested_read_pipeline()?; + let end = self.expect_kind( + |kind| matches!(kind, TokenKind::RBrace), + "expected '}' to close CALL subquery", + )?; + Ok(GqlCallSubquery { + pipeline: Box::new(pipeline), + span: self.span_between(&start.span, &end.span), + }) + } + + fn parse_nested_read_pipeline(&mut self) -> Result { + let first_branch = self.parse_read_branch_tail(Vec::new())?; + let mut union_branches = Vec::new(); + while self.at_keyword(Keyword::Union) { + let union = self.advance(); + let modifier = if self.token_word_eq(self.pos, "all") { + self.advance(); + GqlUnionModifier::All + } else { + GqlUnionModifier::Distinct + }; + self.reject_unsupported_clause()?; + let branch = self.parse_read_branch_tail(Vec::new())?; + union_branches.push(GqlUnionBranch { + modifier, + clauses: branch.clauses, + span: branch.span, + union_span: union.span, + }); + } + let span = self.span_between( + &first_branch + .clauses + .first() + .map(gql_pipeline_clause_span) + .unwrap_or_else(|| first_branch.return_clause.span.clone()), + &self.previous_non_eof_span(), + ); + Ok(GqlReadPipeline { + clauses: first_branch.clauses, + union_branches, + span, + }) + } + fn parse_mutation_statement( &mut self, read_prefix: Vec, + read_prefix_pipeline: Option, ) -> Result { for clause in &read_prefix { if clause.patterns.len() != 1 { @@ -227,10 +394,10 @@ impl<'a> Parser<'a> { let mut mutation_clauses = Vec::new(); while self.at_mutation_clause_start() { mutation_clauses.push(self.parse_mutation_clause()?); - if self.at_match_clause_start() { + if self.at_read_after_write_clause_start() { return Err(EngineError::GqlUnsupported { - feature: "read-after-write matching".to_string(), - message: "MATCH and OPTIONAL MATCH clauses must appear before mutation clauses" + feature: "read-after-write clauses".to_string(), + message: "MATCH, WITH, CALL, UNION, and subquery read stages must appear before mutation clauses" .to_string(), span: self.current().span.clone(), }); @@ -268,10 +435,10 @@ impl<'a> Parser<'a> { } self.reject_unsupported_clause()?; - if self.at_match_clause_start() { + if self.at_read_after_write_clause_start() { return Err(EngineError::GqlUnsupported { - feature: "read-after-write matching".to_string(), - message: "MATCH and OPTIONAL MATCH clauses must appear before mutation clauses" + feature: "read-after-write clauses".to_string(), + message: "MATCH, WITH, CALL, UNION, and subquery read stages must appear before mutation clauses" .to_string(), span: self.current().span.clone(), }); @@ -283,6 +450,7 @@ impl<'a> Parser<'a> { let span = self.span_between(&start_span, &self.previous_non_eof_span()); let mutation = GqlMutationStatement { read_prefix, + read_prefix_pipeline, mutation_clauses, return_tail, span, @@ -297,6 +465,8 @@ impl<'a> Parser<'a> { fn parse_mutation_clause(&mut self) -> Result { if self.at_keyword(Keyword::Create) { Ok(MutationClause::Create(self.parse_create_clause()?)) + } else if self.at_keyword(Keyword::Merge) { + Ok(MutationClause::Merge(self.parse_merge_clause()?)) } else if self.at_keyword(Keyword::Set) { Ok(MutationClause::Set(self.parse_set_clause()?)) } else if self.at_keyword(Keyword::Remove) { @@ -308,6 +478,36 @@ impl<'a> Parser<'a> { } } + fn parse_mutation_read_prefix_pipeline( + &mut self, + ) -> Result<(Vec, GqlReadPipeline), EngineError> { + let start = self.current().span.clone(); + let mut clauses = Vec::new(); + self.parse_read_stage_sequence(&mut clauses)?; + while self.at_keyword(Keyword::With) { + clauses.push(GqlPipelineClause::Projection( + self.parse_projection_clause(GqlProjectionKind::With)?, + )); + self.parse_read_stage_sequence(&mut clauses)?; + } + if clauses.is_empty() { + return Err(self.parse_error_current("expected read stage before mutation clause")); + } + if !self.at_mutation_clause_start() { + return Err(self.parse_error_current("expected mutation clause after read prefix")); + } + let legacy_read_prefix = legacy_match_only_read_prefix(&clauses).unwrap_or_default(); + let span = self.span_between(&start, &self.previous_non_eof_span()); + Ok(( + legacy_read_prefix, + GqlReadPipeline { + clauses, + union_branches: Vec::new(), + span, + }, + )) + } + fn parse_create_clause(&mut self) -> Result { if self.create_clause_is_schema_ddl() { return Err(self @@ -332,6 +532,48 @@ impl<'a> Parser<'a> { }) } + fn parse_merge_clause(&mut self) -> Result { + let start = self.expect_keyword(Keyword::Merge, "expected MERGE clause")?; + let pattern = self.parse_pattern()?; + let mut on_create = None; + let mut on_match = None; + let mut end = pattern.span.clone(); + while self.at_keyword(Keyword::On) { + let on = self.advance(); + if self.at_keyword(Keyword::Create) { + if on_create.is_some() { + return Err(EngineError::GqlParse { + message: "MERGE supports at most one ON CREATE SET action".to_string(), + span: on.span, + }); + } + self.expect_keyword(Keyword::Create, "expected CREATE after ON")?; + let set = self.parse_set_clause()?; + end = set.span.clone(); + on_create = Some(set); + } else if self.at_keyword(Keyword::Match) { + if on_match.is_some() { + return Err(EngineError::GqlParse { + message: "MERGE supports at most one ON MATCH SET action".to_string(), + span: on.span, + }); + } + self.expect_keyword(Keyword::Match, "expected MATCH after ON")?; + let set = self.parse_set_clause()?; + end = set.span.clone(); + on_match = Some(set); + } else { + return Err(self.parse_error_current("expected CREATE or MATCH after ON")); + } + } + Ok(MergeClause { + pattern, + on_create, + on_match, + span: self.span_between(&start.span, &end), + }) + } + fn parse_set_clause(&mut self) -> Result { let start = self.expect_keyword(Keyword::Set, "expected SET clause")?; let mut items = Vec::new(); @@ -528,6 +770,67 @@ impl<'a> Parser<'a> { }) } + fn parse_shortest_path_clause(&mut self) -> Result { + let optional = self.consume_keyword(Keyword::Optional); + let start = self.expect_keyword(Keyword::Match, "expected MATCH clause")?; + let clause_start = optional + .as_ref() + .map(|token| token.span.clone()) + .unwrap_or_else(|| start.span.clone()); + let output_path_alias = + if self.current_is_ident() && self.next_is(|kind| matches!(kind, TokenKind::Equals)) { + let ident = self.parse_ident("expected shortest-path alias")?; + self.expect_kind( + |kind| matches!(kind, TokenKind::Equals), + "expected '=' after shortest-path alias", + )?; + ident + } else { + return Err(EngineError::GqlParse { + message: "shortest-path MATCH requires a path alias before '='".to_string(), + span: self.current().span.clone(), + }); + }; + + let function = self.parse_ident("expected shortest-path function")?; + let mode = if function.name.eq_ignore_ascii_case("shortestPath") { + GqlShortestPathMode::One + } else if function.name.eq_ignore_ascii_case("allShortestPaths") { + GqlShortestPathMode::All + } else if is_shortest_path_function(&function.name) { + return Err(EngineError::GqlUnsupported { + feature: "shortest-path syntax".to_string(), + message: format!( + "shortest-path function '{}' is not supported in the current GQL subset", + function.name + ), + span: function.span, + }); + } else { + return Err(EngineError::GqlParse { + message: "expected shortestPath or allShortestPaths".to_string(), + span: function.span, + }); + }; + self.expect_kind( + |kind| matches!(kind, TokenKind::LParen), + "expected '(' after shortest-path function", + )?; + let pattern = self.parse_pattern()?; + let close = self.expect_kind( + |kind| matches!(kind, TokenKind::RParen), + "expected ')' after shortest-path pattern", + )?; + validate_shortest_path_pattern(self, &pattern)?; + Ok(GqlShortestPathClause { + optional: optional.is_some(), + output_path_alias, + mode, + pattern, + span: self.span_between(&clause_start, &close.span), + }) + } + fn parse_pattern(&mut self) -> Result { self.reject_shortest_path_syntax_here()?; let start = self.current().span.clone(); @@ -839,18 +1142,111 @@ impl<'a> Parser<'a> { fn parse_return_clause(&mut self) -> Result { let start = self.expect_keyword(Keyword::Return, "expected RETURN clause")?; - if self.at_keyword(Keyword::Distinct) { - return Err( - self.unsupported_current("DISTINCT", "DISTINCT is not supported in Phase 31") - ); + let distinct_span = self + .consume_keyword(Keyword::Distinct) + .map(|token| token.span); + let distinct = distinct_span.is_some(); + let body = self.parse_projection_body(false)?; + let end = projection_body_end(&body).clone(); + Ok(ReturnClause { + body, + distinct, + distinct_span, + span: self.span_between(&start.span, &end), + }) + } + + fn parse_projection_clause( + &mut self, + kind: GqlProjectionKind, + ) -> Result { + let start = match kind { + GqlProjectionKind::With => { + self.expect_keyword(Keyword::With, "expected WITH clause")? + } + GqlProjectionKind::Return => { + self.expect_keyword(Keyword::Return, "expected RETURN clause")? + } + }; + let distinct_span = self + .consume_keyword(Keyword::Distinct) + .map(|token| token.span); + let distinct = distinct_span.is_some(); + let body = self.parse_projection_body(kind == GqlProjectionKind::With)?; + let mut end = projection_body_end(&body).clone(); + let order_by = if self.at_keyword(Keyword::Order) { + let order_by = self.parse_order_by()?; + if let Some(last) = order_by.last() { + end = last.span.clone(); + } + order_by + } else { + Vec::new() + }; + let mut skip = None; + if self.at_keyword(Keyword::Skip) || self.at_keyword(Keyword::Offset) { + let expr = self.parse_skip_or_offset()?; + end = expr.span.clone(); + skip = Some(expr); + } + if self.at_keyword(Keyword::Skip) || self.at_keyword(Keyword::Offset) { + return Err(self.parse_error_current("SKIP and OFFSET cannot both be specified")); } + let limit = if self.consume_keyword(Keyword::Limit).is_some() { + let expr = self.parse_expression(0)?.expr; + end = expr.span.clone(); + Some(expr) + } else { + None + }; + let where_clause = + if kind == GqlProjectionKind::With && self.consume_keyword(Keyword::Where).is_some() { + let expr = self.parse_expression(0)?.expr; + end = expr.span.clone(); + Some(expr) + } else { + None + }; + + Ok(GqlProjectionClause { + kind, + distinct, + distinct_span, + body, + where_clause, + order_by, + skip, + limit, + span: self.span_between(&start.span, &end), + }) + } + + fn parse_projection_body(&mut self, allow_mixed_star: bool) -> Result { if let Some(star) = self.consume_if(|kind| matches!(kind, TokenKind::Star)) { - return Ok(ReturnClause { - body: ReturnBody::All(star.span.clone()), - span: self.span_between(&start.span, &star.span), - }); + if self + .consume_if(|kind| matches!(kind, TokenKind::Comma)) + .is_some() + { + if !allow_mixed_star { + return Err(EngineError::GqlUnsupported { + feature: "RETURN * with additional projection items".to_string(), + message: + "RETURN * with additional projection items is deferred until a later phase" + .to_string(), + span: star.span, + }); + } + return Ok(ReturnBody::AllAndItems { + star_span: star.span, + items: self.parse_projection_items()?, + }); + } + return Ok(ReturnBody::All(star.span)); } + Ok(ReturnBody::Items(self.parse_projection_items()?)) + } + fn parse_projection_items(&mut self) -> Result, EngineError> { let mut items = Vec::new(); loop { let expr = self.parse_expression(0)?.expr; @@ -872,14 +1268,7 @@ impl<'a> Parser<'a> { break; } } - let end = items - .last() - .map(|item| item.span.clone()) - .unwrap_or_else(|| start.span.clone()); - Ok(ReturnClause { - body: ReturnBody::Items(items), - span: self.span_between(&start.span, &end), - }) + Ok(items) } fn parse_order_by(&mut self) -> Result, EngineError> { @@ -967,7 +1356,7 @@ impl<'a> Parser<'a> { } fn parse_comparison(&mut self, depth: usize) -> Result { - let mut expr = self.parse_not(depth)?; + let mut expr = self.parse_additive(depth)?; loop { let op = if self .consume_if(|kind| matches!(kind, TokenKind::Equals)) @@ -1001,11 +1390,19 @@ impl<'a> Parser<'a> { Some(BinaryOp::Ge) } else if self.consume_keyword(Keyword::In).is_some() { Some(BinaryOp::In) + } else if self.consume_keyword(Keyword::Starts).is_some() { + self.expect_keyword(Keyword::With, "expected WITH after STARTS")?; + Some(BinaryOp::StartsWith) + } else if self.consume_keyword(Keyword::Ends).is_some() { + self.expect_keyword(Keyword::With, "expected WITH after ENDS")?; + Some(BinaryOp::EndsWith) + } else if self.consume_keyword(Keyword::Contains).is_some() { + Some(BinaryOp::Contains) } else { None }; if let Some(op) = op { - let right = self.parse_not(depth)?; + let right = self.parse_additive(depth)?; expr = self.binary_expr(op, expr, right)?; continue; } @@ -1033,10 +1430,56 @@ impl<'a> Parser<'a> { Ok(expr) } - fn parse_not(&mut self, depth: usize) -> Result { + fn parse_additive(&mut self, depth: usize) -> Result { + let mut expr = self.parse_multiplicative(depth)?; + loop { + let op = if self + .consume_if(|kind| matches!(kind, TokenKind::Plus)) + .is_some() + { + Some(BinaryOp::Add) + } else if self + .consume_if(|kind| matches!(kind, TokenKind::Dash)) + .is_some() + { + Some(BinaryOp::Sub) + } else { + None + }; + let Some(op) = op else { break }; + let right = self.parse_multiplicative(depth)?; + expr = self.binary_expr(op, expr, right)?; + } + Ok(expr) + } + + fn parse_multiplicative(&mut self, depth: usize) -> Result { + let mut expr = self.parse_unary(depth)?; + loop { + let op = if self + .consume_if(|kind| matches!(kind, TokenKind::Star)) + .is_some() + { + Some(BinaryOp::Mul) + } else if self + .consume_if(|kind| matches!(kind, TokenKind::Slash)) + .is_some() + { + Some(BinaryOp::Div) + } else { + None + }; + let Some(op) = op else { break }; + let right = self.parse_unary(depth)?; + expr = self.binary_expr(op, expr, right)?; + } + Ok(expr) + } + + fn parse_unary(&mut self, depth: usize) -> Result { if let Some(not) = self.consume_keyword(Keyword::Not) { self.check_depth(depth + 1, ¬.span)?; - let expr = self.parse_not(depth + 1)?; + let expr = self.parse_unary(depth + 1)?; let span = self.span_between(¬.span, &expr.expr.span); let ast_depth = expr.ast_depth + 1; self.check_depth(ast_depth, &span)?; @@ -1050,6 +1493,22 @@ impl<'a> Parser<'a> { }, ast_depth, }) + } else if let Some(dash) = self.consume_if(|kind| matches!(kind, TokenKind::Dash)) { + self.check_depth(depth + 1, &dash.span)?; + let expr = self.parse_unary(depth + 1)?; + let span = self.span_between(&dash.span, &expr.expr.span); + let ast_depth = expr.ast_depth + 1; + self.check_depth(ast_depth, &span)?; + Ok(ParsedExpr { + expr: Expr { + kind: ExprKind::Unary { + op: UnaryOp::Neg, + expr: Box::new(expr.expr), + }, + span, + }, + ast_depth, + }) } else { self.parse_postfix(depth) } @@ -1140,8 +1599,20 @@ impl<'a> Parser<'a> { TokenKind::Keyword(Keyword::Exists) if self.next_is(|kind| matches!(kind, TokenKind::LBrace)) => { - Err(self - .unsupported_current("subqueries", "subqueries are not supported in Phase 31")) + let start = self.advance(); + self.expect_kind( + |kind| matches!(kind, TokenKind::LBrace), + "expected '{' after EXISTS", + )?; + let pipeline = self.parse_nested_read_pipeline()?; + let end = self.expect_kind( + |kind| matches!(kind, TokenKind::RBrace), + "expected '}' to close EXISTS subquery", + )?; + Ok(Self::leaf_expr(Expr { + kind: ExprKind::ExistsSubquery(Box::new(pipeline)), + span: self.span_between(&start.span, &end.span), + })) } TokenKind::Keyword(Keyword::Exists) if self.next_is(|kind| matches!(kind, TokenKind::LParen)) => @@ -1164,38 +1635,68 @@ impl<'a> Parser<'a> { } TokenKind::LBracket => self.parse_list_literal(depth), TokenKind::LBrace => self.parse_map_expr(depth), - TokenKind::Dash if self.next_is(|kind| matches!(kind, TokenKind::Int(_))) => { - let start = self.advance(); - let next = self.advance(); - let TokenKind::Int(value) = next.kind else { - unreachable!("checked above") - }; - let Some(value) = value.checked_neg() else { - return Err(EngineError::GqlParse { - message: "integer literal is out of range".to_string(), - span: self.span_between(&start.span, &next.span), - }); - }; - Ok(Self::leaf_expr(Expr { - kind: ExprKind::Literal(Literal::Int(value)), - span: self.span_between(&start.span, &next.span), - })) - } - TokenKind::Dash if self.next_is(|kind| matches!(kind, TokenKind::Float(_))) => { - let start = self.advance(); - let next = self.advance(); - let TokenKind::Float(value) = next.kind else { - unreachable!("checked above") - }; - Ok(Self::leaf_expr(Expr { - kind: ExprKind::Literal(Literal::Float(-value)), - span: self.span_between(&start.span, &next.span), - })) - } + TokenKind::Keyword(Keyword::Case) => self.parse_case_expr(depth), _ => Err(self.parse_error_current("expected expression")), } } + fn parse_case_expr(&mut self, depth: usize) -> Result { + let start = self.expect_keyword(Keyword::Case, "expected CASE")?; + self.check_depth(depth + 1, &start.span)?; + + let mut max_depth = 0usize; + let operand = if self.at_keyword(Keyword::When) { + None + } else { + let operand = self.parse_expression(depth + 1)?; + max_depth = max_depth.max(operand.ast_depth); + Some(Box::new(operand.expr)) + }; + + let mut branches = Vec::new(); + while self.consume_keyword(Keyword::When).is_some() { + let when = self.parse_expression(depth + 1)?; + max_depth = max_depth.max(when.ast_depth); + self.expect_keyword(Keyword::Then, "expected THEN after CASE WHEN expression")?; + let then = self.parse_expression(depth + 1)?; + max_depth = max_depth.max(then.ast_depth); + branches.push(CaseBranch { + when: when.expr, + then: then.expr, + }); + } + + if branches.is_empty() { + return Err(EngineError::GqlParse { + message: "CASE requires at least one WHEN branch".to_string(), + span: start.span, + }); + } + + let else_expr = if self.consume_keyword(Keyword::Else).is_some() { + let expr = self.parse_expression(depth + 1)?; + max_depth = max_depth.max(expr.ast_depth); + Some(Box::new(expr.expr)) + } else { + None + }; + let end = self.expect_keyword(Keyword::End, "expected END to close CASE expression")?; + let span = self.span_between(&start.span, &end.span); + let ast_depth = max_depth + 1; + self.check_depth(ast_depth, &span)?; + Ok(ParsedExpr { + expr: Expr { + kind: ExprKind::Case { + operand, + branches, + else_expr, + }, + span, + }, + ast_depth, + }) + } + fn parse_function_call( &mut self, name: String, @@ -1206,14 +1707,18 @@ impl<'a> Parser<'a> { if is_shortest_path_function(&name) { return Err(EngineError::GqlUnsupported { feature: "shortest-path syntax".to_string(), - message: "shortest-path functions are not supported in Phase 31".to_string(), + message: "shortest-path functions are only supported in MATCH path clauses" + .to_string(), span: name_span, }); } + if let Some(function) = aggregate_function_from_name(&lower) { + return self.parse_aggregate_call(function, name, name_span, depth); + } if is_aggregation_function(&lower) { return Err(EngineError::GqlUnsupported { feature: "aggregation".to_string(), - message: "aggregation functions are not supported in Phase 31".to_string(), + message: format!("aggregation function '{}' is not supported", name), span: name_span, }); } @@ -1269,6 +1774,73 @@ impl<'a> Parser<'a> { }) } + fn parse_aggregate_call( + &mut self, + function: AggregateFunction, + name: String, + name_span: SourceSpan, + depth: usize, + ) -> Result { + self.check_depth(depth + 1, &name_span)?; + self.advance(); + self.expect_kind( + |kind| matches!(kind, TokenKind::LParen), + "expected '(' after aggregate function name", + )?; + let distinct_span = self + .consume_keyword(Keyword::Distinct) + .map(|token| token.span); + let distinct = distinct_span.is_some(); + let (arg, max_arg_depth, end) = + if let Some(star) = self.consume_if(|kind| matches!(kind, TokenKind::Star)) { + if function != AggregateFunction::Count { + return Err(EngineError::GqlParse { + message: format!("aggregate function '{}' does not accept '*'", name), + span: star.span, + }); + } + if distinct { + return Err(EngineError::GqlParse { + message: "count(DISTINCT *) is not supported".to_string(), + span: distinct_span.unwrap_or(star.span.clone()), + }); + } + let end = self.expect_kind( + |kind| matches!(kind, TokenKind::RParen), + "expected ')' after aggregate arguments", + )?; + (None, 0, end) + } else { + if self.at_kind(|kind| matches!(kind, TokenKind::RParen)) { + return Err(EngineError::GqlParse { + message: format!("aggregate function '{}' expects an argument", name), + span: name_span.clone(), + }); + } + let parsed = self.parse_expression(depth + 1)?; + let end = self.expect_kind( + |kind| matches!(kind, TokenKind::RParen), + "expected ')' after aggregate arguments", + )?; + (Some(Box::new(parsed.expr)), parsed.ast_depth, end) + }; + let span = self.span_between(&name_span, &end.span); + let ast_depth = max_arg_depth + 1; + self.check_depth(ast_depth, &span)?; + Ok(ParsedExpr { + expr: Expr { + kind: ExprKind::AggregateCall { + function, + distinct, + arg, + name_span, + }, + span, + }, + ast_depth, + }) + } + fn parse_parameter(&mut self) -> Result { let start = self.expect_kind( |kind| matches!(kind, TokenKind::Dollar), @@ -1495,12 +2067,10 @@ impl<'a> Parser<'a> { self.unsupported_current("WITH", "WITH is not supported in Phase 31") ); } - Keyword::Union => { - return Err( - self.unsupported_current("UNION", "UNION is not supported in Phase 31") - ); - } Keyword::Call => { + if self.next_is(|kind| matches!(kind, TokenKind::LBrace)) { + return Ok(()); + } let (feature, message) = if self.next_is(|kind| matches!(kind, TokenKind::LBrace)) { ("subqueries", "subqueries are not supported in Phase 31") @@ -1555,9 +2125,7 @@ impl<'a> Parser<'a> { if self.at_keyword(Keyword::Exists) && self.next_is(|kind| matches!(kind, TokenKind::LBrace)) { - return Err( - self.unsupported_current("subqueries", "subqueries are not supported in Phase 31") - ); + return Ok(()); } Ok(()) } @@ -1654,9 +2222,56 @@ impl<'a> Parser<'a> { || (self.at_keyword(Keyword::Optional) && self.next_keyword_is(Keyword::Match)) } + fn at_regular_match_clause_start(&self) -> bool { + self.at_match_clause_start() && !self.at_shortest_path_match_clause_start() + } + + fn at_call_subquery_start(&self) -> bool { + self.at_keyword(Keyword::Call) && self.next_is(|kind| matches!(kind, TokenKind::LBrace)) + } + + fn at_shortest_path_match_clause_start(&self) -> bool { + let mut index = self.pos; + if self.token_word_eq(index, "optional") { + index += 1; + } + if !self.token_word_eq(index, "match") { + return false; + } + index += 1; + + if self + .tokens + .get(index) + .is_some_and(|token| matches!(token.kind, TokenKind::Ident(_))) + && self + .tokens + .get(index + 1) + .is_some_and(|token| matches!(token.kind, TokenKind::Equals)) + { + return self + .tokens + .get(index + 2) + .and_then(|token| match &token.kind { + TokenKind::Ident(name) => Some(name.as_str()), + _ => None, + }) + .is_some_and(is_shortest_path_function); + } + + self.tokens + .get(index) + .and_then(|token| match &token.kind { + TokenKind::Ident(name) => Some(name.as_str()), + _ => None, + }) + .is_some_and(is_shortest_path_function) + } + fn at_mutation_clause_start(&self) -> bool { match self.current().kind { TokenKind::Keyword(Keyword::Create) => !self.create_clause_is_schema_ddl(), + TokenKind::Keyword(Keyword::Merge) => true, TokenKind::Keyword( Keyword::Set | Keyword::Remove | Keyword::Delete | Keyword::Detach, ) => true, @@ -1664,6 +2279,46 @@ impl<'a> Parser<'a> { } } + fn at_mutation_read_prefix_start(&self) -> bool { + self.at_match_clause_start() + || self.at_call_subquery_start() + || self.at_keyword(Keyword::With) + } + + fn at_read_after_write_clause_start(&self) -> bool { + self.at_match_clause_start() + || self.at_call_subquery_start() + || self.at_keyword(Keyword::With) + || self.at_keyword(Keyword::Union) + } + + fn has_top_level_mutation_before_return(&self) -> bool { + let mut depth = 0usize; + for token in self.tokens.iter().skip(self.pos) { + match &token.kind { + TokenKind::Eof | TokenKind::Semicolon => return false, + TokenKind::LParen | TokenKind::LBracket | TokenKind::LBrace => { + depth = depth.saturating_add(1); + } + TokenKind::RParen | TokenKind::RBracket | TokenKind::RBrace => { + depth = depth.saturating_sub(1); + } + TokenKind::Keyword(keyword) if depth == 0 => match keyword { + Keyword::Return => return false, + Keyword::Create + | Keyword::Merge + | Keyword::Set + | Keyword::Remove + | Keyword::Delete + | Keyword::Detach => return true, + _ => {} + }, + _ => {} + } + } + false + } + fn next_keyword_is(&self, keyword: Keyword) -> bool { matches!( self.tokens.get(self.pos + 1).map(|token| &token.kind), @@ -1805,6 +2460,103 @@ fn remove_item_span(item: &RemoveItem) -> &SourceSpan { } } +fn gql_pipeline_clause_span(clause: &GqlPipelineClause) -> SourceSpan { + match clause { + GqlPipelineClause::Match(clauses) => clauses + .first() + .map(|clause| clause.span.clone()) + .unwrap_or_else(|| SourceSpan::new(0, 0, 1, 1)), + GqlPipelineClause::ShortestPath(shortest) => shortest.span.clone(), + GqlPipelineClause::Call(call) => call.span.clone(), + GqlPipelineClause::Projection(projection) => projection.span.clone(), + } +} + +fn legacy_match_only_read_prefix(clauses: &[GqlPipelineClause]) -> Option> { + let mut matches = Vec::new(); + for clause in clauses { + match clause { + GqlPipelineClause::Match(clauses) => matches.extend(clauses.iter().cloned()), + GqlPipelineClause::ShortestPath(_) + | GqlPipelineClause::Call(_) + | GqlPipelineClause::Projection(_) => return None, + } + } + Some(matches) +} + +fn validate_shortest_path_pattern( + parser: &Parser<'_>, + pattern: &Pattern, +) -> Result<(), EngineError> { + if let Some(path_variable) = pattern.path_variable.as_ref() { + return Err(EngineError::GqlUnsupported { + feature: "shortest-path syntax".to_string(), + message: + "shortest-path MATCH uses the alias before '='; nested path aliases are not supported" + .to_string(), + span: path_variable.span.clone(), + }); + } + if pattern.chains.len() != 1 { + return Err(EngineError::GqlUnsupported { + feature: "shortest-path syntax".to_string(), + message: "shortest-path MATCH supports exactly one relationship pattern".to_string(), + span: pattern.span.clone(), + }); + } + let relationship = &pattern.chains[0].relationship; + if let Some(variable) = relationship.variable.as_ref() { + return Err(EngineError::GqlUnsupported { + feature: "shortest-path relationship alias".to_string(), + message: "relationship aliases are not supported inside shortest-path MATCH" + .to_string(), + span: variable.span.clone(), + }); + } + if relationship.properties.is_some() { + return Err(EngineError::GqlUnsupported { + feature: "weighted GQL shortest path syntax".to_string(), + message: + "relationship properties and weighted shortest-path syntax are not supported in GQL" + .to_string(), + span: relationship.span.clone(), + }); + } + let Some(quantifier) = relationship.quantifier.as_ref() else { + return Err(EngineError::GqlParse { + message: "shortest-path relationship patterns require '*min..max' hop bounds" + .to_string(), + span: relationship.span.clone(), + }); + }; + let quantifier_source = parser.source_for_span(&quantifier.span); + if !quantifier_source.contains("..") + || quantifier_source + .strip_prefix('*') + .is_some_and(|rest| rest.starts_with("..")) + { + return Err(EngineError::GqlParse { + message: "shortest-path relationship patterns require both min and max hop bounds" + .to_string(), + span: quantifier.span.clone(), + }); + } + Ok(()) +} + +fn projection_body_end(body: &ReturnBody) -> &SourceSpan { + match body { + ReturnBody::All(span) => span, + ReturnBody::AllAndItems { items, .. } | ReturnBody::Items(items) => { + &items + .last() + .expect("projection items must be non-empty") + .span + } + } +} + fn span_at_offset(query: &str, offset: usize, length: usize) -> SourceSpan { let mut line = 1u32; let mut column = 1u32; @@ -1837,6 +2589,18 @@ fn is_aggregation_function(lower: &str) -> bool { ) } +fn aggregate_function_from_name(lower: &str) -> Option { + Some(match lower { + "count" => AggregateFunction::Count, + "sum" => AggregateFunction::Sum, + "avg" => AggregateFunction::Avg, + "min" => AggregateFunction::Min, + "max" => AggregateFunction::Max, + "collect" => AggregateFunction::Collect, + _ => return None, + }) +} + fn is_supported_function(lower: &str) -> bool { matches!( lower, @@ -1849,6 +2613,21 @@ fn is_supported_function(lower: &str) -> bool { | "relationships" | "node_ids" | "edge_ids" + | "coalesce" + | "to_string" + | "to_integer" + | "to_float" + | "abs" + | "floor" + | "ceil" + | "round" + | "lower" + | "upper" + | "trim" + | "substring" + | "size" + | "head" + | "last" ) } @@ -2017,6 +2796,47 @@ mod tests { ); } + #[test] + fn parses_supported_shortest_path_clauses() { + let one = parse_ok("MATCH p = shortestPath((a)-[:TYPE*1..5]->(b)) RETURN p"); + assert!(matches!( + &one.pipeline.clauses[0], + GqlPipelineClause::ShortestPath(GqlShortestPathClause { + mode: GqlShortestPathMode::One, + output_path_alias, + .. + }) if output_path_alias.name == "p" + )); + + let all = parse_ok("MATCH p = allShortestPaths((a)-[:TYPE*1..5]-(b)) RETURN p"); + assert!(matches!( + &all.pipeline.clauses[0], + GqlPipelineClause::ShortestPath(GqlShortestPathClause { + mode: GqlShortestPathMode::All, + pattern, + .. + }) if pattern.chains[0].relationship.direction == RelationshipDirection::Undirected + )); + } + + #[test] + fn rejects_invalid_shortest_path_clause_shapes() { + let missing_alias = parse_err("MATCH shortestPath((a)-[:TYPE*1..5]->(b)) RETURN *"); + assert!(matches!(missing_alias, EngineError::GqlParse { .. })); + + let exact_bound = parse_err("MATCH p = shortestPath((a)-[:TYPE*1]->(b)) RETURN p"); + assert!(matches!(exact_bound, EngineError::GqlParse { .. })); + + let missing_min = parse_err("MATCH p = shortestPath((a)-[:TYPE*..5]->(b)) RETURN p"); + assert!(matches!(missing_min, EngineError::GqlParse { .. })); + + let weighted = parse_err("MATCH p = shortestPath((a)-[:TYPE*1..5 {w: 1}]->(b)) RETURN p"); + assert!(matches!( + weighted, + EngineError::GqlUnsupported { feature, .. } if feature == "weighted GQL shortest path syntax" + )); + } + #[test] fn parses_property_maps_in_node_and_relationship_patterns() { let query = parse_ok( @@ -2082,6 +2902,83 @@ mod tests { )); } + #[test] + fn parses_arithmetic_precedence_and_unary_minus() { + let query = parse_ok("MATCH (n) RETURN 1 + 2 * 3, (1 + 2) * 3, -n.age, -(1 + 2)"); + let ReturnBody::Items(items) = &query.return_clause.body else { + panic!("expected return items"); + }; + assert!(matches!( + items[0].expr.kind, + ExprKind::Binary { + op: BinaryOp::Add, + .. + } + )); + let ExprKind::Binary { + op: BinaryOp::Add, + right, + .. + } = &items[0].expr.kind + else { + panic!("expected addition"); + }; + assert!(matches!( + right.kind, + ExprKind::Binary { + op: BinaryOp::Mul, + .. + } + )); + assert!(matches!( + items[1].expr.kind, + ExprKind::Binary { + op: BinaryOp::Mul, + .. + } + )); + assert!(matches!( + items[2].expr.kind, + ExprKind::Unary { + op: UnaryOp::Neg, + .. + } + )); + assert!(matches!( + items[3].expr.kind, + ExprKind::Unary { + op: UnaryOp::Neg, + .. + } + )); + } + + #[test] + fn parses_string_predicates_case_and_scalar_functions() { + let query = parse_ok( + "MATCH (n) WHERE lower(n.name) STARTS WITH 'a' AND n.name ENDS WITH 'z' AND n.name CONTAINS 'd' RETURN CASE WHEN n.age > 1 THEN upper(n.name) ELSE trim(' x ') END AS generic, CASE n.status WHEN 'a' THEN to_string(1) END AS simple, substring(n.name, 1, 2) AS sub", + ); + let where_expr = query.match_clauses[0].where_clause.as_ref().unwrap(); + assert!(format!("{:?}", where_expr.kind).contains("StartsWith")); + assert!(format!("{:?}", where_expr.kind).contains("EndsWith")); + assert!(format!("{:?}", where_expr.kind).contains("Contains")); + let ReturnBody::Items(items) = &query.return_clause.body else { + panic!("expected return items"); + }; + assert!(matches!( + items[0].expr.kind, + ExprKind::Case { operand: None, .. } + )); + assert!(matches!( + items[1].expr.kind, + ExprKind::Case { + operand: Some(_), + .. + } + )); + assert!(matches!(items[2].expr.kind, ExprKind::FunctionCall { .. })); + } + #[test] fn parses_in_and_null_predicates() { let query = parse_ok( @@ -2148,6 +3045,218 @@ mod tests { )); } + #[test] + fn parses_with_pipeline_projection_stages() { + let query = parse_ok("MATCH (n) WITH n RETURN n"); + assert_eq!(query.pipeline.clauses.len(), 3); + assert!(matches!( + query.pipeline.clauses[0], + GqlPipelineClause::Match(_) + )); + let GqlPipelineClause::Projection(with) = &query.pipeline.clauses[1] else { + panic!("expected WITH projection"); + }; + assert_eq!(with.kind, GqlProjectionKind::With); + assert!(!with.distinct); + assert_eq!(with.span.offset, "MATCH (n) ".len()); + let ReturnBody::Items(items) = &with.body else { + panic!("expected WITH item body"); + }; + assert_eq!(items.len(), 1); + assert!(matches!(items[0].expr.kind, ExprKind::Variable(ref name) if name == "n")); + let GqlPipelineClause::Projection(ret) = &query.pipeline.clauses[2] else { + panic!("expected RETURN projection"); + }; + assert_eq!(ret.kind, GqlProjectionKind::Return); + } + + #[test] + fn parses_repeated_with_star_and_distinct() { + let repeated = parse_ok("MATCH (n) WITH n WITH n AS x RETURN x"); + assert_eq!( + repeated + .pipeline + .clauses + .iter() + .filter(|clause| matches!( + clause, + GqlPipelineClause::Projection(GqlProjectionClause { + kind: GqlProjectionKind::With, + .. + }) + )) + .count(), + 2 + ); + + let star = parse_ok("MATCH (n) WITH * RETURN n"); + let GqlPipelineClause::Projection(with_star) = &star.pipeline.clauses[1] else { + panic!("expected WITH projection"); + }; + assert!(matches!(with_star.body, ReturnBody::All(_))); + + let distinct_star = parse_ok("MATCH (n) WITH DISTINCT * RETURN n"); + let GqlPipelineClause::Projection(with_distinct_star) = &distinct_star.pipeline.clauses[1] + else { + panic!("expected WITH projection"); + }; + assert!(with_distinct_star.distinct); + assert!(matches!(with_distinct_star.body, ReturnBody::All(_))); + + let mixed_star = parse_ok("MATCH (n) WITH *, n.name AS name RETURN name"); + let GqlPipelineClause::Projection(with_mixed_star) = &mixed_star.pipeline.clauses[1] else { + panic!("expected WITH projection"); + }; + assert!(matches!( + with_mixed_star.body, + ReturnBody::AllAndItems { .. } + )); + } + + #[test] + fn rejects_return_mixed_star_projections_for_reads_and_mutations() { + for source in [ + "MATCH (n) RETURN *, n", + "CREATE (n:Person {key: 'n'}) RETURN *, n", + ] { + match parse_statement_err(source) { + EngineError::GqlUnsupported { feature, span, .. } => { + assert_eq!(feature, "RETURN * with additional projection items"); + assert!(span.length > 0, "source: {source}"); + } + err => panic!("expected mixed-star unsupported error for {source}, got {err:?}"), + } + } + } + + #[test] + fn parses_with_where_and_string_predicates() { + let source = "MATCH (n) WITH n.name AS name WHERE name STARTS WITH 'a' AND name ENDS WITH 'z' RETURN name"; + let query = parse_ok(source); + let GqlPipelineClause::Projection(with) = &query.pipeline.clauses[1] else { + panic!("expected WITH projection"); + }; + let where_clause = with.where_clause.as_ref().expect("WITH WHERE"); + assert!(format!("{:?}", where_clause.kind).contains("StartsWith")); + assert!(format!("{:?}", where_clause.kind).contains("EndsWith")); + assert_eq!( + where_clause.span.offset, + source.find("name STARTS").unwrap() + ); + } + + #[test] + fn parses_with_projection_local_row_ops() { + let source = "MATCH (n) WITH n ORDER BY n.name SKIP 1 LIMIT 2 WHERE n.active RETURN n"; + let query = parse_ok(source); + let GqlPipelineClause::Projection(with) = &query.pipeline.clauses[1] else { + panic!("expected WITH projection"); + }; + assert_eq!(with.order_by.len(), 1); + assert!(with.where_clause.is_some()); + assert!(matches!( + with.skip.as_ref().unwrap().kind, + ExprKind::Literal(Literal::Int(1)) + )); + assert!(matches!( + with.limit.as_ref().unwrap().kind, + ExprKind::Literal(Literal::Int(2)) + )); + assert_eq!(with.order_by[0].span.offset, source.find("n.name").unwrap()); + assert_eq!( + with.skip.as_ref().unwrap().span.offset, + source.find("1").unwrap() + ); + assert_eq!( + with.limit.as_ref().unwrap().span.offset, + source.find("2").unwrap() + ); + assert_eq!( + with.where_clause.as_ref().unwrap().span.offset, + source.find("n.active").unwrap() + ); + } + + #[test] + fn rejects_with_where_before_projection_local_row_ops() { + let err = parse_statement_err("MATCH (n) WITH n WHERE n.active ORDER BY n.name RETURN n"); + assert!(matches!(err, EngineError::GqlParse { .. })); + } + + #[test] + fn parses_later_match_and_optional_match_after_with() { + let query = parse_ok("MATCH (n) WITH n MATCH (n)-[:R]->(m) RETURN m"); + assert_eq!(query.pipeline.clauses.len(), 4); + let GqlPipelineClause::Match(later_match) = &query.pipeline.clauses[2] else { + panic!("expected later MATCH"); + }; + assert!(!later_match[0].optional); + assert_eq!( + later_match[0].patterns[0] + .start + .variable + .as_ref() + .unwrap() + .name, + "n" + ); + + let optional = parse_ok("MATCH (n) WITH n OPTIONAL MATCH (n)-[:R]->(m) RETURN m"); + let GqlPipelineClause::Match(later_optional) = &optional.pipeline.clauses[2] else { + panic!("expected later OPTIONAL MATCH"); + }; + assert!(later_optional[0].optional); + } + + #[test] + fn parses_return_distinct_projection_shape() { + let query = parse_ok("MATCH (n) RETURN DISTINCT n"); + assert!(query.return_clause.distinct); + let GqlPipelineClause::Projection(ret) = query.pipeline.clauses.last().unwrap() else { + panic!("expected RETURN projection"); + }; + assert!(ret.distinct); + } + + #[test] + fn parses_union_and_union_all_branches() { + let distinct = parse_ok("MATCH (n) RETURN n AS x UNION MATCH (m) RETURN m AS x"); + assert_eq!(distinct.pipeline.union_branches.len(), 1); + assert_eq!( + distinct.pipeline.union_branches[0].modifier, + GqlUnionModifier::Distinct + ); + assert_eq!(distinct.pipeline.union_branches[0].clauses.len(), 2); + + let all = parse_ok( + "MATCH (n) WITH n RETURN n.name AS name UNION ALL MATCH (m) RETURN m.name AS name", + ); + assert_eq!(all.pipeline.union_branches.len(), 1); + assert_eq!( + all.pipeline.union_branches[0].modifier, + GqlUnionModifier::All + ); + assert!(matches!( + all.pipeline.union_branches[0].clauses[0], + GqlPipelineClause::Match(_) + )); + } + + #[test] + fn rejects_union_branch_without_terminal_return() { + let err = parse_err("MATCH (n) RETURN n UNION MATCH (m) WITH m"); + match err { + EngineError::GqlParse { message, span } => { + assert!(message.contains("expected RETURN"), "{message}"); + assert_eq!( + span.offset, + "MATCH (n) RETURN n UNION MATCH (m) WITH m".len() + ); + } + other => panic!("expected parse error, got {other:?}"), + } + } + #[test] fn preserves_parameter_spans() { let source = "MATCH (n {name: $name}) RETURN n.name"; @@ -2338,12 +3447,8 @@ mod tests { "graph catalog/session selection syntax", ), ("MATCH (n)-[*]->(m) RETURN n", "unbounded VLP"), - ("MATCH (n) RETURN count(n)", "aggregation"), - ("MATCH (n) RETURN DISTINCT n", "DISTINCT"), - ("MATCH (n) WITH n RETURN n", "WITH"), - ("MATCH (n) RETURN n UNION MATCH (m) RETURN m", "UNION"), + ("MATCH (n) RETURN stdev(n)", "aggregation"), ("CALL db.labels()", "CALL"), - ("CALL { MATCH (n) RETURN n } RETURN n", "subqueries"), ("MATCH (n:$(label)) RETURN n", "dynamic labels"), ( "MATCH (n)-[r:$(rel_label)]->(m) RETURN r", @@ -2362,14 +3467,6 @@ mod tests { "variable-length relationship syntax", ), ("MATCH (n)--(m){1,3} RETURN n", "Graph Pattern v2"), - ( - "MATCH shortestPath((a)--(b)) RETURN *", - "shortest-path syntax", - ), - ( - "MATCH p = shortestPath((a)--(b)) RETURN p", - "shortest-path syntax", - ), ("MATCH SHORTEST (a)--(b) RETURN *", "shortest-path syntax"), ( "MATCH ANY SHORTEST (a)--(b) RETURN *", @@ -2504,6 +3601,71 @@ mod tests { assert!(matches!(items[3].expr.kind, ExprKind::Map(_))); } + #[test] + fn parses_aggregate_calls_in_projection_and_order_expressions() { + let query = parse_ok( + "MATCH (n) RETURN count(*) + 1 AS total, collect(DISTINCT n.kind) AS kinds ORDER BY count(*) DESC", + ); + let ReturnBody::Items(items) = &query.return_clause.body else { + panic!("expected return items"); + }; + let ExprKind::Binary { left, .. } = &items[0].expr.kind else { + panic!("expected scalar expression containing aggregate"); + }; + assert!(matches!( + &left.kind, + ExprKind::AggregateCall { + function: AggregateFunction::Count, + distinct: false, + arg: None, + .. + } + )); + assert!(matches!( + &items[1].expr.kind, + ExprKind::AggregateCall { + function: AggregateFunction::Collect, + distinct: true, + .. + } + )); + assert!(matches!( + &query.order_by[0].expr.kind, + ExprKind::AggregateCall { + function: AggregateFunction::Count, + arg: None, + .. + } + )); + } + + #[test] + fn parses_count_star_and_rejects_invalid_aggregate_star_forms() { + let query = parse_ok("MATCH (n) RETURN count(*)"); + let ReturnBody::Items(items) = &query.return_clause.body else { + panic!("expected return items"); + }; + assert!(matches!( + &items[0].expr.kind, + ExprKind::AggregateCall { + function: AggregateFunction::Count, + arg: None, + .. + } + )); + + for source in [ + "MATCH (n) RETURN count(DISTINCT *)", + "MATCH (n) RETURN sum(*)", + "MATCH (n) RETURN collect()", + ] { + assert!( + matches!(parse_err(source), EngineError::GqlParse { .. }), + "expected parse error for {source}" + ); + } + } + #[test] fn parse_statement_classifies_reads_as_query() { let statement = parse_statement_ok("MATCH (n:Person) RETURN n ORDER BY n.name LIMIT 10"); @@ -2516,10 +3678,38 @@ mod tests { assert!(query.limit.is_some()); } + #[test] + fn parse_statement_accepts_read_only_exists_and_call_subqueries() { + let exists = parse_statement_ok( + "MATCH (n) WHERE EXISTS { MATCH (n)-[:KNOWS]->(m) RETURN m } RETURN n", + ); + let GqlStatementBody::Query(query) = exists.body else { + panic!("expected query statement"); + }; + let GqlPipelineClause::Match(match_groups) = &query.pipeline.clauses[0] else { + panic!("expected leading MATCH"); + }; + assert!(matches!( + match_groups[0].where_clause.as_ref().map(|expr| &expr.kind), + Some(ExprKind::ExistsSubquery(_)) + )); + + let call = parse_statement_ok("CALL { MATCH (n) RETURN n } RETURN n"); + let GqlStatementBody::Query(query) = call.body else { + panic!("expected query statement"); + }; + assert!(matches!( + query.pipeline.clauses.first(), + Some(GqlPipelineClause::Call(_)) + )); + } + #[test] fn parse_statement_accepts_basic_mutation_skeletons() { let cases = [ "CREATE (n:Person {key: $key}) RETURN n", + "MERGE (n:Person {key: $key}) RETURN n", + "MATCH (a:Person) MATCH (b:Person) MERGE (a)-[r:KNOWS]->(b) RETURN r", "MATCH (n:Person) SET n.name = $name RETURN n", "MATCH (n:Person) SET n += $map RETURN n", "MATCH (n:Person) REMOVE n.name RETURN n", @@ -2544,6 +3734,29 @@ mod tests { } } + #[test] + fn parse_statement_parses_merge_actions() { + let statement = parse_statement_ok( + "MERGE (n:Person {key: $key}) ON CREATE SET n.created = true ON MATCH SET n.seen = $seen RETURN n", + ); + let GqlStatementBody::Mutation(mutation) = statement.body else { + panic!("expected mutation statement"); + }; + let [MutationClause::Merge(merge)] = mutation.mutation_clauses.as_slice() else { + panic!("expected one MERGE clause"); + }; + assert_eq!(merge.pattern.start.variable.as_ref().unwrap().name, "n"); + assert_eq!(merge.pattern.start.labels[0].name, "Person"); + assert_eq!( + merge.pattern.start.properties.as_ref().unwrap().entries[0] + .key + .name, + "key" + ); + assert_eq!(merge.on_create.as_ref().unwrap().items.len(), 1); + assert_eq!(merge.on_match.as_ref().unwrap().items.len(), 1); + } + #[test] fn parse_statement_parses_set_map_merge_item() { let statement = parse_statement_ok("MATCH (n:Person) SET n += $map RETURN n"); @@ -2581,7 +3794,7 @@ mod tests { fn parse_statement_rejects_read_after_write_matching() { match parse_statement_err("MATCH (n) CREATE (m:Person {key: 'm'}) MATCH (m) RETURN m") { EngineError::GqlUnsupported { feature, span, .. } => { - assert_eq!(feature, "read-after-write matching"); + assert_eq!(feature, "read-after-write clauses"); assert!(span.length > 0); } err => panic!("expected read-after-write unsupported error, got {err:?}"), @@ -2616,8 +3829,6 @@ mod tests { #[test] fn parse_statement_rejects_unsupported_mutation_clauses() { for (source, expected_feature) in [ - ("WITH 1 AS n CREATE (m:Person {key: 'm'})", "WITH"), - ("MATCH (n) MERGE (m:Person {key: 'm'})", "write clauses"), ("UNWIND [1] AS n CREATE (m:Person {key: 'm'})", "UNWIND"), ("CALL db.labels()", "CALL"), ( @@ -2636,25 +3847,13 @@ mod tests { } #[test] - fn parse_statement_preserves_read_arithmetic_rejection() { + fn parse_statement_accepts_read_arithmetic() { let source = "MATCH (n) RETURN 1 + 2"; - let read_err = parse_err(source); - let statement_err = parse_statement_err(source); - match (read_err, statement_err) { - ( - EngineError::GqlParse { - message: read_message, - span: read_span, - }, - EngineError::GqlParse { - message: statement_message, - span: statement_span, - }, - ) => { - assert_eq!(read_message, statement_message); - assert_eq!(read_span, statement_span); - } - other => panic!("expected matching parse errors, got {other:?}"), - } + let read = parse_ok(source); + let statement = parse_statement_ok(source); + let GqlStatementBody::Query(statement_query) = statement.body else { + panic!("expected query statement"); + }; + assert_eq!(read, statement_query); } } diff --git a/src/gql/semantic.rs b/src/gql/semantic.rs index 8b2faa0..06c19c0 100644 --- a/src/gql/semantic.rs +++ b/src/gql/semantic.rs @@ -14,6 +14,7 @@ pub(crate) enum GqlAliasKind { Node, Edge, Path, + Scalar, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -80,6 +81,20 @@ pub(crate) struct GqlBoundMatchClause { pub(crate) span: SourceSpan, } +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GqlBoundShortestPathClause { + pub(crate) optional: bool, + pub(crate) output_path_alias: String, + pub(crate) mode: GqlShortestPathMode, + pub(crate) from_alias: String, + pub(crate) to_alias: String, + pub(crate) direction: RelationshipDirection, + pub(crate) rel_types: Vec, + pub(crate) min_hops: u8, + pub(crate) max_hops: u8, + pub(crate) span: SourceSpan, +} + #[derive(Clone, Debug, PartialEq)] pub(crate) struct GqlReturnItemBinding { pub(crate) expr: Expr, @@ -102,15 +117,75 @@ pub(crate) struct GqlSemanticPlan { pub(crate) query: GqlQuery, pub(crate) aliases: GqlAliasTable, pub(crate) clauses: Vec, + pub(crate) pipeline: GqlBoundReadPipeline, pub(crate) returns: GqlReturnPlan, pub(crate) parameters: Vec, pub(crate) parameter_spans: BTreeMap, } +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GqlBoundReadPipeline { + pub(crate) clauses: Vec, + pub(crate) union_branches: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GqlBoundUnionBranch { + pub(crate) modifier: GqlUnionModifier, + pub(crate) clauses: Vec, + pub(crate) returns: GqlReturnPlan, + pub(crate) span: SourceSpan, + pub(crate) union_span: SourceSpan, +} + +#[allow(clippy::large_enum_variant)] +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum GqlBoundPipelineClause { + Match(Vec), + ShortestPath(GqlBoundShortestPathClause), + Call(GqlBoundCallSubquery), + Projection(GqlBoundProjectionClause), +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GqlBoundCallSubquery { + pub(crate) pipeline: GqlBoundReadPipeline, + pub(crate) import_aliases: Vec, + pub(crate) output_aliases: Vec, + pub(crate) span: SourceSpan, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GqlBoundProjectionClause { + pub(crate) kind: GqlProjectionKind, + pub(crate) distinct: bool, + pub(crate) distinct_span: Option, + pub(crate) returns: GqlReturnPlan, + pub(crate) output_aliases: Vec, + pub(crate) where_clause: Option, + pub(crate) order_by: Vec, + pub(crate) skip: Option, + pub(crate) limit: Option, + pub(crate) span: SourceSpan, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GqlProjectionAlias { + pub(crate) name: String, + pub(crate) kind: GqlAliasKind, + pub(crate) span: SourceSpan, +} + +struct BoundProjectionItem { + return_binding: GqlReturnItemBinding, + output_alias: GqlProjectionAlias, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum GqlAliasOrigin { ReadPrefix, Created, + Merged, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -134,9 +209,11 @@ pub(crate) struct GqlMutationSemanticPlan { pub(crate) parameter_spans: BTreeMap, } +#[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, PartialEq)] pub(crate) enum GqlBoundMutationClause { Create(GqlBoundCreateClause), + Merge(GqlBoundMergeClause), Set(GqlBoundSetClause), Remove(GqlBoundRemoveClause), Delete(GqlBoundDeleteClause), @@ -174,6 +251,37 @@ pub(crate) struct GqlBoundCreateEdge { pub(crate) span: SourceSpan, } +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GqlBoundMergeClause { + pub(crate) pattern: GqlBoundMergePattern, + pub(crate) on_create: GqlBoundSetClause, + pub(crate) on_match: GqlBoundSetClause, + pub(crate) span: SourceSpan, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum GqlBoundMergePattern { + Node(GqlBoundMergeNode), + Relationship(GqlBoundMergeRelationship), +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GqlBoundMergeNode { + pub(crate) alias: String, + pub(crate) label: Ident, + pub(crate) key: Expr, + pub(crate) span: SourceSpan, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GqlBoundMergeRelationship { + pub(crate) alias: String, + pub(crate) from_alias: String, + pub(crate) to_alias: String, + pub(crate) rel_type: Ident, + pub(crate) span: SourceSpan, +} + #[derive(Clone, Debug, PartialEq)] pub(crate) struct GqlBoundSetClause { pub(crate) items: Vec, @@ -237,6 +345,36 @@ pub(crate) struct GqlBoundDeleteTarget { pub(crate) span: SourceSpan, } +fn terminal_return_plan(clauses: &[GqlBoundPipelineClause]) -> Result<&GqlReturnPlan, EngineError> { + clauses + .iter() + .rev() + .find_map(|clause| match clause { + GqlBoundPipelineClause::Projection(projection) + if projection.kind == GqlProjectionKind::Return => + { + Some(&projection.returns) + } + _ => None, + }) + .ok_or_else(|| { + EngineError::InvalidOperation("GQL read pipeline must end in RETURN".to_string()) + }) +} + +fn terminal_return_columns(clauses: &[GqlBoundPipelineClause]) -> Result, EngineError> { + terminal_return_plan(clauses).map(return_plan_columns) +} + +fn return_plan_columns(plan: &GqlReturnPlan) -> Vec { + match plan { + GqlReturnPlan::Star { + expanded_aliases, .. + } => expanded_aliases.clone(), + GqlReturnPlan::Items(items) => items.iter().map(|item| item.output_name.clone()).collect(), + } +} + pub(crate) fn bind_query( query: GqlQuery, params: &GqlParams, @@ -249,33 +387,29 @@ pub(crate) fn bind_query( params, }; - let clauses = binder.bind_match_clauses(&query.match_clauses)?; - binder.aliases.user_order = semantic_binding_order(&clauses); - - let returns = binder.bind_return_clause(&query.return_clause)?; - let mut return_aliases = BTreeSet::new(); - if let GqlReturnPlan::Items(items) = &returns { - for item in items { - if let Some(alias) = item.explicit_alias.as_ref() { - return_aliases.insert(alias.clone()); - } - } - } - for item in &query.order_by { - binder.validate_expr(&item.expr, &return_aliases)?; - } - if let Some(skip) = query.skip.as_ref() { - binder.validate_expr(skip, &return_aliases)?; - } - if let Some(limit) = query.limit.as_ref() { - binder.validate_expr(limit, &return_aliases)?; - } + let pipeline = binder.bind_read_pipeline(&query.pipeline)?; + let clauses = if query.is_legacy_single_block() { + pipeline + .clauses + .iter() + .flat_map(|clause| match clause { + GqlBoundPipelineClause::Match(clauses) => clauses.clone(), + GqlBoundPipelineClause::ShortestPath(_) => Vec::new(), + GqlBoundPipelineClause::Call(_) => Vec::new(), + GqlBoundPipelineClause::Projection(_) => Vec::new(), + }) + .collect::>() + } else { + Vec::new() + }; + let returns = terminal_return_plan(&pipeline.clauses)?.clone(); let parameters = binder.parameters.into_iter().collect(); Ok(GqlSemanticPlan { query, aliases: binder.aliases, clauses, + pipeline, returns, parameters, parameter_spans: binder.parameter_spans, @@ -296,13 +430,10 @@ pub(crate) fn bind_mutation( } } - let read_prefix = if statement.read_prefix.is_empty() { - None + let read_prefix = if mutation_statement_has_read_prefix(&statement) { + Some(bind_query(synthetic_read_prefix_query(&statement), params)?) } else { - Some(bind_query( - synthetic_read_prefix_query(&statement.read_prefix, &statement.span), - params, - )?) + None }; let (aliases, user_order) = read_prefix .as_ref() @@ -375,6 +506,110 @@ struct SemanticBinder<'a> { } impl SemanticBinder<'_> { + fn bind_read_pipeline( + &mut self, + pipeline: &GqlReadPipeline, + ) -> Result { + let base_aliases = self.aliases.clone(); + let clauses = self.bind_pipeline_clauses(&pipeline.clauses)?; + let first_columns = terminal_return_columns(&clauses)?; + let mut union_branches = Vec::with_capacity(pipeline.union_branches.len()); + for branch in &pipeline.union_branches { + let mut branch_binder = SemanticBinder { + aliases: base_aliases.clone(), + anonymous_node_counter: 0, + parameters: BTreeSet::new(), + parameter_spans: BTreeMap::new(), + params: self.params, + }; + let branch_clauses = branch_binder.bind_pipeline_clauses(&branch.clauses)?; + let branch_returns = terminal_return_plan(&branch_clauses)?.clone(); + let branch_columns = return_plan_columns(&branch_returns); + if branch_columns.len() != first_columns.len() { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + format!( + "UNION branch returns {} column(s), expected {}", + branch_columns.len(), + first_columns.len() + ), + branch.span.clone(), + )); + } + if branch_columns != first_columns { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + format!( + "UNION branch columns {:?} do not match {:?}", + branch_columns, first_columns + ), + branch.span.clone(), + )); + } + self.parameters.extend(branch_binder.parameters); + self.parameter_spans.extend(branch_binder.parameter_spans); + union_branches.push(GqlBoundUnionBranch { + modifier: branch.modifier, + clauses: branch_clauses, + returns: branch_returns, + span: branch.span.clone(), + union_span: branch.union_span.clone(), + }); + } + Ok(GqlBoundReadPipeline { + clauses, + union_branches, + }) + } + + fn bind_pipeline_clauses( + &mut self, + clauses: &[GqlPipelineClause], + ) -> Result, EngineError> { + let mut bound = Vec::with_capacity(clauses.len()); + for clause in clauses { + match clause { + GqlPipelineClause::Match(clauses) => { + let previous_order = self.aliases.user_order.clone(); + let clauses = self.bind_match_clauses(clauses)?; + self.reconcile_match_binding_order(&previous_order, &clauses); + bound.push(GqlBoundPipelineClause::Match(clauses)); + } + GqlPipelineClause::ShortestPath(shortest) => { + let shortest = self.bind_shortest_path_clause(shortest)?; + bound.push(GqlBoundPipelineClause::ShortestPath(shortest)); + } + GqlPipelineClause::Call(call) => { + let call = self.bind_call_subquery(call)?; + bound.push(GqlBoundPipelineClause::Call(call)); + } + GqlPipelineClause::Projection(projection) => { + let projection = self.bind_projection_clause(projection)?; + bound.push(GqlBoundPipelineClause::Projection(projection)); + } + } + } + Ok(bound) + } + + fn reconcile_match_binding_order( + &mut self, + previous_order: &[String], + clauses: &[GqlBoundMatchClause], + ) { + let mut seen = previous_order.iter().cloned().collect::>(); + let mut order = previous_order.to_vec(); + for alias in semantic_binding_order(clauses) { + let Some(binding) = self.aliases.get(&alias) else { + continue; + }; + if binding.user_visible && seen.insert(alias.clone()) { + order.push(alias); + } + } + self.aliases.user_order = order; + } + fn bind_match_clauses( &mut self, clauses: &[MatchClause], @@ -395,7 +630,7 @@ impl SemanticBinder<'_> { .map(|pattern| self.bind_pattern(pattern)) .collect::, _>>()?; if let Some(where_clause) = clause.where_clause.as_ref() { - self.validate_expr(where_clause, &BTreeSet::new())?; + self.validate_predicate_expr(where_clause, &BTreeSet::new())?; } for pattern in &clause.patterns { self.collect_pattern_parameters(pattern)?; @@ -408,6 +643,117 @@ impl SemanticBinder<'_> { }) } + fn bind_shortest_path_clause( + &mut self, + clause: &GqlShortestPathClause, + ) -> Result { + let from_alias = self.bind_shortest_path_endpoint(&clause.pattern.start)?; + let chain = clause + .pattern + .chains + .first() + .expect("parser validated shortest-path relationship count"); + let to_alias = self.bind_shortest_path_endpoint(&chain.node)?; + let quantifier = chain + .relationship + .quantifier + .as_ref() + .expect("parser validated shortest-path hop bounds"); + self.bind_user_alias(&clause.output_path_alias, GqlAliasKind::Path)?; + Ok(GqlBoundShortestPathClause { + optional: clause.optional, + output_path_alias: clause.output_path_alias.name.clone(), + mode: clause.mode, + from_alias, + to_alias, + direction: chain.relationship.direction, + rel_types: chain.relationship.rel_types.clone(), + min_hops: quantifier.min_hops, + max_hops: quantifier.max_hops, + span: clause.span.clone(), + }) + } + + fn bind_shortest_path_endpoint(&self, pattern: &NodePattern) -> Result { + if !pattern.labels.is_empty() || pattern.properties.is_some() { + return Err(EngineError::GqlUnsupported { + feature: "shortest-path endpoint lookup".to_string(), + message: + "shortest-path endpoints must be bound node aliases; bind label/key endpoints in an earlier MATCH" + .to_string(), + span: pattern.span.clone(), + }); + } + let Some(variable) = pattern.variable.as_ref() else { + return Err(EngineError::GqlUnsupported { + feature: "shortest-path endpoint scan".to_string(), + message: + "shortest-path endpoints must be bound node aliases; broad endpoint scans are not supported" + .to_string(), + span: pattern.span.clone(), + }); + }; + let Some(binding) = self.aliases.get(&variable.name) else { + return Err(gql_semantic_error( + GqlSemanticErrorCode::UnknownVariable, + format!( + "shortest-path endpoint '{}' must be bound before shortest-path MATCH", + variable.name + ), + variable.span.clone(), + )); + }; + if binding.kind != GqlAliasKind::Node { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + format!( + "shortest-path endpoint '{}' must be a node alias", + variable.name + ), + variable.span.clone(), + )); + } + Ok(variable.name.clone()) + } + + fn bind_call_subquery( + &mut self, + call: &GqlCallSubquery, + ) -> Result { + let outer_aliases = self.aliases.clone(); + let (pipeline, import_aliases, output_aliases, parameters, parameter_spans) = + bind_subquery_pipeline_parts(&call.pipeline, &outer_aliases, self.params)?; + for output in &output_aliases { + if self.aliases.contains(&output.name) { + return Err(gql_semantic_error( + GqlSemanticErrorCode::DuplicateAlias, + format!( + "CALL subquery output '{}' collides with an outer alias", + output.name + ), + output.span.clone(), + )); + } + } + for output in &output_aliases { + self.bind_user_alias( + &Ident { + name: output.name.clone(), + span: output.span.clone(), + }, + output.kind, + )?; + } + self.parameters.extend(parameters); + self.parameter_spans.extend(parameter_spans); + Ok(GqlBoundCallSubquery { + pipeline, + import_aliases, + output_aliases, + span: call.span.clone(), + }) + } + fn bind_pattern(&mut self, pattern: &Pattern) -> Result { let (path_alias, user_path_alias, path_span) = if let Some(path_variable) = pattern.path_variable.as_ref() { @@ -593,96 +939,547 @@ impl SemanticBinder<'_> { } fn bind_return_clause(&mut self, clause: &ReturnClause) -> Result { - match &clause.body { - ReturnBody::All(span) => Ok(GqlReturnPlan::Star { - span: span.clone(), - expanded_aliases: self.aliases.user_order.clone(), - }), - ReturnBody::Items(items) => { - let mut bound = Vec::with_capacity(items.len()); - for item in items { - self.validate_expr(&item.expr, &BTreeSet::new())?; - let explicit_alias = item.alias.as_ref().map(|alias| alias.name.clone()); - if let Some(alias) = item.alias.as_ref() { - if is_reserved_user_alias(&alias.name) { - return Err(gql_semantic_error( - GqlSemanticErrorCode::DuplicateAlias, - format!("'{}' is reserved for internal GQL projection", alias.name), - alias.span.clone(), - )); - } - } - let output_name = explicit_alias - .clone() - .unwrap_or_else(|| expression_output_name(&item.expr)); - bound.push(GqlReturnItemBinding { - expr: item.expr.clone(), - explicit_alias, - output_name, - span: item.span.clone(), - }); + self.bind_return_body(&clause.body) + } + + fn bind_projection_clause( + &mut self, + clause: &GqlProjectionClause, + ) -> Result { + let previous_aliases = if clause.kind == GqlProjectionKind::Return { + Some(self.aliases.clone()) + } else { + None + }; + let (returns, output_aliases, next_scope) = + self.bind_projection_body(clause.kind, &clause.body)?; + let star_projection = matches!( + clause.body, + ReturnBody::All(_) | ReturnBody::AllAndItems { .. } + ); + let order_by_contains_aggregate = clause + .order_by + .iter() + .any(|item| expr_contains_aggregate(&item.expr)); + if star_projection + && (projection_body_contains_aggregate(&clause.body) || order_by_contains_aggregate) + { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "* projections cannot be mixed with aggregate calls".to_string(), + clause.span.clone(), + )); + } + if clause.kind == GqlProjectionKind::With { + self.aliases = next_scope; + } + + let mut return_aliases = BTreeSet::new(); + if let GqlReturnPlan::Items(items) = &returns { + for item in items { + if let Some(alias) = item.explicit_alias.as_ref() { + return_aliases.insert(alias.clone()); } - Ok(GqlReturnPlan::Items(bound)) } } + for item in &clause.order_by { + self.validate_projection_expr(&item.expr, &return_aliases)?; + } + if let Some(skip) = clause.skip.as_ref() { + self.validate_expr(skip, &return_aliases)?; + } + if let Some(limit) = clause.limit.as_ref() { + self.validate_expr(limit, &return_aliases)?; + } + if let Some(where_clause) = clause.where_clause.as_ref() { + self.validate_predicate_expr(where_clause, &BTreeSet::new())?; + } + + if let Some(previous_aliases) = previous_aliases { + self.aliases = previous_aliases; + } + + Ok(GqlBoundProjectionClause { + kind: clause.kind, + distinct: clause.distinct, + distinct_span: clause.distinct_span.clone(), + returns, + output_aliases, + where_clause: clause.where_clause.clone(), + order_by: clause.order_by.clone(), + skip: clause.skip.clone(), + limit: clause.limit.clone(), + span: clause.span.clone(), + }) } - fn validate_expr( + fn bind_projection_body( &mut self, - expr: &Expr, - return_aliases: &BTreeSet, - ) -> Result<(), EngineError> { - match &expr.kind { - ExprKind::Literal(_) => Ok(()), - ExprKind::Parameter(name) => self.validate_parameter(name, &expr.span), - ExprKind::Variable(name) => { - if self.aliases.contains(name) || return_aliases.contains(name) { - Ok(()) - } else { - Err(gql_semantic_error( - GqlSemanticErrorCode::UnknownVariable, - format!("unknown variable '{}'", name), - expr.span.clone(), - )) - } - } - ExprKind::PropertyAccess { object, property } => { - self.validate_expr(object, return_aliases)?; - if let ExprKind::Variable(alias) = &object.kind { - if self - .aliases - .get(alias) - .is_some_and(|binding| binding.kind == GqlAliasKind::Path) - && !is_supported_path_property(&property.name) - { - return Err(gql_semantic_error( - GqlSemanticErrorCode::InvalidPropertyAccess, - format!("unsupported path property '{}'", property.name), - property.span.clone(), - )); - } - } - Ok(()) - } - ExprKind::Unary { expr, .. } => self.validate_expr(expr, return_aliases), - ExprKind::Binary { left, right, .. } => { - self.validate_expr(left, return_aliases)?; - self.validate_expr(right, return_aliases) - } - ExprKind::IsNull { expr, .. } => self.validate_expr(expr, return_aliases), - ExprKind::FunctionCall { name, args } => self.validate_function_call(name, args), - ExprKind::List(items) => { - for item in items { - self.validate_expr(item, return_aliases)?; + kind: GqlProjectionKind, + body: &ReturnBody, + ) -> Result<(GqlReturnPlan, Vec, GqlAliasTable), EngineError> { + let item_bindings = if let Some(items) = return_body_items(body) { + self.bind_projection_items(kind, items)? + } else { + Vec::new() + }; + let returns = self.return_plan_from_projection_body(body, &item_bindings); + if kind == GqlProjectionKind::Return { + let output_aliases = + self.return_projection_output_aliases_from_bound(body, &item_bindings); + return Ok((returns, output_aliases, GqlAliasTable::default())); + } + + let mut next_scope = GqlAliasTable::default(); + let mut output_aliases = Vec::new(); + let mut seen = BTreeSet::new(); + + if matches!(body, ReturnBody::All(_) | ReturnBody::AllAndItems { .. }) { + for alias in &self.aliases.user_order { + let Some(binding) = self.aliases.get(alias).cloned() else { + continue; + }; + if !binding.user_visible { + continue; } - Ok(()) + insert_projection_alias( + &mut next_scope, + &mut output_aliases, + &mut seen, + binding.name.clone(), + binding.kind, + binding.span.clone(), + )?; } - ExprKind::Map(map) => self.collect_map_parameters(map), } - } - fn validate_parameter(&mut self, name: &str, span: &SourceSpan) -> Result<(), EngineError> { - if !self.params.contains_key(name) { + for item in item_bindings { + let output = item.output_alias; + insert_projection_alias( + &mut next_scope, + &mut output_aliases, + &mut seen, + output.name, + output.kind, + output.span, + )?; + } + + Ok((returns, output_aliases, next_scope)) + } + + fn return_projection_output_aliases_from_bound( + &self, + body: &ReturnBody, + items: &[BoundProjectionItem], + ) -> Vec { + let mut aliases = self.star_projection_aliases(body); + aliases.extend(items.iter().map(|item| item.output_alias.clone())); + aliases + } + + fn return_plan_from_projection_body( + &self, + body: &ReturnBody, + items: &[BoundProjectionItem], + ) -> GqlReturnPlan { + match body { + ReturnBody::All(span) => GqlReturnPlan::Star { + span: span.clone(), + expanded_aliases: self.aliases.user_order.clone(), + }, + ReturnBody::AllAndItems { star_span, .. } => { + let mut bound = self.star_return_item_bindings(star_span); + bound.extend(items.iter().map(|item| item.return_binding.clone())); + GqlReturnPlan::Items(bound) + } + ReturnBody::Items(_) => GqlReturnPlan::Items( + items + .iter() + .map(|item| item.return_binding.clone()) + .collect(), + ), + } + } + + fn star_projection_aliases(&self, body: &ReturnBody) -> Vec { + if !matches!(body, ReturnBody::All(_) | ReturnBody::AllAndItems { .. }) { + return Vec::new(); + } + self.aliases + .user_order + .iter() + .filter_map(|alias| { + let binding = self.aliases.get(alias)?; + binding.user_visible.then(|| GqlProjectionAlias { + name: binding.name.clone(), + kind: binding.kind, + span: binding.span.clone(), + }) + }) + .collect() + } + + fn bind_projection_items( + &mut self, + kind: GqlProjectionKind, + items: &[ReturnItem], + ) -> Result, EngineError> { + let mut bound = Vec::with_capacity(items.len()); + for item in items { + bound.push(self.bind_projection_item(kind, item)?); + } + Ok(bound) + } + + fn bind_projection_item( + &mut self, + kind: GqlProjectionKind, + item: &ReturnItem, + ) -> Result { + self.validate_projection_expr(&item.expr, &BTreeSet::new())?; + let explicit_alias = item.alias.as_ref().map(|alias| alias.name.clone()); + if let Some(alias) = item.alias.as_ref() { + if is_reserved_user_alias(&alias.name) { + return Err(gql_semantic_error( + GqlSemanticErrorCode::DuplicateAlias, + format!("'{}' is reserved for internal GQL projection", alias.name), + alias.span.clone(), + )); + } + } + let output_name = explicit_alias + .clone() + .unwrap_or_else(|| expression_output_name(&item.expr)); + let return_binding = GqlReturnItemBinding { + expr: item.expr.clone(), + explicit_alias, + output_name, + span: item.span.clone(), + }; + let output_alias = self.projection_item_output(kind, item)?; + Ok(BoundProjectionItem { + return_binding, + output_alias, + }) + } + + fn projection_item_output( + &self, + kind: GqlProjectionKind, + item: &ReturnItem, + ) -> Result { + let direct_binding = variable_name(&item.expr) + .and_then(|name| self.aliases.get(name)) + .cloned(); + let Some(explicit_alias) = item.alias.as_ref() else { + if let Some(binding) = direct_binding { + return Ok(GqlProjectionAlias { + name: binding.name, + kind: binding.kind, + span: item.span.clone(), + }); + } + if kind == GqlProjectionKind::With { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "non-variable WITH projections require an explicit AS alias".to_string(), + item.span.clone(), + )); + } + return Ok(GqlProjectionAlias { + name: expression_output_name(&item.expr), + kind: GqlAliasKind::Scalar, + span: item.span.clone(), + }); + }; + + Ok(GqlProjectionAlias { + name: explicit_alias.name.clone(), + kind: direct_binding + .map(|binding| binding.kind) + .unwrap_or(GqlAliasKind::Scalar), + span: explicit_alias.span.clone(), + }) + } + + fn bind_return_body(&mut self, body: &ReturnBody) -> Result { + match body { + ReturnBody::All(span) => Ok(GqlReturnPlan::Star { + span: span.clone(), + expanded_aliases: self.aliases.user_order.clone(), + }), + ReturnBody::AllAndItems { star_span, items } => { + let mut bound = self.star_return_item_bindings(star_span); + bound.extend(self.bind_return_items(items)?); + Ok(GqlReturnPlan::Items(bound)) + } + ReturnBody::Items(items) => self.bind_return_items(items).map(GqlReturnPlan::Items), + } + } + + fn star_return_item_bindings(&self, span: &SourceSpan) -> Vec { + self.aliases + .user_order + .iter() + .filter_map(|alias| { + let binding = self.aliases.get(alias)?; + binding.user_visible.then(|| GqlReturnItemBinding { + expr: Expr { + kind: ExprKind::Variable(alias.clone()), + span: span.clone(), + }, + explicit_alias: Some(alias.clone()), + output_name: alias.clone(), + span: span.clone(), + }) + }) + .collect() + } + + fn bind_return_items( + &mut self, + items: &[ReturnItem], + ) -> Result, EngineError> { + let mut bound = Vec::with_capacity(items.len()); + for item in items { + self.validate_expr(&item.expr, &BTreeSet::new())?; + let explicit_alias = item.alias.as_ref().map(|alias| alias.name.clone()); + if let Some(alias) = item.alias.as_ref() { + if is_reserved_user_alias(&alias.name) { + return Err(gql_semantic_error( + GqlSemanticErrorCode::DuplicateAlias, + format!("'{}' is reserved for internal GQL projection", alias.name), + alias.span.clone(), + )); + } + } + let output_name = explicit_alias + .clone() + .unwrap_or_else(|| expression_output_name(&item.expr)); + bound.push(GqlReturnItemBinding { + expr: item.expr.clone(), + explicit_alias, + output_name, + span: item.span.clone(), + }); + } + Ok(bound) + } + + fn validate_expr( + &mut self, + expr: &Expr, + return_aliases: &BTreeSet, + ) -> Result<(), EngineError> { + self.validate_expr_aggregate_context(expr, return_aliases, false, false, false) + } + + fn validate_predicate_expr( + &mut self, + expr: &Expr, + return_aliases: &BTreeSet, + ) -> Result<(), EngineError> { + self.validate_expr_aggregate_context(expr, return_aliases, false, false, true) + } + + fn validate_projection_expr( + &mut self, + expr: &Expr, + return_aliases: &BTreeSet, + ) -> Result<(), EngineError> { + self.validate_expr_aggregate_context(expr, return_aliases, true, false, false) + } + + fn validate_expr_aggregate_context( + &mut self, + expr: &Expr, + return_aliases: &BTreeSet, + allow_aggregate: bool, + inside_aggregate: bool, + allow_subquery: bool, + ) -> Result<(), EngineError> { + match &expr.kind { + ExprKind::Literal(_) => Ok(()), + ExprKind::Parameter(name) => self.validate_parameter(name, &expr.span), + ExprKind::Variable(name) => { + if self.aliases.contains(name) || return_aliases.contains(name) { + Ok(()) + } else { + Err(gql_semantic_error( + GqlSemanticErrorCode::UnknownVariable, + format!("unknown variable '{}'", name), + expr.span.clone(), + )) + } + } + ExprKind::PropertyAccess { object, property } => { + self.validate_expr_aggregate_context( + object, + return_aliases, + allow_aggregate, + inside_aggregate, + allow_subquery, + )?; + if let ExprKind::Variable(alias) = &object.kind { + if self + .aliases + .get(alias) + .is_some_and(|binding| binding.kind == GqlAliasKind::Path) + && !is_supported_path_property(&property.name) + { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidPropertyAccess, + format!("unsupported path property '{}'", property.name), + property.span.clone(), + )); + } + } + Ok(()) + } + ExprKind::Unary { expr, .. } => self.validate_expr_aggregate_context( + expr, + return_aliases, + allow_aggregate, + inside_aggregate, + allow_subquery, + ), + ExprKind::Binary { left, right, .. } => { + self.validate_expr_aggregate_context( + left, + return_aliases, + allow_aggregate, + inside_aggregate, + allow_subquery, + )?; + self.validate_expr_aggregate_context( + right, + return_aliases, + allow_aggregate, + inside_aggregate, + allow_subquery, + ) + } + ExprKind::IsNull { expr, .. } => self.validate_expr_aggregate_context( + expr, + return_aliases, + allow_aggregate, + inside_aggregate, + allow_subquery, + ), + ExprKind::FunctionCall { name, args } => self.validate_function_call( + name, + args, + return_aliases, + allow_aggregate, + inside_aggregate, + allow_subquery, + ), + ExprKind::AggregateCall { arg, name_span, .. } => { + if !allow_aggregate { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "aggregate calls are only valid in WITH/RETURN projections and projection ORDER BY".to_string(), + name_span.clone(), + )); + } + if inside_aggregate { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "nested aggregate calls are not supported".to_string(), + name_span.clone(), + )); + } + if let Some(arg) = arg.as_ref() { + self.validate_expr_aggregate_context(arg, return_aliases, true, true, false)?; + } + Ok(()) + } + ExprKind::ExistsSubquery(pipeline) => { + if !allow_subquery { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "EXISTS subqueries are supported only in predicate positions".to_string(), + expr.span.clone(), + )); + } + let outer_aliases = self.aliases.clone(); + let (_, _, _, parameters, parameter_spans) = + bind_subquery_pipeline_parts(pipeline, &outer_aliases, self.params)?; + self.parameters.extend(parameters); + self.parameter_spans.extend(parameter_spans); + Ok(()) + } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand.as_ref() { + self.validate_expr_aggregate_context( + operand, + return_aliases, + allow_aggregate, + inside_aggregate, + allow_subquery, + )?; + } + for branch in branches { + self.validate_expr_aggregate_context( + &branch.when, + return_aliases, + allow_aggregate, + inside_aggregate, + allow_subquery, + )?; + self.validate_expr_aggregate_context( + &branch.then, + return_aliases, + allow_aggregate, + inside_aggregate, + allow_subquery, + )?; + } + if let Some(else_expr) = else_expr.as_ref() { + self.validate_expr_aggregate_context( + else_expr, + return_aliases, + allow_aggregate, + inside_aggregate, + allow_subquery, + )?; + } + Ok(()) + } + ExprKind::List(items) => { + for item in items { + self.validate_expr_aggregate_context( + item, + return_aliases, + allow_aggregate, + inside_aggregate, + allow_subquery, + )?; + } + Ok(()) + } + ExprKind::Map(map) => { + for entry in &map.entries { + self.validate_expr_aggregate_context( + &entry.value, + return_aliases, + allow_aggregate, + inside_aggregate, + allow_subquery, + )?; + } + Ok(()) + } + } + } + + fn validate_parameter(&mut self, name: &str, span: &SourceSpan) -> Result<(), EngineError> { + if !self.params.contains_key(name) { return Err(EngineError::GqlParameter { name: name.to_string(), expected: "GqlParamValue".to_string(), @@ -697,18 +1494,35 @@ impl SemanticBinder<'_> { Ok(()) } - fn validate_function_call(&mut self, name: &Ident, args: &[Expr]) -> Result<(), EngineError> { + fn validate_function_call( + &mut self, + name: &Ident, + args: &[Expr], + return_aliases: &BTreeSet, + allow_aggregate: bool, + inside_aggregate: bool, + allow_subquery: bool, + ) -> Result<(), EngineError> { let function = name.name.to_ascii_lowercase(); - match function.as_str() { - "id" | "labels" | "type" | "length" | "start_node" | "end_node" | "nodes" - | "relationships" | "node_ids" | "edge_ids" => {} - _ => { - return Err(EngineError::GqlUnsupported { - feature: "function".to_string(), - message: format!("function '{}' is not supported in Phase 31", name.name), - span: name.span.clone(), - }); + if is_scalar_function(&function) { + validate_scalar_function_arity(&function, name, args.len())?; + for arg in args { + self.validate_expr_aggregate_context( + arg, + return_aliases, + allow_aggregate, + inside_aggregate, + allow_subquery, + )?; } + return Ok(()); + } + if !is_graph_function(&function) { + return Err(EngineError::GqlUnsupported { + feature: "function".to_string(), + message: format!("function '{}' is not supported in Phase 31", name.name), + span: name.span.clone(), + }); } if args.len() != 1 { return Err(gql_semantic_error( @@ -717,7 +1531,13 @@ impl SemanticBinder<'_> { name.span.clone(), )); } - self.validate_expr(&args[0], &BTreeSet::new())?; + self.validate_expr_aggregate_context( + &args[0], + &BTreeSet::new(), + allow_aggregate, + inside_aggregate, + allow_subquery, + )?; let Some(alias) = variable_name(&args[0]) else { return Err(gql_semantic_error( GqlSemanticErrorCode::InvalidReturnExpression, @@ -737,17 +1557,21 @@ impl SemanticBinder<'_> { | ("relationships", GqlAliasKind::Path) | ("node_ids", GqlAliasKind::Path) | ("edge_ids", GqlAliasKind::Path) => Ok(()), - ("labels", GqlAliasKind::Edge | GqlAliasKind::Path) => Err(gql_semantic_error( - GqlSemanticErrorCode::InvalidReturnExpression, - "labels() expects a node alias".to_string(), - args[0].span.clone(), - )), - ("type", GqlAliasKind::Node | GqlAliasKind::Path) => Err(gql_semantic_error( - GqlSemanticErrorCode::InvalidReturnExpression, - "type() expects an edge alias".to_string(), - args[0].span.clone(), - )), - ("id", GqlAliasKind::Path) => Err(gql_semantic_error( + ("labels", GqlAliasKind::Edge | GqlAliasKind::Path | GqlAliasKind::Scalar) => { + Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "labels() expects a node alias".to_string(), + args[0].span.clone(), + )) + } + ("type", GqlAliasKind::Node | GqlAliasKind::Path | GqlAliasKind::Scalar) => { + Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "type() expects an edge alias".to_string(), + args[0].span.clone(), + )) + } + ("id", GqlAliasKind::Path | GqlAliasKind::Scalar) => Err(gql_semantic_error( GqlSemanticErrorCode::InvalidReturnExpression, "id() expects a node or edge alias".to_string(), args[0].span.clone(), @@ -755,7 +1579,7 @@ impl SemanticBinder<'_> { ( "length" | "start_node" | "end_node" | "nodes" | "relationships" | "node_ids" | "edge_ids", - GqlAliasKind::Node | GqlAliasKind::Edge, + GqlAliasKind::Node | GqlAliasKind::Edge | GqlAliasKind::Scalar, ) => Err(gql_semantic_error( GqlSemanticErrorCode::InvalidReturnExpression, format!("{}() expects a path alias", name.name), @@ -770,6 +1594,274 @@ impl SemanticBinder<'_> { } } +pub(crate) fn bind_subquery_pipeline_for_outer_aliases( + pipeline: &GqlReadPipeline, + outer_aliases: &GqlAliasTable, + params: &GqlParams, +) -> Result<(GqlBoundReadPipeline, Vec, Vec), EngineError> { + let (pipeline, imports, outputs, _, _) = + bind_subquery_pipeline_parts(pipeline, outer_aliases, params)?; + Ok((pipeline, imports, outputs)) +} + +type BoundSubqueryPipelineParts = ( + GqlBoundReadPipeline, + Vec, + Vec, + BTreeSet, + BTreeMap, +); + +fn bind_subquery_pipeline_parts( + pipeline: &GqlReadPipeline, + outer_aliases: &GqlAliasTable, + params: &GqlParams, +) -> Result { + let import_aliases = collect_subquery_import_aliases(pipeline, outer_aliases); + let mut binder = SemanticBinder { + aliases: outer_aliases.clone(), + anonymous_node_counter: 0, + parameters: BTreeSet::new(), + parameter_spans: BTreeMap::new(), + params, + }; + let bound = binder.bind_read_pipeline(pipeline)?; + let outputs = terminal_output_aliases_for_read_pipeline(&bound)?; + Ok(( + bound, + import_aliases, + outputs, + binder.parameters, + binder.parameter_spans, + )) +} + +fn terminal_output_aliases_for_read_pipeline( + pipeline: &GqlBoundReadPipeline, +) -> Result, EngineError> { + let mut outputs = terminal_output_aliases(&pipeline.clauses)?; + for branch in &pipeline.union_branches { + let branch_outputs = terminal_output_aliases(&branch.clauses)?; + if branch_outputs.len() != outputs.len() { + return Err(EngineError::InvalidOperation( + "GQL UNION branch output metadata length mismatch".to_string(), + )); + } + for (output, branch_output) in outputs.iter_mut().zip(branch_outputs.iter()) { + if output.name != branch_output.name { + return Err(EngineError::InvalidOperation( + "GQL UNION branch output metadata name mismatch".to_string(), + )); + } + if output.kind != branch_output.kind { + output.kind = GqlAliasKind::Scalar; + } + } + } + Ok(outputs) +} + +fn terminal_output_aliases( + clauses: &[GqlBoundPipelineClause], +) -> Result, EngineError> { + clauses + .iter() + .rev() + .find_map(|clause| match clause { + GqlBoundPipelineClause::Projection(projection) + if projection.kind == GqlProjectionKind::Return => + { + Some(projection.output_aliases.clone()) + } + _ => None, + }) + .ok_or_else(|| { + EngineError::InvalidOperation("GQL read pipeline must end in RETURN".to_string()) + }) +} + +fn collect_subquery_import_aliases( + pipeline: &GqlReadPipeline, + outer_aliases: &GqlAliasTable, +) -> Vec { + let mut seen = BTreeSet::new(); + collect_pipeline_outer_alias_references(pipeline, outer_aliases, &mut seen); + let mut ordered = Vec::new(); + for alias in &outer_aliases.user_order { + if seen.remove(alias) { + ordered.push(alias.clone()); + } + } + ordered.extend(seen); + ordered +} + +fn collect_pipeline_outer_alias_references( + pipeline: &GqlReadPipeline, + outer_aliases: &GqlAliasTable, + seen: &mut BTreeSet, +) { + for clause in &pipeline.clauses { + collect_pipeline_clause_outer_alias_references(clause, outer_aliases, seen); + } + for branch in &pipeline.union_branches { + for clause in &branch.clauses { + collect_pipeline_clause_outer_alias_references(clause, outer_aliases, seen); + } + } +} + +fn collect_pipeline_clause_outer_alias_references( + clause: &GqlPipelineClause, + outer_aliases: &GqlAliasTable, + seen: &mut BTreeSet, +) { + match clause { + GqlPipelineClause::Match(clauses) => { + for clause in clauses { + for pattern in &clause.patterns { + collect_pattern_outer_alias_references(pattern, outer_aliases, seen); + } + if let Some(where_clause) = clause.where_clause.as_ref() { + collect_expr_outer_alias_references(where_clause, outer_aliases, seen); + } + } + } + GqlPipelineClause::ShortestPath(shortest) => { + collect_pattern_outer_alias_references(&shortest.pattern, outer_aliases, seen); + } + GqlPipelineClause::Call(call) => { + collect_pipeline_outer_alias_references(&call.pipeline, outer_aliases, seen); + } + GqlPipelineClause::Projection(projection) => { + match &projection.body { + ReturnBody::All(_) => {} + ReturnBody::AllAndItems { items, .. } | ReturnBody::Items(items) => { + for item in items { + collect_expr_outer_alias_references(&item.expr, outer_aliases, seen); + } + } + } + if let Some(where_clause) = projection.where_clause.as_ref() { + collect_expr_outer_alias_references(where_clause, outer_aliases, seen); + } + for item in &projection.order_by { + collect_expr_outer_alias_references(&item.expr, outer_aliases, seen); + } + if let Some(skip) = projection.skip.as_ref() { + collect_expr_outer_alias_references(skip, outer_aliases, seen); + } + if let Some(limit) = projection.limit.as_ref() { + collect_expr_outer_alias_references(limit, outer_aliases, seen); + } + } + } +} + +fn collect_pattern_outer_alias_references( + pattern: &Pattern, + outer_aliases: &GqlAliasTable, + seen: &mut BTreeSet, +) { + if let Some(alias) = pattern.path_variable.as_ref() { + collect_outer_alias_name(&alias.name, outer_aliases, seen); + } + collect_node_pattern_outer_alias_references(&pattern.start, outer_aliases, seen); + for chain in &pattern.chains { + if let Some(alias) = chain.relationship.variable.as_ref() { + collect_outer_alias_name(&alias.name, outer_aliases, seen); + } + if let Some(properties) = chain.relationship.properties.as_ref() { + collect_map_outer_alias_references(properties, outer_aliases, seen); + } + collect_node_pattern_outer_alias_references(&chain.node, outer_aliases, seen); + } +} + +fn collect_node_pattern_outer_alias_references( + pattern: &NodePattern, + outer_aliases: &GqlAliasTable, + seen: &mut BTreeSet, +) { + if let Some(alias) = pattern.variable.as_ref() { + collect_outer_alias_name(&alias.name, outer_aliases, seen); + } + if let Some(properties) = pattern.properties.as_ref() { + collect_map_outer_alias_references(properties, outer_aliases, seen); + } +} + +fn collect_map_outer_alias_references( + map: &MapLiteral, + outer_aliases: &GqlAliasTable, + seen: &mut BTreeSet, +) { + for entry in &map.entries { + collect_expr_outer_alias_references(&entry.value, outer_aliases, seen); + } +} + +fn collect_expr_outer_alias_references( + expr: &Expr, + outer_aliases: &GqlAliasTable, + seen: &mut BTreeSet, +) { + match &expr.kind { + ExprKind::Variable(name) => collect_outer_alias_name(name, outer_aliases, seen), + ExprKind::PropertyAccess { object, .. } => { + collect_expr_outer_alias_references(object, outer_aliases, seen) + } + ExprKind::Unary { expr, .. } | ExprKind::IsNull { expr, .. } => { + collect_expr_outer_alias_references(expr, outer_aliases, seen) + } + ExprKind::Binary { left, right, .. } => { + collect_expr_outer_alias_references(left, outer_aliases, seen); + collect_expr_outer_alias_references(right, outer_aliases, seen); + } + ExprKind::FunctionCall { args, .. } | ExprKind::List(args) => { + for arg in args { + collect_expr_outer_alias_references(arg, outer_aliases, seen); + } + } + ExprKind::AggregateCall { arg, .. } => { + if let Some(arg) = arg.as_ref() { + collect_expr_outer_alias_references(arg, outer_aliases, seen); + } + } + ExprKind::ExistsSubquery(pipeline) => { + collect_pipeline_outer_alias_references(pipeline, outer_aliases, seen); + } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand.as_ref() { + collect_expr_outer_alias_references(operand, outer_aliases, seen); + } + for branch in branches { + collect_expr_outer_alias_references(&branch.when, outer_aliases, seen); + collect_expr_outer_alias_references(&branch.then, outer_aliases, seen); + } + if let Some(else_expr) = else_expr.as_ref() { + collect_expr_outer_alias_references(else_expr, outer_aliases, seen); + } + } + ExprKind::Map(map) => collect_map_outer_alias_references(map, outer_aliases, seen), + ExprKind::Literal(_) | ExprKind::Parameter(_) => {} + } +} + +fn collect_outer_alias_name( + name: &str, + outer_aliases: &GqlAliasTable, + seen: &mut BTreeSet, +) { + if outer_aliases.contains(name) { + seen.insert(name.to_string()); + } +} + struct MutationSemanticBinder<'a> { aliases: BTreeMap, user_order: Vec, @@ -790,6 +1882,9 @@ impl MutationSemanticBinder<'_> { MutationClause::Create(create) => self .bind_create_clause(create) .map(GqlBoundMutationClause::Create), + MutationClause::Merge(merge) => self + .bind_merge_clause(merge) + .map(GqlBoundMutationClause::Merge), MutationClause::Set(set) => self.bind_set_clause(set).map(GqlBoundMutationClause::Set), MutationClause::Remove(remove) => self .bind_remove_clause(remove) @@ -800,21 +1895,283 @@ impl MutationSemanticBinder<'_> { } } - fn bind_create_clause( + fn bind_create_clause( + &mut self, + create: &CreateClause, + ) -> Result { + let patterns = create + .patterns + .iter() + .map(|pattern| self.bind_create_pattern(pattern)) + .collect::, _>>()?; + Ok(GqlBoundCreateClause { + patterns, + span: create.span.clone(), + }) + } + + fn bind_merge_clause( + &mut self, + merge: &MergeClause, + ) -> Result { + let pattern = self.bind_merge_pattern(&merge.pattern)?; + let on_create = merge + .on_create + .as_ref() + .map(|set| self.bind_set_clause_with_source_mode(set, true)) + .transpose()? + .unwrap_or_else(|| GqlBoundSetClause { + items: Vec::new(), + span: merge.span.clone(), + }); + let on_match = merge + .on_match + .as_ref() + .map(|set| self.bind_set_clause_with_source_mode(set, true)) + .transpose()? + .unwrap_or_else(|| GqlBoundSetClause { + items: Vec::new(), + span: merge.span.clone(), + }); + Ok(GqlBoundMergeClause { + pattern, + on_create, + on_match, + span: merge.span.clone(), + }) + } + + fn bind_merge_pattern( + &mut self, + pattern: &Pattern, + ) -> Result { + if let Some(path_variable) = pattern.path_variable.as_ref() { + return Err(EngineError::GqlUnsupported { + feature: "MERGE path assignment".to_string(), + message: "MERGE path assignment is not supported".to_string(), + span: path_variable.span.clone(), + }); + } + match pattern.chains.as_slice() { + [] => self + .bind_merge_node_pattern(&pattern.start) + .map(GqlBoundMergePattern::Node), + [chain] => self + .bind_merge_relationship_pattern(&pattern.start, chain) + .map(GqlBoundMergePattern::Relationship), + _ => Err(EngineError::GqlUnsupported { + feature: "general pattern MERGE".to_string(), + message: + "MERGE supports only keyed node patterns and single-hop relationship patterns" + .to_string(), + span: pattern.span.clone(), + }), + } + } + + fn bind_merge_node_pattern( + &mut self, + pattern: &NodePattern, + ) -> Result { + let variable = pattern.variable.as_ref().ok_or_else(|| { + gql_semantic_error( + GqlSemanticErrorCode::UnknownVariable, + "MERGE node pattern requires an alias".to_string(), + pattern.span.clone(), + ) + })?; + if self.aliases.contains_key(&variable.name) { + return Err(gql_semantic_error( + GqlSemanticErrorCode::DuplicateAlias, + format!("MERGE node alias '{}' is already bound", variable.name), + variable.span.clone(), + )); + } + if pattern.labels.is_empty() { + return Err(EngineError::GqlUnsupported { + feature: "unlabeled node MERGE".to_string(), + message: "MERGE node patterns require exactly one static label".to_string(), + span: pattern.span.clone(), + }); + } + if pattern.labels.len() != 1 { + return Err(EngineError::GqlUnsupported { + feature: "multi-label node MERGE".to_string(), + message: "MERGE node patterns require exactly one static label".to_string(), + span: pattern.span.clone(), + }); + } + let label = pattern.labels[0].clone(); + validate_label_token_name(&label.name).map_err(|err| match err { + EngineError::InvalidOperation(message) => gql_semantic_error( + GqlSemanticErrorCode::DynamicLabelNotSupported, + message, + label.span.clone(), + ), + other => other, + })?; + let properties = + pattern + .properties + .as_ref() + .ok_or_else(|| EngineError::GqlUnsupported { + feature: "unkeyed node MERGE".to_string(), + message: "MERGE node patterns require exactly one identity property named key" + .to_string(), + span: pattern.span.clone(), + })?; + if properties.entries.len() != 1 { + return Err(EngineError::GqlUnsupported { + feature: "node MERGE property-map identity".to_string(), + message: "MERGE node identity supports only {key: expr}".to_string(), + span: properties.span.clone(), + }); + } + let entry = &properties.entries[0]; + if entry.key.name != "key" { + return Err(EngineError::GqlUnsupported { + feature: "node MERGE non-key identity property".to_string(), + message: "MERGE node identity property must be named key".to_string(), + span: entry.key.span.clone(), + }); + } + self.validate_expr(&entry.value, &BTreeSet::new(), false)?; + self.reject_statically_element_property_value(&entry.value)?; + self.insert_merged_alias(variable, GqlAliasKind::Node)?; + Ok(GqlBoundMergeNode { + alias: variable.name.clone(), + label, + key: entry.value.clone(), + span: pattern.span.clone(), + }) + } + + fn bind_merge_relationship_pattern( &mut self, - create: &CreateClause, - ) -> Result { - let patterns = create - .patterns - .iter() - .map(|pattern| self.bind_create_pattern(pattern)) - .collect::, _>>()?; - Ok(GqlBoundCreateClause { - patterns, - span: create.span.clone(), + start: &NodePattern, + chain: &PatternChain, + ) -> Result { + let rel = &chain.relationship; + if rel.direction == RelationshipDirection::Undirected { + return Err(EngineError::GqlUnsupported { + feature: "undirected relationship MERGE".to_string(), + message: "MERGE relationship patterns must be directed".to_string(), + span: rel.span.clone(), + }); + } + if rel.quantifier.is_some() { + return Err(EngineError::GqlUnsupported { + feature: "variable-length MERGE".to_string(), + message: "variable-length relationship patterns are not supported in MERGE" + .to_string(), + span: rel.span.clone(), + }); + } + if rel.properties.is_some() { + return Err(EngineError::GqlUnsupported { + feature: "relationship MERGE properties".to_string(), + message: "MERGE relationship patterns do not support identity properties; use ON CREATE SET".to_string(), + span: rel.span.clone(), + }); + } + if rel.rel_types.len() != 1 { + return Err(gql_semantic_error( + GqlSemanticErrorCode::DynamicRelationshipTypeNotSupported, + "MERGE relationship patterns require exactly one static relationship label" + .to_string(), + rel.span.clone(), + )); + } + let rel_type = rel.rel_types[0].clone(); + validate_label_token_name(&rel_type.name).map_err(|err| match err { + EngineError::InvalidOperation(message) => gql_semantic_error( + GqlSemanticErrorCode::DynamicRelationshipTypeNotSupported, + message, + rel_type.span.clone(), + ), + other => other, + })?; + let alias = rel.variable.as_ref().ok_or_else(|| { + gql_semantic_error( + GqlSemanticErrorCode::UnknownVariable, + "MERGE relationship pattern requires an alias".to_string(), + rel.span.clone(), + ) + })?; + if self.aliases.contains_key(&alias.name) { + return Err(gql_semantic_error( + GqlSemanticErrorCode::DuplicateAlias, + format!("MERGE relationship alias '{}' is already bound", alias.name), + alias.span.clone(), + )); + } + let start_alias = self.require_merge_endpoint_alias(start)?; + let end_alias = self.require_merge_endpoint_alias(&chain.node)?; + let (from_alias, to_alias) = match rel.direction { + RelationshipDirection::LeftToRight => (start_alias, end_alias), + RelationshipDirection::RightToLeft => (end_alias, start_alias), + RelationshipDirection::Undirected => unreachable!("rejected above"), + }; + self.insert_merged_alias(alias, GqlAliasKind::Edge)?; + self.record_incident_edge(&from_alias, &to_alias, &alias.name); + Ok(GqlBoundMergeRelationship { + alias: alias.name.clone(), + from_alias, + to_alias, + rel_type, + span: rel.span.clone(), }) } + fn require_merge_endpoint_alias(&self, pattern: &NodePattern) -> Result { + if !pattern.labels.is_empty() || pattern.properties.is_some() { + return Err(EngineError::GqlUnsupported { + feature: "relationship MERGE endpoint pattern".to_string(), + message: "MERGE relationship endpoints must be bare bound node aliases".to_string(), + span: pattern.span.clone(), + }); + } + let variable = pattern + .variable + .as_ref() + .ok_or_else(|| EngineError::GqlUnsupported { + feature: "relationship MERGE endpoint pattern".to_string(), + message: "MERGE relationship endpoints must be bound node aliases".to_string(), + span: pattern.span.clone(), + })?; + let binding = self.aliases.get(&variable.name).ok_or_else(|| { + gql_semantic_error( + GqlSemanticErrorCode::UnknownVariable, + format!( + "unknown MERGE relationship endpoint alias '{}'", + variable.name + ), + variable.span.clone(), + ) + })?; + if binding.kind != GqlAliasKind::Node { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + format!( + "MERGE relationship endpoint '{}' is bound as {:?}, not a node", + variable.name, binding.kind + ), + variable.span.clone(), + )); + } + if self.deleted_aliases.contains(&variable.name) { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + format!( + "MERGE relationship endpoint '{}' was deleted earlier in this statement", + variable.name + ), + variable.span.clone(), + )); + } + Ok(variable.name.clone()) + } + fn bind_create_pattern( &mut self, pattern: &Pattern, @@ -1040,10 +2397,18 @@ impl MutationSemanticBinder<'_> { } fn bind_set_clause(&mut self, set: &SetClause) -> Result { + self.bind_set_clause_with_source_mode(set, false) + } + + fn bind_set_clause_with_source_mode( + &mut self, + set: &SetClause, + allow_created_sources: bool, + ) -> Result { let items = set .items .iter() - .map(|item| self.bind_set_item(item)) + .map(|item| self.bind_set_item_with_source_mode(item, allow_created_sources)) .collect::, _>>()?; Ok(GqlBoundSetClause { items, @@ -1052,6 +2417,14 @@ impl MutationSemanticBinder<'_> { } fn bind_set_item(&mut self, item: &SetItem) -> Result { + self.bind_set_item_with_source_mode(item, false) + } + + fn bind_set_item_with_source_mode( + &mut self, + item: &SetItem, + allow_created_sources: bool, + ) -> Result { match item { SetItem::Property { alias, @@ -1061,7 +2434,10 @@ impl MutationSemanticBinder<'_> { } => { let binding = self.require_target_alias(alias)?; reject_reserved_set_property(binding.kind, property)?; - self.validate_expr(value, &BTreeSet::new(), false)?; + self.validate_expr(value, &BTreeSet::new(), allow_created_sources)?; + if allow_created_sources { + self.reject_commit_dependent_created_source_value(value)?; + } self.reject_statically_element_property_value(value)?; Ok(GqlBoundSetItem::Property { alias: alias.name.clone(), @@ -1073,7 +2449,10 @@ impl MutationSemanticBinder<'_> { } SetItem::MapMerge { alias, value, span } => { let binding = self.require_target_alias(alias)?; - self.validate_expr(value, &BTreeSet::new(), false)?; + self.validate_expr(value, &BTreeSet::new(), allow_created_sources)?; + if allow_created_sources { + self.reject_commit_dependent_created_source_value(value)?; + } self.reject_statically_element_property_value(value)?; Ok(GqlBoundSetItem::MapMerge { alias: alias.name.clone(), @@ -1209,6 +2588,13 @@ impl MutationSemanticBinder<'_> { target.span.clone(), )); } + (_, GqlAliasKind::Scalar) => { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "scalar aliases cannot be deleted".to_string(), + target.span.clone(), + )); + } } targets.push(GqlBoundDeleteTarget { alias: alias.to_string(), @@ -1255,41 +2641,66 @@ impl MutationSemanticBinder<'_> { span: span.clone(), expanded_aliases: self.user_order.clone(), }), - ReturnBody::Items(items) => { - let mut bound = Vec::with_capacity(items.len()); - let mut output_names = BTreeSet::new(); - for item in items { - self.validate_expr(&item.expr, &BTreeSet::new(), true)?; - let explicit_alias = item.alias.as_ref().map(|alias| alias.name.clone()); - if let Some(alias) = item.alias.as_ref() { - if is_reserved_user_alias(&alias.name) { - return Err(gql_semantic_error( - GqlSemanticErrorCode::DuplicateAlias, - format!("'{}' is reserved for internal GQL projection", alias.name), - alias.span.clone(), - )); - } - } - let output_name = explicit_alias - .clone() - .unwrap_or_else(|| expression_output_name(&item.expr)); - if !output_names.insert(output_name.clone()) { - return Err(gql_semantic_error( - GqlSemanticErrorCode::DuplicateAlias, - format!("duplicate mutation RETURN alias '{}'", output_name), - item.span.clone(), - )); - } - bound.push(GqlReturnItemBinding { - expr: item.expr.clone(), - explicit_alias, - output_name, - span: item.span.clone(), - }); - } + ReturnBody::AllAndItems { star_span, items } => { + let mut bound = self + .user_order + .iter() + .map(|alias| GqlReturnItemBinding { + expr: Expr { + kind: ExprKind::Variable(alias.clone()), + span: star_span.clone(), + }, + explicit_alias: Some(alias.clone()), + output_name: alias.clone(), + span: star_span.clone(), + }) + .collect::>(); + let mut item_bound = self.bind_mutation_return_items(items)?; + bound.append(&mut item_bound); Ok(GqlReturnPlan::Items(bound)) } + ReturnBody::Items(items) => self + .bind_mutation_return_items(items) + .map(GqlReturnPlan::Items), + } + } + + fn bind_mutation_return_items( + &mut self, + items: &[ReturnItem], + ) -> Result, EngineError> { + let mut bound = Vec::with_capacity(items.len()); + let mut output_names = BTreeSet::new(); + for item in items { + self.validate_expr(&item.expr, &BTreeSet::new(), true)?; + let explicit_alias = item.alias.as_ref().map(|alias| alias.name.clone()); + if let Some(alias) = item.alias.as_ref() { + if is_reserved_user_alias(&alias.name) { + return Err(gql_semantic_error( + GqlSemanticErrorCode::DuplicateAlias, + format!("'{}' is reserved for internal GQL projection", alias.name), + alias.span.clone(), + )); + } + } + let output_name = explicit_alias + .clone() + .unwrap_or_else(|| expression_output_name(&item.expr)); + if !output_names.insert(output_name.clone()) { + return Err(gql_semantic_error( + GqlSemanticErrorCode::DuplicateAlias, + format!("duplicate mutation RETURN alias '{}'", output_name), + item.span.clone(), + )); + } + bound.push(GqlReturnItemBinding { + expr: item.expr.clone(), + explicit_alias, + output_name, + span: item.span.clone(), + }); } + Ok(bound) } fn validate_new_create_node(&mut self, pattern: &NodePattern) -> Result<(), EngineError> { @@ -1399,10 +2810,13 @@ impl MutationSemanticBinder<'_> { alias.span.clone(), )); } - if binding.kind == GqlAliasKind::Path { + if matches!(binding.kind, GqlAliasKind::Path | GqlAliasKind::Scalar) { return Err(gql_semantic_error( GqlSemanticErrorCode::InvalidPropertyAccess, - "path aliases cannot be mutation targets".to_string(), + format!( + "{} aliases cannot be mutation targets", + kind_name(binding.kind) + ), alias.span.clone(), )); } @@ -1420,11 +2834,15 @@ impl MutationSemanticBinder<'_> { ExprKind::Parameter(name) => self.validate_parameter(name, &expr.span), ExprKind::Variable(name) => { if let Some(binding) = self.aliases.get(name) { - if binding.origin == GqlAliasOrigin::Created && !allow_created_sources { + if matches!( + binding.origin, + GqlAliasOrigin::Created | GqlAliasOrigin::Merged + ) && !allow_created_sources + { return Err(gql_semantic_error( GqlSemanticErrorCode::InvalidReturnExpression, format!( - "created alias '{}' cannot be used as a mutation expression source before commit", + "created or merged alias '{}' cannot be used as a mutation expression source before commit", name ), expr.span.clone(), @@ -1470,7 +2888,36 @@ impl MutationSemanticBinder<'_> { self.validate_expr(expr, return_aliases, allow_created_sources) } ExprKind::FunctionCall { name, args } => { - self.validate_function_call(name, args, allow_created_sources) + self.validate_function_call(name, args, return_aliases, allow_created_sources) + } + ExprKind::AggregateCall { name_span, .. } => Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "aggregate calls are not supported in mutation expressions or mutation RETURN" + .to_string(), + name_span.clone(), + )), + ExprKind::ExistsSubquery(_) => Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "EXISTS subqueries are not supported in mutation expressions or mutation RETURN" + .to_string(), + expr.span.clone(), + )), + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand.as_ref() { + self.validate_expr(operand, return_aliases, allow_created_sources)?; + } + for branch in branches { + self.validate_expr(&branch.when, return_aliases, allow_created_sources)?; + self.validate_expr(&branch.then, return_aliases, allow_created_sources)?; + } + if let Some(else_expr) = else_expr.as_ref() { + self.validate_expr(else_expr, return_aliases, allow_created_sources)?; + } + Ok(()) } ExprKind::List(items) => { for item in items { @@ -1507,19 +2954,23 @@ impl MutationSemanticBinder<'_> { &mut self, name: &Ident, args: &[Expr], + return_aliases: &BTreeSet, allow_created_sources: bool, ) -> Result<(), EngineError> { let function = name.name.to_ascii_lowercase(); - match function.as_str() { - "id" | "labels" | "type" | "length" | "start_node" | "end_node" | "nodes" - | "relationships" | "node_ids" | "edge_ids" => {} - _ => { - return Err(EngineError::GqlUnsupported { - feature: "function".to_string(), - message: format!("function '{}' is not supported", name.name), - span: name.span.clone(), - }); + if is_scalar_function(&function) { + validate_scalar_function_arity(&function, name, args.len())?; + for arg in args { + self.validate_expr(arg, return_aliases, allow_created_sources)?; } + return Ok(()); + } + if !is_graph_function(&function) { + return Err(EngineError::GqlUnsupported { + feature: "function".to_string(), + message: format!("function '{}' is not supported", name.name), + span: name.span.clone(), + }); } if args.len() != 1 { return Err(gql_semantic_error( @@ -1548,17 +2999,21 @@ impl MutationSemanticBinder<'_> { | ("relationships", GqlAliasKind::Path) | ("node_ids", GqlAliasKind::Path) | ("edge_ids", GqlAliasKind::Path) => Ok(()), - ("labels", GqlAliasKind::Edge | GqlAliasKind::Path) => Err(gql_semantic_error( - GqlSemanticErrorCode::InvalidReturnExpression, - "labels() expects a node alias".to_string(), - args[0].span.clone(), - )), - ("type", GqlAliasKind::Node | GqlAliasKind::Path) => Err(gql_semantic_error( - GqlSemanticErrorCode::InvalidReturnExpression, - "type() expects an edge alias".to_string(), - args[0].span.clone(), - )), - ("id", GqlAliasKind::Path) => Err(gql_semantic_error( + ("labels", GqlAliasKind::Edge | GqlAliasKind::Path | GqlAliasKind::Scalar) => { + Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "labels() expects a node alias".to_string(), + args[0].span.clone(), + )) + } + ("type", GqlAliasKind::Node | GqlAliasKind::Path | GqlAliasKind::Scalar) => { + Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "type() expects an edge alias".to_string(), + args[0].span.clone(), + )) + } + ("id", GqlAliasKind::Path | GqlAliasKind::Scalar) => Err(gql_semantic_error( GqlSemanticErrorCode::InvalidReturnExpression, "id() expects a node or edge alias".to_string(), args[0].span.clone(), @@ -1566,7 +3021,7 @@ impl MutationSemanticBinder<'_> { ( "length" | "start_node" | "end_node" | "nodes" | "relationships" | "node_ids" | "edge_ids", - GqlAliasKind::Node | GqlAliasKind::Edge, + GqlAliasKind::Node | GqlAliasKind::Edge | GqlAliasKind::Scalar, ) => Err(gql_semantic_error( GqlSemanticErrorCode::InvalidReturnExpression, format!("{}() expects a path alias", name.name), @@ -1584,6 +3039,9 @@ impl MutationSemanticBinder<'_> { match &expr.kind { ExprKind::Variable(name) => { if let Some(binding) = self.aliases.get(name) { + if binding.kind == GqlAliasKind::Scalar { + return Ok(()); + } return Err(gql_semantic_error( GqlSemanticErrorCode::InvalidReturnExpression, format!( @@ -1610,6 +3068,40 @@ impl MutationSemanticBinder<'_> { name.span.clone(), )); } + if is_scalar_function(&function) { + if let ExprKind::FunctionCall { args, .. } = &expr.kind { + for arg in args { + self.reject_statically_element_property_value(arg)?; + } + } + } + Ok(()) + } + ExprKind::AggregateCall { name_span, .. } => Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "aggregate functions cannot be used as property values".to_string(), + name_span.clone(), + )), + ExprKind::ExistsSubquery(_) => Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + "EXISTS subqueries cannot be used as property values".to_string(), + expr.span.clone(), + )), + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand.as_ref() { + self.reject_statically_element_property_value(operand)?; + } + for branch in branches { + self.reject_statically_element_property_value(&branch.when)?; + self.reject_statically_element_property_value(&branch.then)?; + } + if let Some(else_expr) = else_expr.as_ref() { + self.reject_statically_element_property_value(else_expr)?; + } Ok(()) } ExprKind::List(items) => { @@ -1624,16 +3116,147 @@ impl MutationSemanticBinder<'_> { } Ok(()) } - ExprKind::PropertyAccess { .. } + ExprKind::Unary { expr, .. } | ExprKind::IsNull { expr, .. } => { + self.reject_statically_element_property_value(expr) + } + ExprKind::Binary { left, right, .. } => { + self.reject_statically_element_property_value(left)?; + self.reject_statically_element_property_value(right) + } + ExprKind::PropertyAccess { .. } | ExprKind::Literal(_) | ExprKind::Parameter(_) => { + Ok(()) + } + } + } + + fn reject_commit_dependent_created_source_value(&self, expr: &Expr) -> Result<(), EngineError> { + match &expr.kind { + ExprKind::FunctionCall { name, args } => { + if name.name.eq_ignore_ascii_case("id") { + if let Some(alias) = args.first().and_then(variable_name) { + if self.aliases.get(alias).is_some_and(|binding| { + matches!( + binding.origin, + GqlAliasOrigin::Created | GqlAliasOrigin::Merged + ) && matches!(binding.kind, GqlAliasKind::Node | GqlAliasKind::Edge) + }) { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + format!( + "MERGE action expression cannot read commit-assigned id() from alias '{}' before commit", + alias + ), + name.span.clone(), + )); + } + } + } + for arg in args { + self.reject_commit_dependent_created_source_value(arg)?; + } + Ok(()) + } + ExprKind::PropertyAccess { object, property } => { + if let ExprKind::Variable(alias) = &object.kind { + if let Some(binding) = self.aliases.get(alias) { + if matches!( + binding.origin, + GqlAliasOrigin::Created | GqlAliasOrigin::Merged + ) && is_commit_dependent_created_source_property( + binding.kind, + &property.name, + ) { + return Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidPropertyAccess, + format!( + "MERGE action expression cannot read commit-assigned metadata '{}.{}' before commit", + alias, property.name + ), + property.span.clone(), + )); + } + } + } + self.reject_commit_dependent_created_source_value(object) + } + ExprKind::Unary { expr, .. } | ExprKind::IsNull { expr, .. } => { + self.reject_commit_dependent_created_source_value(expr) + } + ExprKind::Binary { left, right, .. } => { + self.reject_commit_dependent_created_source_value(left)?; + self.reject_commit_dependent_created_source_value(right) + } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand.as_ref() { + self.reject_commit_dependent_created_source_value(operand)?; + } + for branch in branches { + self.reject_commit_dependent_created_source_value(&branch.when)?; + self.reject_commit_dependent_created_source_value(&branch.then)?; + } + if let Some(else_expr) = else_expr.as_ref() { + self.reject_commit_dependent_created_source_value(else_expr)?; + } + Ok(()) + } + ExprKind::List(items) => { + for item in items { + self.reject_commit_dependent_created_source_value(item)?; + } + Ok(()) + } + ExprKind::Map(map) => { + for entry in &map.entries { + self.reject_commit_dependent_created_source_value(&entry.value)?; + } + Ok(()) + } + ExprKind::AggregateCall { .. } + | ExprKind::ExistsSubquery(_) | ExprKind::Literal(_) | ExprKind::Parameter(_) - | ExprKind::Unary { .. } - | ExprKind::Binary { .. } - | ExprKind::IsNull { .. } => Ok(()), + | ExprKind::Variable(_) => Ok(()), + } + } + + fn insert_created_alias( + &mut self, + ident: &Ident, + kind: GqlAliasKind, + ) -> Result<(), EngineError> { + if is_reserved_user_alias(&ident.name) { + return Err(gql_semantic_error( + GqlSemanticErrorCode::DuplicateAlias, + format!("'{}' is reserved for internal GQL projection", ident.name), + ident.span.clone(), + )); + } + if self.aliases.contains_key(&ident.name) { + return Err(gql_semantic_error( + GqlSemanticErrorCode::DuplicateAlias, + format!("created alias '{}' is already bound", ident.name), + ident.span.clone(), + )); } + self.aliases.insert( + ident.name.clone(), + GqlMutationAliasBinding { + name: ident.name.clone(), + kind, + origin: GqlAliasOrigin::Created, + nullable: false, + span: ident.span.clone(), + }, + ); + self.user_order.push(ident.name.clone()); + Ok(()) } - fn insert_created_alias( + fn insert_merged_alias( &mut self, ident: &Ident, kind: GqlAliasKind, @@ -1648,7 +3271,7 @@ impl MutationSemanticBinder<'_> { if self.aliases.contains_key(&ident.name) { return Err(gql_semantic_error( GqlSemanticErrorCode::DuplicateAlias, - format!("created alias '{}' is already bound", ident.name), + format!("merged alias '{}' is already bound", ident.name), ident.span.clone(), )); } @@ -1657,7 +3280,7 @@ impl MutationSemanticBinder<'_> { GqlMutationAliasBinding { name: ident.name.clone(), kind, - origin: GqlAliasOrigin::Created, + origin: GqlAliasOrigin::Merged, nullable: false, span: ident.span.clone(), }, @@ -1710,7 +3333,73 @@ pub(crate) fn gql_semantic_error( } pub(crate) fn is_reserved_user_alias(name: &str) -> bool { - name == DIRECT_NODE_ALIAS || name == DIRECT_EDGE_ALIAS || name.starts_with("__gql_") + name == DIRECT_NODE_ALIAS + || name == DIRECT_EDGE_ALIAS + || name.starts_with("__gql_") + || name.starts_with("__og_") +} + +fn is_graph_function(function: &str) -> bool { + matches!( + function, + "id" | "labels" + | "type" + | "length" + | "start_node" + | "end_node" + | "nodes" + | "relationships" + | "node_ids" + | "edge_ids" + ) +} + +fn is_scalar_function(function: &str) -> bool { + matches!( + function, + "coalesce" + | "to_string" + | "to_integer" + | "to_float" + | "abs" + | "floor" + | "ceil" + | "round" + | "lower" + | "upper" + | "trim" + | "substring" + | "size" + | "head" + | "last" + ) +} + +fn validate_scalar_function_arity( + function: &str, + name: &Ident, + arg_count: usize, +) -> Result<(), EngineError> { + let valid = match function { + "coalesce" => arg_count >= 1, + "substring" => matches!(arg_count, 2 | 3), + "to_string" | "to_integer" | "to_float" | "abs" | "floor" | "ceil" | "round" | "lower" + | "upper" | "trim" | "size" | "head" | "last" => arg_count == 1, + _ => false, + }; + if valid { + return Ok(()); + } + let expected = match function { + "coalesce" => "at least one argument", + "substring" => "two or three arguments", + _ => "exactly one argument", + }; + Err(gql_semantic_error( + GqlSemanticErrorCode::InvalidReturnExpression, + format!("function '{}' expects {expected}", name.name), + name.span.clone(), + )) } fn semantic_binding_order(clauses: &[GqlBoundMatchClause]) -> Vec { @@ -1762,16 +3451,52 @@ pub(crate) fn variable_name(expr: &Expr) -> Option<&str> { } } -fn synthetic_read_prefix_query(read_prefix: &[MatchClause], span: &SourceSpan) -> GqlQuery { +fn mutation_statement_has_read_prefix(statement: &GqlMutationStatement) -> bool { + statement.read_prefix_pipeline.is_some() || !statement.read_prefix.is_empty() +} + +fn synthetic_read_prefix_query(statement: &GqlMutationStatement) -> GqlQuery { + let span = &statement.span; + let return_projection = GqlProjectionClause { + kind: GqlProjectionKind::Return, + distinct: false, + distinct_span: None, + body: ReturnBody::All(span.clone()), + where_clause: None, + order_by: Vec::new(), + skip: None, + limit: None, + span: span.clone(), + }; + let pipeline = if let Some(prefix) = statement.read_prefix_pipeline.as_ref() { + let mut pipeline = prefix.clone(); + pipeline + .clauses + .push(GqlPipelineClause::Projection(return_projection.clone())); + pipeline.span = span.clone(); + pipeline + } else { + GqlReadPipeline { + clauses: vec![ + GqlPipelineClause::Match(statement.read_prefix.clone()), + GqlPipelineClause::Projection(return_projection.clone()), + ], + union_branches: Vec::new(), + span: span.clone(), + } + }; GqlQuery { - match_clauses: read_prefix.to_vec(), + match_clauses: statement.read_prefix.clone(), return_clause: ReturnClause { body: ReturnBody::All(span.clone()), + distinct: false, + distinct_span: None, span: span.clone(), }, order_by: Vec::new(), skip: None, limit: None, + pipeline, span: span.clone(), } } @@ -1802,6 +3527,25 @@ fn read_prefix_mutation_aliases( user_order.push(alias); } } + for alias in &plan.aliases.user_order { + if aliases.contains_key(alias) { + continue; + } + let Some(binding) = plan.aliases.get(alias) else { + continue; + }; + aliases.insert( + alias.clone(), + GqlMutationAliasBinding { + name: alias.clone(), + kind: binding.kind, + origin: GqlAliasOrigin::ReadPrefix, + nullable: false, + span: binding.span.clone(), + }, + ); + user_order.push(alias.clone()); + } (aliases, user_order) } @@ -1886,7 +3630,7 @@ fn reject_reserved_set_property(kind: GqlAliasKind, property: &Ident) -> Result< property.name.as_str(), "id" | "from" | "to" | "label" | "type" | "created_at" | "updated_at" ), - GqlAliasKind::Path => true, + GqlAliasKind::Path | GqlAliasKind::Scalar => true, }; if reserved { return Err(gql_semantic_error( @@ -1902,6 +3646,16 @@ fn reject_reserved_set_property(kind: GqlAliasKind, property: &Ident) -> Result< Ok(()) } +fn is_commit_dependent_created_source_property(kind: GqlAliasKind, property: &str) -> bool { + match kind { + GqlAliasKind::Node => matches!(property, "id" | "created_at" | "updated_at"), + GqlAliasKind::Edge => { + matches!(property, "id" | "from" | "to" | "created_at" | "updated_at") + } + GqlAliasKind::Path | GqlAliasKind::Scalar => false, + } +} + fn reject_reserved_remove_property( kind: GqlAliasKind, property: &Ident, @@ -1928,7 +3682,7 @@ fn reject_reserved_remove_property( | "valid_from" | "valid_to" ), - GqlAliasKind::Path => true, + GqlAliasKind::Path | GqlAliasKind::Scalar => true, }; if reserved { return Err(gql_semantic_error( @@ -1949,7 +3703,166 @@ fn kind_name(kind: GqlAliasKind) -> &'static str { GqlAliasKind::Node => "node", GqlAliasKind::Edge => "edge", GqlAliasKind::Path => "path", + GqlAliasKind::Scalar => "scalar", + } +} + +fn return_body_items(body: &ReturnBody) -> Option<&[ReturnItem]> { + match body { + ReturnBody::All(_) => None, + ReturnBody::AllAndItems { items, .. } | ReturnBody::Items(items) => Some(items), + } +} + +fn projection_body_contains_aggregate(body: &ReturnBody) -> bool { + return_body_items(body) + .map(|items| items.iter().any(|item| expr_contains_aggregate(&item.expr))) + .unwrap_or(false) +} + +fn gql_read_pipeline_contains_aggregate(pipeline: &GqlReadPipeline) -> bool { + pipeline + .clauses + .iter() + .any(gql_pipeline_clause_contains_aggregate) + || pipeline.union_branches.iter().any(|branch| { + branch + .clauses + .iter() + .any(gql_pipeline_clause_contains_aggregate) + }) +} + +fn gql_pipeline_clause_contains_aggregate(clause: &GqlPipelineClause) -> bool { + match clause { + GqlPipelineClause::Match(clauses) => clauses.iter().any(|clause| { + clause + .where_clause + .as_ref() + .is_some_and(expr_contains_aggregate) + || clause.patterns.iter().any(gql_pattern_contains_aggregate) + }), + GqlPipelineClause::ShortestPath(_) => false, + GqlPipelineClause::Call(call) => gql_read_pipeline_contains_aggregate(&call.pipeline), + GqlPipelineClause::Projection(projection) => { + projection_body_contains_aggregate(&projection.body) + || projection + .where_clause + .as_ref() + .is_some_and(expr_contains_aggregate) + || projection + .order_by + .iter() + .any(|item| expr_contains_aggregate(&item.expr)) + || projection + .skip + .as_ref() + .is_some_and(expr_contains_aggregate) + || projection + .limit + .as_ref() + .is_some_and(expr_contains_aggregate) + } + } +} + +fn gql_pattern_contains_aggregate(pattern: &Pattern) -> bool { + pattern + .start + .properties + .as_ref() + .is_some_and(gql_map_contains_aggregate) + || pattern.chains.iter().any(|chain| { + chain + .relationship + .properties + .as_ref() + .is_some_and(gql_map_contains_aggregate) + || chain + .node + .properties + .as_ref() + .is_some_and(gql_map_contains_aggregate) + }) +} + +fn gql_map_contains_aggregate(map: &MapLiteral) -> bool { + map.entries + .iter() + .any(|entry| expr_contains_aggregate(&entry.value)) +} + +fn expr_contains_aggregate(expr: &Expr) -> bool { + match &expr.kind { + ExprKind::AggregateCall { .. } => true, + ExprKind::ExistsSubquery(pipeline) => gql_read_pipeline_contains_aggregate(pipeline), + ExprKind::PropertyAccess { object, .. } => expr_contains_aggregate(object), + ExprKind::Unary { expr, .. } | ExprKind::IsNull { expr, .. } => { + expr_contains_aggregate(expr) + } + ExprKind::Binary { left, right, .. } => { + expr_contains_aggregate(left) || expr_contains_aggregate(right) + } + ExprKind::FunctionCall { args, .. } | ExprKind::List(args) => { + args.iter().any(expr_contains_aggregate) + } + ExprKind::Case { + operand, + branches, + else_expr, + } => { + operand + .as_ref() + .is_some_and(|expr| expr_contains_aggregate(expr)) + || branches.iter().any(|branch| { + expr_contains_aggregate(&branch.when) || expr_contains_aggregate(&branch.then) + }) + || else_expr + .as_ref() + .is_some_and(|expr| expr_contains_aggregate(expr)) + } + ExprKind::Map(map) => map + .entries + .iter() + .any(|entry| expr_contains_aggregate(&entry.value)), + ExprKind::Literal(_) | ExprKind::Parameter(_) | ExprKind::Variable(_) => false, + } +} + +fn insert_projection_alias( + aliases: &mut GqlAliasTable, + output_aliases: &mut Vec, + seen: &mut BTreeSet, + name: String, + kind: GqlAliasKind, + span: SourceSpan, +) -> Result<(), EngineError> { + if is_reserved_user_alias(&name) { + return Err(gql_semantic_error( + GqlSemanticErrorCode::DuplicateAlias, + format!("'{name}' is reserved for internal GQL projection"), + span, + )); + } + if !seen.insert(name.clone()) { + return Err(gql_semantic_error( + GqlSemanticErrorCode::DuplicateAlias, + format!("duplicate projection alias '{name}'"), + span, + )); } + aliases.by_name.insert( + name.clone(), + GqlAliasBinding { + name: name.clone(), + kind, + span: span.clone(), + user_visible: true, + }, + ); + aliases.user_order.push(name.clone()); + output_aliases.push(GqlProjectionAlias { name, kind, span }); + Ok(()) } pub(crate) fn expression_output_name(expr: &Expr) -> String { @@ -1966,6 +3879,27 @@ pub(crate) fn expression_output_name(expr: &Expr) -> String { .join(", "); format!("{}({})", name.name, args) } + ExprKind::AggregateCall { + function, + distinct, + arg, + .. + } => { + let name = match function { + AggregateFunction::Count => "count", + AggregateFunction::Sum => "sum", + AggregateFunction::Avg => "avg", + AggregateFunction::Min => "min", + AggregateFunction::Max => "max", + AggregateFunction::Collect => "collect", + }; + let arg = arg + .as_ref() + .map(|expr| expression_output_name(expr)) + .unwrap_or_else(|| "*".to_string()); + let distinct = if *distinct { "DISTINCT " } else { "" }; + format!("{name}({distinct}{arg})") + } ExprKind::Parameter(name) => format!("${name}"), ExprKind::Literal(Literal::Null) => "null".to_string(), ExprKind::Literal(Literal::Bool(value)) => value.to_string(), @@ -1974,9 +3908,11 @@ pub(crate) fn expression_output_name(expr: &Expr) -> String { ExprKind::Literal(Literal::String(value)) => value.clone(), ExprKind::List(_) => "list".to_string(), ExprKind::Map(_) => "map".to_string(), - ExprKind::Unary { .. } | ExprKind::Binary { .. } | ExprKind::IsNull { .. } => { - "expr".to_string() - } + ExprKind::Unary { .. } + | ExprKind::Binary { .. } + | ExprKind::IsNull { .. } + | ExprKind::Case { .. } + | ExprKind::ExistsSubquery(_) => "expr".to_string(), } } @@ -2007,6 +3943,13 @@ mod tests { } } + fn expect_semantic_code(err: EngineError, code: GqlSemanticErrorCode) { + match err { + EngineError::GqlSemantic { code: actual, .. } => assert_eq!(actual, code), + other => panic!("expected semantic error {code:?}, got {other:?}"), + } + } + #[test] fn binds_ordered_optional_clauses_and_path_aliases() { let plan = bind("MATCH (a) OPTIONAL MATCH p = (a)-[:KNOWS*0..2]->(b) RETURN *").unwrap(); @@ -2032,6 +3975,265 @@ mod tests { assert_eq!(plan.aliases.get("r").unwrap().kind, GqlAliasKind::Edge); } + #[test] + fn with_preserves_and_renames_graph_aliases() { + let preserved = bind("MATCH (n) WITH n RETURN n").unwrap(); + assert_eq!(preserved.aliases.user_order, vec!["n"]); + assert_eq!(preserved.aliases.get("n").unwrap().kind, GqlAliasKind::Node); + + let renamed = bind("MATCH (n) WITH n AS x RETURN x").unwrap(); + assert_eq!(renamed.aliases.user_order, vec!["x"]); + assert_eq!(renamed.aliases.get("x").unwrap().kind, GqlAliasKind::Node); + assert!(!renamed.aliases.contains("n")); + + let dropped = bind("MATCH (n) WITH n AS x RETURN n") + .expect_err("dropped aliases should be hidden after WITH"); + assert!(matches!( + dropped, + EngineError::GqlSemantic { + code: GqlSemanticErrorCode::UnknownVariable, + .. + } + )); + } + + #[test] + fn with_creates_scalar_aliases_usable_in_post_projection_expressions() { + let plan = bind( + "MATCH (n) WITH n.name AS name WHERE name STARTS WITH 'a' RETURN name ORDER BY name", + ) + .unwrap(); + assert_eq!(plan.aliases.user_order, vec!["name"]); + assert_eq!(plan.aliases.get("name").unwrap().kind, GqlAliasKind::Scalar); + let GqlReturnPlan::Items(items) = plan.returns else { + panic!("expected explicit RETURN"); + }; + assert_eq!(items[0].output_name, "name"); + } + + #[test] + fn with_rejects_scalar_aliases_as_pattern_variables() { + let err = bind("MATCH (n) WITH n.name AS name MATCH (name) RETURN name") + .expect_err("scalar alias cannot seed a node pattern"); + assert!(matches!( + err, + EngineError::GqlSemantic { + code: GqlSemanticErrorCode::DuplicateAlias, + .. + } + )); + } + + #[test] + fn later_match_after_with_binds_against_projected_scope() { + let plan = bind("MATCH (n) WITH n MATCH (n)-[:R]->(m) RETURN m").unwrap(); + assert_eq!(plan.aliases.user_order, vec!["n", "m"]); + assert_eq!(plan.aliases.get("n").unwrap().kind, GqlAliasKind::Node); + assert_eq!(plan.aliases.get("m").unwrap().kind, GqlAliasKind::Node); + + let optional = bind("MATCH (n) WITH n OPTIONAL MATCH (n)-[:R]->(m) RETURN m").unwrap(); + let GqlBoundPipelineClause::Match(later_match) = &optional.pipeline.clauses[2] else { + panic!("expected later MATCH after WITH"); + }; + assert_eq!(later_match.len(), 1); + assert!(later_match[0].optional); + } + + #[test] + fn shortest_path_requires_prebound_node_endpoints_and_binds_path_alias() { + let plan = bind( + "MATCH (a) WITH a MATCH (b) WITH a, b \ + MATCH p = shortestPath((a)-[:R*1..3]->(b)) RETURN p", + ) + .unwrap(); + assert_eq!(plan.aliases.get("p").unwrap().kind, GqlAliasKind::Path); + let GqlBoundPipelineClause::ShortestPath(shortest) = &plan.pipeline.clauses[4] else { + panic!("expected shortest-path stage"); + }; + assert_eq!(shortest.output_path_alias, "p"); + assert_eq!(shortest.from_alias, "a"); + assert_eq!(shortest.to_alias, "b"); + assert_eq!(shortest.min_hops, 1); + assert_eq!(shortest.max_hops, 3); + } + + #[test] + fn shortest_path_rejects_broad_or_unbound_endpoints() { + let err = bind("MATCH p = shortestPath((a)-[:R*1..3]->(b)) RETURN p") + .expect_err("unbound endpoint aliases should fail"); + expect_semantic_code(err, GqlSemanticErrorCode::UnknownVariable); + + let err = bind( + "MATCH (a) WITH a \ + MATCH p = shortestPath((a)-[:R*1..3]->(:Target {key: 'b'})) RETURN p", + ) + .expect_err("inline endpoint lookup should be rejected until specified"); + assert!(matches!( + err, + EngineError::GqlUnsupported { feature, .. } + if feature == "shortest-path endpoint lookup" + )); + } + + #[test] + fn with_star_preserves_visible_aliases_and_rejects_collisions() { + let plan = bind("MATCH (a)-[r:R]->(b) WITH * RETURN *").unwrap(); + assert_eq!(plan.aliases.user_order, vec!["a", "r", "b"]); + let GqlReturnPlan::Star { + expanded_aliases, .. + } = plan.returns + else { + panic!("expected RETURN *"); + }; + assert_eq!(expanded_aliases, vec!["a", "r", "b"]); + + let err = bind("MATCH (n) WITH *, n.name AS n RETURN n") + .expect_err("WITH star collisions should be rejected"); + assert!(matches!( + err, + EngineError::GqlSemantic { + code: GqlSemanticErrorCode::DuplicateAlias, + .. + } + )); + } + + #[test] + fn reserved_internal_aliases_are_rejected() { + for source in [ + "MATCH (n) RETURN n AS __og_union_order", + "MATCH (n) WITH n AS __og_union_order RETURN __og_union_order", + ] { + let err = bind(source).expect_err("reserved internal alias should fail"); + assert!( + matches!( + err, + EngineError::GqlSemantic { + code: GqlSemanticErrorCode::DuplicateAlias, + .. + } + ), + "unexpected error for {source}: {err:?}" + ); + } + } + + #[test] + fn with_distinct_span_survives_semantic_binding() { + let plan = bind("MATCH (n) WITH DISTINCT n RETURN n").unwrap(); + let GqlBoundPipelineClause::Projection(with) = &plan.pipeline.clauses[1] else { + panic!("expected WITH projection"); + }; + assert!(with.distinct); + assert!(with + .distinct_span + .as_ref() + .is_some_and(|span| span.length > 0)); + } + + #[test] + fn union_branches_bind_isolated_scopes_and_matching_columns() { + let plan = + bind("MATCH (n) RETURN n.name AS name UNION ALL MATCH (m) RETURN m.name AS name") + .unwrap(); + assert_eq!(plan.pipeline.union_branches.len(), 1); + assert_eq!( + plan.pipeline.union_branches[0].modifier, + GqlUnionModifier::All + ); + assert_eq!(plan.aliases.user_order, vec!["n"]); + assert!(!plan.aliases.contains("m")); + let GqlReturnPlan::Items(items) = &plan.pipeline.union_branches[0].returns else { + panic!("expected branch RETURN items"); + }; + assert_eq!(items[0].output_name, "name"); + } + + #[test] + fn union_branch_column_mismatches_are_semantic_errors() { + let count = bind("MATCH (n) RETURN n AS x UNION MATCH (m) RETURN m AS x, id(m) AS id") + .expect_err("column count mismatch should fail"); + assert!(matches!( + count, + EngineError::GqlSemantic { + code: GqlSemanticErrorCode::InvalidReturnExpression, + .. + } + )); + + let names = bind("MATCH (n) RETURN n AS x UNION MATCH (m) RETURN m AS y") + .expect_err("column name mismatch should fail"); + assert!(matches!( + names, + EngineError::GqlSemantic { + code: GqlSemanticErrorCode::InvalidReturnExpression, + .. + } + )); + } + + #[test] + fn aggregate_calls_are_valid_only_in_projection_contexts() { + bind("MATCH (n) RETURN count(*) + 1 AS total ORDER BY count(*) DESC").unwrap(); + bind("MATCH (n) WITH count(*) AS c WHERE c > 1 RETURN c").unwrap(); + + let plan = + bind("MATCH (n) RETURN count(DISTINCT n.kind), collect(DISTINCT n.kind)").unwrap(); + let GqlReturnPlan::Items(items) = plan.returns else { + panic!("expected aggregate return items"); + }; + assert_eq!(items[0].output_name, "count(DISTINCT n.kind)"); + assert_eq!(items[1].output_name, "collect(DISTINCT n.kind)"); + + for source in [ + "MATCH (n) WHERE count(*) > 1 RETURN n", + "MATCH (n) WITH n WHERE count(*) > 1 RETURN n", + "MATCH (n {score: count(*)}) RETURN n", + "MATCH (n) RETURN count(count(*))", + "MATCH (n) WITH *, count(*) AS c RETURN c", + "MATCH (n) RETURN * ORDER BY count(*)", + "MATCH (n) WITH * ORDER BY count(*) RETURN n", + ] { + let err = bind(source).expect_err("aggregate placement should be rejected"); + assert!( + matches!( + err, + EngineError::GqlSemantic { + code: GqlSemanticErrorCode::InvalidReturnExpression, + .. + } + ), + "unexpected error for {source}: {err:?}" + ); + } + } + + #[test] + fn mutation_rejects_aggregate_expressions() { + for source in [ + "CREATE (n:Person {key: 'a'}) RETURN count(*)", + "CREATE (n:Person {key: 'a', score: count(*)})", + "MATCH (n:Person {key: 'a'}) SET n.score = count(*)", + ] { + let err = bind_mut(source).expect_err("mutation aggregate should be rejected"); + expect_mut_semantic_code(err, GqlSemanticErrorCode::InvalidReturnExpression); + } + } + + #[test] + fn graph_function_kind_validation_survives_with_scope_transitions() { + bind("MATCH p = (a)-[:KNOWS]->(b) WITH p AS x RETURN length(x)").unwrap(); + let err = bind("MATCH (n) WITH n.name AS name RETURN labels(name)") + .expect_err("labels() should reject scalar aliases"); + assert!(matches!( + err, + EngineError::GqlSemantic { + code: GqlSemanticErrorCode::InvalidReturnExpression, + .. + } + )); + } + #[test] fn rejects_multi_hop_relationship_aliases_and_wrong_path_function_kinds() { let rel_alias = bind("MATCH p = (a)-[r:KNOWS*1..2]->(b) RETURN p") @@ -2087,6 +4289,113 @@ mod tests { assert_eq!(e.origin, GqlAliasOrigin::Created); } + #[test] + fn mutation_binds_keyed_node_merge_and_relationship_merge() { + let node = bind_mut( + "MERGE (n:Person {key: 'ada'}) ON CREATE SET n.status = 'new' ON MATCH SET n.status = 'seen' RETURN n", + ) + .unwrap(); + let n = node.aliases.get("n").unwrap(); + assert_eq!(n.kind, GqlAliasKind::Node); + assert_eq!(n.origin, GqlAliasOrigin::Merged); + let [GqlBoundMutationClause::Merge(merge)] = node.clauses.as_slice() else { + panic!("expected node MERGE clause"); + }; + assert_eq!(merge.on_create.items.len(), 1); + assert_eq!(merge.on_match.items.len(), 1); + assert!(matches!( + &merge.pattern, + GqlBoundMergePattern::Node(node) if node.alias == "n" && node.label.name == "Person" + )); + bind_mut("MERGE (n:Person {key: 'ada'}) ON MATCH SET n.count = coalesce(n.count, 0) + 1") + .unwrap(); + for source in [ + "MERGE (n:Person {key: 'ada'}) ON CREATE SET n.source_id = id(n)", + "MERGE (n:Person {key: 'ada'}) ON MATCH SET n.source_created = n.created_at", + ] { + let err = bind_mut(source) + .expect_err("MERGE actions should reject commit-dependent local metadata"); + assert!(matches!( + err, + EngineError::GqlSemantic { + code: GqlSemanticErrorCode::InvalidReturnExpression + | GqlSemanticErrorCode::InvalidPropertyAccess, + .. + } + )); + } + + let relationship = bind_mut( + "MATCH (a:Person) MATCH (b:Person) MERGE (a)-[r:KNOWS]->(b) ON CREATE SET r.status = 'new' RETURN r", + ) + .unwrap(); + let r = relationship.aliases.get("r").unwrap(); + assert_eq!(r.kind, GqlAliasKind::Edge); + assert_eq!(r.origin, GqlAliasOrigin::Merged); + let [GqlBoundMutationClause::Merge(merge)] = relationship.clauses.as_slice() else { + panic!("expected relationship MERGE clause"); + }; + assert!(matches!( + &merge.pattern, + GqlBoundMergePattern::Relationship(rel) + if rel.alias == "r" && rel.from_alias == "a" && rel.to_alias == "b" + && rel.rel_type.name == "KNOWS" + )); + for source in [ + "MATCH (a:Person) MATCH (b:Person) MERGE (a)-[r:KNOWS]->(b) ON CREATE SET r.source_id = id(r)", + "MATCH (a:Person) MATCH (b:Person) MERGE (a)-[r:KNOWS]->(b) ON MATCH SET r.source_from = r.from", + ] { + let err = bind_mut(source) + .expect_err("MERGE relationship actions should reject local edge metadata"); + assert!(matches!( + err, + EngineError::GqlSemantic { + code: GqlSemanticErrorCode::InvalidReturnExpression + | GqlSemanticErrorCode::InvalidPropertyAccess, + .. + } + )); + } + } + + #[test] + fn mutation_rejects_unsupported_merge_shapes() { + for (source, expected_feature) in [ + ("MERGE (n {key: 'a'})", "unlabeled node MERGE"), + ( + "MERGE (n:Person:Employee {key: 'a'})", + "multi-label node MERGE", + ), + ("MERGE (n:Person)", "unkeyed node MERGE"), + ( + "MERGE (n:Person {id: 'a'})", + "node MERGE non-key identity property", + ), + ( + "MERGE (n:Person {key: 'a', name: 'Ada'})", + "node MERGE property-map identity", + ), + ( + "MATCH (a:Person) MATCH (b:Person) MERGE (a)-[r:KNOWS {since: 2026}]->(b)", + "relationship MERGE properties", + ), + ( + "MATCH (a:Person) MATCH (b:Person) MERGE (a:Person)-[r:KNOWS]->(b)", + "relationship MERGE endpoint pattern", + ), + ] { + let err = bind_mut(source).expect_err("unsupported MERGE shape should fail"); + assert!( + matches!(err, EngineError::GqlUnsupported { ref feature, .. } if feature == expected_feature), + "expected unsupported {expected_feature} for {source}, got {err:?}" + ); + } + + let unbound = bind_mut("MERGE (a)-[r:KNOWS]->(b)") + .expect_err("unbound relationship endpoints should fail"); + expect_mut_semantic_code(unbound, GqlSemanticErrorCode::UnknownVariable); + } + #[test] fn mutation_rejects_create_alias_collisions_and_invalid_create_shapes() { let duplicate = bind_mut("CREATE (n:Person {key: 'a'}), (n:Person {key: 'b'})") diff --git a/src/graph_row.rs b/src/graph_row.rs index b4652da..a4bdab2 100644 --- a/src/graph_row.rs +++ b/src/graph_row.rs @@ -2,8 +2,9 @@ use crate::error::EngineError; use crate::property_value_semantics::{ - compare_numeric_keys, numeric_key_from_f64, numeric_key_from_i64, numeric_key_from_u64, - numeric_range_sort_key, NumericRangeSortKey, NumericScalarKey, + compare_numeric_keys, exact_i64_to_f64, exact_u64_to_f64, numeric_key_from_f64, + numeric_key_from_i64, numeric_key_from_u64, numeric_range_sort_key, NumericRangeSortKey, + NumericScalarKey, }; use crate::row_projection::{ EdgeSelectedFieldNeeds, EntityProjectionNeeds, NodeSelectedFieldNeeds, PathSelectedFieldNeeds, @@ -98,6 +99,14 @@ impl GraphBindingSchema { self.add_aliased_slot(alias.into(), GraphBindingSlotKind::Scalar, nullable) } + pub(crate) fn add_internal_scalar( + &mut self, + label: impl Into, + nullable: bool, + ) -> Result { + self.add_unaliased_scalar_slot(label.into(), nullable) + } + pub(crate) fn add_hidden_occurrence( &mut self, label: impl Into, @@ -204,6 +213,32 @@ impl GraphBindingSchema { }); Ok(slot) } + + fn add_unaliased_scalar_slot( + &mut self, + label: String, + nullable: bool, + ) -> Result { + if label.is_empty() { + return Err(EngineError::InvalidOperation( + "graph row internal scalar slot label must be non-empty".to_string(), + )); + } + let slot = GraphBindingSlotRef { + kind: GraphBindingSlotKind::Scalar, + index: next_index(&mut self.scalar_slots), + }; + let slot_position = self.slots.len(); + self.scalar_slot_positions.push(slot_position); + self.slots.push(GraphBindingSlot { + name: label, + user_alias: None, + kind: slot.kind, + index: slot.index, + nullable, + }); + Ok(slot) + } } fn next_index(value: &mut usize) -> usize { @@ -648,7 +683,7 @@ impl GraphBindingRow { return Err(unbound_logical_key_error(&slot.name)); } }, - GraphBindingSlotKind::Scalar => graph_sort_atom_for_value( + GraphBindingSlotKind::Scalar => graph_logical_sort_atom_for_value( &self .scalars .get(slot.index) @@ -729,7 +764,7 @@ impl GraphBindingRow { GraphSlotState::Null => GraphSortAtom::Null, GraphSlotState::Unbound => return Err(unbound_logical_key_error(name)), }, - GraphBindingSlotKind::Scalar => graph_sort_atom_for_value( + GraphBindingSlotKind::Scalar => graph_logical_sort_atom_for_value( &self .scalars .get(slot.index) @@ -1293,11 +1328,167 @@ pub(crate) enum GraphEvalValue { } impl GraphEvalValue { - fn is_null(&self) -> bool { + pub(crate) fn is_null(&self) -> bool { matches!(self, Self::Null) } } +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum GraphCanonicalKey { + Null, + Bool(bool), + Number(NumericRangeSortKey), + String(Vec), + Bytes(Vec), + Node(u64), + Edge(u64), + Path { nodes: Vec, edges: Vec }, + List(Vec), + Map(Vec<(String, GraphCanonicalKey)>), +} + +pub(crate) fn graph_canonical_key_for_value( + value: &GraphEvalValue, +) -> Result { + Ok(match value { + GraphEvalValue::Null => GraphCanonicalKey::Null, + GraphEvalValue::Bool(value) => GraphCanonicalKey::Bool(*value), + GraphEvalValue::Int(value) => { + GraphCanonicalKey::Number(numeric_range_sort_key(numeric_key_from_i64(*value))) + } + GraphEvalValue::UInt(value) => { + GraphCanonicalKey::Number(numeric_range_sort_key(numeric_key_from_u64(*value))) + } + GraphEvalValue::Float(value) => GraphCanonicalKey::Number(numeric_range_sort_key( + numeric_key_from_f64(*value).ok_or_else(|| { + EngineError::InvalidOperation( + "graph pipeline non-finite floats are not valid in canonical keys".to_string(), + ) + })?, + )), + GraphEvalValue::String(value) => GraphCanonicalKey::String(value.as_bytes().to_vec()), + GraphEvalValue::Bytes(value) => GraphCanonicalKey::Bytes(value.clone()), + GraphEvalValue::Node(node) => GraphCanonicalKey::Node(node.id), + GraphEvalValue::Edge(edge) => GraphCanonicalKey::Edge(edge.id), + GraphEvalValue::Path(path) => GraphCanonicalKey::Path { + nodes: path.path.nodes.clone(), + edges: path.path.edges.clone(), + }, + GraphEvalValue::List(values) => GraphCanonicalKey::List( + values + .iter() + .map(graph_canonical_key_for_value) + .collect::, _>>()?, + ), + GraphEvalValue::Map(values) => GraphCanonicalKey::Map( + values + .iter() + .map(|(key, value)| Ok((key.clone(), graph_canonical_key_for_value(value)?))) + .collect::, EngineError>>()?, + ), + }) +} + +pub(crate) fn graph_canonical_key_for_row_slots( + row: &GraphBindingRow, + slots: &[GraphBindingSlotRef], +) -> Result, EngineError> { + slots + .iter() + .map(|slot| { + row.value_for_slot(*slot) + .and_then(|value| graph_canonical_key_for_value(&value)) + }) + .collect() +} + +pub(crate) fn encode_graph_canonical_keys( + keys: &[GraphCanonicalKey], +) -> Result, EngineError> { + let mut bytes = Vec::new(); + push_canonical_len(&mut bytes, keys.len())?; + for key in keys { + encode_graph_canonical_key(&mut bytes, key)?; + } + Ok(bytes) +} + +fn encode_graph_canonical_key( + bytes: &mut Vec, + key: &GraphCanonicalKey, +) -> Result<(), EngineError> { + match key { + GraphCanonicalKey::Null => bytes.push(0), + GraphCanonicalKey::Bool(value) => { + bytes.push(1); + bytes.push(u8::from(*value)); + } + GraphCanonicalKey::Number(value) => { + bytes.push(2); + bytes.extend_from_slice(&value.as_bytes()); + } + GraphCanonicalKey::String(value) => { + bytes.push(3); + push_canonical_bytes(bytes, value)?; + } + GraphCanonicalKey::Bytes(value) => { + bytes.push(4); + push_canonical_bytes(bytes, value)?; + } + GraphCanonicalKey::Node(value) => { + bytes.push(5); + bytes.extend_from_slice(&value.to_be_bytes()); + } + GraphCanonicalKey::Edge(value) => { + bytes.push(6); + bytes.extend_from_slice(&value.to_be_bytes()); + } + GraphCanonicalKey::Path { nodes, edges } => { + bytes.push(7); + push_canonical_u64_vec(bytes, nodes)?; + push_canonical_u64_vec(bytes, edges)?; + } + GraphCanonicalKey::List(values) => { + bytes.push(8); + push_canonical_len(bytes, values.len())?; + for value in values { + encode_graph_canonical_key(bytes, value)?; + } + } + GraphCanonicalKey::Map(values) => { + bytes.push(9); + push_canonical_len(bytes, values.len())?; + for (key, value) in values { + push_canonical_bytes(bytes, key.as_bytes())?; + encode_graph_canonical_key(bytes, value)?; + } + } + } + Ok(()) +} + +fn push_canonical_len(bytes: &mut Vec, len: usize) -> Result<(), EngineError> { + let len = u32::try_from(len).map_err(|_| { + EngineError::InvalidOperation("graph canonical key is too large".to_string()) + })?; + bytes.extend_from_slice(&len.to_be_bytes()); + Ok(()) +} + +fn push_canonical_bytes(bytes: &mut Vec, value: &[u8]) -> Result<(), EngineError> { + push_canonical_len(bytes, value.len())?; + bytes.extend_from_slice(value); + Ok(()) +} + +fn push_canonical_u64_vec(bytes: &mut Vec, values: &[u64]) -> Result<(), EngineError> { + push_canonical_len(bytes, values.len())?; + for value in values { + bytes.extend_from_slice(&value.to_be_bytes()); + } + Ok(()) +} + pub(crate) struct GraphEvalContext<'a> { pub(crate) schema: &'a GraphBindingSchema, pub(crate) row: &'a GraphBindingRow, @@ -1345,10 +1536,21 @@ pub(crate) enum BoundGraphExpr { op: GraphBinaryOp, right: Box, }, + Case { + operand: Option>, + branches: Vec, + else_expr: Option>, + }, IsNull(Box), IsNotNull(Box), } +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct BoundGraphCaseBranch { + when: BoundGraphExpr, + then: BoundGraphExpr, +} + #[derive(Clone, Debug, PartialEq)] pub(crate) struct BoundGraphReturnItem { pub(crate) expr: BoundGraphExpr, @@ -1479,6 +1681,16 @@ fn bind_graph_expr_with_params( .map(|arg| bind_graph_expr_with_params(schema, arg, params)) .collect::, _>>()?, }, + GraphExpr::AggregateCall { .. } => { + return Err(EngineError::InvalidOperation( + "aggregate expressions require graph pipeline projection execution".to_string(), + )); + } + GraphExpr::ExistsSubquery(_) => { + return Err(EngineError::InvalidOperation( + "EXISTS subqueries require graph pipeline predicate execution".to_string(), + )); + } GraphExpr::Unary { op, expr } => BoundGraphExpr::Unary { op: *op, expr: Box::new(bind_graph_expr_with_params(schema, expr, params)?), @@ -1488,6 +1700,31 @@ fn bind_graph_expr_with_params( op: *op, right: Box::new(bind_graph_expr_with_params(schema, right, params)?), }, + GraphExpr::Case { + operand, + branches, + else_expr, + } => BoundGraphExpr::Case { + operand: operand + .as_ref() + .map(|operand| bind_graph_expr_with_params(schema, operand, params).map(Box::new)) + .transpose()?, + branches: branches + .iter() + .map(|branch| { + Ok(BoundGraphCaseBranch { + when: bind_graph_expr_with_params(schema, &branch.when, params)?, + then: bind_graph_expr_with_params(schema, &branch.then, params)?, + }) + }) + .collect::, EngineError>>()?, + else_expr: else_expr + .as_ref() + .map(|else_expr| { + bind_graph_expr_with_params(schema, else_expr, params).map(Box::new) + }) + .transpose()?, + }, GraphExpr::IsNull(expr) => { BoundGraphExpr::IsNull(Box::new(bind_graph_expr_with_params(schema, expr, params)?)) } @@ -1599,14 +1836,22 @@ pub(crate) fn eval_graph_expr( GraphExpr::EdgeField { alias, field } => eval_graph_edge_field(alias, *field, context), GraphExpr::PathField { alias, field } => eval_graph_path_field(alias, *field, context), GraphExpr::Function { name, args } => eval_graph_function(*name, args, context), - GraphExpr::Unary { - op: GraphUnaryOp::Not, - expr, - } => match bool_or_null(&eval_graph_expr(expr, context)?)? { - Some(value) => Ok(GraphEvalValue::Bool(!value)), - None => Ok(GraphEvalValue::Null), - }, + GraphExpr::AggregateCall { .. } => Err(EngineError::InvalidOperation( + "aggregate expressions require graph pipeline projection execution".to_string(), + )), + GraphExpr::ExistsSubquery(_) => Err(EngineError::InvalidOperation( + "EXISTS subqueries require graph pipeline predicate execution".to_string(), + )), + GraphExpr::Unary { op, expr } => { + let value = eval_graph_expr(expr, context)?; + eval_graph_unary_value(*op, &value) + } GraphExpr::Binary { left, op, right } => eval_graph_binary(left, *op, right, context), + GraphExpr::Case { + operand, + branches, + else_expr, + } => eval_graph_case(operand.as_deref(), branches, else_expr.as_deref(), context), GraphExpr::IsNull(expr) => Ok(GraphEvalValue::Bool( eval_graph_expr(expr, context)?.is_null(), )), @@ -1667,16 +1912,18 @@ pub(crate) fn eval_bound_graph_expr( eval_bound_graph_path_field(*slot, *field, context) } BoundGraphExpr::Function { name, args } => eval_bound_graph_function(*name, args, context), - BoundGraphExpr::Unary { - op: GraphUnaryOp::Not, - expr, - } => match bool_or_null(&eval_bound_graph_expr(expr, context)?)? { - Some(value) => Ok(GraphEvalValue::Bool(!value)), - None => Ok(GraphEvalValue::Null), - }, + BoundGraphExpr::Unary { op, expr } => { + let value = eval_bound_graph_expr(expr, context)?; + eval_graph_unary_value(*op, &value) + } BoundGraphExpr::Binary { left, op, right } => { eval_bound_graph_binary(left, *op, right, context) } + BoundGraphExpr::Case { + operand, + branches, + else_expr, + } => eval_bound_graph_case(operand.as_deref(), branches, else_expr.as_deref(), context), BoundGraphExpr::IsNull(expr) => Ok(GraphEvalValue::Bool( eval_bound_graph_expr(expr, context)?.is_null(), )), @@ -1790,6 +2037,9 @@ fn eval_bound_graph_function( args: &[BoundGraphExpr], context: &BoundGraphEvalContext<'_>, ) -> Result { + if is_scalar_graph_function(name) { + return eval_bound_graph_scalar_function(name, args, context); + } if args.len() != 1 { return Err(EngineError::InvalidOperation(format!( "graph row function {} expects exactly one argument", @@ -1900,11 +2150,9 @@ fn eval_bound_graph_slot_function( let Some(path) = context.row.path_for_slot(slot)? else { return Ok(GraphEvalValue::Null); }; - path.path - .nodes + path.nodes .first() - .copied() - .map(GraphBoundNode::id_only) + .cloned() .map(GraphEvalValue::Node) .ok_or_else(|| invalid_path_shape("start_node")) } @@ -1912,11 +2160,9 @@ fn eval_bound_graph_slot_function( let Some(path) = context.row.path_for_slot(slot)? else { return Ok(GraphEvalValue::Null); }; - path.path - .nodes + path.nodes .last() - .copied() - .map(GraphBoundNode::id_only) + .cloned() .map(GraphEvalValue::Node) .ok_or_else(|| invalid_path_shape("end_node")) } @@ -1925,11 +2171,9 @@ fn eval_bound_graph_slot_function( return Ok(GraphEvalValue::Null); }; Ok(GraphEvalValue::List( - path.path - .nodes + path.nodes .iter() - .copied() - .map(GraphBoundNode::id_only) + .cloned() .map(GraphEvalValue::Node) .collect(), )) @@ -1939,11 +2183,9 @@ fn eval_bound_graph_slot_function( return Ok(GraphEvalValue::Null); }; Ok(GraphEvalValue::List( - path.path - .edges + path.edges .iter() - .copied() - .map(GraphBoundEdge::id_only) + .cloned() .map(GraphEvalValue::Edge) .collect(), )) @@ -1959,6 +2201,24 @@ fn eval_bound_graph_slot_function( | GraphFunction::Relationships, _, ) => Err(function_input_error(name, "a path")), + ( + GraphFunction::Coalesce + | GraphFunction::ToString + | GraphFunction::ToInteger + | GraphFunction::ToFloat + | GraphFunction::Abs + | GraphFunction::Floor + | GraphFunction::Ceil + | GraphFunction::Round + | GraphFunction::Lower + | GraphFunction::Upper + | GraphFunction::Trim + | GraphFunction::Substring + | GraphFunction::Size + | GraphFunction::Head + | GraphFunction::Last, + _, + ) => Err(function_input_error(name, "a scalar expression")), } } @@ -1982,38 +2242,22 @@ fn eval_graph_function_value( Ok(GraphEvalValue::UInt(path.path.edges.len() as u64)) } (GraphFunction::StartNode, GraphEvalValue::Path(path)) => path - .path .nodes - .first() - .copied() - .map(GraphBoundNode::id_only) + .into_iter() + .next() .map(GraphEvalValue::Node) .ok_or_else(|| invalid_path_shape("start_node")), (GraphFunction::EndNode, GraphEvalValue::Path(path)) => path - .path .nodes + .into_iter() .last() - .copied() - .map(GraphBoundNode::id_only) .map(GraphEvalValue::Node) .ok_or_else(|| invalid_path_shape("end_node")), (GraphFunction::Nodes, GraphEvalValue::Path(path)) => Ok(GraphEvalValue::List( - path.path - .nodes - .iter() - .copied() - .map(GraphBoundNode::id_only) - .map(GraphEvalValue::Node) - .collect(), + path.nodes.into_iter().map(GraphEvalValue::Node).collect(), )), (GraphFunction::Relationships, GraphEvalValue::Path(path)) => Ok(GraphEvalValue::List( - path.path - .edges - .iter() - .copied() - .map(GraphBoundEdge::id_only) - .map(GraphEvalValue::Edge) - .collect(), + path.edges.into_iter().map(GraphEvalValue::Edge).collect(), )), (GraphFunction::Id, _) => Err(function_input_error(name, "a node or edge")), (GraphFunction::Labels, _) => Err(function_input_error(name, "a node")), @@ -2026,6 +2270,161 @@ fn eval_graph_function_value( | GraphFunction::Relationships, _, ) => Err(function_input_error(name, "a path")), + ( + GraphFunction::Coalesce + | GraphFunction::ToString + | GraphFunction::ToInteger + | GraphFunction::ToFloat + | GraphFunction::Abs + | GraphFunction::Floor + | GraphFunction::Ceil + | GraphFunction::Round + | GraphFunction::Lower + | GraphFunction::Upper + | GraphFunction::Trim + | GraphFunction::Substring + | GraphFunction::Size + | GraphFunction::Head + | GraphFunction::Last, + _, + ) => Err(function_input_error(name, "a scalar expression")), + } +} + +fn eval_bound_graph_case( + operand: Option<&BoundGraphExpr>, + branches: &[BoundGraphCaseBranch], + else_expr: Option<&BoundGraphExpr>, + context: &BoundGraphEvalContext<'_>, +) -> Result { + if let Some(operand) = operand { + let operand_value = eval_bound_graph_expr(operand, context)?; + for branch in branches { + let when_value = eval_bound_graph_expr(&branch.when, context)?; + match eval_graph_binary_values(GraphBinaryOp::Eq, &operand_value, &when_value)? { + GraphEvalValue::Bool(true) => return eval_bound_graph_expr(&branch.then, context), + GraphEvalValue::Bool(false) | GraphEvalValue::Null => {} + _ => unreachable!("equality always returns bool or null"), + } + } + } else { + for branch in branches { + if let Some(true) = bool_or_null(&eval_bound_graph_expr(&branch.when, context)?)? { + return eval_bound_graph_expr(&branch.then, context); + } + } + } + else_expr + .map(|expr| eval_bound_graph_expr(expr, context)) + .unwrap_or(Ok(GraphEvalValue::Null)) +} + +fn eval_graph_case( + operand: Option<&GraphExpr>, + branches: &[crate::types::GraphCaseBranch], + else_expr: Option<&GraphExpr>, + context: &GraphEvalContext<'_>, +) -> Result { + if let Some(operand) = operand { + let operand_value = eval_graph_expr(operand, context)?; + for branch in branches { + let when_value = eval_graph_expr(&branch.when, context)?; + match eval_graph_binary_values(GraphBinaryOp::Eq, &operand_value, &when_value)? { + GraphEvalValue::Bool(true) => return eval_graph_expr(&branch.then, context), + GraphEvalValue::Bool(false) | GraphEvalValue::Null => {} + _ => unreachable!("equality always returns bool or null"), + } + } + } else { + for branch in branches { + if let Some(true) = bool_or_null(&eval_graph_expr(&branch.when, context)?)? { + return eval_graph_expr(&branch.then, context); + } + } + } + else_expr + .map(|expr| eval_graph_expr(expr, context)) + .unwrap_or(Ok(GraphEvalValue::Null)) +} + +pub(crate) fn eval_graph_unary_value( + op: GraphUnaryOp, + value: &GraphEvalValue, +) -> Result { + match op { + GraphUnaryOp::Not => match bool_or_null(value)? { + Some(value) => Ok(GraphEvalValue::Bool(!value)), + None => Ok(GraphEvalValue::Null), + }, + GraphUnaryOp::Neg => eval_graph_numeric_neg(value), + } +} + +pub(crate) fn eval_graph_binary_values( + op: GraphBinaryOp, + left: &GraphEvalValue, + right: &GraphEvalValue, +) -> Result { + match op { + GraphBinaryOp::And => { + let left = bool_or_null(left)?; + let right = bool_or_null(right)?; + Ok(graph_and_truth_value(left, right)) + } + GraphBinaryOp::Or => { + let left = bool_or_null(left)?; + let right = bool_or_null(right)?; + Ok(graph_or_truth_value(left, right)) + } + GraphBinaryOp::Eq + | GraphBinaryOp::Neq + | GraphBinaryOp::Lt + | GraphBinaryOp::Le + | GraphBinaryOp::Gt + | GraphBinaryOp::Ge => compare_graph_binary_values(op, left, right), + GraphBinaryOp::In => eval_graph_in(left, right), + GraphBinaryOp::Add | GraphBinaryOp::Sub | GraphBinaryOp::Mul | GraphBinaryOp::Div => { + eval_graph_arithmetic(op, left, right) + } + GraphBinaryOp::StartsWith | GraphBinaryOp::EndsWith | GraphBinaryOp::Contains => { + eval_graph_string_predicate(op, left, right) + } + } +} + +pub(crate) fn eval_graph_scalar_function_values( + name: GraphFunction, + args: &[GraphEvalValue], +) -> Result { + validate_graph_scalar_function_arity(name, args.len())?; + match name { + GraphFunction::Coalesce => { + for value in args { + if !value.is_null() { + ensure_graph_eval_scalar_domain(name, value)?; + return Ok(value.clone()); + } + } + Ok(GraphEvalValue::Null) + } + GraphFunction::ToString => eval_to_string(&args[0]), + GraphFunction::ToInteger => eval_to_integer(&args[0]), + GraphFunction::ToFloat => eval_to_float(&args[0]), + GraphFunction::Abs => eval_numeric_abs(&args[0]), + GraphFunction::Floor => eval_numeric_rounding(name, &args[0]), + GraphFunction::Ceil => eval_numeric_rounding(name, &args[0]), + GraphFunction::Round => eval_numeric_rounding(name, &args[0]), + GraphFunction::Lower => eval_string_unary(name, &args[0]), + GraphFunction::Upper => eval_string_unary(name, &args[0]), + GraphFunction::Trim => eval_string_unary(name, &args[0]), + GraphFunction::Substring => eval_substring(args), + GraphFunction::Size => eval_size(&args[0]), + GraphFunction::Head => eval_head_or_last(name, &args[0]), + GraphFunction::Last => eval_head_or_last(name, &args[0]), + _ => Err(EngineError::InvalidOperation(format!( + "graph row function {} is not a scalar function", + graph_function_name(name) + ))), } } @@ -2043,10 +2442,17 @@ fn eval_bound_graph_binary( | GraphBinaryOp::Lt | GraphBinaryOp::Le | GraphBinaryOp::Gt - | GraphBinaryOp::Ge => { + | GraphBinaryOp::Ge + | GraphBinaryOp::Add + | GraphBinaryOp::Sub + | GraphBinaryOp::Mul + | GraphBinaryOp::Div + | GraphBinaryOp::StartsWith + | GraphBinaryOp::EndsWith + | GraphBinaryOp::Contains => { let left_value = eval_bound_graph_expr(left, context)?; let right_value = eval_bound_graph_expr(right, context)?; - compare_graph_binary_values(op, &left_value, &right_value) + eval_graph_binary_values(op, &left_value, &right_value) } GraphBinaryOp::In => { let left_value = eval_bound_graph_expr(left, context)?; @@ -2175,6 +2581,9 @@ fn eval_graph_function( args: &[GraphExpr], context: &GraphEvalContext<'_>, ) -> Result { + if is_scalar_graph_function(name) { + return eval_graph_scalar_function(name, args, context); + } if args.len() != 1 { return Err(EngineError::InvalidOperation(format!( "graph row function {} expects exactly one argument", @@ -2188,6 +2597,52 @@ fn eval_graph_function( eval_graph_function_value(name, value) } +fn eval_bound_graph_scalar_function( + name: GraphFunction, + args: &[BoundGraphExpr], + context: &BoundGraphEvalContext<'_>, +) -> Result { + validate_graph_scalar_function_arity(name, args.len())?; + if name == GraphFunction::Coalesce { + for arg in args { + let value = eval_bound_graph_expr(arg, context)?; + if !value.is_null() { + ensure_graph_eval_scalar_domain(name, &value)?; + return Ok(value); + } + } + return Ok(GraphEvalValue::Null); + } + let mut values = Vec::with_capacity(args.len()); + for arg in args { + values.push(eval_bound_graph_expr(arg, context)?); + } + eval_graph_scalar_function_values(name, &values) +} + +fn eval_graph_scalar_function( + name: GraphFunction, + args: &[GraphExpr], + context: &GraphEvalContext<'_>, +) -> Result { + validate_graph_scalar_function_arity(name, args.len())?; + if name == GraphFunction::Coalesce { + for arg in args { + let value = eval_graph_expr(arg, context)?; + if !value.is_null() { + ensure_graph_eval_scalar_domain(name, &value)?; + return Ok(value); + } + } + return Ok(GraphEvalValue::Null); + } + let mut values = Vec::with_capacity(args.len()); + for arg in args { + values.push(eval_graph_expr(arg, context)?); + } + eval_graph_scalar_function_values(name, &values) +} + fn eval_graph_path_derived_function( name: GraphFunction, arg: &GraphExpr, @@ -2271,130 +2726,687 @@ fn eval_graph_binary( | GraphBinaryOp::Lt | GraphBinaryOp::Le | GraphBinaryOp::Gt - | GraphBinaryOp::Ge => { + | GraphBinaryOp::Ge + | GraphBinaryOp::Add + | GraphBinaryOp::Sub + | GraphBinaryOp::Mul + | GraphBinaryOp::Div + | GraphBinaryOp::StartsWith + | GraphBinaryOp::EndsWith + | GraphBinaryOp::Contains => { let left_value = eval_graph_expr(left, context)?; let right_value = eval_graph_expr(right, context)?; - compare_graph_binary_values(op, &left_value, &right_value) + eval_graph_binary_values(op, &left_value, &right_value) } GraphBinaryOp::In => { let left_value = eval_graph_expr(left, context)?; let right_value = eval_graph_expr(right, context)?; eval_graph_in(&left_value, &right_value) } - } + } +} + +fn eval_graph_and( + left: &GraphExpr, + right: &GraphExpr, + context: &GraphEvalContext<'_>, +) -> Result { + let left_value = bool_or_null(&eval_graph_expr(left, context)?)?; + if left_value == Some(false) { + return Ok(GraphEvalValue::Bool(false)); + } + let right_value = bool_or_null(&eval_graph_expr(right, context)?)?; + Ok(graph_and_truth_value(left_value, right_value)) +} + +fn eval_graph_or( + left: &GraphExpr, + right: &GraphExpr, + context: &GraphEvalContext<'_>, +) -> Result { + let left_value = bool_or_null(&eval_graph_expr(left, context)?)?; + if left_value == Some(true) { + return Ok(GraphEvalValue::Bool(true)); + } + let right_value = bool_or_null(&eval_graph_expr(right, context)?)?; + Ok(graph_or_truth_value(left_value, right_value)) +} + +fn graph_and_truth_value(left: Option, right: Option) -> GraphEvalValue { + match (left, right) { + (_, Some(false)) => GraphEvalValue::Bool(false), + (Some(true), Some(true)) => GraphEvalValue::Bool(true), + _ => GraphEvalValue::Null, + } +} + +fn graph_or_truth_value(left: Option, right: Option) -> GraphEvalValue { + match (left, right) { + (_, Some(true)) => GraphEvalValue::Bool(true), + (Some(false), Some(false)) => GraphEvalValue::Bool(false), + _ => GraphEvalValue::Null, + } +} + +fn compare_graph_binary_values( + op: GraphBinaryOp, + left: &GraphEvalValue, + right: &GraphEvalValue, +) -> Result { + if left.is_null() || right.is_null() { + return Ok(GraphEvalValue::Null); + } + match op { + GraphBinaryOp::Eq | GraphBinaryOp::Neq => { + let equal = graph_values_equal(left, right)?; + Ok(GraphEvalValue::Bool(if op == GraphBinaryOp::Eq { + equal + } else { + !equal + })) + } + GraphBinaryOp::Lt | GraphBinaryOp::Le | GraphBinaryOp::Gt | GraphBinaryOp::Ge => { + let ordering = partial_cmp_graph_values(left, right)?.ok_or_else(|| { + EngineError::InvalidOperation( + "graph row ordering comparison is not supported for these values".to_string(), + ) + })?; + Ok(GraphEvalValue::Bool(match op { + GraphBinaryOp::Lt => ordering == Ordering::Less, + GraphBinaryOp::Le => matches!(ordering, Ordering::Less | Ordering::Equal), + GraphBinaryOp::Gt => ordering == Ordering::Greater, + GraphBinaryOp::Ge => matches!(ordering, Ordering::Greater | Ordering::Equal), + _ => unreachable!(), + })) + } + GraphBinaryOp::And + | GraphBinaryOp::Or + | GraphBinaryOp::In + | GraphBinaryOp::Add + | GraphBinaryOp::Sub + | GraphBinaryOp::Mul + | GraphBinaryOp::Div + | GraphBinaryOp::StartsWith + | GraphBinaryOp::EndsWith + | GraphBinaryOp::Contains => unreachable!(), + } +} + +fn eval_graph_in( + left: &GraphEvalValue, + right: &GraphEvalValue, +) -> Result { + if left.is_null() || right.is_null() { + return Ok(GraphEvalValue::Null); + } + let GraphEvalValue::List(items) = right else { + return Err(EngineError::InvalidOperation( + "graph row IN requires a list right-hand operand".to_string(), + )); + }; + let mut saw_null = false; + for item in items { + if item.is_null() { + saw_null = true; + } else if graph_values_equal(left, item)? { + return Ok(GraphEvalValue::Bool(true)); + } + } + Ok(if saw_null { + GraphEvalValue::Null + } else { + GraphEvalValue::Bool(false) + }) +} + +fn bool_or_null(value: &GraphEvalValue) -> Result, EngineError> { + match value { + GraphEvalValue::Bool(value) => Ok(Some(*value)), + GraphEvalValue::Null => Ok(None), + _ => Err(EngineError::InvalidOperation( + "graph row boolean operators require boolean or null operands".to_string(), + )), + } +} + +#[derive(Clone, Copy, Debug)] +enum NumericEvalOperand { + Int(i64), + UInt(u64), + Float(f64), +} + +fn numeric_operand(value: &GraphEvalValue) -> Result, EngineError> { + Ok(match value { + GraphEvalValue::Null => None, + GraphEvalValue::Int(value) => Some(NumericEvalOperand::Int(*value)), + GraphEvalValue::UInt(value) => Some(NumericEvalOperand::UInt(*value)), + GraphEvalValue::Float(value) => Some(NumericEvalOperand::Float(checked_finite_float( + *value, + "graph row numeric expression", + )?)), + _ => { + return Err(EngineError::InvalidOperation( + "graph row numeric operators require numeric or null operands".to_string(), + )); + } + }) +} + +fn eval_graph_numeric_neg(value: &GraphEvalValue) -> Result { + let Some(value) = numeric_operand(value)? else { + return Ok(GraphEvalValue::Null); + }; + match value { + NumericEvalOperand::Int(value) => value + .checked_neg() + .map(GraphEvalValue::Int) + .ok_or_else(|| integer_overflow_error("negation")), + NumericEvalOperand::UInt(0) => Ok(GraphEvalValue::Int(0)), + NumericEvalOperand::UInt(value) => { + if value == (i64::MAX as u64) + 1 { + return Ok(GraphEvalValue::Int(i64::MIN)); + } + let signed = i64::try_from(value).map_err(|_| integer_overflow_error("negation"))?; + signed + .checked_neg() + .map(GraphEvalValue::Int) + .ok_or_else(|| integer_overflow_error("negation")) + } + NumericEvalOperand::Float(value) => { + let result = -value; + checked_finite_float(result, "graph row numeric negation result") + .map(GraphEvalValue::Float) + } + } +} + +fn eval_graph_arithmetic( + op: GraphBinaryOp, + left: &GraphEvalValue, + right: &GraphEvalValue, +) -> Result { + let Some(left) = numeric_operand(left)? else { + return Ok(GraphEvalValue::Null); + }; + let Some(right) = numeric_operand(right)? else { + return Ok(GraphEvalValue::Null); + }; + + if matches!(op, GraphBinaryOp::Div) { + if numeric_operand_is_zero(right) { + return Err(EngineError::InvalidOperation( + "graph row division by zero".to_string(), + )); + } + let result = numeric_operand_to_f64(left)? / numeric_operand_to_f64(right)?; + return checked_finite_float(result, "graph row division result") + .map(GraphEvalValue::Float); + } + + if matches!(left, NumericEvalOperand::Float(_)) || matches!(right, NumericEvalOperand::Float(_)) + { + let left = numeric_operand_to_f64(left)?; + let right = numeric_operand_to_f64(right)?; + let result = match op { + GraphBinaryOp::Add => left + right, + GraphBinaryOp::Sub => left - right, + GraphBinaryOp::Mul => left * right, + _ => unreachable!("non-arithmetic operator in arithmetic evaluator"), + }; + return checked_finite_float(result, "graph row arithmetic result") + .map(GraphEvalValue::Float); + } + + eval_graph_integer_arithmetic(op, left, right) +} + +fn eval_graph_integer_arithmetic( + op: GraphBinaryOp, + left: NumericEvalOperand, + right: NumericEvalOperand, +) -> Result { + match (op, left, right) { + (GraphBinaryOp::Add, NumericEvalOperand::Int(left), NumericEvalOperand::Int(right)) => left + .checked_add(right) + .map(GraphEvalValue::Int) + .ok_or_else(|| integer_overflow_error("addition")), + (GraphBinaryOp::Add, NumericEvalOperand::UInt(left), NumericEvalOperand::UInt(right)) => { + left.checked_add(right) + .map(GraphEvalValue::UInt) + .ok_or_else(|| integer_overflow_error("addition")) + } + (GraphBinaryOp::Sub, NumericEvalOperand::Int(left), NumericEvalOperand::Int(right)) => left + .checked_sub(right) + .map(GraphEvalValue::Int) + .ok_or_else(|| integer_overflow_error("subtraction")), + (GraphBinaryOp::Sub, NumericEvalOperand::UInt(left), NumericEvalOperand::UInt(right)) => { + if left >= right { + Ok(GraphEvalValue::UInt(left - right)) + } else { + integer_result_from_mixed_i128(i128::from(left) - i128::from(right), true) + } + } + (GraphBinaryOp::Mul, NumericEvalOperand::Int(left), NumericEvalOperand::Int(right)) => left + .checked_mul(right) + .map(GraphEvalValue::Int) + .ok_or_else(|| integer_overflow_error("multiplication")), + (GraphBinaryOp::Mul, NumericEvalOperand::UInt(left), NumericEvalOperand::UInt(right)) => { + left.checked_mul(right) + .map(GraphEvalValue::UInt) + .ok_or_else(|| integer_overflow_error("multiplication")) + } + (op @ (GraphBinaryOp::Add | GraphBinaryOp::Sub | GraphBinaryOp::Mul), left, right) => { + let left = numeric_integer_to_i128(left); + let right = numeric_integer_to_i128(right); + let result = match op { + GraphBinaryOp::Add => left.checked_add(right), + GraphBinaryOp::Sub => left.checked_sub(right), + GraphBinaryOp::Mul => left.checked_mul(right), + _ => unreachable!(), + } + .ok_or_else(|| integer_overflow_error(arithmetic_name(op)))?; + integer_result_from_mixed_i128(result, true) + } + _ => unreachable!("non-integer arithmetic passed to integer evaluator"), + } +} + +fn numeric_integer_to_i128(value: NumericEvalOperand) -> i128 { + match value { + NumericEvalOperand::Int(value) => i128::from(value), + NumericEvalOperand::UInt(value) => i128::from(value), + NumericEvalOperand::Float(_) => unreachable!("float passed to integer evaluator"), + } +} + +fn integer_result_from_mixed_i128( + value: i128, + prefer_signed: bool, +) -> Result { + if value < 0 { + return i64::try_from(value) + .map(GraphEvalValue::Int) + .map_err(|_| integer_overflow_error("integer arithmetic")); + } + if prefer_signed && value <= i128::from(i64::MAX) { + return Ok(GraphEvalValue::Int(value as i64)); + } + u64::try_from(value) + .map(GraphEvalValue::UInt) + .map_err(|_| integer_overflow_error("integer arithmetic")) +} + +fn numeric_operand_to_f64(value: NumericEvalOperand) -> Result { + let value = match value { + NumericEvalOperand::Int(value) => { + exact_i64_to_f64(value, "graph row float arithmetic input")? + } + NumericEvalOperand::UInt(value) => { + exact_u64_to_f64(value, "graph row float arithmetic input")? + } + NumericEvalOperand::Float(value) => value, + }; + checked_finite_float(value, "graph row float arithmetic input") +} + +fn numeric_operand_is_zero(value: NumericEvalOperand) -> bool { + match value { + NumericEvalOperand::Int(value) => value == 0, + NumericEvalOperand::UInt(value) => value == 0, + NumericEvalOperand::Float(value) => value == 0.0, + } +} + +fn arithmetic_name(op: GraphBinaryOp) -> &'static str { + match op { + GraphBinaryOp::Add => "addition", + GraphBinaryOp::Sub => "subtraction", + GraphBinaryOp::Mul => "multiplication", + GraphBinaryOp::Div => "division", + _ => "arithmetic", + } +} + +fn integer_overflow_error(operation: &str) -> EngineError { + EngineError::InvalidOperation(format!("graph row integer {operation} overflowed")) +} + +fn checked_finite_float(value: f64, context: &str) -> Result { + if value.is_finite() { + Ok(value) + } else { + Err(EngineError::InvalidOperation(format!( + "{context} must be finite" + ))) + } +} + +fn eval_graph_string_predicate( + op: GraphBinaryOp, + left: &GraphEvalValue, + right: &GraphEvalValue, +) -> Result { + if left.is_null() || right.is_null() { + return Ok(GraphEvalValue::Null); + } + let (GraphEvalValue::String(left), GraphEvalValue::String(right)) = (left, right) else { + return Err(EngineError::InvalidOperation( + "graph row string predicates require string or null operands".to_string(), + )); + }; + Ok(GraphEvalValue::Bool(match op { + GraphBinaryOp::StartsWith => left.starts_with(right), + GraphBinaryOp::EndsWith => left.ends_with(right), + GraphBinaryOp::Contains => left.contains(right), + _ => unreachable!("non-string predicate operator"), + })) +} + +fn validate_graph_scalar_function_arity( + name: GraphFunction, + arg_count: usize, +) -> Result<(), EngineError> { + let valid = match name { + GraphFunction::Coalesce => arg_count >= 1, + GraphFunction::Substring => matches!(arg_count, 2 | 3), + GraphFunction::ToString + | GraphFunction::ToInteger + | GraphFunction::ToFloat + | GraphFunction::Abs + | GraphFunction::Floor + | GraphFunction::Ceil + | GraphFunction::Round + | GraphFunction::Lower + | GraphFunction::Upper + | GraphFunction::Trim + | GraphFunction::Size + | GraphFunction::Head + | GraphFunction::Last => arg_count == 1, + _ => false, + }; + if valid { + return Ok(()); + } + let expected = match name { + GraphFunction::Coalesce => "at least one argument", + GraphFunction::Substring => "two or three arguments", + _ => "exactly one argument", + }; + Err(EngineError::InvalidOperation(format!( + "graph row function {} expects {expected}", + graph_function_name(name) + ))) +} + +fn is_scalar_graph_function(name: GraphFunction) -> bool { + matches!( + name, + GraphFunction::Coalesce + | GraphFunction::ToString + | GraphFunction::ToInteger + | GraphFunction::ToFloat + | GraphFunction::Abs + | GraphFunction::Floor + | GraphFunction::Ceil + | GraphFunction::Round + | GraphFunction::Lower + | GraphFunction::Upper + | GraphFunction::Trim + | GraphFunction::Substring + | GraphFunction::Size + | GraphFunction::Head + | GraphFunction::Last + ) +} + +fn eval_to_string(value: &GraphEvalValue) -> Result { + Ok(match value { + GraphEvalValue::Null => GraphEvalValue::Null, + GraphEvalValue::Bool(value) => GraphEvalValue::String(value.to_string()), + GraphEvalValue::Int(value) => GraphEvalValue::String(value.to_string()), + GraphEvalValue::UInt(value) => GraphEvalValue::String(value.to_string()), + GraphEvalValue::Float(value) => GraphEvalValue::String( + checked_finite_float(*value, "to_string float input")?.to_string(), + ), + GraphEvalValue::String(value) => GraphEvalValue::String(value.clone()), + _ => { + return Err(EngineError::InvalidOperation( + "graph row function to_string expects a scalar numeric, boolean, string, or null value" + .to_string(), + )); + } + }) +} + +fn eval_to_integer(value: &GraphEvalValue) -> Result { + Ok(match value { + GraphEvalValue::Null => GraphEvalValue::Null, + GraphEvalValue::Int(value) => GraphEvalValue::Int(*value), + GraphEvalValue::UInt(value) => { + GraphEvalValue::Int(i64::try_from(*value).map_err(|_| { + EngineError::InvalidOperation( + "graph row function to_integer cannot represent uint value as int".to_string(), + ) + })?) + } + GraphEvalValue::Float(value) => GraphEvalValue::Int(float_to_i64_checked(*value)?), + GraphEvalValue::String(value) => { + GraphEvalValue::Int(value.parse::().map_err(|_| { + EngineError::InvalidOperation( + "graph row function to_integer expects a base-10 integer string".to_string(), + ) + })?) + } + _ => { + return Err(EngineError::InvalidOperation( + "graph row function to_integer expects numeric, string, or null input".to_string(), + )); + } + }) } -fn eval_graph_and( - left: &GraphExpr, - right: &GraphExpr, - context: &GraphEvalContext<'_>, -) -> Result { - let left_value = bool_or_null(&eval_graph_expr(left, context)?)?; - if left_value == Some(false) { - return Ok(GraphEvalValue::Bool(false)); - } - let right_value = bool_or_null(&eval_graph_expr(right, context)?)?; - Ok(graph_and_truth_value(left_value, right_value)) +fn eval_to_float(value: &GraphEvalValue) -> Result { + Ok(match value { + GraphEvalValue::Null => GraphEvalValue::Null, + GraphEvalValue::Int(value) => { + GraphEvalValue::Float(exact_i64_to_f64(*value, "to_float integer input")?) + } + GraphEvalValue::UInt(value) => { + GraphEvalValue::Float(exact_u64_to_f64(*value, "to_float unsigned integer input")?) + } + GraphEvalValue::Float(value) => { + GraphEvalValue::Float(checked_finite_float(*value, "to_float float input")?) + } + GraphEvalValue::String(value) => GraphEvalValue::Float(checked_finite_float( + value.parse::().map_err(|_| { + EngineError::InvalidOperation( + "graph row function to_float expects a finite float string".to_string(), + ) + })?, + "to_float string result", + )?), + _ => { + return Err(EngineError::InvalidOperation( + "graph row function to_float expects numeric, string, or null input".to_string(), + )); + } + }) } -fn eval_graph_or( - left: &GraphExpr, - right: &GraphExpr, - context: &GraphEvalContext<'_>, -) -> Result { - let left_value = bool_or_null(&eval_graph_expr(left, context)?)?; - if left_value == Some(true) { - return Ok(GraphEvalValue::Bool(true)); +fn float_to_i64_checked(value: f64) -> Result { + let value = checked_finite_float(value, "to_integer float input")?; + if value.fract() != 0.0 || value < i64::MIN as f64 || value >= 9_223_372_036_854_775_808.0 { + return Err(EngineError::InvalidOperation( + "graph row function to_integer expects an integral float in i64 range".to_string(), + )); } - let right_value = bool_or_null(&eval_graph_expr(right, context)?)?; - Ok(graph_or_truth_value(left_value, right_value)) + Ok(value as i64) } -fn graph_and_truth_value(left: Option, right: Option) -> GraphEvalValue { - match (left, right) { - (_, Some(false)) => GraphEvalValue::Bool(false), - (Some(true), Some(true)) => GraphEvalValue::Bool(true), - _ => GraphEvalValue::Null, +fn eval_numeric_abs(value: &GraphEvalValue) -> Result { + let Some(value) = numeric_operand(value)? else { + return Ok(GraphEvalValue::Null); + }; + match value { + NumericEvalOperand::Int(value) => value + .checked_abs() + .map(GraphEvalValue::Int) + .ok_or_else(|| integer_overflow_error("absolute value")), + NumericEvalOperand::UInt(value) => Ok(GraphEvalValue::UInt(value)), + NumericEvalOperand::Float(value) => { + checked_finite_float(value.abs(), "graph row abs result").map(GraphEvalValue::Float) + } } } -fn graph_or_truth_value(left: Option, right: Option) -> GraphEvalValue { - match (left, right) { - (_, Some(true)) => GraphEvalValue::Bool(true), - (Some(false), Some(false)) => GraphEvalValue::Bool(false), - _ => GraphEvalValue::Null, +fn eval_numeric_rounding( + name: GraphFunction, + value: &GraphEvalValue, +) -> Result { + let Some(value) = numeric_operand(value)? else { + return Ok(GraphEvalValue::Null); + }; + match value { + NumericEvalOperand::Int(value) => Ok(GraphEvalValue::Int(value)), + NumericEvalOperand::UInt(value) => Ok(GraphEvalValue::UInt(value)), + NumericEvalOperand::Float(value) => { + let result = match name { + GraphFunction::Floor => value.floor(), + GraphFunction::Ceil => value.ceil(), + GraphFunction::Round => value.round(), + _ => unreachable!("non-rounding function"), + }; + checked_finite_float(result, "graph row numeric rounding result") + .map(GraphEvalValue::Float) + } } } -fn compare_graph_binary_values( - op: GraphBinaryOp, - left: &GraphEvalValue, - right: &GraphEvalValue, +fn eval_string_unary( + name: GraphFunction, + value: &GraphEvalValue, ) -> Result { - if left.is_null() || right.is_null() { + if value.is_null() { return Ok(GraphEvalValue::Null); } - match op { - GraphBinaryOp::Eq | GraphBinaryOp::Neq => { - let equal = graph_values_equal(left, right)?; - Ok(GraphEvalValue::Bool(if op == GraphBinaryOp::Eq { - equal - } else { - !equal - })) - } - GraphBinaryOp::Lt | GraphBinaryOp::Le | GraphBinaryOp::Gt | GraphBinaryOp::Ge => { - let ordering = partial_cmp_graph_values(left, right)?.ok_or_else(|| { - EngineError::InvalidOperation( - "graph row ordering comparison is not supported for these values".to_string(), - ) - })?; - Ok(GraphEvalValue::Bool(match op { - GraphBinaryOp::Lt => ordering == Ordering::Less, - GraphBinaryOp::Le => matches!(ordering, Ordering::Less | Ordering::Equal), - GraphBinaryOp::Gt => ordering == Ordering::Greater, - GraphBinaryOp::Ge => matches!(ordering, Ordering::Greater | Ordering::Equal), - _ => unreachable!(), - })) - } - GraphBinaryOp::And | GraphBinaryOp::Or | GraphBinaryOp::In => unreachable!(), - } + let GraphEvalValue::String(value) = value else { + return Err(EngineError::InvalidOperation(format!( + "graph row function {} expects string or null input", + graph_function_name(name) + ))); + }; + Ok(GraphEvalValue::String(match name { + GraphFunction::Lower => value.to_lowercase(), + GraphFunction::Upper => value.to_uppercase(), + GraphFunction::Trim => value.trim().to_string(), + _ => unreachable!("non-string function"), + })) } -fn eval_graph_in( - left: &GraphEvalValue, - right: &GraphEvalValue, -) -> Result { - if left.is_null() || right.is_null() { +fn eval_substring(args: &[GraphEvalValue]) -> Result { + if args.iter().any(GraphEvalValue::is_null) { return Ok(GraphEvalValue::Null); } - let GraphEvalValue::List(items) = right else { + let GraphEvalValue::String(value) = &args[0] else { return Err(EngineError::InvalidOperation( - "graph row IN requires a list right-hand operand".to_string(), + "graph row function substring expects a string value".to_string(), )); }; - let mut saw_null = false; - for item in items { - if item.is_null() { - saw_null = true; - } else if graph_values_equal(left, item)? { - return Ok(GraphEvalValue::Bool(true)); - } + let start = nonnegative_usize_arg(&args[1], "substring start")?; + let length = args + .get(2) + .map(|value| nonnegative_usize_arg(value, "substring length")) + .transpose()?; + let chars = value.chars().collect::>(); + if start >= chars.len() { + return Ok(GraphEvalValue::String(String::new())); + } + let end = length + .map(|length| start.saturating_add(length).min(chars.len())) + .unwrap_or(chars.len()); + Ok(GraphEvalValue::String(chars[start..end].iter().collect())) +} + +fn nonnegative_usize_arg(value: &GraphEvalValue, context: &str) -> Result { + match value { + GraphEvalValue::Int(value) if *value >= 0 => usize::try_from(*value).map_err(|_| { + EngineError::InvalidOperation(format!("graph row function {context} is out of range")) + }), + GraphEvalValue::UInt(value) => usize::try_from(*value).map_err(|_| { + EngineError::InvalidOperation(format!("graph row function {context} is out of range")) + }), + _ => Err(EngineError::InvalidOperation(format!( + "graph row function {context} expects a non-negative integer" + ))), } - Ok(if saw_null { - GraphEvalValue::Null - } else { - GraphEvalValue::Bool(false) +} + +fn eval_size(value: &GraphEvalValue) -> Result { + Ok(match value { + GraphEvalValue::Null => GraphEvalValue::Null, + GraphEvalValue::String(value) => GraphEvalValue::UInt(value.chars().count() as u64), + GraphEvalValue::List(value) => GraphEvalValue::UInt(value.len() as u64), + GraphEvalValue::Map(value) => GraphEvalValue::UInt(value.len() as u64), + _ => { + return Err(EngineError::InvalidOperation( + "graph row function size expects string, list, map, or null input".to_string(), + )); + } }) } -fn bool_or_null(value: &GraphEvalValue) -> Result, EngineError> { +fn eval_head_or_last( + name: GraphFunction, + value: &GraphEvalValue, +) -> Result { + if value.is_null() { + return Ok(GraphEvalValue::Null); + } + let GraphEvalValue::List(values) = value else { + return Err(EngineError::InvalidOperation(format!( + "graph row function {} expects list or null input", + graph_function_name(name) + ))); + }; + let value = match name { + GraphFunction::Head => values.first().cloned().unwrap_or(GraphEvalValue::Null), + GraphFunction::Last => values.last().cloned().unwrap_or(GraphEvalValue::Null), + _ => unreachable!("non-list endpoint function"), + }; + ensure_graph_eval_scalar_domain(name, &value)?; + Ok(value) +} + +fn ensure_graph_eval_scalar_domain( + name: GraphFunction, + value: &GraphEvalValue, +) -> Result<(), EngineError> { match value { - GraphEvalValue::Bool(value) => Ok(Some(*value)), - GraphEvalValue::Null => Ok(None), - _ => Err(EngineError::InvalidOperation( - "graph row boolean operators require boolean or null operands".to_string(), - )), + GraphEvalValue::Null + | GraphEvalValue::Bool(_) + | GraphEvalValue::Int(_) + | GraphEvalValue::UInt(_) + | GraphEvalValue::String(_) + | GraphEvalValue::Bytes(_) => Ok(()), + GraphEvalValue::Float(value) => { + checked_finite_float(*value, "graph row scalar function result").map(|_| ()) + } + GraphEvalValue::List(values) => { + for value in values { + ensure_graph_eval_scalar_domain(name, value)?; + } + Ok(()) + } + GraphEvalValue::Map(values) => { + for value in values.values() { + ensure_graph_eval_scalar_domain(name, value)?; + } + Ok(()) + } + GraphEvalValue::Node(_) | GraphEvalValue::Edge(_) | GraphEvalValue::Path(_) => Err( + function_input_error(name, "scalar, list, map, or null input"), + ), } } @@ -2509,6 +3521,8 @@ pub(crate) enum GraphSortAtom { nodes: Vec, edges: Vec, }, + List(Vec), + Map(Vec<(String, GraphSortAtom)>), } pub(crate) fn graph_sort_atom_for_value( @@ -2547,6 +3561,26 @@ pub(crate) fn graph_sort_atom_for_value( }) } +pub(crate) fn graph_logical_sort_atom_for_value( + value: &GraphEvalValue, +) -> Result { + Ok(match value { + GraphEvalValue::List(values) => GraphSortAtom::List( + values + .iter() + .map(graph_logical_sort_atom_for_value) + .collect::, _>>()?, + ), + GraphEvalValue::Map(values) => GraphSortAtom::Map( + values + .iter() + .map(|(key, value)| Ok((key.clone(), graph_logical_sort_atom_for_value(value)?))) + .collect::, EngineError>>()?, + ), + _ => graph_sort_atom_for_value(value)?, + }) +} + pub(crate) fn compare_graph_sort_atoms(left: &GraphSortAtom, right: &GraphSortAtom) -> Ordering { match (left, right) { (GraphSortAtom::Null, GraphSortAtom::Null) => Ordering::Equal, @@ -2568,6 +3602,8 @@ fn graph_sort_atom_rank(value: &GraphSortAtom) -> u8 { GraphSortAtom::Node(_) => 4, GraphSortAtom::Edge(_) => 5, GraphSortAtom::Path { .. } => 6, + GraphSortAtom::List(_) => 7, + GraphSortAtom::Map(_) => 8, } } @@ -2594,6 +3630,22 @@ fn graph_sort_atom_payload_cmp(left: &GraphSortAtom, right: &GraphSortAtom) -> O .cmp(right_hops) .then_with(|| left_nodes.cmp(right_nodes)) .then_with(|| left_edges.cmp(right_edges)), + (GraphSortAtom::List(left), GraphSortAtom::List(right)) => left + .iter() + .zip(right.iter()) + .map(|(left, right)| compare_graph_sort_atoms(left, right)) + .find(|ordering| !ordering.is_eq()) + .unwrap_or_else(|| left.len().cmp(&right.len())), + (GraphSortAtom::Map(left), GraphSortAtom::Map(right)) => left + .iter() + .zip(right.iter()) + .map(|((left_key, left_value), (right_key, right_value))| { + left_key + .cmp(right_key) + .then_with(|| compare_graph_sort_atoms(left_value, right_value)) + }) + .find(|ordering| !ordering.is_eq()) + .unwrap_or_else(|| left.len().cmp(&right.len())), _ => Ordering::Equal, } } @@ -2643,11 +3695,13 @@ fn bound_expr_to_output_value( ) -> Result { match expr { BoundGraphExpr::Function { name, args } if args.len() == 1 => { - if let BoundGraphExpr::Binding(slot) = &args[0] { - if slot.kind == GraphBindingSlotKind::Path { - return bound_path_function_to_output_value( - *name, *slot, projection, output, context, - ); + if path_output_graph_function(*name) { + if let BoundGraphExpr::Binding(slot) = &args[0] { + if slot.kind == GraphBindingSlotKind::Path { + return bound_path_function_to_output_value( + *name, *slot, projection, output, context, + ); + } } } } @@ -2717,9 +3771,27 @@ fn bound_path_function_to_output_value( let value = eval_bound_graph_slot_function(name, slot, context)?; graph_eval_to_output_value(&value, projection, output) } + _ => Err(EngineError::InvalidOperation(format!( + "graph row function {} does not produce path output", + graph_function_name(name) + ))), } } +fn path_output_graph_function(name: GraphFunction) -> bool { + matches!( + name, + GraphFunction::StartNode + | GraphFunction::EndNode + | GraphFunction::Nodes + | GraphFunction::Relationships + | GraphFunction::Length + | GraphFunction::Id + | GraphFunction::Labels + | GraphFunction::Type + ) +} + fn graph_bound_node_to_output_value( node: &GraphBoundNode, projection: &GraphReturnProjection, @@ -2736,7 +3808,7 @@ fn graph_bound_edge_to_output_value( graph_eval_to_output_value(&GraphEvalValue::Edge(edge.clone()), projection, output) } -fn graph_eval_to_output_value( +pub(crate) fn graph_eval_to_output_value( value: &GraphEvalValue, projection: &GraphReturnProjection, output: &GraphOutputOptions, @@ -3616,6 +4688,13 @@ fn collect_expr_output_needs( GraphExpr::Function { name, args } => { collect_function_output_needs(schema, *name, args, projection, output, needs) } + GraphExpr::AggregateCall { arg, .. } => { + if let Some(arg) = arg { + collect_expr_output_needs(schema, arg, projection, output, needs)?; + } + Ok(()) + } + GraphExpr::ExistsSubquery(_) => Ok(()), GraphExpr::List(items) => { for item in items { collect_expr_output_needs(schema, item, projection, output, needs)?; @@ -3628,6 +4707,19 @@ fn collect_expr_output_needs( } Ok(()) } + GraphExpr::Case { + branches, + else_expr, + .. + } => { + for branch in branches { + collect_expr_output_needs(schema, &branch.then, projection, output, needs)?; + } + if let Some(else_expr) = else_expr { + collect_expr_output_needs(schema, else_expr, projection, output, needs)?; + } + Ok(()) + } GraphExpr::Unary { .. } | GraphExpr::Binary { .. } | GraphExpr::IsNull(_) @@ -3688,6 +4780,7 @@ fn collect_function_output_needs( GraphFunction::Id | GraphFunction::Labels | GraphFunction::Type | GraphFunction::Length => { Ok(()) } + _ => Ok(()), } } @@ -3935,6 +5028,12 @@ fn collect_expr_projection_needs( } collect_function_projection_needs(*name, args, needs, need_class)?; } + GraphExpr::AggregateCall { arg, .. } => { + if let Some(arg) = arg { + collect_expr_projection_needs(arg, schema, needs, need_class)?; + } + } + GraphExpr::ExistsSubquery(_) => {} GraphExpr::Unary { expr, .. } | GraphExpr::IsNull(expr) | GraphExpr::IsNotNull(expr) => { collect_expr_projection_needs(expr, schema, needs, need_class)? } @@ -3942,6 +5041,22 @@ fn collect_expr_projection_needs( collect_expr_projection_needs(left, schema, needs, need_class)?; collect_expr_projection_needs(right, schema, needs, need_class)?; } + GraphExpr::Case { + operand, + branches, + else_expr, + } => { + if let Some(operand) = operand { + collect_expr_projection_needs(operand, schema, needs, need_class)?; + } + for branch in branches { + collect_expr_projection_needs(&branch.when, schema, needs, need_class)?; + collect_expr_projection_needs(&branch.then, schema, needs, need_class)?; + } + if let Some(else_expr) = else_expr { + collect_expr_projection_needs(else_expr, schema, needs, need_class)?; + } + } GraphExpr::List(items) => { for item in items { collect_expr_projection_needs(item, schema, needs, need_class)?; @@ -4015,6 +5130,7 @@ fn collect_function_projection_needs( GraphFunction::Nodes => merge_path_node_need(alias, None, needs, need_class)?, GraphFunction::Relationships => merge_path_edge_need(alias, None, needs, need_class)?, GraphFunction::Id | GraphFunction::Length => {} + _ => {} } return Ok(()); } @@ -4047,6 +5163,7 @@ fn collect_function_projection_needs( | GraphFunction::EndNode | GraphFunction::Nodes | GraphFunction::Relationships => {} + _ => {} } Ok(()) } @@ -4251,7 +5368,7 @@ fn path_needs_from_element( } } -fn node_source_needs_from_element( +pub(crate) fn node_source_needs_from_element( projection: GraphElementProjection, include_vectors: bool, ) -> Option { @@ -4263,7 +5380,7 @@ fn node_source_needs_from_element( } } -fn edge_source_needs_from_element( +pub(crate) fn edge_source_needs_from_element( projection: GraphElementProjection, ) -> Option { match projection { @@ -4274,7 +5391,7 @@ fn edge_source_needs_from_element( } } -fn path_source_needs_from_element( +pub(crate) fn path_source_needs_from_element( projection: GraphElementProjection, include_vectors: bool, ) -> Option { @@ -4295,7 +5412,7 @@ fn node_needs_from_selected(selected: &GraphSelectedNodeProjection) -> NodeSelec } } -fn node_source_needs_from_selected( +pub(crate) fn node_source_needs_from_selected( selected: &GraphSelectedNodeProjection, ) -> Option { if selected.labels @@ -4319,7 +5436,7 @@ fn edge_needs_from_selected(selected: &GraphSelectedEdgeProjection) -> EdgeSelec } } -fn edge_source_needs_from_selected( +pub(crate) fn edge_source_needs_from_selected( selected: &GraphSelectedEdgeProjection, ) -> Option { if selected.from @@ -4348,7 +5465,7 @@ fn path_needs_from_selected(selected: &GraphSelectedPathProjection) -> PathSelec } } -fn path_source_needs_from_selected( +pub(crate) fn path_source_needs_from_selected( selected: &GraphSelectedPathProjection, ) -> Option { let nodes = selected @@ -4628,6 +5745,21 @@ fn graph_function_name(name: GraphFunction) -> &'static str { GraphFunction::EndNode => "end_node", GraphFunction::Nodes => "nodes", GraphFunction::Relationships => "relationships", + GraphFunction::Coalesce => "coalesce", + GraphFunction::ToString => "to_string", + GraphFunction::ToInteger => "to_integer", + GraphFunction::ToFloat => "to_float", + GraphFunction::Abs => "abs", + GraphFunction::Floor => "floor", + GraphFunction::Ceil => "ceil", + GraphFunction::Round => "round", + GraphFunction::Lower => "lower", + GraphFunction::Upper => "upper", + GraphFunction::Trim => "trim", + GraphFunction::Substring => "substring", + GraphFunction::Size => "size", + GraphFunction::Head => "head", + GraphFunction::Last => "last", } } @@ -4651,3 +5783,413 @@ impl GraphVectorSelectionExt for GraphVectorSelection { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{GraphCaseBranch, GraphExpr}; + + fn eval(expr: GraphExpr) -> Result { + let schema = GraphBindingSchema::new(); + let row = schema.empty_row(); + let params = BTreeMap::new(); + eval_graph_expr( + &expr, + &GraphEvalContext { + schema: &schema, + row: &row, + params: ¶ms, + }, + ) + } + + fn binary(op: GraphBinaryOp, left: GraphExpr, right: GraphExpr) -> GraphExpr { + GraphExpr::Binary { + left: Box::new(left), + op, + right: Box::new(right), + } + } + + fn function(name: GraphFunction, args: Vec) -> GraphExpr { + GraphExpr::Function { name, args } + } + + #[test] + fn canonical_keys_cover_scalar_numeric_and_nested_domains() { + let int_key = graph_canonical_key_for_value(&GraphEvalValue::Int(1)).unwrap(); + let uint_key = graph_canonical_key_for_value(&GraphEvalValue::UInt(1)).unwrap(); + let float_key = graph_canonical_key_for_value(&GraphEvalValue::Float(1.0)).unwrap(); + assert_eq!(int_key, uint_key); + assert_eq!(uint_key, float_key); + + assert_eq!( + graph_canonical_key_for_value(&GraphEvalValue::List(vec![ + GraphEvalValue::Null, + GraphEvalValue::Bool(true), + GraphEvalValue::String("a".to_string()), + GraphEvalValue::Bytes(vec![1, 2]), + ])) + .unwrap(), + GraphCanonicalKey::List(vec![ + GraphCanonicalKey::Null, + GraphCanonicalKey::Bool(true), + GraphCanonicalKey::String(b"a".to_vec()), + GraphCanonicalKey::Bytes(vec![1, 2]), + ]) + ); + assert_eq!( + graph_canonical_key_for_value(&GraphEvalValue::Map(BTreeMap::from([ + ("z".to_string(), GraphEvalValue::UInt(2)), + ("a".to_string(), GraphEvalValue::String("x".to_string())), + ]))) + .unwrap(), + GraphCanonicalKey::Map(vec![ + ("a".to_string(), GraphCanonicalKey::String(b"x".to_vec())), + ( + "z".to_string(), + GraphCanonicalKey::Number(numeric_range_sort_key(numeric_key_from_u64(2))) + ), + ]) + ); + assert!(graph_canonical_key_for_value(&GraphEvalValue::Float(f64::NAN)).is_err()); + } + + #[test] + fn canonical_keys_use_graph_identity_without_hydrated_elements() { + let path = GraphBoundPath::id_only(GraphPath { + nodes: vec![1, 2], + edges: vec![9], + }) + .unwrap(); + assert_eq!( + graph_canonical_key_for_value(&GraphEvalValue::Node(GraphBoundNode::id_only(7))) + .unwrap(), + GraphCanonicalKey::Node(7) + ); + assert_eq!( + graph_canonical_key_for_value(&GraphEvalValue::Edge(GraphBoundEdge::id_only(8))) + .unwrap(), + GraphCanonicalKey::Edge(8) + ); + assert_eq!( + graph_canonical_key_for_value(&GraphEvalValue::Path(path)).unwrap(), + GraphCanonicalKey::Path { + nodes: vec![1, 2], + edges: vec![9], + } + ); + + let mut schema = GraphBindingSchema::new(); + let n = schema.add_node_alias("n", false).unwrap(); + let e = schema.add_edge_alias("e", false).unwrap(); + let v = schema.add_scalar_alias("v", false).unwrap(); + let mut row = schema.empty_row(); + row.bind_node(n, GraphBoundNode::id_only(7)).unwrap(); + row.bind_edge(e, GraphBoundEdge::id_only(8)).unwrap(); + row.bind_scalar(v, GraphEvalValue::Int(1)).unwrap(); + assert_eq!( + graph_canonical_key_for_row_slots(&row, &[n, e, v]).unwrap(), + vec![ + GraphCanonicalKey::Node(7), + GraphCanonicalKey::Edge(8), + GraphCanonicalKey::Number(numeric_range_sort_key(numeric_key_from_i64(1))), + ] + ); + } + + #[test] + fn rich_graph_expr_checked_numeric_arithmetic() { + assert_eq!( + eval(binary( + GraphBinaryOp::Add, + GraphExpr::Int(1), + GraphExpr::UInt(2) + )) + .unwrap(), + GraphEvalValue::Int(3) + ); + assert_eq!( + eval(binary( + GraphBinaryOp::Div, + GraphExpr::UInt(7), + GraphExpr::Int(2) + )) + .unwrap(), + GraphEvalValue::Float(3.5) + ); + assert_eq!( + eval(GraphExpr::Unary { + op: GraphUnaryOp::Neg, + expr: Box::new(GraphExpr::UInt((i64::MAX as u64) + 1)), + }) + .unwrap(), + GraphEvalValue::Int(i64::MIN) + ); + assert_eq!( + eval(GraphExpr::Function { + name: GraphFunction::ToFloat, + args: vec![GraphExpr::UInt(9_007_199_254_740_992)], + }) + .unwrap(), + GraphEvalValue::Float(9_007_199_254_740_992.0) + ); + assert!(eval(GraphExpr::Function { + name: GraphFunction::ToFloat, + args: vec![GraphExpr::UInt(9_007_199_254_740_993)], + }) + .is_err()); + assert!(eval(binary( + GraphBinaryOp::Add, + GraphExpr::UInt(u64::MAX), + GraphExpr::Float(1.0) + )) + .is_err()); + assert!(eval(binary( + GraphBinaryOp::Add, + GraphExpr::Int(i64::MAX), + GraphExpr::Int(1) + )) + .is_err()); + assert!(eval(binary( + GraphBinaryOp::Mul, + GraphExpr::UInt(u64::MAX), + GraphExpr::UInt(2) + )) + .is_err()); + assert!(eval(binary( + GraphBinaryOp::Div, + GraphExpr::Int(1), + GraphExpr::Int(0) + )) + .is_err()); + assert!(eval(GraphExpr::Unary { + op: GraphUnaryOp::Neg, + expr: Box::new(GraphExpr::UInt((i64::MAX as u64) + 2)), + }) + .is_err()); + assert!(eval(binary( + GraphBinaryOp::Add, + GraphExpr::Float(f64::INFINITY), + GraphExpr::Float(1.0) + )) + .is_err()); + assert!(eval(binary( + GraphBinaryOp::Mul, + GraphExpr::Float(f64::MAX), + GraphExpr::Float(2.0) + )) + .is_err()); + } + + #[test] + fn rich_graph_expr_string_predicates_and_nulls() { + assert_eq!( + eval(binary( + GraphBinaryOp::StartsWith, + GraphExpr::String("Ada".to_string()), + GraphExpr::String("A".to_string()) + )) + .unwrap(), + GraphEvalValue::Bool(true) + ); + assert_eq!( + eval(binary( + GraphBinaryOp::EndsWith, + GraphExpr::String("Ada".to_string()), + GraphExpr::String("z".to_string()) + )) + .unwrap(), + GraphEvalValue::Bool(false) + ); + assert_eq!( + eval(binary( + GraphBinaryOp::Contains, + GraphExpr::Null, + GraphExpr::String("d".to_string()) + )) + .unwrap(), + GraphEvalValue::Null + ); + assert!(eval(binary( + GraphBinaryOp::Contains, + GraphExpr::Int(1), + GraphExpr::String("d".to_string()) + )) + .is_err()); + } + + #[test] + fn rich_graph_expr_case_semantics() { + let generic = GraphExpr::Case { + operand: None, + branches: vec![ + GraphCaseBranch { + when: GraphExpr::Bool(false), + then: GraphExpr::String("no".to_string()), + }, + GraphCaseBranch { + when: GraphExpr::Bool(true), + then: GraphExpr::String("yes".to_string()), + }, + ], + else_expr: Some(Box::new(GraphExpr::String("else".to_string()))), + }; + assert_eq!( + eval(generic).unwrap(), + GraphEvalValue::String("yes".to_string()) + ); + + let simple = GraphExpr::Case { + operand: Some(Box::new(GraphExpr::String("b".to_string()))), + branches: vec![GraphCaseBranch { + when: GraphExpr::String("a".to_string()), + then: GraphExpr::Int(1), + }], + else_expr: None, + }; + assert_eq!(eval(simple).unwrap(), GraphEvalValue::Null); + } + + #[test] + fn rich_graph_expr_scalar_functions() { + assert_eq!( + eval(function( + GraphFunction::Coalesce, + vec![GraphExpr::Null, GraphExpr::String("x".to_string())] + )) + .unwrap(), + GraphEvalValue::String("x".to_string()) + ); + assert_eq!( + eval(function( + GraphFunction::ToInteger, + vec![GraphExpr::String("42".to_string())] + )) + .unwrap(), + GraphEvalValue::Int(42) + ); + assert_eq!( + eval(function(GraphFunction::Abs, vec![GraphExpr::Int(-7)])).unwrap(), + GraphEvalValue::Int(7) + ); + assert_eq!( + eval(function( + GraphFunction::Substring, + vec![ + GraphExpr::String("abcdef".to_string()), + GraphExpr::Int(1), + GraphExpr::Int(3), + ], + )) + .unwrap(), + GraphEvalValue::String("bcd".to_string()) + ); + assert_eq!( + eval(function( + GraphFunction::Size, + vec![GraphExpr::Map(BTreeMap::from([( + "a".to_string(), + GraphExpr::Int(1), + )]))], + )) + .unwrap(), + GraphEvalValue::UInt(1) + ); + assert_eq!( + eval(function( + GraphFunction::Head, + vec![GraphExpr::List(vec![GraphExpr::Int(1), GraphExpr::Int(2)])], + )) + .unwrap(), + GraphEvalValue::Int(1) + ); + assert_eq!( + eval(function( + GraphFunction::Last, + vec![GraphExpr::List(vec![]),] + )) + .unwrap(), + GraphEvalValue::Null + ); + } + + #[test] + fn rich_graph_expr_value_passing_scalar_functions_reject_graph_elements() { + let mut schema = GraphBindingSchema::new(); + let node = schema.add_node_alias("n", false).unwrap(); + let mut row = schema.empty_row(); + row.bind_node(node, GraphBoundNode::id_only(7)).unwrap(); + let params = BTreeMap::new(); + let eval_with_node = |expr: GraphExpr| { + eval_graph_expr( + &expr, + &GraphEvalContext { + schema: &schema, + row: &row, + params: ¶ms, + }, + ) + }; + + let element_case = GraphExpr::Case { + operand: None, + branches: vec![GraphCaseBranch { + when: GraphExpr::Bool(true), + then: GraphExpr::Binding("n".to_string()), + }], + else_expr: Some(Box::new(GraphExpr::String("fallback".to_string()))), + }; + assert!(eval_with_node(function( + GraphFunction::Coalesce, + vec![GraphExpr::Null, element_case] + )) + .unwrap_err() + .to_string() + .contains("coalesce expects scalar, list, map, or null input")); + + assert!(eval_with_node(function( + GraphFunction::Coalesce, + vec![GraphExpr::Map(BTreeMap::from([( + "n".to_string(), + GraphExpr::Binding("n".to_string()), + )]))] + )) + .is_err()); + + assert!(eval_with_node(function( + GraphFunction::Head, + vec![GraphExpr::List(vec![GraphExpr::Binding("n".to_string())])] + )) + .unwrap_err() + .to_string() + .contains("head expects scalar, list, map, or null input")); + + assert!(eval_with_node(function( + GraphFunction::Coalesce, + vec![GraphExpr::Float(f64::NAN), GraphExpr::Int(1)] + )) + .unwrap_err() + .to_string() + .contains("scalar function result must be finite")); + + assert!(eval_with_node(function( + GraphFunction::Head, + vec![GraphExpr::List(vec![GraphExpr::Float(f64::INFINITY)])] + )) + .unwrap_err() + .to_string() + .contains("scalar function result must be finite")); + + assert!(eval_with_node(function( + GraphFunction::Coalesce, + vec![GraphExpr::Map(BTreeMap::from([( + "bad".to_string(), + GraphExpr::Float(f64::NEG_INFINITY), + )]))] + )) + .unwrap_err() + .to_string() + .contains("scalar function result must be finite")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9763d8a..1c01ff2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -92,31 +92,35 @@ pub use types::{ GqlMutationOperationExplain, GqlMutationReadPrefixExplain, GqlMutationReturnExplain, GqlMutationStats, GqlNode, GqlParamValue, GqlParams, GqlRow, GqlRowOperation, GqlSemanticErrorCode, GqlStatementKind, GqlValue, GraphBinaryOp, GraphCapExplain, - GraphCursorExplain, GraphEdgeField, GraphEdgePattern, GraphEdgeValue, GraphElementProjection, - GraphExecutionSummaries, GraphExplainNode, GraphExpr, GraphFunction, GraphNodeField, - GraphNodePattern, GraphNodeValue, GraphOptionalGroup, GraphOrderDirection, GraphOrderExplain, - GraphOrderItem, GraphOutputMode, GraphOutputOptions, GraphPageRequest, GraphParamValue, - GraphPatch, GraphPath, GraphPathField, GraphPathValue, GraphPatternPiece, - GraphProjectionExplain, GraphPropertySelection, GraphQueryOptions, GraphReturnItem, - GraphReturnProjection, GraphRow, GraphRowExplain, GraphRowOperationExplain, GraphRowQuery, - GraphRowResult, GraphRowStats, GraphSelectedEdgeProjection, GraphSelectedNodeProjection, - GraphSelectedPathProjection, GraphSelectedProjection, GraphUnaryOp, GraphValue, - GraphVariableLengthPattern, GraphVectorSelection, HnswConfig, IntoNodeLabels, - IsConnectedOptions, LabelMatchMode, ManifestState, NeighborEntry, NeighborOptions, - NodeFilterExpr, NodeIdBuildHasher, NodeIdHasher, NodeIdMap, NodeIdSet, NodeInput, NodeKeyQuery, - NodeLabelFilter, NodeLabelInfo, NodePropertyIndexInfo, NodeQuery, NodeQueryOrder, NodeView, - PageRequest, PageResult, PatchResult, PprAlgorithm, PprApproxMeta, PprOptions, PprResult, - PropValue, PropertyRangeBound, PropertyRangeCursor, PropertyRangePageRequest, - PropertyRangePageResult, PrunePolicy, PrunePolicyInfo, PruneResult, QueryEdgeIdsResult, - QueryEdgesResult, QueryNodeIdsResult, QueryNodesResult, QueryPlan, QueryPlanKind, - QueryPlanNode, QueryPlanNote, QueryPlanPublicInputs, QueryPlanPublicName, QueryPlanWarning, - ScoringMode, ScrubFindingType, ScrubReport, SecondaryIndexKind, SecondaryIndexManifestEntry, - SecondaryIndexState, SecondaryIndexTarget, SegmentInfo, SegmentScrubResult, ShortestPath, - ShortestPathOptions, SourceSpan, SparseVector, Subgraph, SubgraphOptions, TombstoneEntry, - TopKOptions, TraversalCursor, TraversalHit, TraversalPageResult, TraverseOptions, - TxnCommitResult, TxnEdgeRef, TxnEdgeView, TxnIntent, TxnLocalRef, TxnNodeRef, TxnNodeView, - UpsertEdgeOptions, UpsertNodeOptions, VectorHit, VectorSearchMode, VectorSearchRequest, - VectorSearchScope, WalSyncMode, DEFAULT_DENSE_EF_SEARCH, + GraphCaseBranch, GraphCursorExplain, GraphEdgeField, GraphEdgePattern, GraphEdgeValue, + GraphElementProjection, GraphExecutionSummaries, GraphExplainNode, GraphExpr, GraphFunction, + GraphNodeField, GraphNodePattern, GraphNodeValue, GraphOptionalGroup, GraphOrderDirection, + GraphOrderExplain, GraphOrderItem, GraphOutputMode, GraphOutputOptions, GraphPageRequest, + GraphParamValue, GraphPatch, GraphPath, GraphPathField, GraphPathValue, GraphPatternPiece, + GraphPipelineCapExplain, GraphPipelineExplain, GraphPipelineMatchStage, GraphPipelineOptions, + GraphPipelineQuery, GraphPipelineResult, GraphPipelineStage, GraphPipelineStageExplain, + GraphPipelineStats, GraphProjectItem, GraphProjectKind, GraphProjectStage, + GraphProjectionExplain, GraphProjectionItems, GraphPropertySelection, GraphQueryOptions, + GraphReturnItem, GraphReturnProjection, GraphRow, GraphRowExplain, GraphRowOperationExplain, + GraphRowQuery, GraphRowResult, GraphRowStats, GraphSelectedEdgeProjection, + GraphSelectedNodeProjection, GraphSelectedPathProjection, GraphSelectedProjection, + GraphShortestPathEndpoint, GraphShortestPathMode, GraphShortestPathStage, GraphSubqueryStage, + GraphUnaryOp, GraphUnionStage, GraphValue, GraphVariableLengthPattern, GraphVectorSelection, + HnswConfig, IntoNodeLabels, IsConnectedOptions, LabelMatchMode, ManifestState, NeighborEntry, + NeighborOptions, NodeFilterExpr, NodeIdBuildHasher, NodeIdHasher, NodeIdMap, NodeIdSet, + NodeInput, NodeKeyQuery, NodeLabelFilter, NodeLabelInfo, NodePropertyIndexInfo, NodeQuery, + NodeQueryOrder, NodeView, PageRequest, PageResult, PatchResult, PprAlgorithm, PprApproxMeta, + PprOptions, PprResult, PropValue, PropertyRangeBound, PropertyRangeCursor, + PropertyRangePageRequest, PropertyRangePageResult, PrunePolicy, PrunePolicyInfo, PruneResult, + QueryEdgeIdsResult, QueryEdgesResult, QueryNodeIdsResult, QueryNodesResult, QueryPlan, + QueryPlanKind, QueryPlanNode, QueryPlanNote, QueryPlanPublicInputs, QueryPlanPublicName, + QueryPlanWarning, ScoringMode, ScrubFindingType, ScrubReport, SecondaryIndexKind, + SecondaryIndexManifestEntry, SecondaryIndexState, SecondaryIndexTarget, SegmentInfo, + SegmentScrubResult, ShortestPath, ShortestPathOptions, SourceSpan, SparseVector, Subgraph, + SubgraphOptions, TombstoneEntry, TopKOptions, TraversalCursor, TraversalHit, + TraversalPageResult, TraverseOptions, TxnCommitResult, TxnEdgeRef, TxnEdgeView, TxnIntent, + TxnLocalRef, TxnNodeRef, TxnNodeView, UpsertEdgeOptions, UpsertNodeOptions, VectorHit, + VectorSearchMode, VectorSearchRequest, VectorSearchScope, WalSyncMode, DEFAULT_DENSE_EF_SEARCH, }; #[doc(hidden)] diff --git a/src/property_value_semantics.rs b/src/property_value_semantics.rs index 6f7b3e1..a8843e2 100644 --- a/src/property_value_semantics.rs +++ b/src/property_value_semantics.rs @@ -214,6 +214,36 @@ pub(crate) fn numeric_key_from_u64(value: u64) -> NumericScalarKey { } } +pub(crate) fn exact_i64_to_f64(value: i64, context: &str) -> Result { + let converted = value as f64; + let converted_key = numeric_key_from_f64(converted).ok_or_else(|| { + EngineError::InvalidOperation(format!("{context} produced a non-finite float")) + })?; + let exact_key = numeric_key_from_i64(value); + if converted_key == exact_key { + Ok(converted) + } else { + Err(EngineError::InvalidOperation(format!( + "{context} cannot represent integer value exactly as float" + ))) + } +} + +pub(crate) fn exact_u64_to_f64(value: u64, context: &str) -> Result { + let converted = value as f64; + let converted_key = numeric_key_from_f64(converted).ok_or_else(|| { + EngineError::InvalidOperation(format!("{context} produced a non-finite float")) + })?; + let exact_key = numeric_key_from_u64(value); + if converted_key == exact_key { + Ok(converted) + } else { + Err(EngineError::InvalidOperation(format!( + "{context} cannot represent unsigned integer value exactly as float" + ))) + } +} + pub(crate) fn numeric_key_from_f64(value: f64) -> Option { if !value.is_finite() { return None; @@ -692,6 +722,20 @@ mod tests { ); } + #[test] + fn exact_integer_to_float_rejects_rounded_values() { + assert_eq!( + exact_u64_to_f64(9_007_199_254_740_992, "test").unwrap(), + 9_007_199_254_740_992.0 + ); + assert!(exact_u64_to_f64(9_007_199_254_740_993, "test").is_err()); + assert_eq!( + exact_i64_to_f64(i64::MIN, "test").unwrap(), + -9_223_372_036_854_775_808.0 + ); + assert!(exact_i64_to_f64(i64::MAX, "test").is_err()); + } + #[test] fn numeric_key_signed_unsigned_boundaries() { assert_eq!( diff --git a/src/row_projection.rs b/src/row_projection.rs index 54767b7..17dec31 100644 --- a/src/row_projection.rs +++ b/src/row_projection.rs @@ -913,7 +913,7 @@ impl NodeSelectedFieldNeeds { Ok(()) } - fn merge_from( + pub(crate) fn merge_from( &mut self, other: &Self, need_class: ProjectionNeedClass, @@ -940,7 +940,7 @@ impl EdgeSelectedFieldNeeds { Ok(()) } - fn merge_from( + pub(crate) fn merge_from( &mut self, other: &Self, need_class: ProjectionNeedClass, @@ -980,7 +980,7 @@ impl PathSelectedFieldNeeds { Ok(()) } - fn merge_from( + pub(crate) fn merge_from( &mut self, other: &Self, need_class: ProjectionNeedClass, diff --git a/src/types.rs b/src/types.rs index 16f43f7..6742bfc 100644 --- a/src/types.rs +++ b/src/types.rs @@ -65,6 +65,13 @@ pub struct GqlExecutionOptions { pub max_cursor_bytes: usize, pub max_mutation_rows: usize, pub max_mutation_ops: usize, + pub max_pipeline_rows: usize, + pub max_groups: usize, + pub max_collect_items: usize, + pub max_union_branches: usize, + pub max_subquery_invocations: usize, + pub max_subquery_depth: usize, + pub max_shortest_path_pairs: usize, pub max_query_bytes: usize, pub max_param_bytes: usize, pub max_ast_depth: usize, @@ -91,6 +98,13 @@ impl Default for GqlExecutionOptions { max_cursor_bytes: 16 * 1024, max_mutation_rows: 10_000, max_mutation_ops: 50_000, + max_pipeline_rows: 65_536, + max_groups: 65_536, + max_collect_items: 65_536, + max_union_branches: 16, + max_subquery_invocations: 4_096, + max_subquery_depth: 2, + max_shortest_path_pairs: 4_096, max_query_bytes: 1_048_576, max_param_bytes: 1_048_576, max_ast_depth: 256, @@ -227,6 +241,7 @@ pub enum GqlLoweringTarget { NodeQuery, EdgeQuery, GraphRowQuery, + GraphPipelineQuery, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -306,6 +321,13 @@ pub struct GqlExecutionCapSummary { pub max_cursor_bytes: usize, pub max_mutation_rows: usize, pub max_mutation_ops: usize, + pub max_pipeline_rows: usize, + pub max_groups: usize, + pub max_collect_items: usize, + pub max_union_branches: usize, + pub max_subquery_invocations: usize, + pub max_subquery_depth: usize, + pub max_shortest_path_pairs: usize, pub max_query_bytes: usize, pub max_param_bytes: usize, pub max_ast_depth: usize, @@ -1502,6 +1524,164 @@ pub struct GraphRowQuery { pub options: GraphQueryOptions, } +/// Public structured graph pipeline query request. +#[derive(Clone, Debug, PartialEq)] +pub struct GraphPipelineQuery { + pub stages: Vec, + pub params: BTreeMap, + pub at_epoch: Option, + pub page: GraphPageRequest, + pub output: GraphOutputOptions, + pub options: GraphPipelineOptions, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum GraphPipelineStage { + Match(GraphPipelineMatchStage), + Project(GraphProjectStage), + ShortestPath(GraphShortestPathStage), + Call(GraphSubqueryStage), + Union(GraphUnionStage), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GraphPipelineMatchStage { + pub optional: bool, + pub nodes: Vec, + pub pieces: Vec, + pub where_: Option, + /// Optional-match candidate predicate evaluated before left-outer null extension. + pub optional_candidate_where: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GraphProjectStage { + pub kind: GraphProjectKind, + pub items: GraphProjectionItems, + pub distinct: bool, + pub where_: Option, + pub order_by: Vec, + pub skip: Option, + pub limit: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GraphProjectKind { + With, + Return, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum GraphProjectionItems { + Star, + Items(Vec), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GraphProjectItem { + pub expr: GraphExpr, + pub alias: Option, + pub projection: GraphReturnProjection, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GraphUnionStage { + pub branches: Vec, + pub all: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GraphSubqueryStage { + pub query: Box, + pub import_aliases: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GraphShortestPathStage { + pub optional: bool, + pub output_path_alias: String, + pub mode: GraphShortestPathMode, + pub from: GraphShortestPathEndpoint, + pub to: GraphShortestPathEndpoint, + pub direction: Direction, + pub edge_label_filter: Vec, + pub min_hops: u8, + pub max_hops: u8, + pub weight_field: Option, + pub max_cost: Option, + pub max_paths: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GraphShortestPathMode { + One, + All, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum GraphShortestPathEndpoint { + Alias(String), + NodeId(u64), + NodeKey { label: String, key: String }, + Expr(GraphExpr), +} + +/// Graph pipeline validation, safety, and explain options. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GraphPipelineOptions { + pub allow_full_scan: bool, + pub max_rows: usize, + pub max_pipeline_rows: usize, + pub max_groups: usize, + pub max_collect_items: usize, + pub max_union_branches: usize, + pub max_subquery_invocations: usize, + pub max_subquery_depth: usize, + pub max_shortest_path_pairs: usize, + pub max_intermediate_bindings: usize, + pub max_frontier: usize, + pub max_path_hops: u8, + pub max_paths_per_start: usize, + pub max_order_materialization: usize, + pub max_skip: usize, + pub max_cursor_bytes: usize, + pub max_query_bytes: usize, + pub max_param_bytes: usize, + pub max_ast_depth: usize, + pub max_literal_items: usize, + pub include_plan: bool, + pub profile: bool, +} + +impl Default for GraphPipelineOptions { + fn default() -> Self { + Self { + allow_full_scan: false, + max_rows: 10_000, + max_pipeline_rows: 65_536, + max_groups: 65_536, + max_collect_items: 65_536, + max_union_branches: 16, + max_subquery_invocations: 4_096, + max_subquery_depth: 2, + max_shortest_path_pairs: 4_096, + max_intermediate_bindings: 65_536, + max_frontier: 65_536, + max_path_hops: 16, + max_paths_per_start: 4_096, + max_order_materialization: 65_536, + max_skip: 100_000, + max_cursor_bytes: 16 * 1024, + max_query_bytes: 1_048_576, + max_param_bytes: 1_048_576, + max_ast_depth: 256, + max_literal_items: 10_000, + include_plan: false, + profile: false, + } + } +} + /// Parameter value accepted by graph-row requests. #[derive(Clone, Debug, PartialEq)] pub enum GraphParamValue { @@ -1600,6 +1780,12 @@ pub enum GraphExpr { name: GraphFunction, args: Vec, }, + AggregateCall { + function: GraphAggregateFunction, + distinct: bool, + arg: Option>, + }, + ExistsSubquery(GraphSubqueryStage), Unary { op: GraphUnaryOp, expr: Box, @@ -1609,10 +1795,21 @@ pub enum GraphExpr { op: GraphBinaryOp, right: Box, }, + Case { + operand: Option>, + branches: Vec, + else_expr: Option>, + }, IsNull(Box), IsNotNull(Box), } +#[derive(Clone, Debug, PartialEq)] +pub struct GraphCaseBranch { + pub when: GraphExpr, + pub then: GraphExpr, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum GraphNodeField { Id, @@ -1653,11 +1850,37 @@ pub enum GraphFunction { EndNode, Nodes, Relationships, + Coalesce, + ToString, + ToInteger, + ToFloat, + Abs, + Floor, + Ceil, + Round, + Lower, + Upper, + Trim, + Substring, + Size, + Head, + Last, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum GraphAggregateFunction { + Count, + Sum, + Avg, + Min, + Max, + Collect, } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum GraphUnaryOp { Not, + Neg, } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -1671,6 +1894,13 @@ pub enum GraphBinaryOp { Gt, Ge, In, + Add, + Sub, + Mul, + Div, + StartsWith, + EndsWith, + Contains, } /// One output column requested by a graph-row query. @@ -1921,6 +2151,37 @@ pub struct GraphRowStats { pub warnings: Vec, } +/// Result of a graph pipeline query. +#[derive(Clone, Debug, PartialEq)] +pub struct GraphPipelineResult { + pub columns: Vec, + pub rows: Vec, + pub next_cursor: Option, + pub stats: GraphPipelineStats, + pub plan: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GraphPipelineStats { + pub rows_returned: usize, + pub rows_entered_pipeline: usize, + pub rows_after_filter: usize, + pub intermediate_rows: usize, + pub pipeline_rows_materialized: usize, + pub groups: usize, + pub collect_items: usize, + pub union_branches: usize, + pub union_dedup_keys: usize, + pub subquery_invocations: usize, + pub subquery_cache_hits: usize, + pub shortest_path_pairs: usize, + pub shortest_path_cache_hits: usize, + pub db_hits: usize, + pub elapsed_us: Option, + pub effective_at_epoch: i64, + pub warnings: Vec, +} + /// Explain output for graph-row planning and execution. #[derive(Clone, Debug, PartialEq)] pub struct GraphRowExplain { @@ -1938,6 +2199,59 @@ pub struct GraphRowExplain { pub notes: Vec, } +/// Explain output for graph pipeline planning and execution. +#[derive(Clone, Debug, PartialEq)] +pub struct GraphPipelineExplain { + pub columns: Vec, + pub effective_at_epoch: Option, + pub fingerprint: String, + pub stages: Vec, + pub row_ops: Vec, + pub order: GraphOrderExplain, + pub cursor: GraphCursorExplain, + pub projection: GraphProjectionExplain, + pub caps: GraphPipelineCapExplain, + pub summaries: GraphExecutionSummaries, + pub stats: GraphPipelineStats, + pub warnings: Vec, + pub notes: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GraphPipelineStageExplain { + pub index: usize, + pub kind: String, + pub detail: String, + pub columns: Vec, + pub graph_row: Option>, + pub warnings: Vec, + pub notes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GraphPipelineCapExplain { + pub allow_full_scan: bool, + pub max_rows: usize, + pub max_pipeline_rows: usize, + pub max_groups: usize, + pub max_collect_items: usize, + pub max_union_branches: usize, + pub max_subquery_invocations: usize, + pub max_subquery_depth: usize, + pub max_shortest_path_pairs: usize, + pub max_intermediate_bindings: usize, + pub max_frontier: usize, + pub max_path_hops: u8, + pub max_paths_per_start: usize, + pub max_order_materialization: usize, + pub max_skip: usize, + pub max_cursor_bytes: usize, + pub max_query_bytes: usize, + pub max_param_bytes: usize, + pub max_ast_depth: usize, + pub max_literal_items: usize, +} + /// Minimal graph-row explain plan node. Future work can fill in richer structured /// details without changing the root explain contract. #[derive(Clone, Debug, PartialEq, Eq)]