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/// When the query fails with a core error, the JSON body carries its
96/// `VELES-XXX` code, and the status follows its variant:
97/// - `404 NOT_FOUND` (`VELES-002`) — collection not found
98/// - `400 BAD_REQUEST` (`VELES-010`) — parse error, not a MATCH query,
99///   invalid threshold, or an unbound query parameter
100/// - otherwise: `404 NOT_FOUND` for a missing point, edge or node;
101///   `409 CONFLICT` for a collection or edge that already exists;
102///   `400 BAD_REQUEST` for other invalid input; `503 SERVICE_UNAVAILABLE`
103///   for a locked database or a guard rail; `500 INTERNAL_SERVER_ERROR` for
104///   anything else
105#[utoipa::path(
106    post,
107    path = "/collections/{name}/match",
108    tag = "graph",
109    params(("name" = String, Path, description = "Collection name")),
110    request_body = MatchQueryRequest,
111    responses(
112        (status = 200, description = "Match query results", body = MatchQueryResponse),
113        (status = 400, description = "Parse error or invalid query", body = ErrorResponse),
114        (status = 404, description = "Collection not found", body = ErrorResponse),
115        (status = 500, description = "Internal server error", body = ErrorResponse),
116        (status = 503, description = "A guard rail stopped the query", body = ErrorResponse),
117        (status = 422, response = crate::types::MalformedBody)
118    )
119)]
120pub async fn match_query(
121    Path(collection_name): Path<String>,
122    State(state): State<Arc<AppState>>,
123    Json(request): Json<MatchQueryRequest>,
124) -> axum::response::Response {
125    // MATCH execution is a synchronous graph traversal (lock-taking core
126    // code) — run it on the blocking pool so the async workers stay
127    // responsive.
128    let state_clone = Arc::clone(&state);
129    let outcome = crate::handlers::helpers::run_blocking(move || {
130        run_match(&state_clone, &collection_name, &request)
131    })
132    .await;
133    match outcome {
134        Ok(Ok(response)) => Json(response).into_response(),
135        Ok(Err(e)) => auto_core_error_response(&e),
136        Err(resp) => resp,
137    }
138}
139
140/// Resolve, parse, validate, and execute a MATCH request, surfacing every
141/// failure as a `velesdb_core::Error` so the handler can route it through
142/// `auto_core_error_response` (canonical VELES code + HTTP status).
143fn run_match(
144    state: &AppState,
145    collection_name: &str,
146    request: &MatchQueryRequest,
147) -> Result<MatchQueryResponse, Error> {
148    let start = std::time::Instant::now();
149
150    let collection = resolve_match_collection(state, collection_name)
151        .ok_or_else(|| Error::CollectionNotFound(collection_name.to_string()))?;
152
153    let match_clause = parse_match_clause(&request.query)?;
154    validate_threshold(request.threshold)?;
155
156    // Gate the read (CORE-2). MATCH is a graph-traversal read; a `?`-propagated
157    // Deny refuses it, and a scope narrowing (no filter channel here) fails
158    // closed.
159    if state
160        .db
161        .authorize_read(
162            collection_name,
163            velesdb_core::observer::QueryOperationKind::GraphTraversal,
164            None,
165            None,
166        )?
167        .is_some()
168    {
169        return Err(Error::Config(
170            "scope narrowing is not supported for MATCH queries".to_string(),
171        ));
172    }
173
174    let results = execute_match(&collection, &match_clause, request)?;
175
176    let count = results.len();
177    #[allow(clippy::cast_possible_truncation)]
178    let took_ms = start.elapsed().as_millis() as u64;
179
180    Ok(MatchQueryResponse {
181        results,
182        took_ms,
183        count,
184        meta: MatchQueryMeta {
185            velesql_contract_version: VELESQL_CONTRACT_VERSION.to_string(),
186        },
187    })
188}
189
190/// Parse a query string and extract the MATCH clause.
191///
192/// Both a syntax error and a non-MATCH query are client-side query mistakes,
193/// so they map to `Error::Query` (`VELES-010`, 400).
194fn parse_match_clause(query_str: &str) -> Result<velesdb_core::velesql::MatchClause, Error> {
195    let query = velesdb_core::velesql::Parser::parse(query_str)?;
196    query.match_clause.ok_or_else(|| {
197        Error::Query(
198            "Query is not a MATCH query. Use MATCH (...) RETURN ... \
199             or call /query for SELECT statements."
200                .to_string(),
201        )
202    })
203}
204
205/// Validate that threshold (if provided) is in [0.0, 1.0].
206fn validate_threshold(threshold: Option<f32>) -> Result<(), Error> {
207    if let Some(t) = threshold {
208        if !(0.0..=1.0).contains(&t) {
209            return Err(Error::Query(format!(
210                "Invalid threshold: {t}. Must be between 0.0 and 1.0"
211            )));
212        }
213    }
214    Ok(())
215}
216
217enum MatchCollection {
218    Vector(velesdb_core::collection::VectorCollection),
219    Graph(velesdb_core::collection::GraphCollection),
220}
221
222fn resolve_match_collection(state: &AppState, name: &str) -> Option<MatchCollection> {
223    state
224        .db
225        .get_vector_collection(name)
226        .map(MatchCollection::Vector)
227        .or_else(|| {
228            state
229                .db
230                .get_graph_collection(name)
231                .map(MatchCollection::Graph)
232        })
233}
234
235fn execute_match(
236    collection: &MatchCollection,
237    match_clause: &velesdb_core::velesql::MatchClause,
238    request: &MatchQueryRequest,
239) -> Result<Vec<MatchQueryResultItem>, Error> {
240    let raw_results = if let Some(ref vector) = request.vector {
241        let threshold = request.threshold.unwrap_or(0.0);
242        match collection {
243            MatchCollection::Vector(coll) => {
244                coll.execute_match_with_similarity(match_clause, vector, threshold, &request.params)
245            }
246            MatchCollection::Graph(coll) => {
247                coll.execute_match_with_similarity(match_clause, vector, threshold, &request.params)
248            }
249        }
250    } else {
251        match collection {
252            MatchCollection::Vector(coll) => coll.execute_match(match_clause, &request.params),
253            MatchCollection::Graph(coll) => coll.execute_match(match_clause, &request.params),
254        }
255    };
256
257    raw_results.map(|results| {
258        results
259            .into_iter()
260            .map(|r| MatchQueryResultItem {
261                bindings: r.bindings,
262                score: r.score,
263                depth: r.depth,
264                projected: r.projected,
265            })
266            .collect()
267    })
268}
269
270#[cfg(test)]
271#[path = "match_query_tests.rs"]
272mod tests;