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 execution is a synchronous graph traversal (lock-taking core
121    // code) — run it on the blocking pool so the async workers stay
122    // responsive.
123    let state_clone = Arc::clone(&state);
124    let outcome = crate::handlers::helpers::run_blocking(move || {
125        run_match(&state_clone, &collection_name, &request)
126    })
127    .await;
128    match outcome {
129        Ok(Ok(response)) => Json(response).into_response(),
130        Ok(Err(e)) => auto_core_error_response(&e),
131        Err(resp) => resp,
132    }
133}
134
135/// Resolve, parse, validate, and execute a MATCH request, surfacing every
136/// failure as a `velesdb_core::Error` so the handler can route it through
137/// `auto_core_error_response` (canonical VELES code + HTTP status).
138fn run_match(
139    state: &AppState,
140    collection_name: &str,
141    request: &MatchQueryRequest,
142) -> Result<MatchQueryResponse, Error> {
143    let start = std::time::Instant::now();
144
145    let collection = resolve_match_collection(state, collection_name)
146        .ok_or_else(|| Error::CollectionNotFound(collection_name.to_string()))?;
147
148    let match_clause = parse_match_clause(&request.query)?;
149    validate_threshold(request.threshold)?;
150
151    // Gate the read (CORE-2). MATCH is a graph-traversal read; a `?`-propagated
152    // Deny refuses it, and a scope narrowing (no filter channel here) fails
153    // closed.
154    if state
155        .db
156        .authorize_read(
157            collection_name,
158            velesdb_core::observer::QueryOperationKind::GraphTraversal,
159            None,
160            None,
161        )?
162        .is_some()
163    {
164        return Err(Error::Config(
165            "scope narrowing is not supported for MATCH queries".to_string(),
166        ));
167    }
168
169    let results = execute_match(&collection, &match_clause, request)?;
170
171    let count = results.len();
172    #[allow(clippy::cast_possible_truncation)]
173    let took_ms = start.elapsed().as_millis() as u64;
174
175    Ok(MatchQueryResponse {
176        results,
177        took_ms,
178        count,
179        meta: MatchQueryMeta {
180            velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
181        },
182    })
183}
184
185/// Parse a query string and extract the MATCH clause.
186///
187/// Both a syntax error and a non-MATCH query are client-side query mistakes,
188/// so they map to `Error::Query` (`VELES-010`, 400).
189fn parse_match_clause(query_str: &str) -> Result<velesdb_core::velesql::MatchClause, Error> {
190    let query = velesdb_core::velesql::Parser::parse(query_str)?;
191    query.match_clause.ok_or_else(|| {
192        Error::Query(
193            "Query is not a MATCH query. Use MATCH (...) RETURN ... \
194             or call /query for SELECT statements."
195                .to_string(),
196        )
197    })
198}
199
200/// Validate that threshold (if provided) is in [0.0, 1.0].
201fn validate_threshold(threshold: Option<f32>) -> Result<(), Error> {
202    if let Some(t) = threshold {
203        if !(0.0..=1.0).contains(&t) {
204            return Err(Error::Query(format!(
205                "Invalid threshold: {t}. Must be between 0.0 and 1.0"
206            )));
207        }
208    }
209    Ok(())
210}
211
212enum MatchCollection {
213    Vector(velesdb_core::collection::VectorCollection),
214    Graph(velesdb_core::collection::GraphCollection),
215}
216
217fn resolve_match_collection(state: &AppState, name: &str) -> Option<MatchCollection> {
218    state
219        .db
220        .get_vector_collection(name)
221        .map(MatchCollection::Vector)
222        .or_else(|| {
223            state
224                .db
225                .get_graph_collection(name)
226                .map(MatchCollection::Graph)
227        })
228}
229
230fn execute_match(
231    collection: &MatchCollection,
232    match_clause: &velesdb_core::velesql::MatchClause,
233    request: &MatchQueryRequest,
234) -> Result<Vec<MatchQueryResultItem>, Error> {
235    let raw_results = if let Some(ref vector) = request.vector {
236        let threshold = request.threshold.unwrap_or(0.0);
237        match collection {
238            MatchCollection::Vector(coll) => {
239                coll.execute_match_with_similarity(match_clause, vector, threshold, &request.params)
240            }
241            MatchCollection::Graph(coll) => {
242                coll.execute_match_with_similarity(match_clause, vector, threshold, &request.params)
243            }
244        }
245    } else {
246        match collection {
247            MatchCollection::Vector(coll) => coll.execute_match(match_clause, &request.params),
248            MatchCollection::Graph(coll) => coll.execute_match(match_clause, &request.params),
249        }
250    };
251
252    raw_results.map(|results| {
253        results
254            .into_iter()
255            .map(|r| MatchQueryResultItem {
256                bindings: r.bindings,
257                score: r.score,
258                depth: r.depth,
259                projected: r.projected,
260            })
261            .collect()
262    })
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_match_query_request_deserialize() {
271        let json = r#"{
272            "query": "MATCH (a:Person)-[:KNOWS]->(b) RETURN a.name",
273            "params": {}
274        }"#;
275
276        let request: MatchQueryRequest = serde_json::from_str(json).unwrap();
277        assert!(request.query.contains("MATCH"));
278        assert!(request.params.is_empty());
279    }
280
281    #[test]
282    fn test_match_query_response_serialize() {
283        let response = MatchQueryResponse {
284            results: vec![MatchQueryResultItem {
285                bindings: HashMap::from([("a".to_string(), 123)]),
286                score: Some(0.95),
287                depth: 1,
288                projected: HashMap::new(),
289            }],
290            took_ms: 15,
291            count: 1,
292            meta: MatchQueryMeta {
293                velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
294            },
295        };
296
297        let json = serde_json::to_string(&response).unwrap();
298        assert!(json.contains("bindings"));
299        assert!(json.contains("0.95"));
300    }
301
302    #[test]
303    fn test_match_query_bindings_serialized_as_strings() {
304        let above_safe = (1_u64 << 53) + 1; // 9_007_199_254_740_993
305        let response = MatchQueryResponse {
306            results: vec![MatchQueryResultItem {
307                bindings: HashMap::from([("a".to_string(), above_safe)]),
308                score: None,
309                depth: 0,
310                projected: HashMap::new(),
311            }],
312            took_ms: 0,
313            count: 1,
314            meta: MatchQueryMeta {
315                velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
316            },
317        };
318
319        let json = serde_json::to_value(&response).unwrap();
320        assert_eq!(
321            json["results"][0]["bindings"]["a"],
322            serde_json::json!("9007199254740993"),
323            "binding IDs must serialize as JSON strings for JS precision safety"
324        );
325    }
326
327    #[test]
328    fn test_match_query_response_with_projected_properties() {
329        let mut projected = HashMap::new();
330        projected.insert("author.name".to_string(), serde_json::json!("John Doe"));
331
332        let response = MatchQueryResponse {
333            results: vec![MatchQueryResultItem {
334                bindings: HashMap::from([("author".to_string(), 42)]),
335                score: Some(0.92),
336                depth: 1,
337                projected,
338            }],
339            took_ms: 10,
340            count: 1,
341            meta: MatchQueryMeta {
342                velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
343            },
344        };
345
346        let json = serde_json::to_string(&response).unwrap();
347        assert!(json.contains("John Doe"));
348        assert!(json.contains("author.name"));
349    }
350
351    /// Regression (parity backlog #1): the graph REST `/match` handler must honor
352    /// `RETURN ... ORDER BY`, matching the SQL `/query` pipeline. This exercises
353    /// the exact handler path (`parse_match_clause` -> `execute_match`) that
354    /// previously bypassed the ordering finalize step and returned raw traversal
355    /// order. Ages are scrambled vs id order so traversal order != requested
356    /// age-descending order.
357    #[test]
358    fn test_match_handler_applies_return_order_by() {
359        use velesdb_core::collection::VectorCollection;
360        use velesdb_core::{DistanceMetric, Point, StorageMode};
361
362        let temp = tempfile::tempdir().expect("temp dir");
363        let coll = VectorCollection::create(
364            temp.path().to_path_buf(),
365            "people",
366            4,
367            DistanceMetric::Cosine,
368            StorageMode::default(),
369        )
370        .expect("create collection");
371
372        let ages = [(1_u64, 30), (2, 10), (3, 50), (4, 20), (5, 40)];
373        let points: Vec<Point> = ages
374            .iter()
375            .map(|(id, age)| {
376                Point::new(
377                    *id,
378                    vec![1.0, 0.0, 0.0, 0.0],
379                    Some(serde_json::json!({"_labels": ["Person"], "age": age})),
380                )
381            })
382            .collect();
383        coll.upsert(points).expect("upsert Person nodes");
384
385        let collection = MatchCollection::Vector(coll);
386        let request = MatchQueryRequest {
387            query: "MATCH (n:Person) RETURN n ORDER BY n.age DESC LIMIT 10".to_string(),
388            params: HashMap::new(),
389            vector: None,
390            threshold: None,
391        };
392        let clause = parse_match_clause(&request.query).expect("parse MATCH clause");
393        let results = execute_match(&collection, &clause, &request).expect("execute_match");
394
395        let ids: Vec<u64> = results
396            .iter()
397            .map(|r| *r.bindings.get("n").expect("binding 'n'"))
398            .collect();
399        assert_eq!(
400            ids,
401            vec![3, 5, 1, 4, 2],
402            "/match must honor RETURN ORDER BY n.age DESC (ages 50,40,30,20,10)"
403        );
404    }
405}