uni_query/lib.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Query execution layer for the Uni graph database.
5//!
6//! This crate provides OpenCypher query parsing, logical planning, and
7//! execution against Uni's object-store-backed property graph.
8//!
9//! # Modules
10//!
11//! - [`query`] — planner, executor, DataFusion integration, pushdown logic
12//! - [`types`] — public value types (`Value`, `Node`, `Edge`, `Path`, etc.)
13//!
14//! # Quick Start
15//!
16//! ```rust,ignore
17//! let executor = Executor::new(storage);
18//! let planner = QueryPlanner::new(schema);
19//! let plan = planner.plan(cypher_ast)?;
20//! let result = executor.execute_plan(plan, ¶ms).await?;
21//! ```
22
23#![recursion_limit = "256"]
24
25pub mod procedures_plugin;
26pub mod projection_store;
27pub mod query;
28pub mod types;
29
30pub use query::executor::core::{OperatorStats, ProfileOutput};
31pub use query::executor::procedure::{
32 ProcedureOutput, ProcedureParam, ProcedureRegistry, ProcedureValueType, RegisteredProcedure,
33};
34pub use query::executor::{CustomFunctionRegistry, CustomScalarFn, Executor, ResultNormalizer};
35// M8.6: session-scoped plugin registry plumbing. Host crates wrap their
36// per-query execution paths with `scoped_with_session_plugin_registry`;
37// the executor consults `current_session_plugin_registry` at the UDF /
38// procedure / Locy-aggregate resolution sites.
39pub use query::df_udfs_plugin::{
40 CURRENT_PRINCIPAL, SESSION_PLUGIN_REGISTRY, current_principal, current_session_plugin_registry,
41 maybe_scope_with_principal, scoped_with_principal, scoped_with_session_context,
42 scoped_with_session_plugin_registry,
43};
44pub use query::planner::{
45 CostEstimates, ExplainOutput, ForkIndexLookup, FusionKind, IndexUsage, LogicalPlan,
46 QueryPlanner, fuse_create_set, rewrite_for_fork_fusion,
47};
48pub use types::{
49 Edge, ExecuteResult, FromValue, Node, Path, QueryCursor, QueryMetrics, QueryResult,
50 QueryWarning, Row, Value,
51};
52pub use uni_cypher::ast::{Query as CypherQuery, TimeTravelSpec};
53
54/// Validate that a query AST contains only read clauses.
55///
56/// Rejects any query that contains CREATE, MERGE, DELETE, SET, REMOVE,
57/// or schema commands, **including writes nested inside a `CALL { … }`
58/// subquery**.
59///
60/// Procedure calls (`CALL proc(...)`) are not classified here because their
61/// read/write nature is registry-dependent; use [`validate_read_only_with`] to
62/// also reject write procedures when a classifier is available.
63///
64/// # Errors
65///
66/// Returns `Err(message)` describing the first write clause found. Used to
67/// enforce read-only access for time-travel queries (`VERSION AS OF` /
68/// `TIMESTAMP AS OF`) and for `Session::query`.
69pub fn validate_read_only(query: &CypherQuery) -> Result<(), String> {
70 validate_read_only_with(query, &|_| false)
71}
72
73/// Like [`validate_read_only`], but also rejects procedure calls that
74/// `is_write_procedure` classifies as mutating.
75///
76/// The predicate receives the procedure name (e.g. `"db.create.something"`) and
77/// returns `true` if invoking it could mutate the graph or schema. Callers that
78/// hold a plugin registry can back it with the registered `ProcedureMode`;
79/// callers without one should use [`validate_read_only`], which treats every
80/// procedure as read-only (AST-determinable writes are still rejected).
81///
82/// # Errors
83///
84/// Returns `Err(message)` describing the first write clause, write subquery, or
85/// write procedure found.
86pub fn validate_read_only_with(
87 query: &CypherQuery,
88 is_write_procedure: &dyn Fn(&str) -> bool,
89) -> Result<(), String> {
90 use uni_cypher::ast::{CallKind, Clause, Query, Statement};
91
92 fn check_statement(
93 stmt: &Statement,
94 is_write_procedure: &dyn Fn(&str) -> bool,
95 ) -> Result<(), String> {
96 for clause in &stmt.clauses {
97 match clause {
98 Clause::Create(_)
99 | Clause::Merge(_)
100 | Clause::Delete(_)
101 | Clause::Set(_)
102 | Clause::Remove(_) => {
103 return Err(
104 "Write clauses (CREATE, MERGE, DELETE, SET, REMOVE) are not allowed \
105 in a read-only context"
106 .to_string(),
107 );
108 }
109 Clause::Call(call) => match &call.kind {
110 // A subquery can itself contain writes; the planner fully
111 // supports them, so the validator must recurse to match.
112 CallKind::Subquery(inner) => check_query(inner, is_write_procedure)?,
113 CallKind::Procedure { procedure, .. } => {
114 if is_write_procedure(procedure) {
115 return Err(format!(
116 "Write procedure CALL {procedure}(...) is not allowed \
117 in a read-only context"
118 ));
119 }
120 }
121 },
122 _ => {}
123 }
124 }
125 Ok(())
126 }
127
128 fn check_query(q: &Query, is_write_procedure: &dyn Fn(&str) -> bool) -> Result<(), String> {
129 match q {
130 Query::Single(stmt) => check_statement(stmt, is_write_procedure),
131 Query::Union { left, right, .. } => {
132 check_query(left, is_write_procedure)?;
133 check_query(right, is_write_procedure)
134 }
135 Query::Explain(inner) => check_query(inner, is_write_procedure),
136 Query::TimeTravel { query, .. } => check_query(query, is_write_procedure),
137 Query::Schema(cmd) => {
138 use uni_cypher::ast::SchemaCommand;
139 match cmd.as_ref() {
140 // Read-only schema commands are allowed
141 SchemaCommand::ShowConstraints(_)
142 | SchemaCommand::ShowIndexes(_)
143 | SchemaCommand::ShowDatabase
144 | SchemaCommand::ShowConfig
145 | SchemaCommand::ShowStatistics => Ok(()),
146 // All other schema commands mutate state
147 _ => Err(
148 "Mutating schema commands are not allowed in read-only context".to_string(),
149 ),
150 }
151 }
152 }
153 }
154
155 check_query(query, is_write_procedure)
156}