1use std::collections::HashMap;
11use std::sync::atomic::AtomicBool;
12#[allow(unused_imports)]
13use zeph_db::sql;
14
15use zeph_db::{ActiveDialect, DbPool};
16
17use crate::vector_store::{
18 BoxFuture, FieldValue, ScoredVectorPoint, ScrollResult, ScrollWithIdsResult, VectorFilter,
19 VectorPoint, VectorStore, VectorStoreError,
20};
21
22pub struct DbVectorStore {
27 pool: DbPool,
28}
29
30pub type SqliteVectorStore = DbVectorStore;
32
33impl DbVectorStore {
34 #[must_use]
39 pub fn new(pool: DbPool) -> Self {
40 Self { pool }
41 }
42}
43
44use zeph_common::math::cosine_similarity;
45
46fn matches_filter(payload: &HashMap<String, serde_json::Value>, filter: &VectorFilter) -> bool {
47 for cond in &filter.must {
48 let Some(val) = payload.get(&cond.field) else {
49 return false;
50 };
51 let matches = match &cond.value {
52 FieldValue::Integer(i) => val.as_i64().is_some_and(|v| v == *i),
53 FieldValue::Text(t) => val.as_str().is_some_and(|v| v == t.as_str()),
54 };
55 if !matches {
56 return false;
57 }
58 }
59 for cond in &filter.must_not {
60 let Some(val) = payload.get(&cond.field) else {
61 continue;
62 };
63 let matches = match &cond.value {
64 FieldValue::Integer(i) => val.as_i64().is_some_and(|v| v == *i),
65 FieldValue::Text(t) => val.as_str().is_some_and(|v| v == t.as_str()),
66 };
67 if matches {
68 return false;
69 }
70 }
71 true
72}
73
74impl VectorStore for DbVectorStore {
75 fn ensure_collection(
76 &self,
77 collection: &str,
78 _vector_size: u64,
79 ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
80 let collection = collection.to_owned();
81 Box::pin(async move {
82 let sql = zeph_db::rewrite_placeholders(&format!(
83 "{} INTO vector_collections (name) VALUES (?){}",
84 <ActiveDialect as zeph_db::dialect::Dialect>::INSERT_IGNORE,
85 <ActiveDialect as zeph_db::dialect::Dialect>::CONFLICT_NOTHING,
86 ));
87 zeph_db::query(sqlx::AssertSqlSafe(sql))
88 .bind(&collection)
89 .execute(&self.pool)
90 .await
91 .map_err(|e| VectorStoreError::Collection(e.to_string()))?;
92 Ok(())
93 })
94 }
95
96 fn collection_exists(&self, collection: &str) -> BoxFuture<'_, Result<bool, VectorStoreError>> {
97 let collection = collection.to_owned();
98 Box::pin(async move {
99 let row: (i64,) = zeph_db::query_as(sql!(
100 "SELECT COUNT(*) FROM vector_collections WHERE name = ?"
101 ))
102 .bind(&collection)
103 .fetch_one(&self.pool)
104 .await
105 .map_err(|e| VectorStoreError::Connection(e.to_string()))?;
106 Ok(row.0 > 0)
107 })
108 }
109
110 fn delete_collection(&self, collection: &str) -> BoxFuture<'_, Result<(), VectorStoreError>> {
111 let collection = collection.to_owned();
112 Box::pin(async move {
113 zeph_db::query(sql!("DELETE FROM vector_points WHERE collection = ?"))
114 .bind(&collection)
115 .execute(&self.pool)
116 .await
117 .map_err(|e| VectorStoreError::Delete(e.to_string()))?;
118 zeph_db::query(sql!("DELETE FROM vector_collections WHERE name = ?"))
119 .bind(&collection)
120 .execute(&self.pool)
121 .await
122 .map_err(|e| VectorStoreError::Delete(e.to_string()))?;
123 Ok(())
124 })
125 }
126
127 fn upsert(
128 &self,
129 collection: &str,
130 points: Vec<VectorPoint>,
131 ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
132 let collection = collection.to_owned();
133 #[cfg(feature = "profiling")]
134 let span = tracing::info_span!(
135 "memory.vector_store",
136 operation = "upsert",
137 collection = %collection
138 );
139 let fut = Box::pin(async move {
140 for point in points {
141 let vector_bytes: Vec<u8> =
142 point.vector.iter().flat_map(|f| f.to_le_bytes()).collect();
143 let payload_json = serde_json::to_string(&point.payload)
144 .map_err(|e| VectorStoreError::Serialization(e.to_string()))?;
145 zeph_db::query(
146 sql!("INSERT INTO vector_points (id, collection, vector, payload) VALUES (?, ?, ?, ?) \
147 ON CONFLICT(collection, id) DO UPDATE SET vector = excluded.vector, payload = excluded.payload"),
148 )
149 .bind(&point.id)
150 .bind(&collection)
151 .bind(&vector_bytes)
152 .bind(&payload_json)
153 .execute(&self.pool)
154 .await
155 .map_err(|e| VectorStoreError::Upsert(e.to_string()))?;
156 }
157 Ok(())
158 });
159 #[cfg(feature = "profiling")]
160 return Box::pin(tracing::Instrument::instrument(fut, span));
161 #[cfg(not(feature = "profiling"))]
162 fut
163 }
164
165 fn search_clamp_diagnostics(&self) -> (&'static str, &'static AtomicBool) {
166 static CLAMP_WARNED: AtomicBool = AtomicBool::new(false);
167 ("DbVectorStore::search", &CLAMP_WARNED)
168 }
169
170 fn search_clamped(
171 &self,
172 collection: &str,
173 vector: Vec<f32>,
174 limit: u64,
175 filter: Option<VectorFilter>,
176 ) -> BoxFuture<'_, Result<Vec<ScoredVectorPoint>, VectorStoreError>> {
177 let collection = collection.to_owned();
178 #[cfg(feature = "profiling")]
179 let span = tracing::info_span!(
180 "memory.vector_store",
181 operation = "search",
182 collection = %collection
183 );
184 let fut = Box::pin(async move {
185 let rows: Vec<(String, Vec<u8>, String)> = zeph_db::query_as(sql!(
186 "SELECT id, vector, payload FROM vector_points WHERE collection = ?"
187 ))
188 .bind(&collection)
189 .fetch_all(&self.pool)
190 .await
191 .map_err(|e| VectorStoreError::Search(e.to_string()))?;
192
193 let limit_usize = usize::try_from(limit).unwrap_or(usize::MAX);
194 let mut scored: Vec<ScoredVectorPoint> = rows
195 .into_iter()
196 .filter_map(|(id, blob, payload_str)| {
197 if blob.len() % 4 != 0 {
198 return None;
199 }
200 let stored: Vec<f32> = blob
201 .chunks_exact(4)
202 .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
203 .collect();
204 let payload: HashMap<String, serde_json::Value> =
205 serde_json::from_str(&payload_str).unwrap_or_default();
206
207 if filter
208 .as_ref()
209 .is_some_and(|f| !matches_filter(&payload, f))
210 {
211 return None;
212 }
213
214 let score = cosine_similarity(&vector, &stored);
215 Some(ScoredVectorPoint { id, score, payload })
216 })
217 .collect();
218
219 scored.sort_by(|a, b| {
220 b.score
221 .partial_cmp(&a.score)
222 .unwrap_or(std::cmp::Ordering::Equal)
223 });
224 scored.truncate(limit_usize);
225 Ok(scored)
226 });
227 #[cfg(feature = "profiling")]
228 return Box::pin(tracing::Instrument::instrument(fut, span));
229 #[cfg(not(feature = "profiling"))]
230 fut
231 }
232
233 fn delete_by_ids(
234 &self,
235 collection: &str,
236 ids: Vec<String>,
237 ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
238 let collection = collection.to_owned();
239 #[cfg(feature = "profiling")]
240 let span = tracing::info_span!(
241 "memory.vector_store",
242 operation = "delete",
243 collection = %collection
244 );
245 let fut = Box::pin(async move {
246 for id in ids {
247 zeph_db::query(sql!(
248 "DELETE FROM vector_points WHERE collection = ? AND id = ?"
249 ))
250 .bind(&collection)
251 .bind(&id)
252 .execute(&self.pool)
253 .await
254 .map_err(|e| VectorStoreError::Delete(e.to_string()))?;
255 }
256 Ok(())
257 });
258 #[cfg(feature = "profiling")]
259 return Box::pin(tracing::Instrument::instrument(fut, span));
260 #[cfg(not(feature = "profiling"))]
261 fut
262 }
263
264 fn scroll_all(
265 &self,
266 collection: &str,
267 key_field: &str,
268 ) -> BoxFuture<'_, Result<ScrollResult, VectorStoreError>> {
269 let collection = collection.to_owned();
270 let key_field = key_field.to_owned();
271 Box::pin(async move {
272 let rows: Vec<(String, String)> = zeph_db::query_as(sql!(
273 "SELECT id, payload FROM vector_points WHERE collection = ?"
274 ))
275 .bind(&collection)
276 .fetch_all(&self.pool)
277 .await
278 .map_err(|e| VectorStoreError::Scroll(e.to_string()))?;
279
280 let mut result = ScrollResult::new();
281 for (id, payload_str) in rows {
282 let payload: HashMap<String, serde_json::Value> =
283 serde_json::from_str(&payload_str).unwrap_or_default();
284 if let Some(val) = payload.get(&key_field) {
285 let mut map = HashMap::new();
286 map.insert(
287 key_field.clone(),
288 val.as_str().unwrap_or_default().to_owned(),
289 );
290 result.insert(id, map);
291 }
292 }
293 Ok(result)
294 })
295 }
296
297 fn scroll_all_with_point_ids(
298 &self,
299 collection: &str,
300 key_field: &str,
301 ) -> BoxFuture<'_, Result<ScrollWithIdsResult, VectorStoreError>> {
302 let collection = collection.to_owned();
303 let key_field = key_field.to_owned();
304 Box::pin(async move {
305 let rows: Vec<(String, String)> = zeph_db::query_as(sql!(
306 "SELECT id, payload FROM vector_points WHERE collection = ?"
307 ))
308 .bind(&collection)
309 .fetch_all(&self.pool)
310 .await
311 .map_err(|e| VectorStoreError::Scroll(e.to_string()))?;
312
313 let mut result = Vec::new();
314 for (point_id, payload_str) in rows {
315 let payload: HashMap<String, serde_json::Value> =
316 serde_json::from_str(&payload_str).unwrap_or_default();
317 let Some(key_val) = payload.get(&key_field).and_then(|v| v.as_str()) else {
318 continue;
319 };
320 let mut fields = HashMap::new();
321 for (k, v) in &payload {
322 if let Some(s) = v.as_str() {
323 fields.insert(k.clone(), s.to_owned());
324 }
325 }
326 fields.insert(key_field.clone(), key_val.to_owned());
328 result.push((point_id, fields));
329 }
330 Ok(result)
331 })
332 }
333
334 fn health_check(&self) -> BoxFuture<'_, Result<bool, VectorStoreError>> {
335 Box::pin(async move {
336 zeph_db::query_scalar::<_, i32>(sql!("SELECT 1"))
337 .fetch_one(&self.pool)
338 .await
339 .map(|_| true)
340 .map_err(|e| VectorStoreError::Collection(e.to_string()))
341 })
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::store::SqliteStore;
349 use crate::vector_store::FieldCondition;
350
351 async fn setup() -> (DbVectorStore, SqliteStore) {
352 let store = SqliteStore::new(":memory:").await.unwrap();
353 let pool = store.pool().clone();
354 let vs = DbVectorStore::new(pool);
355 (vs, store)
356 }
357
358 #[tokio::test]
359 async fn ensure_and_exists() {
360 let (vs, _) = setup().await;
361 assert!(!vs.collection_exists("col1").await.unwrap());
362 vs.ensure_collection("col1", 4).await.unwrap();
363 assert!(vs.collection_exists("col1").await.unwrap());
364 vs.ensure_collection("col1", 4).await.unwrap();
366 assert!(vs.collection_exists("col1").await.unwrap());
367 }
368
369 #[tokio::test]
370 async fn delete_collection() {
371 let (vs, _) = setup().await;
372 vs.ensure_collection("col1", 4).await.unwrap();
373 vs.upsert(
374 "col1",
375 vec![VectorPoint {
376 id: "p1".into(),
377 vector: vec![1.0, 0.0, 0.0, 0.0],
378 payload: HashMap::new(),
379 }],
380 )
381 .await
382 .unwrap();
383 vs.delete_collection("col1").await.unwrap();
384 assert!(!vs.collection_exists("col1").await.unwrap());
385 }
386
387 #[tokio::test]
388 async fn upsert_and_search() {
389 let (vs, _) = setup().await;
390 vs.ensure_collection("c", 4).await.unwrap();
391 vs.upsert(
392 "c",
393 vec![
394 VectorPoint {
395 id: "a".into(),
396 vector: vec![1.0, 0.0, 0.0, 0.0],
397 payload: HashMap::from([("role".into(), serde_json::json!("user"))]),
398 },
399 VectorPoint {
400 id: "b".into(),
401 vector: vec![0.0, 1.0, 0.0, 0.0],
402 payload: HashMap::from([("role".into(), serde_json::json!("assistant"))]),
403 },
404 ],
405 )
406 .await
407 .unwrap();
408
409 let results = vs
410 .search("c", vec![1.0, 0.0, 0.0, 0.0], 2, None)
411 .await
412 .unwrap();
413 assert_eq!(results.len(), 2);
414 assert_eq!(results[0].id, "a");
415 assert!((results[0].score - 1.0).abs() < 1e-5);
416 }
417
418 #[tokio::test]
422 #[tracing_test::traced_test]
423 async fn search_clamps_oversized_limit() {
424 let (vs, _) = setup().await;
425 vs.ensure_collection("c", 4).await.unwrap();
426 let points: Vec<VectorPoint> = (0..(crate::MAX_SEARCH_LIMIT + 10))
427 .map(|i| VectorPoint {
428 id: format!("p{i}"),
429 vector: vec![1.0, 0.0, 0.0, 0.0],
430 payload: HashMap::new(),
431 })
432 .collect();
433 vs.upsert("c", points).await.unwrap();
434
435 let results = vs
436 .search("c", vec![1.0, 0.0, 0.0, 0.0], u64::MAX, None)
437 .await
438 .unwrap();
439 assert_eq!(results.len(), crate::MAX_SEARCH_LIMIT);
440 assert!(
441 logs_contain("requested search limit exceeds MAX_SEARCH_LIMIT"),
442 "expected a one-shot warn when the trait-impl clamp actually reduces the requested limit"
443 );
444 }
445
446 #[tokio::test]
447 async fn search_with_filter() {
448 let (vs, _) = setup().await;
449 vs.ensure_collection("c", 4).await.unwrap();
450 vs.upsert(
451 "c",
452 vec![
453 VectorPoint {
454 id: "a".into(),
455 vector: vec![1.0, 0.0, 0.0, 0.0],
456 payload: HashMap::from([("role".into(), serde_json::json!("user"))]),
457 },
458 VectorPoint {
459 id: "b".into(),
460 vector: vec![1.0, 0.0, 0.0, 0.0],
461 payload: HashMap::from([("role".into(), serde_json::json!("assistant"))]),
462 },
463 ],
464 )
465 .await
466 .unwrap();
467
468 let filter = VectorFilter {
469 must: vec![FieldCondition {
470 field: "role".into(),
471 value: FieldValue::Text("user".into()),
472 }],
473 must_not: vec![],
474 };
475 let results = vs
476 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, Some(filter))
477 .await
478 .unwrap();
479 assert_eq!(results.len(), 1);
480 assert_eq!(results[0].id, "a");
481 }
482
483 #[tokio::test]
484 async fn delete_by_ids() {
485 let (vs, _) = setup().await;
486 vs.ensure_collection("c", 4).await.unwrap();
487 vs.upsert(
488 "c",
489 vec![
490 VectorPoint {
491 id: "a".into(),
492 vector: vec![1.0, 0.0, 0.0, 0.0],
493 payload: HashMap::new(),
494 },
495 VectorPoint {
496 id: "b".into(),
497 vector: vec![0.0, 1.0, 0.0, 0.0],
498 payload: HashMap::new(),
499 },
500 ],
501 )
502 .await
503 .unwrap();
504 vs.delete_by_ids("c", vec!["a".into()]).await.unwrap();
505 let results = vs
506 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, None)
507 .await
508 .unwrap();
509 assert_eq!(results.len(), 1);
510 assert_eq!(results[0].id, "b");
511 }
512
513 #[tokio::test]
514 async fn scroll_all() {
515 let (vs, _) = setup().await;
516 vs.ensure_collection("c", 4).await.unwrap();
517 vs.upsert(
518 "c",
519 vec![VectorPoint {
520 id: "p1".into(),
521 vector: vec![1.0, 0.0, 0.0, 0.0],
522 payload: HashMap::from([("text".into(), serde_json::json!("hello"))]),
523 }],
524 )
525 .await
526 .unwrap();
527 let result = vs.scroll_all("c", "text").await.unwrap();
528 assert_eq!(result.len(), 1);
529 assert_eq!(result["p1"]["text"], "hello");
530 }
531
532 #[tokio::test]
533 async fn upsert_updates_existing() {
534 let (vs, _) = setup().await;
535 vs.ensure_collection("c", 4).await.unwrap();
536 vs.upsert(
537 "c",
538 vec![VectorPoint {
539 id: "p1".into(),
540 vector: vec![1.0, 0.0, 0.0, 0.0],
541 payload: HashMap::from([("v".into(), serde_json::json!(1))]),
542 }],
543 )
544 .await
545 .unwrap();
546 vs.upsert(
547 "c",
548 vec![VectorPoint {
549 id: "p1".into(),
550 vector: vec![0.0, 1.0, 0.0, 0.0],
551 payload: HashMap::from([("v".into(), serde_json::json!(2))]),
552 }],
553 )
554 .await
555 .unwrap();
556 let results = vs
557 .search("c", vec![0.0, 1.0, 0.0, 0.0], 1, None)
558 .await
559 .unwrap();
560 assert_eq!(results.len(), 1);
561 assert!((results[0].score - 1.0).abs() < 1e-5);
562 }
563
564 #[test]
565 fn cosine_similarity_import_wired() {
566 assert!(!cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]).is_nan());
568 }
569
570 #[tokio::test]
571 async fn search_with_must_not_filter() {
572 let (vs, _) = setup().await;
573 vs.ensure_collection("c", 4).await.unwrap();
574 vs.upsert(
575 "c",
576 vec![
577 VectorPoint {
578 id: "a".into(),
579 vector: vec![1.0, 0.0, 0.0, 0.0],
580 payload: HashMap::from([("role".into(), serde_json::json!("user"))]),
581 },
582 VectorPoint {
583 id: "b".into(),
584 vector: vec![1.0, 0.0, 0.0, 0.0],
585 payload: HashMap::from([("role".into(), serde_json::json!("system"))]),
586 },
587 ],
588 )
589 .await
590 .unwrap();
591
592 let filter = VectorFilter {
593 must: vec![],
594 must_not: vec![FieldCondition {
595 field: "role".into(),
596 value: FieldValue::Text("system".into()),
597 }],
598 };
599 let results = vs
600 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, Some(filter))
601 .await
602 .unwrap();
603 assert_eq!(results.len(), 1);
604 assert_eq!(results[0].id, "a");
605 }
606
607 #[tokio::test]
608 async fn search_with_integer_filter() {
609 let (vs, _) = setup().await;
610 vs.ensure_collection("c", 4).await.unwrap();
611 vs.upsert(
612 "c",
613 vec![
614 VectorPoint {
615 id: "a".into(),
616 vector: vec![1.0, 0.0, 0.0, 0.0],
617 payload: HashMap::from([("conv_id".into(), serde_json::json!(1))]),
618 },
619 VectorPoint {
620 id: "b".into(),
621 vector: vec![1.0, 0.0, 0.0, 0.0],
622 payload: HashMap::from([("conv_id".into(), serde_json::json!(2))]),
623 },
624 ],
625 )
626 .await
627 .unwrap();
628
629 let filter = VectorFilter {
630 must: vec![FieldCondition {
631 field: "conv_id".into(),
632 value: FieldValue::Integer(1),
633 }],
634 must_not: vec![],
635 };
636 let results = vs
637 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, Some(filter))
638 .await
639 .unwrap();
640 assert_eq!(results.len(), 1);
641 assert_eq!(results[0].id, "a");
642 }
643
644 #[tokio::test]
645 async fn search_empty_collection() {
646 let (vs, _) = setup().await;
647 vs.ensure_collection("c", 4).await.unwrap();
648 let results = vs
649 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, None)
650 .await
651 .unwrap();
652 assert!(results.is_empty());
653 }
654
655 #[tokio::test]
656 async fn search_with_must_not_integer_filter() {
657 let (vs, _) = setup().await;
658 vs.ensure_collection("c", 4).await.unwrap();
659 vs.upsert(
660 "c",
661 vec![
662 VectorPoint {
663 id: "a".into(),
664 vector: vec![1.0, 0.0, 0.0, 0.0],
665 payload: HashMap::from([("conv_id".into(), serde_json::json!(1))]),
666 },
667 VectorPoint {
668 id: "b".into(),
669 vector: vec![1.0, 0.0, 0.0, 0.0],
670 payload: HashMap::from([("conv_id".into(), serde_json::json!(2))]),
671 },
672 ],
673 )
674 .await
675 .unwrap();
676
677 let filter = VectorFilter {
678 must: vec![],
679 must_not: vec![FieldCondition {
680 field: "conv_id".into(),
681 value: FieldValue::Integer(1),
682 }],
683 };
684 let results = vs
685 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, Some(filter))
686 .await
687 .unwrap();
688 assert_eq!(results.len(), 1);
689 assert_eq!(results[0].id, "b");
690 }
691
692 #[tokio::test]
693 async fn search_with_combined_must_and_must_not() {
694 let (vs, _) = setup().await;
695 vs.ensure_collection("c", 4).await.unwrap();
696 vs.upsert(
697 "c",
698 vec![
699 VectorPoint {
700 id: "a".into(),
701 vector: vec![1.0, 0.0, 0.0, 0.0],
702 payload: HashMap::from([
703 ("role".into(), serde_json::json!("user")),
704 ("conv_id".into(), serde_json::json!(1)),
705 ]),
706 },
707 VectorPoint {
708 id: "b".into(),
709 vector: vec![1.0, 0.0, 0.0, 0.0],
710 payload: HashMap::from([
711 ("role".into(), serde_json::json!("user")),
712 ("conv_id".into(), serde_json::json!(2)),
713 ]),
714 },
715 VectorPoint {
716 id: "c".into(),
717 vector: vec![1.0, 0.0, 0.0, 0.0],
718 payload: HashMap::from([
719 ("role".into(), serde_json::json!("assistant")),
720 ("conv_id".into(), serde_json::json!(1)),
721 ]),
722 },
723 ],
724 )
725 .await
726 .unwrap();
727
728 let filter = VectorFilter {
729 must: vec![FieldCondition {
730 field: "role".into(),
731 value: FieldValue::Text("user".into()),
732 }],
733 must_not: vec![FieldCondition {
734 field: "conv_id".into(),
735 value: FieldValue::Integer(2),
736 }],
737 };
738 let results = vs
739 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, Some(filter))
740 .await
741 .unwrap();
742 assert_eq!(results.len(), 1);
744 assert_eq!(results[0].id, "a");
745 }
746
747 #[tokio::test]
748 async fn scroll_all_missing_key_field() {
749 let (vs, _) = setup().await;
750 vs.ensure_collection("c", 4).await.unwrap();
751 vs.upsert(
752 "c",
753 vec![VectorPoint {
754 id: "p1".into(),
755 vector: vec![1.0, 0.0, 0.0, 0.0],
756 payload: HashMap::from([("other".into(), serde_json::json!("value"))]),
757 }],
758 )
759 .await
760 .unwrap();
761 let result = vs.scroll_all("c", "text").await.unwrap();
763 assert!(
764 result.is_empty(),
765 "points without the key field must not appear in scroll result"
766 );
767 }
768
769 #[tokio::test]
770 async fn delete_by_ids_empty_and_nonexistent() {
771 let (vs, _) = setup().await;
772 vs.ensure_collection("c", 4).await.unwrap();
773 vs.upsert(
774 "c",
775 vec![VectorPoint {
776 id: "a".into(),
777 vector: vec![1.0, 0.0, 0.0, 0.0],
778 payload: HashMap::new(),
779 }],
780 )
781 .await
782 .unwrap();
783
784 vs.delete_by_ids("c", vec![]).await.unwrap();
786
787 vs.delete_by_ids("c", vec!["nonexistent".into()])
789 .await
790 .unwrap();
791
792 let results = vs
794 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, None)
795 .await
796 .unwrap();
797 assert_eq!(results.len(), 1);
798 assert_eq!(results[0].id, "a");
799 }
800
801 #[tokio::test]
802 async fn search_corrupt_blob_skipped() {
803 let (vs, store) = setup().await;
804 vs.ensure_collection("c", 4).await.unwrap();
805
806 vs.upsert(
808 "c",
809 vec![VectorPoint {
810 id: "valid".into(),
811 vector: vec![1.0, 0.0, 0.0, 0.0],
812 payload: HashMap::new(),
813 }],
814 )
815 .await
816 .unwrap();
817
818 let corrupt_blob: Vec<u8> = vec![0xFF, 0xFE, 0xFD];
821 let payload_json = r"{}";
822 zeph_db::query(sql!(
823 "INSERT INTO vector_points (id, collection, vector, payload) VALUES (?, ?, ?, ?)"
824 ))
825 .bind("corrupt")
826 .bind("c")
827 .bind(&corrupt_blob)
828 .bind(payload_json)
829 .execute(store.pool())
830 .await
831 .unwrap();
832
833 let results = vs
835 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, None)
836 .await
837 .unwrap();
838 assert_eq!(results.len(), 1);
839 assert_eq!(results[0].id, "valid");
840 }
841
842 #[tokio::test]
843 async fn scroll_all_with_point_ids_basic() {
844 let (vs, _) = setup().await;
845 vs.ensure_collection("c", 4).await.unwrap();
846 vs.upsert(
847 "c",
848 vec![
849 VectorPoint {
850 id: "p1".into(),
851 vector: vec![1.0, 0.0, 0.0, 0.0],
852 payload: HashMap::from([
853 ("entity_id_str".into(), serde_json::json!("42")),
854 ("name".into(), serde_json::json!("alice")),
855 ]),
856 },
857 VectorPoint {
858 id: "p2".into(),
859 vector: vec![0.0, 1.0, 0.0, 0.0],
860 payload: HashMap::from([
861 ("entity_id_str".into(), serde_json::json!("99")),
862 ("name".into(), serde_json::json!("bob")),
863 ]),
864 },
865 ],
866 )
867 .await
868 .unwrap();
869
870 let result = vs
871 .scroll_all_with_point_ids("c", "entity_id_str")
872 .await
873 .unwrap();
874 assert_eq!(result.len(), 2);
875
876 let mut by_id: std::collections::BTreeMap<
878 String,
879 std::collections::HashMap<String, String>,
880 > = result.into_iter().collect();
881 let p1 = by_id.remove("p1").expect("p1 missing");
882 let p2 = by_id.remove("p2").expect("p2 missing");
883 assert_eq!(p1.get("entity_id_str").map(String::as_str), Some("42"));
884 assert_eq!(p1.get("name").map(String::as_str), Some("alice"));
885 assert_eq!(p2.get("entity_id_str").map(String::as_str), Some("99"));
886 assert_eq!(p2.get("name").map(String::as_str), Some("bob"));
887 }
888
889 #[tokio::test]
890 async fn scroll_all_with_point_ids_skips_missing_key_field() {
891 let (vs, _) = setup().await;
892 vs.ensure_collection("c", 4).await.unwrap();
893 vs.upsert(
894 "c",
895 vec![
896 VectorPoint {
897 id: "has-key".into(),
898 vector: vec![1.0, 0.0, 0.0, 0.0],
899 payload: HashMap::from([("entity_id_str".into(), serde_json::json!("7"))]),
900 },
901 VectorPoint {
902 id: "no-key".into(),
903 vector: vec![0.0, 1.0, 0.0, 0.0],
904 payload: HashMap::from([("other".into(), serde_json::json!("value"))]),
905 },
906 ],
907 )
908 .await
909 .unwrap();
910
911 let result = vs
912 .scroll_all_with_point_ids("c", "entity_id_str")
913 .await
914 .unwrap();
915 assert_eq!(result.len(), 1);
917 assert_eq!(result[0].0, "has-key");
918 assert_eq!(
919 result[0].1.get("entity_id_str").map(String::as_str),
920 Some("7")
921 );
922 }
923}