1use async_trait::async_trait;
19use serde::Deserialize;
20use serde_json::json;
21
22use crate::{Document, FilterOp, MetadataFilter, SearchResult, VectorStore, VectorStoreError};
23
24#[derive(Debug, Clone)]
26pub struct Neo4jConfig {
27 pub uri: String,
29 pub username: String,
31 pub password: String,
33 pub database: String,
35 pub node_label: String,
37 pub index_name: String,
39 pub embedding_property: String,
41 pub content_property: String,
43 pub metadata_property: String,
45 pub id_property: String,
47}
48
49impl Neo4jConfig {
50 pub fn new(
52 uri: impl Into<String>,
53 username: impl Into<String>,
54 password: impl Into<String>,
55 index_name: impl Into<String>,
56 ) -> Self {
57 Self {
58 uri: uri.into(),
59 username: username.into(),
60 password: password.into(),
61 database: "neo4j".to_string(),
62 node_label: "Document".to_string(),
63 index_name: index_name.into(),
64 embedding_property: "embedding".to_string(),
65 content_property: "content".to_string(),
66 metadata_property: "metadata".to_string(),
67 id_property: "id".to_string(),
68 }
69 }
70
71 pub fn from_env_result() -> Result<Self, VectorStoreError> {
73 let uri = std::env::var("NEO4J_URI").map_err(|_| {
74 VectorStoreError::ConfigError("NEO4J_URI environment variable not set".to_string())
75 })?;
76 let username = std::env::var("NEO4J_USERNAME").map_err(|_| {
77 VectorStoreError::ConfigError("NEO4J_USERNAME environment variable not set".to_string())
78 })?;
79 let password = std::env::var("NEO4J_PASSWORD").map_err(|_| {
80 VectorStoreError::ConfigError("NEO4J_PASSWORD environment variable not set".to_string())
81 })?;
82 let index_name = std::env::var("NEO4J_VECTOR_INDEX_NAME").map_err(|_| {
83 VectorStoreError::ConfigError(
84 "NEO4J_VECTOR_INDEX_NAME environment variable not set".to_string(),
85 )
86 })?;
87 let database = std::env::var("NEO4J_DATABASE").unwrap_or_else(|_| "neo4j".to_string());
88 Ok(Self {
89 uri,
90 username,
91 password,
92 database,
93 index_name,
94 ..Default::default()
95 })
96 }
97
98 pub fn with_database(mut self, database: impl Into<String>) -> Self {
100 self.database = database.into();
101 self
102 }
103
104 pub fn with_node_label(mut self, label: impl Into<String>) -> Self {
106 self.node_label = label.into();
107 self
108 }
109
110 pub fn with_embedding_property(mut self, prop: impl Into<String>) -> Self {
112 self.embedding_property = prop.into();
113 self
114 }
115
116 pub fn with_content_property(mut self, prop: impl Into<String>) -> Self {
118 self.content_property = prop.into();
119 self
120 }
121}
122
123impl Default for Neo4jConfig {
124 fn default() -> Self {
125 Self {
126 uri: "bolt://localhost:7687".to_string(),
127 username: "neo4j".to_string(),
128 password: String::new(),
129 database: "neo4j".to_string(),
130 node_label: "Document".to_string(),
131 index_name: "vector_index".to_string(),
132 embedding_property: "embedding".to_string(),
133 content_property: "content".to_string(),
134 metadata_property: "metadata".to_string(),
135 id_property: "id".to_string(),
136 }
137 }
138}
139
140pub struct Neo4jVectorStore {
144 config: Neo4jConfig,
145 client: reqwest::Client,
146}
147
148impl std::fmt::Debug for Neo4jVectorStore {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 f.debug_struct("Neo4jVectorStore")
151 .field("uri", &self.config.uri)
152 .field("index", &self.config.index_name)
153 .finish()
154 }
155}
156
157impl Neo4jVectorStore {
158 pub fn new(config: Neo4jConfig) -> Self {
160 Self {
161 config,
162 client: reqwest::Client::new(),
163 }
164 }
165
166 pub fn from_env_result() -> Result<Self, VectorStoreError> {
168 Ok(Self::new(Neo4jConfig::from_env_result()?))
169 }
170
171 fn tx_url(&self) -> String {
173 let http_uri = self
175 .config
176 .uri
177 .replace("bolt://", "http://")
178 .replace("neo4j://", "http://")
179 .replace("bolt+s://", "https://")
180 .replace("neo4j+s://", "https://");
181 format!(
182 "{}/db/{}/tx/commit",
183 http_uri.trim_end_matches('/'),
184 self.config.database
185 )
186 }
187
188 fn validate_config(&self) -> Result<(), VectorStoreError> {
192 validate_cypher_identifier(&self.config.node_label)?;
193 validate_cypher_identifier(&self.config.id_property)?;
194 validate_cypher_identifier(&self.config.content_property)?;
195 validate_cypher_identifier(&self.config.embedding_property)?;
196 validate_cypher_identifier(&self.config.metadata_property)?;
197 Ok(())
198 }
199
200 async fn run_query(
202 &self,
203 query: &str,
204 params: serde_json::Value,
205 ) -> Result<Neo4jResponse, VectorStoreError> {
206 let body = json!({
207 "statements": [{
208 "statement": query,
209 "parameters": params,
210 }]
211 });
212
213 let response = self
214 .client
215 .post(self.tx_url())
216 .header("Content-Type", "application/json")
217 .header(
218 "Authorization",
219 format!(
220 "Basic {}",
221 base64_encode(format!("{}:{}", self.config.username, self.config.password))
222 ),
223 )
224 .json(&body)
225 .send()
226 .await
227 .map_err(|e| VectorStoreError::ConnectionError(e.to_string()))?;
228
229 let status = response.status();
230 if !status.is_success() {
231 let error_text = response.text().await.unwrap_or_default();
232 return Err(VectorStoreError::ConnectionError(format!(
233 "HTTP {}: {}",
234 status, error_text
235 )));
236 }
237
238 let neo4j_response: Neo4jResponse = response
239 .json()
240 .await
241 .map_err(|e| VectorStoreError::StorageError(e.to_string()))?;
242
243 if let Some(errors) = &neo4j_response.errors {
245 if !errors.is_empty() {
246 let msg = errors
247 .iter()
248 .map(|e| e.message.clone())
249 .collect::<Vec<_>>()
250 .join("; ");
251 return Err(VectorStoreError::StorageError(msg));
252 }
253 }
254
255 Ok(neo4j_response)
256 }
257
258 fn search_query_prefix(&self) -> String {
260 "CALL db.index.vector.queryNodes($index_name, $k, $query_vector) \
261 YIELD node, score"
262 .to_string()
263 }
264
265 fn search_query_suffix(&self) -> String {
267 format!(
268 " RETURN node.{id_prop} AS id, \
269 node.{content_prop} AS content, \
270 node.{metadata_prop} AS metadata, \
271 score \
272 ORDER BY score DESC",
273 id_prop = self.config.id_property,
274 content_prop = self.config.content_property,
275 metadata_prop = self.config.metadata_property,
276 )
277 }
278
279 fn parse_search_results(&self, response: Neo4jResponse) -> Vec<SearchResult> {
281 let Some(neo4j_result) = response.results.first() else {
282 return Vec::new();
283 };
284
285 let mut search_results = Vec::new();
286 for row in &neo4j_result.data {
287 if row.row.len() >= 4 {
288 let id = row.row[0].as_str().unwrap_or_default().to_string();
289 let content = row.row[1].as_str().unwrap_or_default().to_string();
290 let score = row.row[3].as_f64().unwrap_or(0.0) as f32;
291
292 let mut doc = Document::new(content).with_id(id);
293
294 if let Some(meta_obj) = row.row[2].as_object() {
296 for (key, value) in meta_obj {
297 doc = doc.with_metadata(key, value.clone());
302 }
303 }
304
305 search_results.push(SearchResult {
306 document: doc,
307 score,
308 });
309 }
310 }
311
312 search_results
313 }
314}
315
316pub fn filter_to_cypher(
324 filter: &MetadataFilter,
325 metadata_prop: &str,
326) -> Result<(String, serde_json::Value), VectorStoreError> {
327 validate_cypher_identifier(metadata_prop)?;
330 let mut state = CypherState { next: 0 };
331 let mut params = serde_json::Map::new();
332 let expr = state.expr(filter, metadata_prop, &mut params)?;
333 Ok((expr, serde_json::Value::Object(params)))
334}
335
336struct CypherState {
338 next: usize,
339}
340
341impl CypherState {
342 fn expr(
343 &mut self,
344 filter: &MetadataFilter,
345 metadata_prop: &str,
346 params: &mut serde_json::Map<String, serde_json::Value>,
347 ) -> Result<String, VectorStoreError> {
348 match filter {
349 MetadataFilter::Field { key, op, value } => {
350 let kp = format!("f{}k", self.next);
351 let vp = format!("f{}v", self.next);
352 self.next += 1;
353
354 params.insert(kp.clone(), serde_json::Value::String(key.clone()));
355 let target = format!("node.{}[${}]", metadata_prop, kp);
357
358 match op {
359 FilterOp::In | FilterOp::Nin => {
360 let arr = value.as_array().ok_or_else(|| {
361 VectorStoreError::UnsupportedFilter(format!(
362 "IN/NIN requires an array value for field `{}`, got {}",
363 key,
364 value_type_name(value)
365 ))
366 })?;
367 params.insert(vp.clone(), serde_json::Value::Array(arr.clone()));
368 if matches!(op, FilterOp::In) {
369 Ok(format!("{} IN ${}", target, vp))
370 } else {
371 Ok(format!("NOT {} IN ${}", target, vp))
372 }
373 }
374 _ => {
375 if !matches!(
376 value,
377 serde_json::Value::String(_)
378 | serde_json::Value::Number(_)
379 | serde_json::Value::Bool(_)
380 ) {
381 return Err(VectorStoreError::UnsupportedFilter(format!(
382 "cannot translate value of type {} to a Cypher comparison for field `{}`",
383 value_type_name(value),
384 key
385 )));
386 }
387 params.insert(vp.clone(), value.clone());
388 Ok(format!("{} {} ${}", target, cypher_op(op), vp))
389 }
390 }
391 }
392 MetadataFilter::And(filters) => self.join(filters, metadata_prop, params, "AND"),
393 MetadataFilter::Or(filters) => self.join(filters, metadata_prop, params, "OR"),
394 }
395 }
396
397 fn join(
398 &mut self,
399 filters: &[MetadataFilter],
400 metadata_prop: &str,
401 params: &mut serde_json::Map<String, serde_json::Value>,
402 keyword: &str,
403 ) -> Result<String, VectorStoreError> {
404 let parts = filters
405 .iter()
406 .map(|f| self.expr(f, metadata_prop, params))
407 .collect::<Result<Vec<String>, _>>()?;
408 if parts.is_empty() {
409 return Ok("TRUE".to_string());
410 }
411 Ok(parts
412 .iter()
413 .map(|p| format!("({})", p))
414 .collect::<Vec<_>>()
415 .join(&format!(" {} ", keyword)))
416 }
417}
418
419pub(crate) fn validate_cypher_identifier(name: &str) -> Result<(), VectorStoreError> {
426 let mut chars = name.chars();
427 let valid = match chars.next() {
428 Some(c) => c.is_ascii_alphabetic() || c == '_',
429 None => false,
430 } && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
431 if valid {
432 Ok(())
433 } else {
434 Err(VectorStoreError::ConfigError(format!(
435 "invalid Cypher identifier {name:?}: must match [A-Za-z_][A-Za-z0-9_]* \
436 (labels and property names are interpolated verbatim)"
437 )))
438 }
439}
440
441fn cypher_op(op: &FilterOp) -> &'static str {
442 match op {
443 FilterOp::Eq => "=",
444 FilterOp::Ne => "<>",
445 FilterOp::Gt => ">",
446 FilterOp::Gte => ">=",
447 FilterOp::Lt => "<",
448 FilterOp::Lte => "<=",
449 FilterOp::In | FilterOp::Nin => unreachable!("handled by filter_to_cypher"),
450 }
451}
452
453fn value_type_name(value: &serde_json::Value) -> &'static str {
454 match value {
455 serde_json::Value::Null => "null",
456 serde_json::Value::Bool(_) => "boolean",
457 serde_json::Value::Number(_) => "number",
458 serde_json::Value::String(_) => "string",
459 serde_json::Value::Array(_) => "array",
460 serde_json::Value::Object(_) => "object",
461 }
462}
463
464fn base64_encode(input: String) -> String {
466 const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
467 let bytes = input.as_bytes();
468 let mut result = String::new();
469 let mut i = 0;
470 while i < bytes.len() {
471 let b0 = bytes[i];
472 let b1 = if i + 1 < bytes.len() { bytes[i + 1] } else { 0 };
473 let b2 = if i + 2 < bytes.len() { bytes[i + 2] } else { 0 };
474
475 result.push(CHARSET[((b0 >> 2) & 0x3F) as usize] as char);
476 result.push(CHARSET[(((b0 << 4) | (b1 >> 4)) & 0x3F) as usize] as char);
477 result.push(if i + 1 < bytes.len() {
478 CHARSET[(((b1 << 2) | (b2 >> 6)) & 0x3F) as usize] as char
479 } else {
480 '='
481 });
482 result.push(if i + 2 < bytes.len() {
483 CHARSET[(b2 & 0x3F) as usize] as char
484 } else {
485 '='
486 });
487
488 i += 3;
489 }
490 result
491}
492
493#[derive(Debug, Deserialize)]
499struct Neo4jResponse {
500 results: Vec<Neo4jResult>,
501 errors: Option<Vec<Neo4jError>>,
502}
503
504#[derive(Debug, Deserialize)]
505struct Neo4jResult {
506 data: Vec<Neo4jRow>,
507}
508
509#[derive(Debug, Deserialize)]
510struct Neo4jRow {
511 row: Vec<serde_json::Value>,
512}
513
514#[derive(Debug, Deserialize)]
515struct Neo4jError {
516 message: String,
517}
518
519#[async_trait]
520impl VectorStore for Neo4jVectorStore {
521 async fn add_documents(
522 &self,
523 documents: Vec<Document>,
524 embeddings: Vec<Vec<f32>>,
525 ) -> Result<Vec<String>, VectorStoreError> {
526 self.validate_config()?;
527 if documents.len() != embeddings.len() {
528 return Err(VectorStoreError::EmbeddingError(
529 "Number of documents and embeddings must match".to_string(),
530 ));
531 }
532
533 let ids: Vec<String> = documents
534 .iter()
535 .map(|doc| {
536 doc.id
537 .clone()
538 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
539 })
540 .collect();
541
542 let rows: Vec<serde_json::Value> = documents
544 .into_iter()
545 .zip(embeddings)
546 .zip(ids.iter())
547 .map(|((doc, vec), id)| {
548 let metadata: serde_json::Value = doc
549 .metadata
550 .iter()
551 .map(|(k, v)| (k.clone(), json!(v)))
552 .collect();
553 json!({
554 "id": id,
555 "content": doc.content,
556 "embedding": vec,
557 "metadata": metadata,
558 })
559 })
560 .collect();
561
562 let query = format!(
563 "UNWIND $rows AS row \
564 MERGE (n:{label} {{{id_prop}: row.id}}) \
565 SET n.{content_prop} = row.content, \
566 n.{embedding_prop} = row.embedding, \
567 n.{metadata_prop} = row.metadata",
568 label = self.config.node_label,
569 id_prop = self.config.id_property,
570 content_prop = self.config.content_property,
571 embedding_prop = self.config.embedding_property,
572 metadata_prop = self.config.metadata_property,
573 );
574
575 self.run_query(&query, json!({ "rows": rows })).await?;
576
577 Ok(ids)
578 }
579
580 async fn similarity_search(
581 &self,
582 query_embedding: &[f32],
583 k: usize,
584 ) -> Result<Vec<SearchResult>, VectorStoreError> {
585 self.validate_config()?;
586 let query = format!(
587 "{}{}",
588 self.search_query_prefix(),
589 self.search_query_suffix()
590 );
591
592 let params = json!({
593 "index_name": self.config.index_name,
594 "k": k,
595 "query_vector": query_embedding,
596 });
597
598 let response = self.run_query(&query, params).await?;
599 Ok(self.parse_search_results(response))
600 }
601
602 async fn similarity_search_with_filter(
605 &self,
606 query_embedding: &[f32],
607 k: usize,
608 filter: Option<&MetadataFilter>,
609 ) -> Result<Vec<SearchResult>, VectorStoreError> {
610 self.validate_config()?;
611 let mut query = self.search_query_prefix();
612 let mut params = json!({
613 "index_name": self.config.index_name,
614 "k": k,
615 "query_vector": query_embedding,
616 });
617 if let Some(f) = filter {
618 let (where_clause, extra_params) = filter_to_cypher(f, &self.config.metadata_property)?;
619 query.push_str(&format!(" WHERE {}", where_clause));
620 if let (Some(base), Some(extra)) = (params.as_object_mut(), extra_params.as_object()) {
621 base.extend(extra.clone());
622 }
623 }
624 query.push_str(&self.search_query_suffix());
625
626 let response = self.run_query(&query, params).await?;
627 Ok(self.parse_search_results(response))
628 }
629
630 async fn get_document(&self, id: &str) -> Result<Option<Document>, VectorStoreError> {
631 self.validate_config()?;
632 let query = format!(
633 "MATCH (n:{label} {{{id_prop}: $id}}) \
634 RETURN n.{content_prop} AS content, n.{metadata_prop} AS metadata",
635 label = self.config.node_label,
636 id_prop = self.config.id_property,
637 content_prop = self.config.content_property,
638 metadata_prop = self.config.metadata_property,
639 );
640
641 let response = self.run_query(&query, json!({ "id": id })).await?;
642
643 let result = response.results.first();
644 let Some(neo4j_result) = result else {
645 return Ok(None);
646 };
647
648 let row = neo4j_result.data.first();
649 let Some(row) = row else {
650 return Ok(None);
651 };
652
653 if row.row.is_empty() {
654 return Ok(None);
655 }
656
657 let content = row.row[0].as_str().unwrap_or_default().to_string();
658 let mut doc = Document::new(content).with_id(id);
659
660 if row.row.len() > 1 {
661 if let Some(meta_obj) = row.row[1].as_object() {
662 for (key, value) in meta_obj {
663 doc = doc.with_metadata(key, value.clone());
665 }
666 }
667 }
668
669 Ok(Some(doc))
670 }
671
672 async fn get_embedding(&self, id: &str) -> Result<Option<Vec<f32>>, VectorStoreError> {
673 self.validate_config()?;
674 let query = format!(
675 "MATCH (n:{label} {{{id_prop}: $id}}) \
676 RETURN n.{embedding_prop} AS embedding",
677 label = self.config.node_label,
678 id_prop = self.config.id_property,
679 embedding_prop = self.config.embedding_property,
680 );
681
682 let response = self.run_query(&query, json!({ "id": id })).await?;
683
684 let result = response.results.first();
685 let Some(neo4j_result) = result else {
686 return Ok(None);
687 };
688
689 let row = neo4j_result.data.first();
690 let Some(row) = row else {
691 return Ok(None);
692 };
693
694 if row.row.is_empty() {
695 return Ok(None);
696 }
697
698 let embedding: Vec<f32> = row.row[0]
699 .as_array()
700 .map(|arr| {
701 arr.iter()
702 .filter_map(|v| v.as_f64().map(|f| f as f32))
703 .collect()
704 })
705 .unwrap_or_default();
706
707 if embedding.is_empty() {
708 Ok(None)
709 } else {
710 Ok(Some(embedding))
711 }
712 }
713
714 async fn delete_document(&self, id: &str) -> Result<(), VectorStoreError> {
715 self.validate_config()?;
716 let query = format!(
717 "MATCH (n:{label} {{{id_prop}: $id}}) \
718 DETACH DELETE n",
719 label = self.config.node_label,
720 id_prop = self.config.id_property,
721 );
722
723 self.run_query(&query, json!({ "id": id })).await?;
724 Ok(())
725 }
726
727 async fn count(&self) -> usize {
728 let query = format!(
729 "MATCH (n:{label}) RETURN count(n) AS cnt",
730 label = self.config.node_label,
731 );
732
733 let result = self.run_query(&query, json!({})).await;
734 match result {
735 Ok(response) => {
736 if let Some(neo4j_result) = response.results.first() {
737 if let Some(row) = neo4j_result.data.first() {
738 if let Some(cnt) = row.row.first() {
739 return cnt.as_u64().unwrap_or(0) as usize;
740 }
741 }
742 }
743 0
744 }
745 Err(_) => 0,
746 }
747 }
748
749 async fn clear(&self) -> Result<(), VectorStoreError> {
750 let query = format!(
751 "MATCH (n:{label}) \
752 DETACH DELETE n",
753 label = self.config.node_label,
754 );
755
756 self.run_query(&query, json!({})).await?;
757 Ok(())
758 }
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764
765 #[test]
766 fn test_config_new() {
767 let config = Neo4jConfig::new("bolt://localhost:7687", "neo4j", "pass", "my_index");
768 assert_eq!(config.uri, "bolt://localhost:7687");
769 assert_eq!(config.username, "neo4j");
770 assert_eq!(config.password, "pass");
771 assert_eq!(config.index_name, "my_index");
772 assert_eq!(config.database, "neo4j");
773 }
774
775 #[test]
776 fn test_config_builder() {
777 let config = Neo4jConfig::new("bolt://localhost:7687", "neo4j", "pass", "idx")
778 .with_database("mydb")
779 .with_node_label("Chunk")
780 .with_embedding_property("vec")
781 .with_content_property("text");
782 assert_eq!(config.database, "mydb");
783 assert_eq!(config.node_label, "Chunk");
784 assert_eq!(config.embedding_property, "vec");
785 assert_eq!(config.content_property, "text");
786 }
787
788 #[test]
789 fn test_config_default() {
790 let config = Neo4jConfig::default();
791 assert_eq!(config.uri, "bolt://localhost:7687");
792 assert_eq!(config.node_label, "Document");
793 assert_eq!(config.embedding_property, "embedding");
794 }
795
796 #[test]
797 fn test_tx_url_bolt() {
798 let config = Neo4jConfig::new("bolt://localhost:7687", "neo4j", "pass", "idx");
799 let store = Neo4jVectorStore::new(config);
800 assert_eq!(store.tx_url(), "http://localhost:7687/db/neo4j/tx/commit");
801 }
802
803 #[test]
804 fn test_tx_url_neo4j_scheme() {
805 let config = Neo4jConfig::new("neo4j://host:7687", "neo4j", "pass", "idx");
806 let store = Neo4jVectorStore::new(config);
807 assert_eq!(store.tx_url(), "http://host:7687/db/neo4j/tx/commit");
808 }
809
810 #[test]
811 fn test_tx_url_bolt_s() {
812 let config = Neo4jConfig::new("bolt+s://host:7687", "neo4j", "pass", "idx");
813 let store = Neo4jVectorStore::new(config);
814 assert_eq!(store.tx_url(), "https://host:7687/db/neo4j/tx/commit");
815 }
816
817 #[test]
818 fn test_tx_url_custom_database() {
819 let config =
820 Neo4jConfig::new("bolt://localhost:7687", "neo4j", "pass", "idx").with_database("mydb");
821 let store = Neo4jVectorStore::new(config);
822 assert_eq!(store.tx_url(), "http://localhost:7687/db/mydb/tx/commit");
823 }
824
825 #[test]
826 fn test_base64_encode() {
827 let encoded = base64_encode("neo4j:password".to_string());
829 assert_eq!(encoded, "bmVvNGo6cGFzc3dvcmQ=");
830 }
831
832 #[test]
833 fn test_base64_encode_empty() {
834 let encoded = base64_encode(String::new());
835 assert_eq!(encoded, "");
836 }
837
838 #[test]
839 fn test_store_new() {
840 let config = Neo4jConfig::new("bolt://localhost:7687", "neo4j", "pass", "idx");
841 let _store = Neo4jVectorStore::new(config);
842 }
843
844 #[test]
845 fn test_store_debug() {
846 let config = Neo4jConfig::new("bolt://localhost:7687", "neo4j", "pass", "idx");
847 let store = Neo4jVectorStore::new(config);
848 let debug_str = format!("{:?}", store);
849 assert!(debug_str.contains("Neo4jVectorStore"));
850 assert!(debug_str.contains("idx"));
851 }
852
853 #[test]
855 fn test_validate_cypher_identifier() {
856 for good in ["metadata", "embedding", "_x", "a1", "node_label"] {
858 assert!(validate_cypher_identifier(good).is_ok(), "{good:?} should be accepted");
859 }
860 for bad in ["", "1x", "meta data", "metadata] DETACH", "metadata}", "`meta`", "a-b", "a.b"] {
863 assert!(
864 validate_cypher_identifier(bad).is_err(),
865 "{bad:?} should be rejected"
866 );
867 }
868 }
869
870 #[test]
872 fn test_filter_to_cypher_rejects_unsafe_property() {
873 let f = MetadataFilter::field("lang", FilterOp::Eq, "rust");
874 let err = filter_to_cypher(&f, "metadata] RETURN 1").unwrap_err();
875 assert!(
876 matches!(err, VectorStoreError::ConfigError(_)),
877 "expected ConfigError, got {err:?}"
878 );
879 }
880
881 #[tokio::test]
884 async fn test_store_op_rejects_unsafe_config_without_network() {
885 let config = Neo4jConfig::new("bolt://localhost:7687", "neo4j", "pass", "idx")
886 .with_embedding_property("embedding] DETACH DELETE n");
887 let store = Neo4jVectorStore::new(config);
888
889 let err = store
890 .add_documents(vec![], vec![])
891 .await
892 .expect_err("unsafe config must fail before the network");
893 assert!(
894 matches!(err, VectorStoreError::ConfigError(_)),
895 "expected ConfigError, got {err:?}"
896 );
897 }
898
899 #[test]
901 fn test_filter_to_cypher_field() {
902 let f = MetadataFilter::field("lang", FilterOp::Eq, "rust");
903 let (expr, params) = filter_to_cypher(&f, "metadata").unwrap();
904 assert_eq!(expr, "node.metadata[$f0k] = $f0v");
905 assert_eq!(params["f0k"], "lang");
906 assert_eq!(params["f0v"], "rust");
907
908 let (expr, params) = filter_to_cypher(
909 &MetadataFilter::field("year", FilterOp::Gte, 2020),
910 "metadata",
911 )
912 .unwrap();
913 assert_eq!(expr, "node.metadata[$f0k] >= $f0v");
914 assert_eq!(params["f0v"], 2020);
915
916 let (expr, _) = filter_to_cypher(
918 &MetadataFilter::field("lang", FilterOp::Ne, "rust"),
919 "metadata",
920 )
921 .unwrap();
922 assert_eq!(expr, "node.metadata[$f0k] <> $f0v");
923 }
924
925 #[test]
927 fn test_filter_to_cypher_in_nin() {
928 let (expr, params) = filter_to_cypher(
929 &MetadataFilter::field("tags", FilterOp::In, vec!["a", "b"]),
930 "meta",
931 )
932 .unwrap();
933 assert_eq!(expr, "node.meta[$f0k] IN $f0v");
934 assert_eq!(params["f0v"], serde_json::json!(["a", "b"]));
935
936 let (expr, _) = filter_to_cypher(
937 &MetadataFilter::field("tags", FilterOp::Nin, vec!["x"]),
938 "meta",
939 )
940 .unwrap();
941 assert_eq!(expr, "NOT node.meta[$f0k] IN $f0v");
942
943 let err = filter_to_cypher(&MetadataFilter::field("tags", FilterOp::In, "oops"), "meta");
945 assert!(matches!(err, Err(VectorStoreError::UnsupportedFilter(_))));
946 }
947
948 #[test]
950 fn test_filter_to_cypher_and_or() {
951 let f = MetadataFilter::or(vec![
952 MetadataFilter::field("lang", FilterOp::Eq, "python"),
953 MetadataFilter::and(vec![
954 MetadataFilter::field("lang", FilterOp::Eq, "rust"),
955 MetadataFilter::field("year", FilterOp::Gt, 2020),
956 ]),
957 ]);
958 let (expr, params) = filter_to_cypher(&f, "metadata").unwrap();
959 assert_eq!(
960 expr,
961 "(node.metadata[$f0k] = $f0v) OR ((node.metadata[$f1k] = $f1v) AND (node.metadata[$f2k] > $f2v))"
962 );
963 assert_eq!(params["f0v"], "python");
964 assert_eq!(params["f1v"], "rust");
965 assert_eq!(params["f2v"], 2020);
966 }
967
968 #[test]
970 fn test_filter_to_cypher_unsupported_value() {
971 let f = MetadataFilter::field("nested", FilterOp::Eq, serde_json::json!({ "a": 1 }));
972 assert!(matches!(
973 filter_to_cypher(&f, "metadata"),
974 Err(VectorStoreError::UnsupportedFilter(_))
975 ));
976 }
977}