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    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/// Request body for MATCH query execution.
24#[derive(Debug, Deserialize, ToSchema)]
25pub struct MatchQueryRequest {
26    /// VelesQL MATCH query string.
27    pub query: String,
28    /// Query parameters (e.g., vectors, values).
29    #[serde(default)]
30    pub params: HashMap<String, serde_json::Value>,
31    /// Query vector for similarity scoring (EPIC-058 US-007).
32    #[serde(default)]
33    pub vector: Option<Vec<f32>>,
34    /// Similarity threshold (0.0 to 1.0, default 0.0).
35    #[serde(default)]
36    pub threshold: Option<f32>,
37}
38
39/// Single result from MATCH query.
40#[derive(Debug, Serialize, ToSchema)]
41pub struct MatchQueryResultItem {
42    /// Variable bindings from pattern matching.
43    #[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    /// Similarity score (if similarity() was used).
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub score: Option<f32>,
49    /// Traversal depth.
50    pub depth: u32,
51    /// Projected properties from RETURN clause (EPIC-058 US-007).
52    #[serde(skip_serializing_if = "HashMap::is_empty")]
53    pub projected: HashMap<String, serde_json::Value>,
54}
55
56/// Response for MATCH query execution.
57#[derive(Debug, Serialize, ToSchema)]
58pub struct MatchQueryResponse {
59    /// Query results.
60    pub results: Vec<MatchQueryResultItem>,
61    /// Execution time in milliseconds.
62    pub took_ms: u64,
63    /// Number of results.
64    pub count: usize,
65    /// Response metadata.
66    pub meta: MatchQueryMeta,
67}
68
69/// Metadata section for MATCH query responses.
70#[derive(Debug, Serialize, ToSchema)]
71pub struct MatchQueryMeta {
72    /// VelesQL contract version used by this response.
73    pub velesql_contract_version: String,
74}
75
76/// Execute a MATCH query on a collection.
77///
78/// # Endpoint
79///
80/// `POST /collections/{name}/match`
81///
82/// # Example Request
83///
84/// ```json
85/// {
86///   "query": "MATCH (a:Person)-[:KNOWS]->(b) WHERE similarity(a.vec, $v) > 0.8 RETURN a.name",
87///   "params": {
88///     "v": [0.1, 0.2, 0.3]
89///   }
90/// }
91/// ```
92///
93/// # Errors
94///
95/// All failures are mapped through the canonical `auto_core_error_response`,
96/// so the JSON body carries the `VELES-XXX` code and the HTTP status is
97/// derived from the core error variant:
98/// - `404 NOT_FOUND` (`VELES-002`) — collection not found
99/// - `400 BAD_REQUEST` (`VELES-010`) — parse error, not a MATCH query,
100///   invalid threshold, or an unbound query parameter
101/// - other core variants map per [`super::helpers::http_status_for_error`]
102#[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
126/// Resolve, parse, validate, and execute a MATCH request, surfacing every
127/// failure as a `velesdb_core::Error` so the handler can route it through
128/// `auto_core_error_response` (canonical VELES code + HTTP status).
129fn 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    let results = execute_match(&collection, &match_clause, request)?;
143
144    let count = results.len();
145    #[allow(clippy::cast_possible_truncation)]
146    let took_ms = start.elapsed().as_millis() as u64;
147
148    Ok(MatchQueryResponse {
149        results,
150        took_ms,
151        count,
152        meta: MatchQueryMeta {
153            velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
154        },
155    })
156}
157
158/// Parse a query string and extract the MATCH clause.
159///
160/// Both a syntax error and a non-MATCH query are client-side query mistakes,
161/// so they map to `Error::Query` (`VELES-010`, 400).
162fn parse_match_clause(query_str: &str) -> Result<velesdb_core::velesql::MatchClause, Error> {
163    let query = velesdb_core::velesql::Parser::parse(query_str)?;
164    query.match_clause.ok_or_else(|| {
165        Error::Query(
166            "Query is not a MATCH query. Use MATCH (...) RETURN ... \
167             or call /query for SELECT statements."
168                .to_string(),
169        )
170    })
171}
172
173/// Validate that threshold (if provided) is in [0.0, 1.0].
174fn validate_threshold(threshold: Option<f32>) -> Result<(), Error> {
175    if let Some(t) = threshold {
176        if !(0.0..=1.0).contains(&t) {
177            return Err(Error::Query(format!(
178                "Invalid threshold: {t}. Must be between 0.0 and 1.0"
179            )));
180        }
181    }
182    Ok(())
183}
184
185enum MatchCollection {
186    Vector(velesdb_core::collection::VectorCollection),
187    Graph(velesdb_core::collection::GraphCollection),
188}
189
190fn resolve_match_collection(state: &AppState, name: &str) -> Option<MatchCollection> {
191    state
192        .db
193        .get_vector_collection(name)
194        .map(MatchCollection::Vector)
195        .or_else(|| {
196            state
197                .db
198                .get_graph_collection(name)
199                .map(MatchCollection::Graph)
200        })
201}
202
203fn execute_match(
204    collection: &MatchCollection,
205    match_clause: &velesdb_core::velesql::MatchClause,
206    request: &MatchQueryRequest,
207) -> Result<Vec<MatchQueryResultItem>, Error> {
208    let raw_results = if let Some(ref vector) = request.vector {
209        let threshold = request.threshold.unwrap_or(0.0);
210        match collection {
211            MatchCollection::Vector(coll) => {
212                coll.execute_match_with_similarity(match_clause, vector, threshold, &request.params)
213            }
214            MatchCollection::Graph(coll) => {
215                coll.execute_match_with_similarity(match_clause, vector, threshold, &request.params)
216            }
217        }
218    } else {
219        match collection {
220            MatchCollection::Vector(coll) => coll.execute_match(match_clause, &request.params),
221            MatchCollection::Graph(coll) => coll.execute_match(match_clause, &request.params),
222        }
223    };
224
225    raw_results.map(|results| {
226        results
227            .into_iter()
228            .map(|r| MatchQueryResultItem {
229                bindings: r.bindings,
230                score: r.score,
231                depth: r.depth,
232                projected: r.projected,
233            })
234            .collect()
235    })
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn test_match_query_request_deserialize() {
244        let json = r#"{
245            "query": "MATCH (a:Person)-[:KNOWS]->(b) RETURN a.name",
246            "params": {}
247        }"#;
248
249        let request: MatchQueryRequest = serde_json::from_str(json).unwrap();
250        assert!(request.query.contains("MATCH"));
251        assert!(request.params.is_empty());
252    }
253
254    #[test]
255    fn test_match_query_response_serialize() {
256        let response = MatchQueryResponse {
257            results: vec![MatchQueryResultItem {
258                bindings: HashMap::from([("a".to_string(), 123)]),
259                score: Some(0.95),
260                depth: 1,
261                projected: HashMap::new(),
262            }],
263            took_ms: 15,
264            count: 1,
265            meta: MatchQueryMeta {
266                velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
267            },
268        };
269
270        let json = serde_json::to_string(&response).unwrap();
271        assert!(json.contains("bindings"));
272        assert!(json.contains("0.95"));
273    }
274
275    #[test]
276    fn test_match_query_bindings_serialized_as_strings() {
277        let above_safe = (1_u64 << 53) + 1; // 9_007_199_254_740_993
278        let response = MatchQueryResponse {
279            results: vec![MatchQueryResultItem {
280                bindings: HashMap::from([("a".to_string(), above_safe)]),
281                score: None,
282                depth: 0,
283                projected: HashMap::new(),
284            }],
285            took_ms: 0,
286            count: 1,
287            meta: MatchQueryMeta {
288                velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
289            },
290        };
291
292        let json = serde_json::to_value(&response).unwrap();
293        assert_eq!(
294            json["results"][0]["bindings"]["a"],
295            serde_json::json!("9007199254740993"),
296            "binding IDs must serialize as JSON strings for JS precision safety"
297        );
298    }
299
300    #[test]
301    fn test_match_query_response_with_projected_properties() {
302        let mut projected = HashMap::new();
303        projected.insert("author.name".to_string(), serde_json::json!("John Doe"));
304
305        let response = MatchQueryResponse {
306            results: vec![MatchQueryResultItem {
307                bindings: HashMap::from([("author".to_string(), 42)]),
308                score: Some(0.92),
309                depth: 1,
310                projected,
311            }],
312            took_ms: 10,
313            count: 1,
314            meta: MatchQueryMeta {
315                velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
316            },
317        };
318
319        let json = serde_json::to_string(&response).unwrap();
320        assert!(json.contains("John Doe"));
321        assert!(json.contains("author.name"));
322    }
323
324    /// Regression (parity backlog #1): the graph REST `/match` handler must honor
325    /// `RETURN ... ORDER BY`, matching the SQL `/query` pipeline. This exercises
326    /// the exact handler path (`parse_match_clause` -> `execute_match`) that
327    /// previously bypassed the ordering finalize step and returned raw traversal
328    /// order. Ages are scrambled vs id order so traversal order != requested
329    /// age-descending order.
330    #[test]
331    fn test_match_handler_applies_return_order_by() {
332        use velesdb_core::collection::VectorCollection;
333        use velesdb_core::{DistanceMetric, Point, StorageMode};
334
335        let temp = tempfile::tempdir().expect("temp dir");
336        let coll = VectorCollection::create(
337            temp.path().to_path_buf(),
338            "people",
339            4,
340            DistanceMetric::Cosine,
341            StorageMode::default(),
342        )
343        .expect("create collection");
344
345        let ages = [(1_u64, 30), (2, 10), (3, 50), (4, 20), (5, 40)];
346        let points: Vec<Point> = ages
347            .iter()
348            .map(|(id, age)| {
349                Point::new(
350                    *id,
351                    vec![1.0, 0.0, 0.0, 0.0],
352                    Some(serde_json::json!({"_labels": ["Person"], "age": age})),
353                )
354            })
355            .collect();
356        coll.upsert(points).expect("upsert Person nodes");
357
358        let collection = MatchCollection::Vector(coll);
359        let request = MatchQueryRequest {
360            query: "MATCH (n:Person) RETURN n ORDER BY n.age DESC LIMIT 10".to_string(),
361            params: HashMap::new(),
362            vector: None,
363            threshold: None,
364        };
365        let clause = parse_match_clause(&request.query).expect("parse MATCH clause");
366        let results = execute_match(&collection, &clause, &request).expect("execute_match");
367
368        let ids: Vec<u64> = results
369            .iter()
370            .map(|r| *r.bindings.get("n").expect("binding 'n'"))
371            .collect();
372        assert_eq!(
373            ids,
374            vec![3, 5, 1, 4, 2],
375            "/match must honor RETURN ORDER BY n.age DESC (ages 50,40,30,20,10)"
376        );
377    }
378}