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    // Gate the read (CORE-2). MATCH is a graph-traversal read; a `?`-propagated
143    // Deny refuses it, and a scope narrowing (no filter channel here) fails
144    // closed.
145    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
176/// Parse a query string and extract the MATCH clause.
177///
178/// Both a syntax error and a non-MATCH query are client-side query mistakes,
179/// so they map to `Error::Query` (`VELES-010`, 400).
180fn 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
191/// Validate that threshold (if provided) is in [0.0, 1.0].
192fn 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; // 9_007_199_254_740_993
296        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    /// Regression (parity backlog #1): the graph REST `/match` handler must honor
343    /// `RETURN ... ORDER BY`, matching the SQL `/query` pipeline. This exercises
344    /// the exact handler path (`parse_match_clause` -> `execute_match`) that
345    /// previously bypassed the ordering finalize step and returned raw traversal
346    /// order. Ages are scrambled vs id order so traversal order != requested
347    /// age-descending order.
348    #[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}