Skip to main content

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