1use axum::{
8 extract::{Path, State},
9 response::IntoResponse,
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;
17use velesdb_core::Error;
18
19use crate::handlers::helpers::auto_core_error_response;
20use crate::types::{ErrorResponse, VELESQL_CONTRACT_VERSION};
21use crate::AppState;
22
23#[derive(Debug, Deserialize, ToSchema)]
25pub struct MatchQueryRequest {
26 pub query: String,
28 #[serde(default)]
30 pub params: HashMap<String, serde_json::Value>,
31 #[serde(default)]
33 pub vector: Option<Vec<f32>>,
34 #[serde(default)]
36 pub threshold: Option<f32>,
37}
38
39#[derive(Debug, Serialize, ToSchema)]
41pub struct MatchQueryResultItem {
42 #[serde(serialize_with = "serde_id::serialize_id_map_as_strings")]
44 #[cfg_attr(feature = "openapi", schema(schema_with = serde_id::id_map_schema))]
45 pub bindings: HashMap<String, u64>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub score: Option<f32>,
49 pub depth: u32,
51 #[serde(skip_serializing_if = "HashMap::is_empty")]
53 pub projected: HashMap<String, serde_json::Value>,
54}
55
56#[derive(Debug, Serialize, ToSchema)]
58pub struct MatchQueryResponse {
59 pub results: Vec<MatchQueryResultItem>,
61 pub took_ms: u64,
63 pub count: usize,
65 pub meta: MatchQueryMeta,
67}
68
69#[derive(Debug, Serialize, ToSchema)]
71pub struct MatchQueryMeta {
72 pub velesql_contract_version: String,
74}
75
76#[utoipa::path(
103 post,
104 path = "/collections/{name}/match",
105 tag = "graph",
106 params(("name" = String, Path, description = "Collection name")),
107 request_body = MatchQueryRequest,
108 responses(
109 (status = 200, description = "Match query results", body = MatchQueryResponse),
110 (status = 400, description = "Parse error or invalid query", body = ErrorResponse),
111 (status = 404, description = "Collection not found", body = ErrorResponse),
112 (status = 500, description = "Internal server error", body = ErrorResponse)
113 )
114)]
115pub async fn match_query(
116 Path(collection_name): Path<String>,
117 State(state): State<Arc<AppState>>,
118 Json(request): Json<MatchQueryRequest>,
119) -> axum::response::Response {
120 match run_match(&state, &collection_name, &request) {
121 Ok(response) => Json(response).into_response(),
122 Err(e) => auto_core_error_response(&e),
123 }
124}
125
126fn run_match(
130 state: &AppState,
131 collection_name: &str,
132 request: &MatchQueryRequest,
133) -> Result<MatchQueryResponse, Error> {
134 let start = std::time::Instant::now();
135
136 let collection = resolve_match_collection(state, collection_name)
137 .ok_or_else(|| Error::CollectionNotFound(collection_name.to_string()))?;
138
139 let match_clause = parse_match_clause(&request.query)?;
140 validate_threshold(request.threshold)?;
141
142 if state
146 .db
147 .authorize_read(
148 collection_name,
149 velesdb_core::observer::QueryOperationKind::GraphTraversal,
150 None,
151 None,
152 )?
153 .is_some()
154 {
155 return Err(Error::Config(
156 "scope narrowing is not supported for MATCH queries".to_string(),
157 ));
158 }
159
160 let results = execute_match(&collection, &match_clause, request)?;
161
162 let count = results.len();
163 #[allow(clippy::cast_possible_truncation)]
164 let took_ms = start.elapsed().as_millis() as u64;
165
166 Ok(MatchQueryResponse {
167 results,
168 took_ms,
169 count,
170 meta: MatchQueryMeta {
171 velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
172 },
173 })
174}
175
176fn parse_match_clause(query_str: &str) -> Result<velesdb_core::velesql::MatchClause, Error> {
181 let query = velesdb_core::velesql::Parser::parse(query_str)?;
182 query.match_clause.ok_or_else(|| {
183 Error::Query(
184 "Query is not a MATCH query. Use MATCH (...) RETURN ... \
185 or call /query for SELECT statements."
186 .to_string(),
187 )
188 })
189}
190
191fn validate_threshold(threshold: Option<f32>) -> Result<(), Error> {
193 if let Some(t) = threshold {
194 if !(0.0..=1.0).contains(&t) {
195 return Err(Error::Query(format!(
196 "Invalid threshold: {t}. Must be between 0.0 and 1.0"
197 )));
198 }
199 }
200 Ok(())
201}
202
203enum MatchCollection {
204 Vector(velesdb_core::collection::VectorCollection),
205 Graph(velesdb_core::collection::GraphCollection),
206}
207
208fn resolve_match_collection(state: &AppState, name: &str) -> Option<MatchCollection> {
209 state
210 .db
211 .get_vector_collection(name)
212 .map(MatchCollection::Vector)
213 .or_else(|| {
214 state
215 .db
216 .get_graph_collection(name)
217 .map(MatchCollection::Graph)
218 })
219}
220
221fn execute_match(
222 collection: &MatchCollection,
223 match_clause: &velesdb_core::velesql::MatchClause,
224 request: &MatchQueryRequest,
225) -> Result<Vec<MatchQueryResultItem>, Error> {
226 let raw_results = if let Some(ref vector) = request.vector {
227 let threshold = request.threshold.unwrap_or(0.0);
228 match collection {
229 MatchCollection::Vector(coll) => {
230 coll.execute_match_with_similarity(match_clause, vector, threshold, &request.params)
231 }
232 MatchCollection::Graph(coll) => {
233 coll.execute_match_with_similarity(match_clause, vector, threshold, &request.params)
234 }
235 }
236 } else {
237 match collection {
238 MatchCollection::Vector(coll) => coll.execute_match(match_clause, &request.params),
239 MatchCollection::Graph(coll) => coll.execute_match(match_clause, &request.params),
240 }
241 };
242
243 raw_results.map(|results| {
244 results
245 .into_iter()
246 .map(|r| MatchQueryResultItem {
247 bindings: r.bindings,
248 score: r.score,
249 depth: r.depth,
250 projected: r.projected,
251 })
252 .collect()
253 })
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn test_match_query_request_deserialize() {
262 let json = r#"{
263 "query": "MATCH (a:Person)-[:KNOWS]->(b) RETURN a.name",
264 "params": {}
265 }"#;
266
267 let request: MatchQueryRequest = serde_json::from_str(json).unwrap();
268 assert!(request.query.contains("MATCH"));
269 assert!(request.params.is_empty());
270 }
271
272 #[test]
273 fn test_match_query_response_serialize() {
274 let response = MatchQueryResponse {
275 results: vec![MatchQueryResultItem {
276 bindings: HashMap::from([("a".to_string(), 123)]),
277 score: Some(0.95),
278 depth: 1,
279 projected: HashMap::new(),
280 }],
281 took_ms: 15,
282 count: 1,
283 meta: MatchQueryMeta {
284 velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
285 },
286 };
287
288 let json = serde_json::to_string(&response).unwrap();
289 assert!(json.contains("bindings"));
290 assert!(json.contains("0.95"));
291 }
292
293 #[test]
294 fn test_match_query_bindings_serialized_as_strings() {
295 let above_safe = (1_u64 << 53) + 1; let response = MatchQueryResponse {
297 results: vec![MatchQueryResultItem {
298 bindings: HashMap::from([("a".to_string(), above_safe)]),
299 score: None,
300 depth: 0,
301 projected: HashMap::new(),
302 }],
303 took_ms: 0,
304 count: 1,
305 meta: MatchQueryMeta {
306 velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
307 },
308 };
309
310 let json = serde_json::to_value(&response).unwrap();
311 assert_eq!(
312 json["results"][0]["bindings"]["a"],
313 serde_json::json!("9007199254740993"),
314 "binding IDs must serialize as JSON strings for JS precision safety"
315 );
316 }
317
318 #[test]
319 fn test_match_query_response_with_projected_properties() {
320 let mut projected = HashMap::new();
321 projected.insert("author.name".to_string(), serde_json::json!("John Doe"));
322
323 let response = MatchQueryResponse {
324 results: vec![MatchQueryResultItem {
325 bindings: HashMap::from([("author".to_string(), 42)]),
326 score: Some(0.92),
327 depth: 1,
328 projected,
329 }],
330 took_ms: 10,
331 count: 1,
332 meta: MatchQueryMeta {
333 velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
334 },
335 };
336
337 let json = serde_json::to_string(&response).unwrap();
338 assert!(json.contains("John Doe"));
339 assert!(json.contains("author.name"));
340 }
341
342 #[test]
349 fn test_match_handler_applies_return_order_by() {
350 use velesdb_core::collection::VectorCollection;
351 use velesdb_core::{DistanceMetric, Point, StorageMode};
352
353 let temp = tempfile::tempdir().expect("temp dir");
354 let coll = VectorCollection::create(
355 temp.path().to_path_buf(),
356 "people",
357 4,
358 DistanceMetric::Cosine,
359 StorageMode::default(),
360 )
361 .expect("create collection");
362
363 let ages = [(1_u64, 30), (2, 10), (3, 50), (4, 20), (5, 40)];
364 let points: Vec<Point> = ages
365 .iter()
366 .map(|(id, age)| {
367 Point::new(
368 *id,
369 vec![1.0, 0.0, 0.0, 0.0],
370 Some(serde_json::json!({"_labels": ["Person"], "age": age})),
371 )
372 })
373 .collect();
374 coll.upsert(points).expect("upsert Person nodes");
375
376 let collection = MatchCollection::Vector(coll);
377 let request = MatchQueryRequest {
378 query: "MATCH (n:Person) RETURN n ORDER BY n.age DESC LIMIT 10".to_string(),
379 params: HashMap::new(),
380 vector: None,
381 threshold: None,
382 };
383 let clause = parse_match_clause(&request.query).expect("parse MATCH clause");
384 let results = execute_match(&collection, &clause, &request).expect("execute_match");
385
386 let ids: Vec<u64> = results
387 .iter()
388 .map(|r| *r.bindings.get("n").expect("binding 'n'"))
389 .collect();
390 assert_eq!(
391 ids,
392 vec![3, 5, 1, 4, 2],
393 "/match must honor RETURN ORDER BY n.age DESC (ages 50,40,30,20,10)"
394 );
395 }
396}