1use crate::graph_service::GraphServiceError;
2use crate::value::{from_json as json_value_to_core, to_json as value_to_json};
3use axum::{
4 extract::{Json, State},
5 response::Json as JsonResponse,
6};
7use linkrs_metrics::{ErrorInfo, ErrorType, QueryMetrics, QueryPhase, QueryProfile, StatsManager};
8use linkrs_wire::query::{
9 BatchQueryRequest, BatchQueryResponse, ExplainRequest, QueryData, QueryMetadata, QueryRequest,
10 QueryResponse, QueryStageTimings, ValidateRequest, ValidateResponse,
11};
12use std::sync::Arc;
13use std::time::Instant;
14
15use crate::http::{error::HttpError, state::AppState};
16use crate::storage::{
17 StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
18};
19
20#[utoipa::path(
21 post,
22 operation_id = "post_v1_query",
23 path = "/v1/query",
24 tag = "Query",
25 request_body = QueryRequest,
26 responses(
27 (status = 200, body = QueryResponse, description = "Query executed"),
28 (status = 500, description = "Internal error")
29 )
30)]
31pub async fn execute<
32 S: StorageClient
33 + StorageSchemaContextOps
34 + StorageSyncContextOps
35 + StorageOperationContextOps
36 + Clone
37 + Send
38 + Sync
39 + 'static,
40>(
41 State(state): State<AppState<S>>,
42 Json(request): Json<QueryRequest>,
43) -> Result<JsonResponse<QueryResponse>, HttpError> {
44 let graph_service = state.server.get_graph_service();
45
46 let parameters = json_params_to_core(&request.parameters);
47 let session_variables = json_params_to_core(&request.session_variables);
48
49 let consistency = match request.consistency.as_deref() {
51 Some(s)
52 if s.eq_ignore_ascii_case("read_your_writes")
53 || s.eq_ignore_ascii_case("ryw")
54 || s.eq_ignore_ascii_case("read-your-writes") =>
55 {
56 linkrs_api::api_core::types::ConsistencyLevel::ReadYourWrites(
57 linkrs_core::types::ReadYourWritesConfig {
58 timeout_ms: request.consistency_timeout_ms.unwrap_or(2000),
59 minimum_lsn: request.minimum_lsn.map(linkrs_core::types::CommitLsn::new),
60 },
61 )
62 }
63 _ if request.consistency_timeout_ms.is_some() => {
64 linkrs_api::api_core::types::ConsistencyLevel::ReadYourWrites(
65 linkrs_core::types::ReadYourWritesConfig {
66 timeout_ms: request.consistency_timeout_ms.unwrap(),
67 minimum_lsn: request.minimum_lsn.map(linkrs_core::types::CommitLsn::new),
68 },
69 )
70 }
71 _ => linkrs_api::api_core::types::ConsistencyLevel::Eventual,
72 };
73
74 let row_limit = result_size_limit(&state);
76 let stats = state.server.get_stats_manager().clone();
77 let trace_id = new_trace_id();
78 let start = Instant::now();
79 let result = match graph_service
80 .execute_with_consistency(
81 request.session_id,
82 &request.query,
83 parameters,
84 session_variables,
85 consistency,
86 )
87 .await
88 {
89 Ok(exec_result) => {
90 let store = state.server.config_store();
94 let config_path = state.server.get_config_path();
95 match crate::http::handlers::config::resolve_query_config_intent(
96 exec_result,
97 &store,
98 config_path.as_deref(),
99 ) {
100 Ok(resolved) => {
101 let elapsed_us = start.elapsed().as_micros() as u64;
102 let response =
103 query_result_to_response(resolved, row_limit, &trace_id, elapsed_us);
104 record_success_profile(
105 &stats,
106 request.session_id,
107 &request.query,
108 trace_id.clone(),
109 elapsed_us,
110 response.metadata.rows_returned,
111 );
112 Ok::<_, HttpError>(response)
113 }
114 Err(e) => {
115 let elapsed_us = start.elapsed().as_micros() as u64;
116 record_failure_profile(
117 &stats,
118 request.session_id,
119 &request.query,
120 trace_id.clone(),
121 elapsed_us,
122 &e,
123 );
124 Ok::<_, HttpError>(error_with_trace(
125 "CONFIG_ERROR".to_string(),
126 GraphServiceError::new(e),
127 trace_id,
128 elapsed_us,
129 ))
130 }
131 }
132 }
133 Err(e) => {
134 let elapsed_us = start.elapsed().as_micros() as u64;
135 record_failure_profile(
136 &stats,
137 request.session_id,
138 &request.query,
139 trace_id.clone(),
140 elapsed_us,
141 e.message(),
142 );
143 Ok::<_, HttpError>(error_with_trace(
144 "QUERY_ERROR".to_string(),
145 e,
146 trace_id,
147 elapsed_us,
148 ))
149 }
150 };
151
152 Ok(JsonResponse(result?))
153}
154
155#[utoipa::path(
156 post,
157 path = "/v1/query/batch",
158 tag = "Query",
159 request_body = BatchQueryRequest,
160 responses(
161 (status = 200, body = BatchQueryResponse, description = "Batch queries executed"),
162 (status = 500, description = "Internal error")
163 )
164)]
165pub async fn execute_batch<
166 S: StorageClient
167 + StorageSchemaContextOps
168 + StorageSyncContextOps
169 + StorageOperationContextOps
170 + crate::storage::AutoCommitBatchOps
171 + Clone
172 + Send
173 + Sync
174 + 'static,
175>(
176 State(state): State<AppState<S>>,
177 Json(request): Json<BatchQueryRequest>,
178) -> Result<JsonResponse<BatchQueryResponse>, HttpError> {
179 let graph_service = state.server.get_graph_service();
180
181 let parameters = json_params_to_core(&request.parameters);
182 let session_variables = json_params_to_core(&request.session_variables);
183
184 let outcomes = graph_service
185 .execute_batch(
186 request.session_id,
187 &request.statements,
188 parameters,
189 session_variables,
190 )
191 .await;
192
193 let store = state.server.config_store();
194 let config_path = state.server.get_config_path();
195 let row_limit = result_size_limit(&state);
196 let stats = state.server.get_stats_manager().clone();
197 let batch_start = Instant::now();
198 let results = outcomes
199 .into_iter()
200 .enumerate()
201 .map(|(index, outcome)| {
202 let trace_id = new_trace_id();
203 let elapsed_us = batch_start.elapsed().as_micros() as u64;
204 let query_text = request.statements.get(index).cloned().unwrap_or_default();
205 match outcome {
206 Ok(exec_result) => {
207 match crate::http::handlers::config::resolve_query_config_intent(
208 exec_result,
209 &store,
210 config_path.as_deref(),
211 ) {
212 Ok(resolved) => {
213 let response = query_result_to_response(
214 resolved, row_limit, &trace_id, elapsed_us,
215 );
216 record_success_profile(
217 &stats,
218 request.session_id,
219 &query_text,
220 trace_id,
221 elapsed_us,
222 response.metadata.rows_returned,
223 );
224 response
225 }
226 Err(e) => {
227 record_failure_profile(
228 &stats,
229 request.session_id,
230 &query_text,
231 trace_id.clone(),
232 elapsed_us,
233 &e,
234 );
235 error_with_trace(
236 "CONFIG_ERROR".into(),
237 GraphServiceError::new(e),
238 trace_id,
239 elapsed_us,
240 )
241 }
242 }
243 }
244 Err(e) => {
245 record_failure_profile(
246 &stats,
247 request.session_id,
248 &query_text,
249 trace_id.clone(),
250 elapsed_us,
251 e.message(),
252 );
253 error_with_trace("QUERY_ERROR".into(), e, trace_id, elapsed_us)
254 }
255 }
256 })
257 .collect();
258
259 Ok(JsonResponse(BatchQueryResponse { results }))
260}
261
262#[utoipa::path(
263 post,
264 path = "/v1/query/validate",
265 tag = "Query",
266 request_body = ValidateRequest,
267 responses(
268 (status = 200, body = ValidateResponse, description = "Query validation result"),
269 (status = 500, description = "Internal error")
270 )
271)]
272pub async fn validate<
273 S: StorageClient
274 + StorageSchemaContextOps
275 + StorageSyncContextOps
276 + StorageOperationContextOps
277 + Clone
278 + Send
279 + Sync
280 + 'static,
281>(
282 State(state): State<AppState<S>>,
283 Json(request): Json<ValidateRequest>,
284) -> Result<JsonResponse<ValidateResponse>, HttpError> {
285 match validate_gql(&request.query) {
287 Ok(_) => {
288 let estimated_rows = if !request.need_estimate
291 || crate::graph_service::GraphService::<S>::is_command_like(&request.query)
292 {
293 None
294 } else {
295 state
296 .server
297 .get_graph_service()
298 .estimate_rows(request.session_id, &request.query)
299 .await
300 };
301 Ok(JsonResponse(ValidateResponse {
302 valid: true,
303 message: "Query is valid".to_string(),
304 estimated_rows,
305 }))
306 }
307 Err(e) => Ok(JsonResponse(ValidateResponse {
308 valid: false,
309 message: e,
310 estimated_rows: None,
311 })),
312 }
313}
314
315#[utoipa::path(
316 post,
317 operation_id = "post_v1_query_explain",
318 path = "/v1/query/explain",
319 tag = "Query",
320 request_body = ExplainRequest,
321 responses(
322 (status = 200, body = QueryResponse, description = "Query plan"),
323 (status = 500, description = "Internal error")
324 )
325)]
326pub async fn explain<
327 S: StorageClient
328 + StorageSchemaContextOps
329 + StorageSyncContextOps
330 + StorageOperationContextOps
331 + Clone
332 + Send
333 + Sync
334 + 'static,
335>(
336 State(state): State<AppState<S>>,
337 Json(request): Json<ExplainRequest>,
338) -> Result<JsonResponse<QueryResponse>, HttpError> {
339 let graph_service = state.server.get_graph_service();
340 let parameters = json_params_to_core(&request.parameters);
341 let session_variables = json_params_to_core(&request.session_variables);
342 let row_limit = result_size_limit(&state);
343 let trace_id = new_trace_id();
344 let start = Instant::now();
345 match graph_service
346 .explain(
347 request.session_id,
348 &request.query,
349 parameters,
350 session_variables,
351 )
352 .await
353 {
354 Ok(exec_result) => {
355 let elapsed_us = start.elapsed().as_micros() as u64;
356 Ok(JsonResponse(query_result_to_response(
357 exec_result,
358 row_limit,
359 &trace_id,
360 elapsed_us,
361 )))
362 }
363 Err(e) => {
364 let elapsed_us = start.elapsed().as_micros() as u64;
365 record_failure_profile(
366 &state.server.get_stats_manager().clone(),
367 request.session_id,
368 &request.query,
369 trace_id.clone(),
370 elapsed_us,
371 e.message(),
372 );
373 Ok(JsonResponse(error_with_trace(
374 "QUERY_ERROR".to_string(),
375 e,
376 trace_id,
377 elapsed_us,
378 )))
379 }
380 }
381}
382
383pub(crate) fn validate_gql(query: &str) -> Result<Vec<String>, String> {
390 if query.trim().is_empty() {
391 return Err("query must not be empty".to_string());
392 }
393 let mut parser = crate::query::parser::Parser::new(query);
394 let result = parser.parse().map_err(|e| format!("syntax error: {e}"))?;
395 if parser.has_errors() {
396 return Err(format!("syntax error: {}", parser.take_errors()));
397 }
398 crate::query::binder::Binder::new()
399 .bind(result.ast)
400 .map_err(|e| format!("semantic error: {e}"))?;
401 Ok(extract_parameter_names(query))
402}
403
404pub(crate) fn extract_parameter_names(query: &str) -> Vec<String> {
406 let mut names = Vec::new();
407 let bytes = query.as_bytes();
408 let mut i = 0;
409 while i < bytes.len() {
410 if bytes[i] == b'@' {
411 let mut j = i + 1;
412 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
413 j += 1;
414 }
415 if j > i + 1 {
416 let name = &query[i + 1..j];
417 if !names.iter().any(|n: &String| n == name) {
418 names.push(name.to_string());
419 }
420 }
421 i = j;
422 } else {
423 i += 1;
424 }
425 }
426 names
427}
428
429fn new_trace_id() -> String {
430 uuid::Uuid::new_v4().to_string()
431}
432
433fn stages_for_elapsed_us(elapsed_us: u64) -> QueryStageTimings {
434 QueryStageTimings {
435 parse_ms: 0.0,
436 validate_ms: 0.0,
437 plan_ms: 0.0,
438 optimize_ms: 0.0,
439 execute_ms: elapsed_us as f64 / 1000.0,
440 }
441}
442
443fn error_with_trace(
444 code: String,
445 error: GraphServiceError,
446 trace_id: String,
447 elapsed_us: u64,
448) -> QueryResponse {
449 let position = error.position().map(|p| linkrs_wire::query::ErrorPosition {
450 line: p.line,
451 column: p.column,
452 });
453 let mut response =
454 QueryResponse::error_with_position(code, error.message().to_string(), None, position);
455 response.metadata.trace_id = Some(trace_id);
456 response.metadata.stages = Some(stages_for_elapsed_us(elapsed_us));
457 response.metadata.execution_time_ms = elapsed_us / 1000;
458 response
459}
460
461fn record_success_profile(
462 stats: &Arc<StatsManager>,
463 session_id: i64,
464 query_text: &str,
465 trace_id: String,
466 elapsed_us: u64,
467 rows_returned: usize,
468) {
469 let mut profile = QueryProfile::new(session_id, query_text.to_string());
470 profile.trace_id = trace_id;
471 profile.total_duration_us = elapsed_us;
472 profile.stages.execute_us = elapsed_us;
473 profile.result_count = rows_returned;
474 let mut metrics = QueryMetrics::new();
475 metrics.execute_time_us = elapsed_us;
476 metrics.total_time_us = elapsed_us;
477 metrics.result_row_count = rows_returned;
478 stats.record_query_metrics(&metrics);
479 stats.record_query_profile(profile);
480}
481
482fn record_failure_profile(
483 stats: &Arc<StatsManager>,
484 session_id: i64,
485 query_text: &str,
486 trace_id: String,
487 elapsed_us: u64,
488 message: &str,
489) {
490 let mut profile = QueryProfile::new(session_id, query_text.to_string());
491 profile.trace_id = trace_id;
492 profile.total_duration_us = elapsed_us;
493 profile.stages.execute_us = elapsed_us;
494 let mut metrics = QueryMetrics::new();
495 metrics.execute_time_us = elapsed_us;
496 metrics.total_time_us = elapsed_us;
497 stats.record_query_metrics(&metrics);
498 let error_info = ErrorInfo::new(
499 ErrorType::ExecutionError,
500 QueryPhase::Execute,
501 message.to_string(),
502 );
503 stats.record_failed_query(profile, error_info);
504}
505
506fn query_result_to_response(
512 result: linkrs_api::api_core::QueryResult,
513 row_limit: Option<usize>,
514 trace_id: &str,
515 elapsed_us: u64,
516) -> QueryResponse {
517 let space_id = result.space_summary().map(|s| s.id);
521 let (columns, mut rows): (
522 Vec<String>,
523 Vec<std::collections::HashMap<String, serde_json::Value>>,
524 ) = match result.execution {
525 crate::query::executor::base::ExecutionResult::DataSet { data } => {
526 let rows = data
527 .rows
528 .iter()
529 .map(|row| {
530 data.col_names
531 .iter()
532 .zip(row.iter())
533 .map(|(col, value)| (col.clone(), value_to_json(value.clone())))
534 .collect()
535 })
536 .collect();
537 (data.col_names, rows)
538 }
539 crate::query::executor::base::ExecutionResult::SpaceSwitched(summary) => {
540 let row = std::collections::HashMap::from([
541 (
542 "space_name".to_string(),
543 serde_json::Value::String(summary.name.clone()),
544 ),
545 (
546 "space_id".to_string(),
547 serde_json::Value::Number(summary.id.into()),
548 ),
549 (
550 "vid_type".to_string(),
551 serde_json::Value::String(summary.vid_type.to_string()),
552 ),
553 ]);
554 (
555 vec![
556 "space_name".to_string(),
557 "space_id".to_string(),
558 "vid_type".to_string(),
559 ],
560 vec![row],
561 )
562 }
563 _ => (vec![], vec![]),
564 };
565 let truncated = match row_limit {
566 Some(limit) if rows.len() > limit => {
567 rows.truncate(limit);
568 true
569 }
570 _ => false,
571 };
572 let row_count = rows.len();
573 let execution_time_ms = if result.metadata.execution_time_ms == 0 {
574 elapsed_us / 1000
575 } else {
576 result.metadata.execution_time_ms
577 };
578
579 QueryResponse::success(
580 QueryData::new(columns, rows),
581 QueryMetadata {
582 execution_time_ms,
583 rows_scanned: result.metadata.rows_scanned,
584 rows_returned: row_count,
585 space_id,
586 truncated,
587 trace_id: Some(trace_id.to_string()),
588 stages: Some(stages_for_elapsed_us(elapsed_us)),
589 plan_node_count: None,
590 result_row_count: Some(row_count),
591 },
592 )
593}
594
595pub(crate) fn result_size_limit<
599 S: StorageClient
600 + StorageSchemaContextOps
601 + StorageSyncContextOps
602 + StorageOperationContextOps
603 + Clone
604 + Send
605 + Sync
606 + 'static,
607>(
608 state: &AppState<S>,
609) -> Option<usize> {
610 let store = state.server.config_store();
611 let guard = store.read();
612 let resources = guard.query_resource();
613 if resources.has_result_size_limit() {
614 Some(resources.max_result_size)
615 } else {
616 None
617 }
618}
619
620pub(crate) fn json_params_to_core(
623 params: &std::collections::HashMap<String, serde_json::Value>,
624) -> Option<std::collections::HashMap<String, linkrs_core::Value>> {
625 if params.is_empty() {
626 return None;
627 }
628 Some(
629 params
630 .iter()
631 .map(|(k, v)| (k.clone(), json_value_to_core(v)))
632 .collect(),
633 )
634}