velesdb_server/handlers/query/
mod.rs1pub mod aggregation;
4pub mod explain;
5pub(crate) mod velesql_helpers;
6
7pub use aggregation::__path_aggregate;
8pub use aggregation::aggregate;
9pub use explain::{__path_explain, explain};
10
11use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
12use std::sync::Arc;
13use velesdb_core::collection::search::query::projection;
14#[cfg(test)]
15use velesdb_core::velesql;
16use velesdb_core::velesql::{DmlStatement, Query, SelectColumns};
17
18use crate::types::{
19 QueryRequest, QueryResponse, QueryResponseMeta, QueryType, VELESQL_CONTRACT_VERSION,
20};
21use crate::AppState;
22
23use crate::handlers::helpers::run_blocking;
24use aggregation::execute_aggregation_query;
25use explain::condition_has_vector_search;
26use velesql_helpers::{parse_and_validate, velesql_collection_not_found, velesql_error};
27
28fn requires_mutation_dispatch(parsed: &Query) -> bool {
32 parsed.is_ddl_query()
33 || parsed.is_introspection_query()
34 || parsed.is_admin_query()
35 || parsed.is_train()
36 || is_ast_routed_dml(parsed)
37}
38
39fn is_ast_routed_dml(parsed: &Query) -> bool {
47 matches!(
48 parsed.dml,
49 Some(
50 DmlStatement::InsertEdge(_)
51 | DmlStatement::Delete(_)
52 | DmlStatement::DeleteEdge(_)
53 | DmlStatement::SelectEdges(_)
54 | DmlStatement::InsertNode(_)
55 )
56 )
57}
58
59#[utoipa::path(
67 post,
68 path = "/query",
69 tag = "query",
70 request_body = QueryRequest,
71 responses(
72 (status = 200, description = "Query results", body = QueryResponse),
73 (status = 400, description = "Query syntax error", body = crate::types::QueryErrorResponse),
74 (status = 422, description = "Query validation/execution error", body = crate::types::VelesqlErrorResponse),
75 (status = 404, description = "Collection not found", body = crate::types::VelesqlErrorResponse)
76 )
77)]
78pub async fn query(
79 State(state): State<Arc<AppState>>,
80 Json(req): Json<QueryRequest>,
81) -> impl IntoResponse {
82 let start = std::time::Instant::now();
83 state.operational_metrics.inc_queries();
84
85 let parsed = match parse_and_validate(&req.query) {
86 Ok(q) => q,
87 Err(resp) => {
88 state.operational_metrics.inc_errors();
89 return resp;
90 }
91 };
92
93 let state_clone = Arc::clone(&state);
97 run_blocking(move || dispatch_parsed_query(&state_clone, &parsed, &req, start))
98 .await
99 .unwrap_or_else(|resp| resp)
100}
101
102fn dispatch_parsed_query(
106 state: &Arc<AppState>,
107 parsed: &Query,
108 req: &QueryRequest,
109 start: std::time::Instant,
110) -> axum::response::Response {
111 if requires_mutation_dispatch(parsed) {
115 return execute_mutation_query(state, parsed, &req.params, start);
116 }
117
118 let collection_name = match resolve_collection_name(parsed, req) {
119 Ok(name) => name,
120 Err(resp) => {
121 state.operational_metrics.inc_errors();
122 return resp;
123 }
124 };
125
126 if parsed.select.is_aggregation_query() {
128 return execute_aggregation_query(state, &collection_name, parsed, &req.params, start);
129 }
130
131 let results = match execute_standard_query(state, parsed, &collection_name, req) {
132 Ok(r) => r,
133 Err(resp) => {
134 state.operational_metrics.inc_errors();
135 return resp;
136 }
137 };
138
139 build_query_response(state, start, results, &parsed.select.columns)
140}
141
142fn execute_mutation_query(
154 state: &Arc<AppState>,
155 parsed: &Query,
156 params: &std::collections::HashMap<String, serde_json::Value>,
157 start: std::time::Instant,
158) -> axum::response::Response {
159 match state.db.execute_query(parsed, params) {
160 Ok(results) => build_query_response(state, start, results, &parsed.select.columns),
161 Err(e) => {
162 state.operational_metrics.inc_errors();
163 velesql_error(
164 StatusCode::UNPROCESSABLE_ENTITY,
165 "VELESQL_MUTATION_ERROR",
166 &e.to_string(),
167 "Check collection name, statement syntax, and target existence",
168 None,
169 )
170 }
171 }
172}
173
174#[allow(clippy::result_large_err)]
176fn resolve_collection_name(
177 parsed: &Query,
178 req: &QueryRequest,
179) -> Result<String, axum::response::Response> {
180 if parsed.is_match_query() {
181 req.collection
182 .as_ref()
183 .filter(|name| !name.is_empty())
184 .cloned()
185 .ok_or_else(|| {
186 velesql_error(
187 StatusCode::UNPROCESSABLE_ENTITY,
188 "VELESQL_MISSING_COLLECTION",
189 "MATCH query via /query requires `collection` in request body",
190 "Add `collection` to the /query JSON body or use /collections/{name}/match",
191 Some(serde_json::json!({
192 "field": "collection",
193 "endpoint": "/query",
194 "query_type": "MATCH"
195 })),
196 )
197 })
198 } else {
199 Ok(parsed.select.from.clone())
200 }
201}
202
203#[allow(clippy::result_large_err)]
205fn execute_standard_query(
206 state: &Arc<AppState>,
207 parsed: &Query,
208 collection_name: &str,
209 req: &QueryRequest,
210) -> Result<Vec<velesdb_core::SearchResult>, axum::response::Response> {
211 let execute_result = if parsed.is_match_query() {
212 let mut params = req.params.clone();
213 params
214 .entry("_collection".to_string())
215 .or_insert_with(|| serde_json::json!(collection_name));
216 state.db.execute_query(parsed, ¶ms)
217 } else {
218 state.db.execute_query(parsed, &req.params)
219 };
220
221 execute_result.map_err(|e| match e {
222 velesdb_core::Error::CollectionNotFound(name) => velesql_collection_not_found(&name),
223 other => velesql_error(
224 StatusCode::UNPROCESSABLE_ENTITY,
225 "VELESQL_EXECUTION_ERROR",
226 &other.to_string(),
227 "Validate query semantics and parameter types against the target collection",
228 None,
229 ),
230 })
231}
232
233fn build_query_response(
244 state: &Arc<AppState>,
245 start: std::time::Instant,
246 results: Vec<velesdb_core::SearchResult>,
247 select_columns: &SelectColumns,
248) -> axum::response::Response {
249 let elapsed = start.elapsed();
250 let timing_ms = elapsed.as_secs_f64() * 1000.0;
251 #[allow(clippy::cast_possible_truncation)]
252 let took_ms = timing_ms.round() as u64;
254 state
255 .query_duration_histogram
256 .observe(elapsed.as_secs_f64());
257 let projected = projection::project_results(&results, select_columns);
258 let rows_returned = projected.len();
259
260 Json(QueryResponse {
261 results: projected,
262 timing_ms,
263 took_ms,
264 rows_returned,
265 meta: QueryResponseMeta {
266 velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
267 count: rows_returned,
268 },
269 })
270 .into_response()
271}
272
273#[allow(dead_code)] pub fn detect_query_type(query: &Query) -> QueryType {
284 if query.is_ddl_query() {
285 return QueryType::Ddl;
286 }
287
288 if query.is_dml_query() {
289 return QueryType::Dml;
290 }
291
292 if query.is_match_query() {
293 return QueryType::Graph;
294 }
295
296 if query.select.is_aggregation_query() {
297 return QueryType::Aggregation;
298 }
299
300 let has_vector = query
301 .select
302 .where_clause
303 .as_ref()
304 .map(condition_has_vector_search)
305 .unwrap_or(false);
306
307 if has_vector {
308 return QueryType::Search;
309 }
310
311 QueryType::Rows
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn test_detect_query_type_search() {
320 let parsed = velesql::Parser::parse(
321 "SELECT * FROM docs WHERE similarity(embedding, $v) > 0.8 LIMIT 10",
322 )
323 .unwrap();
324 assert_eq!(detect_query_type(&parsed), QueryType::Search);
325 }
326
327 #[test]
328 fn test_detect_query_type_aggregation() {
329 let parsed =
330 velesql::Parser::parse("SELECT category, COUNT(*) FROM products GROUP BY category")
331 .unwrap();
332 assert_eq!(detect_query_type(&parsed), QueryType::Aggregation);
333 }
334
335 #[test]
336 fn test_detect_query_type_rows() {
337 let parsed =
338 velesql::Parser::parse("SELECT name, price FROM products WHERE price > 100").unwrap();
339 assert_eq!(detect_query_type(&parsed), QueryType::Rows);
340 }
341
342 #[test]
343 fn test_detect_query_type_graph() {
344 let parsed =
345 velesql::Parser::parse("MATCH (n:Person)-[:KNOWS]->(m) RETURN n.name, m.name LIMIT 10")
346 .unwrap();
347 assert_eq!(detect_query_type(&parsed), QueryType::Graph);
348 }
349
350 #[test]
351 fn test_detect_query_type_hybrid_vector_aggregation() {
352 let parsed = velesql::Parser::parse(
354 "SELECT category, COUNT(*) FROM docs WHERE similarity(embedding, $v) > 0.7 GROUP BY category",
355 )
356 .unwrap();
357 assert_eq!(detect_query_type(&parsed), QueryType::Aggregation);
358 }
359
360 #[test]
361 fn test_detect_query_type_ddl_create() {
362 let parsed =
363 velesql::Parser::parse("CREATE COLLECTION docs (dimension = 768, metric = 'cosine');")
364 .unwrap();
365 assert_eq!(detect_query_type(&parsed), QueryType::Ddl);
366 }
367
368 #[test]
369 fn test_detect_query_type_ddl_drop() {
370 let parsed = velesql::Parser::parse("DROP COLLECTION docs;").unwrap();
371 assert_eq!(detect_query_type(&parsed), QueryType::Ddl);
372 }
373
374 #[test]
375 fn test_detect_query_type_dml_insert_edge() {
376 let parsed = velesql::Parser::parse(
377 "INSERT EDGE INTO kg (source = 1, target = 2, label = 'KNOWS');",
378 )
379 .unwrap();
380 assert_eq!(detect_query_type(&parsed), QueryType::Dml);
381 }
382
383 #[test]
384 fn test_detect_query_type_dml_delete() {
385 let parsed = velesql::Parser::parse("DELETE FROM docs WHERE id = 1;").unwrap();
386 assert_eq!(detect_query_type(&parsed), QueryType::Dml);
387 }
388}