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;
16
17use crate::types::VELESQL_CONTRACT_VERSION;
18use crate::AppState;
19
20#[derive(Debug, Deserialize, ToSchema)]
22pub struct MatchQueryRequest {
23 pub query: String,
25 #[serde(default)]
27 pub params: HashMap<String, serde_json::Value>,
28 #[serde(default)]
30 pub vector: Option<Vec<f32>>,
31 #[serde(default)]
33 pub threshold: Option<f32>,
34}
35
36#[derive(Debug, Serialize, ToSchema)]
38pub struct MatchQueryResultItem {
39 pub bindings: HashMap<String, u64>,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub score: Option<f32>,
44 pub depth: u32,
46 #[serde(skip_serializing_if = "HashMap::is_empty")]
48 pub projected: HashMap<String, serde_json::Value>,
49}
50
51#[derive(Debug, Serialize, ToSchema)]
53pub struct MatchQueryResponse {
54 pub results: Vec<MatchQueryResultItem>,
56 pub took_ms: u64,
58 pub count: usize,
60 pub meta: MatchQueryMeta,
62}
63
64#[derive(Debug, Serialize, ToSchema)]
66pub struct MatchQueryMeta {
67 pub velesql_contract_version: String,
69}
70
71#[derive(Debug, Serialize, ToSchema)]
73pub struct MatchQueryError {
74 pub error: String,
76 pub code: String,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub hint: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub details: Option<serde_json::Value>,
84}
85
86#[utoipa::path(
110 post,
111 path = "/collections/{name}/match",
112 tag = "graph",
113 params(("name" = String, Path, description = "Collection name")),
114 request_body = MatchQueryRequest,
115 responses(
116 (status = 200, description = "Match query results", body = MatchQueryResponse),
117 (status = 400, description = "Parse error or invalid query", body = MatchQueryError),
118 (status = 404, description = "Collection not found", body = MatchQueryError),
119 (status = 500, description = "Internal server error", body = MatchQueryError)
120 )
121)]
122pub async fn match_query(
123 Path(collection_name): Path<String>,
124 State(state): State<Arc<AppState>>,
125 Json(request): Json<MatchQueryRequest>,
126) -> Result<Json<MatchQueryResponse>, (StatusCode, Json<MatchQueryError>)> {
127 let start = std::time::Instant::now();
128
129 let collection = state
130 .db
131 .get_vector_collection(&collection_name)
132 .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
221fn execute_match(
223 collection: &velesdb_core::collection::VectorCollection,
224 match_clause: &velesdb_core::velesql::MatchClause,
225 request: &MatchQueryRequest,
226) -> Result<Vec<MatchQueryResultItem>, (StatusCode, Json<MatchQueryError>)> {
227 let raw_results = if let Some(ref vector) = request.vector {
228 let threshold = request.threshold.unwrap_or(0.0);
229 collection.execute_match_with_similarity(match_clause, vector, threshold, &request.params)
230 } else {
231 collection.execute_match(match_clause, &request.params)
232 };
233
234 raw_results
235 .map(|results| {
236 results
237 .into_iter()
238 .map(|r| MatchQueryResultItem {
239 bindings: r.bindings,
240 score: r.score,
241 depth: r.depth,
242 projected: r.projected,
243 })
244 .collect()
245 })
246 .map_err(|e| {
247 mk_match_error(
248 StatusCode::INTERNAL_SERVER_ERROR,
249 format!("Execution error: {}", e),
250 "EXECUTION_ERROR",
251 "Validate graph labels/properties and parameter types for this collection",
252 None,
253 )
254 })
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn test_match_query_request_deserialize() {
263 let json = r#"{
264 "query": "MATCH (a:Person)-[:KNOWS]->(b) RETURN a.name",
265 "params": {}
266 }"#;
267
268 let request: MatchQueryRequest = serde_json::from_str(json).unwrap();
269 assert!(request.query.contains("MATCH"));
270 assert!(request.params.is_empty());
271 }
272
273 #[test]
274 fn test_match_query_response_serialize() {
275 let response = MatchQueryResponse {
276 results: vec![MatchQueryResultItem {
277 bindings: HashMap::from([("a".to_string(), 123)]),
278 score: Some(0.95),
279 depth: 1,
280 projected: HashMap::new(),
281 }],
282 took_ms: 15,
283 count: 1,
284 meta: MatchQueryMeta {
285 velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
286 },
287 };
288
289 let json = serde_json::to_string(&response).unwrap();
290 assert!(json.contains("bindings"));
291 assert!(json.contains("0.95"));
292 }
293
294 #[test]
295 fn test_match_query_response_with_projected_properties() {
296 let mut projected = HashMap::new();
297 projected.insert("author.name".to_string(), serde_json::json!("John Doe"));
298
299 let response = MatchQueryResponse {
300 results: vec![MatchQueryResultItem {
301 bindings: HashMap::from([("author".to_string(), 42)]),
302 score: Some(0.92),
303 depth: 1,
304 projected,
305 }],
306 took_ms: 10,
307 count: 1,
308 meta: MatchQueryMeta {
309 velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
310 },
311 };
312
313 let json = serde_json::to_string(&response).unwrap();
314 assert!(json.contains("John Doe"));
315 assert!(json.contains("author.name"));
316 }
317}