1use 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#[derive(Debug, Deserialize, ToSchema)]
25pub struct MatchQueryRequest {
26 pub query: String,
28 #[serde(default)]
30 pub params: HashMap<String, serde_json::Value>,
31 #[serde(default)]
33 pub vector: Option<Vec<f32>>,
34 #[serde(default)]
36 pub threshold: Option<f32>,
37}
38
39#[derive(Debug, Serialize, ToSchema)]
41pub struct MatchQueryResultItem {
42 #[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 #[serde(skip_serializing_if = "Option::is_none")]
48 pub score: Option<f32>,
49 pub depth: u32,
51 #[serde(skip_serializing_if = "HashMap::is_empty")]
53 pub projected: HashMap<String, serde_json::Value>,
54}
55
56#[derive(Debug, Serialize, ToSchema)]
58pub struct MatchQueryResponse {
59 pub results: Vec<MatchQueryResultItem>,
61 pub took_ms: u64,
63 pub count: usize,
65 pub meta: MatchQueryMeta,
67}
68
69#[derive(Debug, Serialize, ToSchema)]
71pub struct MatchQueryMeta {
72 pub velesql_contract_version: String,
74}
75
76#[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 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
140fn 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 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
190fn 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
205fn 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;