Skip to main content

lc_vector_stores/
neo4j.rs

1// lc-vector-stores/src/neo4j.rs
2//! Neo4j vector store implementation.
3//!
4//! Uses Neo4j's vector index feature (available since Neo4j 5.11) for
5//! similarity search via the Cypher API over HTTP.
6//!
7//! # Example
8//!
9//! ```rust,ignore
10//! use lc_vector_stores::neo4j::{Neo4jVectorStore, Neo4jConfig};
11//!
12//! let config = Neo4jConfig::new("bolt://localhost:7687", "neo4j", "password", "my_index");
13//! let store = Neo4jVectorStore::new(config);
14//! store.add_documents(docs, embeddings).await?;
15//! let results = store.similarity_search(&query_embedding, 5).await?;
16//! ```
17
18use async_trait::async_trait;
19use serde::Deserialize;
20use serde_json::json;
21
22use crate::{Document, FilterOp, MetadataFilter, SearchResult, VectorStore, VectorStoreError};
23
24/// Neo4j vector store configuration.
25#[derive(Debug, Clone)]
26pub struct Neo4jConfig {
27    /// Neo4j URI (e.g., "bolt://localhost:7687" or "neo4j://localhost:7687").
28    pub uri: String,
29    /// Username.
30    pub username: String,
31    /// Password.
32    pub password: String,
33    /// Database name (default: "neo4j").
34    pub database: String,
35    /// Node label for vector documents (default: "Document").
36    pub node_label: String,
37    /// Vector index name.
38    pub index_name: String,
39    /// Embedding property name on the node (default: "embedding").
40    pub embedding_property: String,
41    /// Content property name on the node (default: "content").
42    pub content_property: String,
43    /// Metadata property name on the node (default: "metadata").
44    pub metadata_property: String,
45    /// ID property name on the node (default: "id").
46    pub id_property: String,
47}
48
49impl Neo4jConfig {
50    /// Creates a new Neo4jConfig.
51    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    /// Creates config from environment variables.
72    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    /// Sets the database name.
99    pub fn with_database(mut self, database: impl Into<String>) -> Self {
100        self.database = database.into();
101        self
102    }
103
104    /// Sets the node label.
105    pub fn with_node_label(mut self, label: impl Into<String>) -> Self {
106        self.node_label = label.into();
107        self
108    }
109
110    /// Sets the embedding property name.
111    pub fn with_embedding_property(mut self, prop: impl Into<String>) -> Self {
112        self.embedding_property = prop.into();
113        self
114    }
115
116    /// Sets the content property name.
117    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
140/// Neo4j vector store.
141///
142/// Communicates with Neo4j via the HTTP transaction API.
143pub 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    /// Creates a new Neo4jVectorStore with the given configuration.
159    pub fn new(config: Neo4jConfig) -> Self {
160        Self {
161            config,
162            client: reqwest::Client::new(),
163        }
164    }
165
166    /// Creates from environment variables.
167    pub fn from_env_result() -> Result<Self, VectorStoreError> {
168        Ok(Self::new(Neo4jConfig::from_env_result()?))
169    }
170
171    /// Builds the HTTP API URL for the transaction endpoint.
172    fn tx_url(&self) -> String {
173        // Convert bolt:// or neo4j:// to http:// for the REST API
174        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    /// Executes a Cypher query via the HTTP transaction API.
189    async fn run_query(
190        &self,
191        query: &str,
192        params: serde_json::Value,
193    ) -> Result<Neo4jResponse, VectorStoreError> {
194        let body = json!({
195            "statements": [{
196                "statement": query,
197                "parameters": params,
198            }]
199        });
200
201        let response = self
202            .client
203            .post(self.tx_url())
204            .header("Content-Type", "application/json")
205            .header(
206                "Authorization",
207                format!(
208                    "Basic {}",
209                    base64_encode(format!("{}:{}", self.config.username, self.config.password))
210                ),
211            )
212            .json(&body)
213            .send()
214            .await
215            .map_err(|e| VectorStoreError::ConnectionError(e.to_string()))?;
216
217        let status = response.status();
218        if !status.is_success() {
219            let error_text = response.text().await.unwrap_or_default();
220            return Err(VectorStoreError::ConnectionError(format!(
221                "HTTP {}: {}",
222                status, error_text
223            )));
224        }
225
226        let neo4j_response: Neo4jResponse = response
227            .json()
228            .await
229            .map_err(|e| VectorStoreError::StorageError(e.to_string()))?;
230
231        // Check for Neo4j-level errors
232        if let Some(errors) = &neo4j_response.errors {
233            if !errors.is_empty() {
234                let msg = errors
235                    .iter()
236                    .map(|e| e.message.clone())
237                    .collect::<Vec<_>>()
238                    .join("; ");
239                return Err(VectorStoreError::StorageError(msg));
240            }
241        }
242
243        Ok(neo4j_response)
244    }
245
246    /// `db.index.vector.queryNodes` 前缀(不含 WHERE/RETURN)。
247    fn search_query_prefix(&self) -> String {
248        "CALL db.index.vector.queryNodes($index_name, $k, $query_vector) \
249         YIELD node, score"
250            .to_string()
251    }
252
253    /// RETURN 子句与排序(普通与过滤检索共用)。
254    fn search_query_suffix(&self) -> String {
255        format!(
256            " RETURN node.{id_prop} AS id, \
257                    node.{content_prop} AS content, \
258                    node.{metadata_prop} AS metadata, \
259                    score \
260             ORDER BY score DESC",
261            id_prop = self.config.id_property,
262            content_prop = self.config.content_property,
263            metadata_prop = self.config.metadata_property,
264        )
265    }
266
267    /// 把 queryNodes 的返回行解析成 [`SearchResult`](普通与过滤检索共用)。
268    fn parse_search_results(&self, response: Neo4jResponse) -> Vec<SearchResult> {
269        let Some(neo4j_result) = response.results.first() else {
270            return Vec::new();
271        };
272
273        let mut search_results = Vec::new();
274        for row in &neo4j_result.data {
275            if row.row.len() >= 4 {
276                let id = row.row[0].as_str().unwrap_or_default().to_string();
277                let content = row.row[1].as_str().unwrap_or_default().to_string();
278                let score = row.row[3].as_f64().unwrap_or(0.0) as f32;
279
280                let mut doc = Document::new(content).with_id(id);
281
282                // Parse metadata from JSON object
283                if let Some(meta_obj) = row.row[2].as_object() {
284                    for (key, value) in meta_obj {
285                        if let Some(s) = value.as_str() {
286                            doc = doc.with_metadata(key, s);
287                        } else {
288                            doc = doc.with_metadata(key, value.to_string());
289                        }
290                    }
291                }
292
293                search_results.push(SearchResult {
294                    document: doc,
295                    score,
296                });
297            }
298        }
299
300        search_results
301    }
302}
303
304/// S3: [`MetadataFilter`] → Cypher `WHERE` 表达式 + 参数表。
305///
306/// - 每个字段条件生成 `node.{metadata_prop}[$fNk] <op> $fNv`,key 与 value 全部
307///   参数化(防 Cypher 注入);参数名单调递增。
308/// - `In`/`Nin` 要求值是数组,生成 `... IN $fNv` / `NOT ... IN $fNv`。
309/// - 标量操作(`Eq/Ne/Gt/Gte/Lt/Lte`)只接受字符串/数字/布尔值,其余类型返回
310///   [`VectorStoreError::UnsupportedFilter`],不静默忽略。
311pub fn filter_to_cypher(
312    filter: &MetadataFilter,
313    metadata_prop: &str,
314) -> Result<(String, serde_json::Value), VectorStoreError> {
315    let mut state = CypherState { next: 0 };
316    let mut params = serde_json::Map::new();
317    let expr = state.expr(filter, metadata_prop, &mut params)?;
318    Ok((expr, serde_json::Value::Object(params)))
319}
320
321/// 递归翻译的计数器(保证参数名唯一)。
322struct CypherState {
323    next: usize,
324}
325
326impl CypherState {
327    fn expr(
328        &mut self,
329        filter: &MetadataFilter,
330        metadata_prop: &str,
331        params: &mut serde_json::Map<String, serde_json::Value>,
332    ) -> Result<String, VectorStoreError> {
333        match filter {
334            MetadataFilter::Field { key, op, value } => {
335                let kp = format!("f{}k", self.next);
336                let vp = format!("f{}v", self.next);
337                self.next += 1;
338
339                params.insert(kp.clone(), serde_json::Value::String(key.clone()));
340                // 节点上的 metadata 属性是 map,用 `node.metadata[$k]` 按下标取键值
341                let target = format!("node.{}[${}]", metadata_prop, kp);
342
343                match op {
344                    FilterOp::In | FilterOp::Nin => {
345                        let arr = value.as_array().ok_or_else(|| {
346                            VectorStoreError::UnsupportedFilter(format!(
347                                "IN/NIN requires an array value for field `{}`, got {}",
348                                key,
349                                value_type_name(value)
350                            ))
351                        })?;
352                        params.insert(vp.clone(), serde_json::Value::Array(arr.clone()));
353                        if matches!(op, FilterOp::In) {
354                            Ok(format!("{} IN ${}", target, vp))
355                        } else {
356                            Ok(format!("NOT {} IN ${}", target, vp))
357                        }
358                    }
359                    _ => {
360                        if !matches!(
361                            value,
362                            serde_json::Value::String(_)
363                                | serde_json::Value::Number(_)
364                                | serde_json::Value::Bool(_)
365                        ) {
366                            return Err(VectorStoreError::UnsupportedFilter(format!(
367                                "cannot translate value of type {} to a Cypher comparison for field `{}`",
368                                value_type_name(value),
369                                key
370                            )));
371                        }
372                        params.insert(vp.clone(), value.clone());
373                        Ok(format!("{} {} ${}", target, cypher_op(op), vp))
374                    }
375                }
376            }
377            MetadataFilter::And(filters) => self.join(filters, metadata_prop, params, "AND"),
378            MetadataFilter::Or(filters) => self.join(filters, metadata_prop, params, "OR"),
379        }
380    }
381
382    fn join(
383        &mut self,
384        filters: &[MetadataFilter],
385        metadata_prop: &str,
386        params: &mut serde_json::Map<String, serde_json::Value>,
387        keyword: &str,
388    ) -> Result<String, VectorStoreError> {
389        let parts = filters
390            .iter()
391            .map(|f| self.expr(f, metadata_prop, params))
392            .collect::<Result<Vec<String>, _>>()?;
393        if parts.is_empty() {
394            return Ok("TRUE".to_string());
395        }
396        Ok(parts
397            .iter()
398            .map(|p| format!("({})", p))
399            .collect::<Vec<_>>()
400            .join(&format!(" {} ", keyword)))
401    }
402}
403
404fn cypher_op(op: &FilterOp) -> &'static str {
405    match op {
406        FilterOp::Eq => "=",
407        FilterOp::Ne => "<>",
408        FilterOp::Gt => ">",
409        FilterOp::Gte => ">=",
410        FilterOp::Lt => "<",
411        FilterOp::Lte => "<=",
412        FilterOp::In | FilterOp::Nin => unreachable!("handled by filter_to_cypher"),
413    }
414}
415
416fn value_type_name(value: &serde_json::Value) -> &'static str {
417    match value {
418        serde_json::Value::Null => "null",
419        serde_json::Value::Bool(_) => "boolean",
420        serde_json::Value::Number(_) => "number",
421        serde_json::Value::String(_) => "string",
422        serde_json::Value::Array(_) => "array",
423        serde_json::Value::Object(_) => "object",
424    }
425}
426
427/// Base64 encoding helper (no external dependency needed).
428fn base64_encode(input: String) -> String {
429    const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
430    let bytes = input.as_bytes();
431    let mut result = String::new();
432    let mut i = 0;
433    while i < bytes.len() {
434        let b0 = bytes[i];
435        let b1 = if i + 1 < bytes.len() { bytes[i + 1] } else { 0 };
436        let b2 = if i + 2 < bytes.len() { bytes[i + 2] } else { 0 };
437
438        result.push(CHARSET[((b0 >> 2) & 0x3F) as usize] as char);
439        result.push(CHARSET[(((b0 << 4) | (b1 >> 4)) & 0x3F) as usize] as char);
440        result.push(if i + 1 < bytes.len() {
441            CHARSET[(((b1 << 2) | (b2 >> 6)) & 0x3F) as usize] as char
442        } else {
443            '='
444        });
445        result.push(if i + 2 < bytes.len() {
446            CHARSET[(b2 & 0x3F) as usize] as char
447        } else {
448            '='
449        });
450
451        i += 3;
452    }
453    result
454}
455
456// ---------------------------------------------------------------------------
457// Neo4j HTTP API response types
458// ---------------------------------------------------------------------------
459
460/// Neo4j transaction commit response.
461#[derive(Debug, Deserialize)]
462struct Neo4jResponse {
463    results: Vec<Neo4jResult>,
464    errors: Option<Vec<Neo4jError>>,
465}
466
467#[derive(Debug, Deserialize)]
468struct Neo4jResult {
469    data: Vec<Neo4jRow>,
470}
471
472#[derive(Debug, Deserialize)]
473struct Neo4jRow {
474    row: Vec<serde_json::Value>,
475}
476
477#[derive(Debug, Deserialize)]
478struct Neo4jError {
479    message: String,
480}
481
482#[async_trait]
483impl VectorStore for Neo4jVectorStore {
484    async fn add_documents(
485        &self,
486        documents: Vec<Document>,
487        embeddings: Vec<Vec<f32>>,
488    ) -> Result<Vec<String>, VectorStoreError> {
489        if documents.len() != embeddings.len() {
490            return Err(VectorStoreError::EmbeddingError(
491                "Number of documents and embeddings must match".to_string(),
492            ));
493        }
494
495        let ids: Vec<String> = documents
496            .iter()
497            .map(|doc| {
498                doc.id
499                    .clone()
500                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
501            })
502            .collect();
503
504        // Build UNWIND Cypher for batch insert
505        let rows: Vec<serde_json::Value> = documents
506            .into_iter()
507            .zip(embeddings)
508            .zip(ids.iter())
509            .map(|((doc, vec), id)| {
510                let metadata: serde_json::Value = doc
511                    .metadata
512                    .iter()
513                    .map(|(k, v)| (k.clone(), json!(v)))
514                    .collect();
515                json!({
516                    "id": id,
517                    "content": doc.content,
518                    "embedding": vec,
519                    "metadata": metadata,
520                })
521            })
522            .collect();
523
524        let query = format!(
525            "UNWIND $rows AS row \
526             MERGE (n:{label} {{{id_prop}: row.id}}) \
527             SET n.{content_prop} = row.content, \
528                 n.{embedding_prop} = row.embedding, \
529                 n.{metadata_prop} = row.metadata",
530            label = self.config.node_label,
531            id_prop = self.config.id_property,
532            content_prop = self.config.content_property,
533            embedding_prop = self.config.embedding_property,
534            metadata_prop = self.config.metadata_property,
535        );
536
537        self.run_query(&query, json!({ "rows": rows })).await?;
538
539        Ok(ids)
540    }
541
542    async fn similarity_search(
543        &self,
544        query_embedding: &[f32],
545        k: usize,
546    ) -> Result<Vec<SearchResult>, VectorStoreError> {
547        let query = format!(
548            "{}{}",
549            self.search_query_prefix(),
550            self.search_query_suffix()
551        );
552
553        let params = json!({
554            "index_name": self.config.index_name,
555            "k": k,
556            "query_vector": query_embedding,
557        });
558
559        let response = self.run_query(&query, params).await?;
560        Ok(self.parse_search_results(response))
561    }
562
563    /// S3: 带元数据过滤的相似度检索 —— 用 Cypher `WHERE` 在服务端过滤
564    /// `db.index.vector.queryNodes` 返回的结果(参数化 key/value,防注入)。
565    async fn similarity_search_with_filter(
566        &self,
567        query_embedding: &[f32],
568        k: usize,
569        filter: Option<&MetadataFilter>,
570    ) -> Result<Vec<SearchResult>, VectorStoreError> {
571        let mut query = self.search_query_prefix();
572        let mut params = json!({
573            "index_name": self.config.index_name,
574            "k": k,
575            "query_vector": query_embedding,
576        });
577        if let Some(f) = filter {
578            let (where_clause, extra_params) = filter_to_cypher(f, &self.config.metadata_property)?;
579            query.push_str(&format!(" WHERE {}", where_clause));
580            if let (Some(base), Some(extra)) = (params.as_object_mut(), extra_params.as_object()) {
581                base.extend(extra.clone());
582            }
583        }
584        query.push_str(&self.search_query_suffix());
585
586        let response = self.run_query(&query, params).await?;
587        Ok(self.parse_search_results(response))
588    }
589
590    async fn get_document(&self, id: &str) -> Result<Option<Document>, VectorStoreError> {
591        let query = format!(
592            "MATCH (n:{label} {{{id_prop}: $id}}) \
593             RETURN n.{content_prop} AS content, n.{metadata_prop} AS metadata",
594            label = self.config.node_label,
595            id_prop = self.config.id_property,
596            content_prop = self.config.content_property,
597            metadata_prop = self.config.metadata_property,
598        );
599
600        let response = self.run_query(&query, json!({ "id": id })).await?;
601
602        let result = response.results.first();
603        let Some(neo4j_result) = result else {
604            return Ok(None);
605        };
606
607        let row = neo4j_result.data.first();
608        let Some(row) = row else {
609            return Ok(None);
610        };
611
612        if row.row.is_empty() {
613            return Ok(None);
614        }
615
616        let content = row.row[0].as_str().unwrap_or_default().to_string();
617        let mut doc = Document::new(content).with_id(id);
618
619        if row.row.len() > 1 {
620            if let Some(meta_obj) = row.row[1].as_object() {
621                for (key, value) in meta_obj {
622                    if let Some(s) = value.as_str() {
623                        doc = doc.with_metadata(key, s);
624                    } else {
625                        doc = doc.with_metadata(key, value.to_string());
626                    }
627                }
628            }
629        }
630
631        Ok(Some(doc))
632    }
633
634    async fn get_embedding(&self, id: &str) -> Result<Option<Vec<f32>>, VectorStoreError> {
635        let query = format!(
636            "MATCH (n:{label} {{{id_prop}: $id}}) \
637             RETURN n.{embedding_prop} AS embedding",
638            label = self.config.node_label,
639            id_prop = self.config.id_property,
640            embedding_prop = self.config.embedding_property,
641        );
642
643        let response = self.run_query(&query, json!({ "id": id })).await?;
644
645        let result = response.results.first();
646        let Some(neo4j_result) = result else {
647            return Ok(None);
648        };
649
650        let row = neo4j_result.data.first();
651        let Some(row) = row else {
652            return Ok(None);
653        };
654
655        if row.row.is_empty() {
656            return Ok(None);
657        }
658
659        let embedding: Vec<f32> = row.row[0]
660            .as_array()
661            .map(|arr| {
662                arr.iter()
663                    .filter_map(|v| v.as_f64().map(|f| f as f32))
664                    .collect()
665            })
666            .unwrap_or_default();
667
668        if embedding.is_empty() {
669            Ok(None)
670        } else {
671            Ok(Some(embedding))
672        }
673    }
674
675    async fn delete_document(&self, id: &str) -> Result<(), VectorStoreError> {
676        let query = format!(
677            "MATCH (n:{label} {{{id_prop}: $id}}) \
678             DETACH DELETE n",
679            label = self.config.node_label,
680            id_prop = self.config.id_property,
681        );
682
683        self.run_query(&query, json!({ "id": id })).await?;
684        Ok(())
685    }
686
687    async fn count(&self) -> usize {
688        let query = format!(
689            "MATCH (n:{label}) RETURN count(n) AS cnt",
690            label = self.config.node_label,
691        );
692
693        let result = self.run_query(&query, json!({})).await;
694        match result {
695            Ok(response) => {
696                if let Some(neo4j_result) = response.results.first() {
697                    if let Some(row) = neo4j_result.data.first() {
698                        if let Some(cnt) = row.row.first() {
699                            return cnt.as_u64().unwrap_or(0) as usize;
700                        }
701                    }
702                }
703                0
704            }
705            Err(_) => 0,
706        }
707    }
708
709    async fn clear(&self) -> Result<(), VectorStoreError> {
710        let query = format!(
711            "MATCH (n:{label}) \
712             DETACH DELETE n",
713            label = self.config.node_label,
714        );
715
716        self.run_query(&query, json!({})).await?;
717        Ok(())
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    #[test]
726    fn test_config_new() {
727        let config = Neo4jConfig::new("bolt://localhost:7687", "neo4j", "pass", "my_index");
728        assert_eq!(config.uri, "bolt://localhost:7687");
729        assert_eq!(config.username, "neo4j");
730        assert_eq!(config.password, "pass");
731        assert_eq!(config.index_name, "my_index");
732        assert_eq!(config.database, "neo4j");
733    }
734
735    #[test]
736    fn test_config_builder() {
737        let config = Neo4jConfig::new("bolt://localhost:7687", "neo4j", "pass", "idx")
738            .with_database("mydb")
739            .with_node_label("Chunk")
740            .with_embedding_property("vec")
741            .with_content_property("text");
742        assert_eq!(config.database, "mydb");
743        assert_eq!(config.node_label, "Chunk");
744        assert_eq!(config.embedding_property, "vec");
745        assert_eq!(config.content_property, "text");
746    }
747
748    #[test]
749    fn test_config_default() {
750        let config = Neo4jConfig::default();
751        assert_eq!(config.uri, "bolt://localhost:7687");
752        assert_eq!(config.node_label, "Document");
753        assert_eq!(config.embedding_property, "embedding");
754    }
755
756    #[test]
757    fn test_tx_url_bolt() {
758        let config = Neo4jConfig::new("bolt://localhost:7687", "neo4j", "pass", "idx");
759        let store = Neo4jVectorStore::new(config);
760        assert_eq!(store.tx_url(), "http://localhost:7687/db/neo4j/tx/commit");
761    }
762
763    #[test]
764    fn test_tx_url_neo4j_scheme() {
765        let config = Neo4jConfig::new("neo4j://host:7687", "neo4j", "pass", "idx");
766        let store = Neo4jVectorStore::new(config);
767        assert_eq!(store.tx_url(), "http://host:7687/db/neo4j/tx/commit");
768    }
769
770    #[test]
771    fn test_tx_url_bolt_s() {
772        let config = Neo4jConfig::new("bolt+s://host:7687", "neo4j", "pass", "idx");
773        let store = Neo4jVectorStore::new(config);
774        assert_eq!(store.tx_url(), "https://host:7687/db/neo4j/tx/commit");
775    }
776
777    #[test]
778    fn test_tx_url_custom_database() {
779        let config =
780            Neo4jConfig::new("bolt://localhost:7687", "neo4j", "pass", "idx").with_database("mydb");
781        let store = Neo4jVectorStore::new(config);
782        assert_eq!(store.tx_url(), "http://localhost:7687/db/mydb/tx/commit");
783    }
784
785    #[test]
786    fn test_base64_encode() {
787        // "neo4j:password" in base64
788        let encoded = base64_encode("neo4j:password".to_string());
789        assert_eq!(encoded, "bmVvNGo6cGFzc3dvcmQ=");
790    }
791
792    #[test]
793    fn test_base64_encode_empty() {
794        let encoded = base64_encode(String::new());
795        assert_eq!(encoded, "");
796    }
797
798    #[test]
799    fn test_store_new() {
800        let config = Neo4jConfig::new("bolt://localhost:7687", "neo4j", "pass", "idx");
801        let _store = Neo4jVectorStore::new(config);
802    }
803
804    #[test]
805    fn test_store_debug() {
806        let config = Neo4jConfig::new("bolt://localhost:7687", "neo4j", "pass", "idx");
807        let store = Neo4jVectorStore::new(config);
808        let debug_str = format!("{:?}", store);
809        assert!(debug_str.contains("Neo4jVectorStore"));
810        assert!(debug_str.contains("idx"));
811    }
812
813    /// S3: 单字段条件 → 参数化 Cypher 表达式 + 参数表。
814    #[test]
815    fn test_filter_to_cypher_field() {
816        let f = MetadataFilter::field("lang", FilterOp::Eq, "rust");
817        let (expr, params) = filter_to_cypher(&f, "metadata").unwrap();
818        assert_eq!(expr, "node.metadata[$f0k] = $f0v");
819        assert_eq!(params["f0k"], "lang");
820        assert_eq!(params["f0v"], "rust");
821
822        let (expr, params) = filter_to_cypher(
823            &MetadataFilter::field("year", FilterOp::Gte, 2020),
824            "metadata",
825        )
826        .unwrap();
827        assert_eq!(expr, "node.metadata[$f0k] >= $f0v");
828        assert_eq!(params["f0v"], 2020);
829
830        // Ne → <>(Cypher 的不等于)
831        let (expr, _) = filter_to_cypher(
832            &MetadataFilter::field("lang", FilterOp::Ne, "rust"),
833            "metadata",
834        )
835        .unwrap();
836        assert_eq!(expr, "node.metadata[$f0k] <> $f0v");
837    }
838
839    /// S3: IN/NOT IN → 数组参数。
840    #[test]
841    fn test_filter_to_cypher_in_nin() {
842        let (expr, params) = filter_to_cypher(
843            &MetadataFilter::field("tags", FilterOp::In, vec!["a", "b"]),
844            "meta",
845        )
846        .unwrap();
847        assert_eq!(expr, "node.meta[$f0k] IN $f0v");
848        assert_eq!(params["f0v"], serde_json::json!(["a", "b"]));
849
850        let (expr, _) = filter_to_cypher(
851            &MetadataFilter::field("tags", FilterOp::Nin, vec!["x"]),
852            "meta",
853        )
854        .unwrap();
855        assert_eq!(expr, "NOT node.meta[$f0k] IN $f0v");
856
857        // IN 值非数组 → 显式报错
858        let err = filter_to_cypher(&MetadataFilter::field("tags", FilterOp::In, "oops"), "meta");
859        assert!(matches!(err, Err(VectorStoreError::UnsupportedFilter(_))));
860    }
861
862    /// S3: AND/OR 组合 → 括号 + 参数名继续递增不冲突。
863    #[test]
864    fn test_filter_to_cypher_and_or() {
865        let f = MetadataFilter::or(vec![
866            MetadataFilter::field("lang", FilterOp::Eq, "python"),
867            MetadataFilter::and(vec![
868                MetadataFilter::field("lang", FilterOp::Eq, "rust"),
869                MetadataFilter::field("year", FilterOp::Gt, 2020),
870            ]),
871        ]);
872        let (expr, params) = filter_to_cypher(&f, "metadata").unwrap();
873        assert_eq!(
874            expr,
875            "(node.metadata[$f0k] = $f0v) OR ((node.metadata[$f1k] = $f1v) AND (node.metadata[$f2k] > $f2v))"
876        );
877        assert_eq!(params["f0v"], "python");
878        assert_eq!(params["f1v"], "rust");
879        assert_eq!(params["f2v"], 2020);
880    }
881
882    /// S3: 不可表达的标量比较(对象值)显式报错。
883    #[test]
884    fn test_filter_to_cypher_unsupported_value() {
885        let f = MetadataFilter::field("nested", FilterOp::Eq, serde_json::json!({ "a": 1 }));
886        assert!(matches!(
887            filter_to_cypher(&f, "metadata"),
888            Err(VectorStoreError::UnsupportedFilter(_))
889        ));
890    }
891}