1pub mod raw;
4pub mod relations;
5pub mod streaming;
6
7pub use raw::upsert_points_raw;
8pub use relations::{get_point_relations, relate_points, set_point_ttl, unrelate_points};
9pub use streaming::{
10 __path_enable_streaming, __path_stream_insert, __path_stream_upsert_points, enable_streaming,
11 stream_insert, stream_upsert_points,
12};
13
14use axum::{
15 extract::{Path, State},
16 http::StatusCode,
17 response::IntoResponse,
18 Json,
19};
20use std::sync::Arc;
21
22use crate::types::{
23 ErrorResponse, ScrollPoint, ScrollRequest, ScrollResponse, SparseVectorInput,
24 UpsertPointsRequest,
25};
26use crate::AppState;
27use velesdb_core::Point;
28
29use crate::handlers::helpers::{
30 auto_core_error_response, error_response, get_vector_collection_or_404,
31};
32
33use velesdb_core::index::sparse::SparseVector;
34
35fn convert_sparse_inputs(
40 sparse_vector: Option<SparseVectorInput>,
41 sparse_vectors: Option<std::collections::BTreeMap<String, SparseVectorInput>>,
42) -> Result<Option<std::collections::BTreeMap<String, SparseVector>>, String> {
43 let has_single = sparse_vector.is_some();
44 let has_named = sparse_vectors.as_ref().is_some_and(|m| !m.is_empty());
45
46 if !has_single && !has_named {
47 return Ok(None);
48 }
49
50 let mut result = std::collections::BTreeMap::new();
51
52 if let Some(sv_input) = sparse_vector {
54 let sv = sv_input.into_sparse_vector()?;
55 result.insert(String::new(), sv);
56 }
57
58 if let Some(named) = sparse_vectors {
60 merge_named_sparse_vectors(named, &mut result)?;
61 }
62
63 Ok(Some(result))
64}
65
66fn merge_named_sparse_vectors(
72 named: std::collections::BTreeMap<String, SparseVectorInput>,
73 result: &mut std::collections::BTreeMap<String, SparseVector>,
74) -> Result<(), String> {
75 for (name, sv_input) in named {
76 let sv = sv_input
77 .into_sparse_vector()
78 .map_err(|e| format!("sparse_vectors['{name}']: {e}"))?;
79 if name.is_empty() && result.contains_key("") {
80 tracing::debug!(
81 "sparse_vector (default \"\") is being overwritten by \
82 sparse_vectors[\"\"] — supply only one to avoid ambiguity"
83 );
84 }
85 result.insert(name, sv);
86 }
87 Ok(())
88}
89
90const MAX_UPSERT_BATCH_SIZE: usize = 100_000;
97
98#[utoipa::path(
100 post,
101 path = "/collections/{name}/points",
102 tag = "points",
103 params(
104 ("name" = String, Path, description = "Collection name")
105 ),
106 request_body = UpsertPointsRequest,
107 responses(
108 (status = 200, description = "Points upserted", body = Object),
109 (status = 404, description = "Collection not found", body = ErrorResponse),
110 (status = 400, description = "Invalid request or batch too large", body = ErrorResponse)
111 )
112)]
113pub async fn upsert_points(
114 State(state): State<Arc<AppState>>,
115 Path(name): Path<String>,
116 Json(req): Json<UpsertPointsRequest>,
117) -> impl IntoResponse {
118 if req.points.len() > MAX_UPSERT_BATCH_SIZE {
119 return error_response(
120 StatusCode::BAD_REQUEST,
121 format!(
122 "Batch too large: {} points (max {MAX_UPSERT_BATCH_SIZE})",
123 req.points.len()
124 ),
125 );
126 }
127
128 let collection = match get_vector_collection_or_404(&state, &name) {
129 Ok(c) => c,
130 Err(resp) => return resp,
131 };
132
133 let points = match build_points_from_request(req) {
134 Ok(p) => p,
135 Err(e) => {
136 return error_response(StatusCode::BAD_REQUEST, e);
137 }
138 };
139
140 let result = tokio::task::spawn_blocking(move || collection.upsert_bulk(&points)).await;
143
144 upsert_result_to_response(&state, &name, result)
145}
146
147pub(super) fn upsert_result_to_response(
153 state: &AppState,
154 name: &str,
155 result: Result<velesdb_core::Result<usize>, tokio::task::JoinError>,
156) -> axum::response::Response {
157 match result {
158 Ok(Ok(inserted)) => {
159 state.db.notify_upsert(name, inserted);
160 Json(serde_json::json!({
161 "message": "Points upserted",
162 "count": inserted
163 }))
164 .into_response()
165 }
166 Ok(Err(e)) => auto_core_error_response(&e),
167 Err(e) => error_response(
168 StatusCode::INTERNAL_SERVER_ERROR,
169 format!("Task panicked: {e}"),
170 ),
171 }
172}
173
174fn build_points_from_request(req: UpsertPointsRequest) -> Result<Vec<Point>, String> {
176 let mut points: Vec<Point> = Vec::with_capacity(req.points.len());
177 for p in req.points {
178 let sparse = convert_sparse_inputs(p.sparse_vector, p.sparse_vectors)?;
179 let mut point = Point::new(p.id, p.vector, p.payload);
180 point.sparse_vectors = sparse;
181 points.push(point);
182 }
183 Ok(points)
184}
185
186#[utoipa::path(
188 get,
189 path = "/collections/{name}/points/{id}",
190 tag = "points",
191 params(
192 ("name" = String, Path, description = "Collection name"),
193 ("id" = u64, Path, description = "Point ID")
194 ),
195 responses(
196 (status = 200, description = "Point found", body = Object),
197 (status = 404, description = "Point or collection not found", body = ErrorResponse)
198 )
199)]
200pub async fn get_point(
201 State(state): State<Arc<AppState>>,
202 Path((name, id)): Path<(String, u64)>,
203) -> impl IntoResponse {
204 let collection = match get_vector_collection_or_404(&state, &name) {
205 Ok(c) => c,
206 Err(resp) => return resp,
207 };
208
209 let points = collection.get(&[id]);
210
211 match points.into_iter().next().flatten() {
212 Some(point) => Json(serde_json::json!({
213 "id": point.id,
214 "vector": point.vector,
215 "payload": point.payload
216 }))
217 .into_response(),
218 None => auto_core_error_response(&velesdb_core::Error::PointNotFound(id)),
222 }
223}
224
225#[utoipa::path(
227 delete,
228 path = "/collections/{name}/points/{id}",
229 tag = "points",
230 params(
231 ("name" = String, Path, description = "Collection name"),
232 ("id" = u64, Path, description = "Point ID")
233 ),
234 responses(
235 (status = 200, description = "Point deleted", body = Object),
236 (status = 404, description = "Point or collection not found", body = ErrorResponse)
237 )
238)]
239pub async fn delete_point(
240 State(state): State<Arc<AppState>>,
241 Path((name, id)): Path<(String, u64)>,
242) -> impl IntoResponse {
243 let collection = match get_vector_collection_or_404(&state, &name) {
244 Ok(c) => c,
245 Err(resp) => return resp,
246 };
247
248 match collection.delete(&[id]) {
249 Ok(()) => Json(serde_json::json!({
250 "message": "Point deleted",
251 "id": id
252 }))
253 .into_response(),
254 Err(e) => auto_core_error_response(&e),
255 }
256}
257
258const MAX_SCROLL_BATCH_SIZE: u32 = 10_000;
260
261#[utoipa::path(
263 post,
264 path = "/collections/{name}/points/scroll",
265 tag = "points",
266 params(("name" = String, Path, description = "Collection name")),
267 request_body = ScrollRequest,
268 responses(
269 (status = 200, description = "Scroll batch", body = ScrollResponse),
270 (status = 400, description = "Invalid request", body = ErrorResponse),
271 (status = 404, description = "Collection not found", body = ErrorResponse)
272 )
273)]
274pub async fn scroll_points(
275 State(state): State<Arc<AppState>>,
276 Path(name): Path<String>,
277 Json(req): Json<ScrollRequest>,
278) -> impl IntoResponse {
279 if req.batch_size == 0 || req.batch_size > MAX_SCROLL_BATCH_SIZE {
280 return error_response(
281 StatusCode::BAD_REQUEST,
282 "batch_size must be between 1 and 10000".to_string(),
283 );
284 }
285
286 let collection = match get_vector_collection_or_404(&state, &name) {
287 Ok(c) => c,
288 Err(resp) => return resp,
289 };
290
291 let filter = match parse_scroll_filter(&req.filter) {
292 Ok(f) => f,
293 Err(resp) => return resp,
294 };
295
296 let batch_size = req.batch_size as usize;
297 let cursor = req.cursor;
298
299 let result = tokio::task::spawn_blocking(move || {
301 collection.scroll_batch(cursor, batch_size, filter.as_ref())
302 })
303 .await;
304
305 match result {
306 Ok(Ok(batch)) => build_scroll_response(batch),
307 Ok(Err(e)) => auto_core_error_response(&e),
308 Err(e) => error_response(
309 StatusCode::INTERNAL_SERVER_ERROR,
310 format!("Task panicked: {e}"),
311 ),
312 }
313}
314
315#[allow(clippy::result_large_err)]
317fn parse_scroll_filter(
318 filter_json: &Option<serde_json::Value>,
319) -> Result<Option<velesdb_core::Filter>, axum::response::Response> {
320 let Some(ref json) = filter_json else {
321 return Ok(None);
322 };
323 serde_json::from_value::<velesdb_core::Filter>(json.clone())
324 .map(Some)
325 .map_err(|e| error_response(StatusCode::BAD_REQUEST, format!("Invalid filter: {e}")))
326}
327
328fn build_scroll_response(batch: velesdb_core::ScrollBatch) -> axum::response::Response {
330 let points: Vec<ScrollPoint> = batch
331 .points
332 .into_iter()
333 .map(|p| ScrollPoint {
334 id: p.id,
335 vector: p.vector,
336 payload: p.payload,
337 })
338 .collect();
339 Json(ScrollResponse {
340 next_cursor: batch.next_cursor,
341 points,
342 })
343 .into_response()
344}
345
346const MAX_BULK_DELETE_SIZE: usize = 10_000;
348
349#[derive(serde::Deserialize, utoipa::ToSchema)]
351pub struct BulkDeleteRequest {
352 pub ids: Vec<u64>,
354}
355
356#[utoipa::path(
377 post,
378 path = "/collections/{name}/points/delete",
379 tag = "points",
380 params(
381 ("name" = String, Path, description = "Collection name")
382 ),
383 request_body = BulkDeleteRequest,
384 responses(
385 (status = 200, description = "Points deleted", body = Object),
386 (status = 400, description = "Batch too large", body = ErrorResponse),
387 (status = 404, description = "Collection not found", body = ErrorResponse),
388 (status = 500, description = "Delete failed", body = ErrorResponse)
389 )
390)]
391pub async fn bulk_delete_points(
392 State(state): State<Arc<AppState>>,
393 Path(name): Path<String>,
394 Json(req): Json<BulkDeleteRequest>,
395) -> impl IntoResponse {
396 if req.ids.is_empty() {
397 return Json(serde_json::json!({
398 "message": "No points to delete",
399 "collection": name,
400 "deleted_count": 0
401 }))
402 .into_response();
403 }
404
405 if req.ids.len() > MAX_BULK_DELETE_SIZE {
406 return error_response(
407 StatusCode::BAD_REQUEST,
408 format!(
409 "Batch too large: {} IDs (max {MAX_BULK_DELETE_SIZE})",
410 req.ids.len()
411 ),
412 );
413 }
414
415 let collection = match get_vector_collection_or_404(&state, &name) {
416 Ok(c) => c,
417 Err(resp) => return resp,
418 };
419
420 let ids = req.ids;
421 let count = ids.len();
422 let coll_name = name.clone();
423
424 let result = tokio::task::spawn_blocking(move || collection.delete(&ids)).await;
425 match result {
426 Ok(Ok(())) => Json(serde_json::json!({
427 "message": "Points deleted",
428 "collection": coll_name,
429 "deleted_count": count
430 }))
431 .into_response(),
432 Ok(Err(e)) => auto_core_error_response(&e),
433 Err(join_err) => error_response(
434 StatusCode::INTERNAL_SERVER_ERROR,
435 format!("bulk_delete task panicked: {join_err}"),
436 ),
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 #[test]
445 fn upsert_batch_constant_matches_expected_value() {
446 assert_eq!(MAX_UPSERT_BATCH_SIZE, 100_000);
447 }
448
449 #[test]
450 fn scroll_batch_constant_matches_expected_value() {
451 assert_eq!(MAX_SCROLL_BATCH_SIZE, 10_000);
452 }
453
454 #[test]
455 fn bulk_delete_batch_constant_matches_expected_value() {
456 assert_eq!(MAX_BULK_DELETE_SIZE, 10_000);
457 }
458
459 #[test]
460 fn upsert_batch_limit_is_larger_than_delete_limit() {
461 assert!(MAX_UPSERT_BATCH_SIZE > MAX_BULK_DELETE_SIZE);
463 }
464}