1use axum::{
8 extract::{Path, State},
9 http::StatusCode,
10 Json,
11};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::Arc;
15use utoipa::ToSchema;
16use velesdb_core::api_types::serde_id;
17
18use crate::types::VELESQL_CONTRACT_VERSION;
19use crate::AppState;
20
21#[derive(Debug, Deserialize, ToSchema)]
23pub struct MatchQueryRequest {
24 pub query: String,
26 #[serde(default)]
28 pub params: HashMap<String, serde_json::Value>,
29 #[serde(default)]
31 pub vector: Option<Vec<f32>>,
32 #[serde(default)]
34 pub threshold: Option<f32>,
35}
36
37#[derive(Debug, Serialize, ToSchema)]
39pub struct MatchQueryResultItem {
40 #[serde(serialize_with = "serde_id::serialize_id_map_as_strings")]
42 #[cfg_attr(feature = "openapi", schema(schema_with = serde_id::id_map_schema))]
43 pub bindings: HashMap<String, u64>,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub score: Option<f32>,
47 pub depth: u32,
49 #[serde(skip_serializing_if = "HashMap::is_empty")]
51 pub projected: HashMap<String, serde_json::Value>,
52}
53
54#[derive(Debug, Serialize, ToSchema)]
56pub struct MatchQueryResponse {
57 pub results: Vec<MatchQueryResultItem>,
59 pub took_ms: u64,
61 pub count: usize,
63 pub meta: MatchQueryMeta,
65}
66
67#[derive(Debug, Serialize, ToSchema)]
69pub struct MatchQueryMeta {
70 pub velesql_contract_version: String,
72}
73
74#[derive(Debug, Serialize, ToSchema)]
76pub struct MatchQueryError {
77 pub error: String,
79 pub code: String,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub hint: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub details: Option<serde_json::Value>,
87}
88
89#[utoipa::path(
113 post,
114 path = "/collections/{name}/match",
115 tag = "graph",
116 params(("name" = String, Path, description = "Collection name")),
117 request_body = MatchQueryRequest,
118 responses(
119 (status = 200, description = "Match query results", body = MatchQueryResponse),
120 (status = 400, description = "Parse error or invalid query", body = MatchQueryError),
121 (status = 404, description = "Collection not found", body = MatchQueryError),
122 (status = 500, description = "Internal server error", body = MatchQueryError)
123 )
124)]
125pub async fn match_query(
126 Path(collection_name): Path<String>,
127 State(state): State<Arc<AppState>>,
128 Json(request): Json<MatchQueryRequest>,
129) -> Result<Json<MatchQueryResponse>, (StatusCode, Json<MatchQueryError>)> {
130 let start = std::time::Instant::now();
131
132 let collection = resolve_match_collection(&state, &collection_name).ok_or_else(|| {
133 mk_match_error(
134 StatusCode::NOT_FOUND,
135 format!("Collection '{}' not found", collection_name),
136 "COLLECTION_NOT_FOUND",
137 "Create the collection first or correct the collection name in the route",
138 Some(serde_json::json!({ "collection": collection_name })),
139 )
140 })?;
141
142 let match_clause = parse_match_clause(&request.query)?;
143 validate_threshold(request.threshold)?;
144
145 let results = execute_match(&collection, &match_clause, &request)?;
146
147 let count = results.len();
148 #[allow(clippy::cast_possible_truncation)]
149 let took_ms = start.elapsed().as_millis() as u64;
150
151 Ok(Json(MatchQueryResponse {
152 results,
153 took_ms,
154 count,
155 meta: MatchQueryMeta {
156 velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
157 },
158 }))
159}
160
161fn mk_match_error(
163 status: StatusCode,
164 error: String,
165 code: &str,
166 hint: &str,
167 details: Option<serde_json::Value>,
168) -> (StatusCode, Json<MatchQueryError>) {
169 (
170 status,
171 Json(MatchQueryError {
172 error,
173 code: code.to_string(),
174 hint: Some(hint.to_string()),
175 details,
176 }),
177 )
178}
179
180fn parse_match_clause(
182 query_str: &str,
183) -> Result<velesdb_core::velesql::MatchClause, (StatusCode, Json<MatchQueryError>)> {
184 let query = velesdb_core::velesql::Parser::parse(query_str).map_err(|e| {
185 mk_match_error(
186 StatusCode::BAD_REQUEST,
187 format!("Parse error: {}", e),
188 "PARSE_ERROR",
189 "Check MATCH syntax and bound parameters",
190 None,
191 )
192 })?;
193
194 query.match_clause.ok_or_else(|| {
195 mk_match_error(
196 StatusCode::BAD_REQUEST,
197 "Query is not a MATCH query".to_string(),
198 "NOT_MATCH_QUERY",
199 "Use MATCH (...) RETURN ... or call /query for SELECT statements",
200 None,
201 )
202 })
203}
204
205fn validate_threshold(threshold: Option<f32>) -> Result<(), (StatusCode, Json<MatchQueryError>)> {
207 if let Some(t) = threshold {
208 if !(0.0..=1.0).contains(&t) {
209 return Err(mk_match_error(
210 StatusCode::BAD_REQUEST,
211 format!("Invalid threshold: {}. Must be between 0.0 and 1.0", t),
212 "INVALID_THRESHOLD",
213 "Provide threshold in inclusive range [0.0, 1.0]",
214 Some(serde_json::json!({ "threshold": t })),
215 ));
216 }
217 }
218 Ok(())
219}
220
221enum MatchCollection {
222 Vector(velesdb_core::collection::VectorCollection),
223 Graph(velesdb_core::collection::GraphCollection),
224}
225
226fn resolve_match_collection(state: &AppState, name: &str) -> Option<MatchCollection> {
227 state
228 .db
229 .get_vector_collection(name)
230 .map(MatchCollection::Vector)
231 .or_else(|| {
232 state
233 .db
234 .get_graph_collection(name)
235 .map(MatchCollection::Graph)
236 })
237}
238
239fn execute_match(
240 collection: &MatchCollection,
241 match_clause: &velesdb_core::velesql::MatchClause,
242 request: &MatchQueryRequest,
243) -> Result<Vec<MatchQueryResultItem>, (StatusCode, Json<MatchQueryError>)> {
244 let raw_results = if let Some(ref vector) = request.vector {
245 let threshold = request.threshold.unwrap_or(0.0);
246 match collection {
247 MatchCollection::Vector(coll) => {
248 coll.execute_match_with_similarity(match_clause, vector, threshold, &request.params)
249 }
250 MatchCollection::Graph(coll) => {
251 coll.execute_match_with_similarity(match_clause, vector, threshold, &request.params)
252 }
253 }
254 } else {
255 match collection {
256 MatchCollection::Vector(coll) => coll.execute_match(match_clause, &request.params),
257 MatchCollection::Graph(coll) => coll.execute_match(match_clause, &request.params),
258 }
259 };
260
261 raw_results
262 .map(|results| {
263 results
264 .into_iter()
265 .map(|r| MatchQueryResultItem {
266 bindings: r.bindings,
267 score: r.score,
268 depth: r.depth,
269 projected: r.projected,
270 })
271 .collect()
272 })
273 .map_err(|e| {
274 mk_match_error(
275 StatusCode::INTERNAL_SERVER_ERROR,
276 format!("Execution error: {}", e),
277 "EXECUTION_ERROR",
278 "Validate graph labels/properties and parameter types for this collection",
279 None,
280 )
281 })
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn test_match_query_request_deserialize() {
290 let json = r#"{
291 "query": "MATCH (a:Person)-[:KNOWS]->(b) RETURN a.name",
292 "params": {}
293 }"#;
294
295 let request: MatchQueryRequest = serde_json::from_str(json).unwrap();
296 assert!(request.query.contains("MATCH"));
297 assert!(request.params.is_empty());
298 }
299
300 #[test]
301 fn test_match_query_response_serialize() {
302 let response = MatchQueryResponse {
303 results: vec![MatchQueryResultItem {
304 bindings: HashMap::from([("a".to_string(), 123)]),
305 score: Some(0.95),
306 depth: 1,
307 projected: HashMap::new(),
308 }],
309 took_ms: 15,
310 count: 1,
311 meta: MatchQueryMeta {
312 velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
313 },
314 };
315
316 let json = serde_json::to_string(&response).unwrap();
317 assert!(json.contains("bindings"));
318 assert!(json.contains("0.95"));
319 }
320
321 #[test]
322 fn test_match_query_bindings_serialized_as_strings() {
323 let above_safe = (1_u64 << 53) + 1; let response = MatchQueryResponse {
325 results: vec![MatchQueryResultItem {
326 bindings: HashMap::from([("a".to_string(), above_safe)]),
327 score: None,
328 depth: 0,
329 projected: HashMap::new(),
330 }],
331 took_ms: 0,
332 count: 1,
333 meta: MatchQueryMeta {
334 velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
335 },
336 };
337
338 let json = serde_json::to_value(&response).unwrap();
339 assert_eq!(
340 json["results"][0]["bindings"]["a"],
341 serde_json::json!("9007199254740993"),
342 "binding IDs must serialize as JSON strings for JS precision safety"
343 );
344 }
345
346 #[test]
347 fn test_match_query_response_with_projected_properties() {
348 let mut projected = HashMap::new();
349 projected.insert("author.name".to_string(), serde_json::json!("John Doe"));
350
351 let response = MatchQueryResponse {
352 results: vec![MatchQueryResultItem {
353 bindings: HashMap::from([("author".to_string(), 42)]),
354 score: Some(0.92),
355 depth: 1,
356 projected,
357 }],
358 took_ms: 10,
359 count: 1,
360 meta: MatchQueryMeta {
361 velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
362 },
363 };
364
365 let json = serde_json::to_string(&response).unwrap();
366 assert!(json.contains("John Doe"));
367 assert!(json.contains("author.name"));
368 }
369}