Skip to main content

velesdb_server/handlers/
match_query.rs

1//! MATCH query handler for REST API (EPIC-045 US-007).
2//!
3//! Provides endpoint for executing graph pattern matching queries.
4
5// EPIC-058 US-007: MATCH query handler now wired to /collections/{name}/match
6
7use 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/// Request body for MATCH query execution.
22#[derive(Debug, Deserialize, ToSchema)]
23pub struct MatchQueryRequest {
24    /// VelesQL MATCH query string.
25    pub query: String,
26    /// Query parameters (e.g., vectors, values).
27    #[serde(default)]
28    pub params: HashMap<String, serde_json::Value>,
29    /// Query vector for similarity scoring (EPIC-058 US-007).
30    #[serde(default)]
31    pub vector: Option<Vec<f32>>,
32    /// Similarity threshold (0.0 to 1.0, default 0.0).
33    #[serde(default)]
34    pub threshold: Option<f32>,
35}
36
37/// Single result from MATCH query.
38#[derive(Debug, Serialize, ToSchema)]
39pub struct MatchQueryResultItem {
40    /// Variable bindings from pattern matching.
41    #[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    /// Similarity score (if similarity() was used).
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub score: Option<f32>,
47    /// Traversal depth.
48    pub depth: u32,
49    /// Projected properties from RETURN clause (EPIC-058 US-007).
50    #[serde(skip_serializing_if = "HashMap::is_empty")]
51    pub projected: HashMap<String, serde_json::Value>,
52}
53
54/// Response for MATCH query execution.
55#[derive(Debug, Serialize, ToSchema)]
56pub struct MatchQueryResponse {
57    /// Query results.
58    pub results: Vec<MatchQueryResultItem>,
59    /// Execution time in milliseconds.
60    pub took_ms: u64,
61    /// Number of results.
62    pub count: usize,
63    /// Response metadata.
64    pub meta: MatchQueryMeta,
65}
66
67/// Metadata section for MATCH query responses.
68#[derive(Debug, Serialize, ToSchema)]
69pub struct MatchQueryMeta {
70    /// VelesQL contract version used by this response.
71    pub velesql_contract_version: String,
72}
73
74/// Error response for MATCH query.
75#[derive(Debug, Serialize, ToSchema)]
76pub struct MatchQueryError {
77    /// Error message.
78    pub error: String,
79    /// Error code.
80    pub code: String,
81    /// Actionable hint for developers.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub hint: Option<String>,
84    /// Optional details for diagnostics.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub details: Option<serde_json::Value>,
87}
88
89/// Execute a MATCH query on a collection.
90///
91/// # Endpoint
92///
93/// `POST /collections/{name}/match`
94///
95/// # Example Request
96///
97/// ```json
98/// {
99///   "query": "MATCH (a:Person)-[:KNOWS]->(b) WHERE similarity(a.vec, $v) > 0.8 RETURN a.name",
100///   "params": {
101///     "v": [0.1, 0.2, 0.3]
102///   }
103/// }
104/// ```
105///
106/// # Errors
107///
108/// Returns error tuple with status code and JSON error in these cases:
109/// - `404 NOT_FOUND` - Collection not found
110/// - `400 BAD_REQUEST` - Parse error or not a MATCH query
111/// - `500 INTERNAL_SERVER_ERROR` - Execution error
112#[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
161/// Build a match query error tuple.
162fn 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
180/// Parse a query string and extract the MATCH clause.
181fn 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
205/// Validate that threshold (if provided) is in [0.0, 1.0].
206fn 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; // 9_007_199_254_740_993
324        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}