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    /// 0.26.0 §1.9: validates every config string that is interpolated verbatim into a
189    /// Cypher statement (node label + the four property names). Called at the top of each
190    /// store operation so an unsafe name fails-fast before it reaches the DB.
191    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    /// Executes a Cypher query via the HTTP transaction API.
201    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        // Check for Neo4j-level errors
244        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    /// `db.index.vector.queryNodes` 前缀(不含 WHERE/RETURN)。
259    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    /// RETURN 子句与排序(普通与过滤检索共用)。
266    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    /// 把 queryNodes 的返回行解析成 [`SearchResult`](普通与过滤检索共用)。
280    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                // Parse metadata from JSON object
295                if let Some(meta_obj) = row.row[2].as_object() {
296                    for (key, value) in meta_obj {
297                        // M-22: preserve the original value (number / bool / object).
298                        // Coercing everything through `to_string()` turned `2020` into
299                        // "2020" / `true` into "true", so server-side numeric filtering
300                        // matched but the returned type came back wrong.
301                        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
316/// S3: [`MetadataFilter`] → Cypher `WHERE` 表达式 + 参数表。
317///
318/// - 每个字段条件生成 `node.{metadata_prop}[$fNk] <op> $fNv`,key 与 value 全部
319///   参数化(防 Cypher 注入);参数名单调递增。
320/// - `In`/`Nin` 要求值是数组,生成 `... IN $fNv` / `NOT ... IN $fNv`。
321/// - 标量操作(`Eq/Ne/Gt/Gte/Lt/Lte`)只接受字符串/数字/布尔值,其余类型返回
322///   [`VectorStoreError::UnsupportedFilter`],不静默忽略。
323pub fn filter_to_cypher(
324    filter: &MetadataFilter,
325    metadata_prop: &str,
326) -> Result<(String, serde_json::Value), VectorStoreError> {
327    // 0.26.0 §1.9: `metadata_prop` is interpolated into `node.{prop}[$k]` verbatim —
328    // refuse anything that is not a bare Cypher identifier before it reaches the query.
329    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
336/// 递归翻译的计数器(保证参数名唯一)。
337struct 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                // 节点上的 metadata 属性是 map,用 `node.metadata[$k]` 按下标取键值
356                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
419/// S3 / 0.26.0 §1.9: validates a name that will be interpolated verbatim into a Cypher
420/// statement (node label, `{id,content,metadata,embedding}_property`) is a **bare Cypher
421/// identifier**: `[A-Za-z_][A-Za-z0-9_]*`. The property names and label are configuration
422/// strings inserted raw into `node.{prop}` / `(n:{label})` positions; pgvector already
423/// whitelists these, neo4j previously did not. Rejecting anything wider (escaped backtick
424/// identifiers included) keeps the whitelist tight and closes the gap.
425pub(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
464/// Base64 encoding helper (no external dependency needed).
465fn 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// ---------------------------------------------------------------------------
494// Neo4j HTTP API response types
495// ---------------------------------------------------------------------------
496
497/// Neo4j transaction commit response.
498#[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        // Build UNWIND Cypher for batch insert
543        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    /// S3: 带元数据过滤的相似度检索 —— 用 Cypher `WHERE` 在服务端过滤
603    /// `db.index.vector.queryNodes` 返回的结果(参数化 key/value,防注入)。
604    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                    // M-22: preserve original number/bool/object values (no string coercion).
664                    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        // "neo4j:password" in base64
828        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    /// 0.26.0 §1.9: bare-identifier whitelist for interpolated label/property names.
854    #[test]
855    fn test_validate_cypher_identifier() {
856        // Accept plain bare identifiers.
857        for good in ["metadata", "embedding", "_x", "a1", "node_label"] {
858            assert!(validate_cypher_identifier(good).is_ok(), "{good:?} should be accepted");
859        }
860        // Reject anything that is not a bare identifier (injection surface when
861        // interpolated verbatim into `node.{prop}` / `(n:{label})`).
862        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    /// 0.26.0 §1.9: the public Cypher translator refuses a non-identifier metadata property.
871    #[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    /// 0.26.0 §1.9: a store configured with an unsafe interpolated value must fail-fast
882    /// (ConfigError) before any network call, on every store operation.
883    #[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    /// S3: 单字段条件 → 参数化 Cypher 表达式 + 参数表。
900    #[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        // Ne → <>(Cypher 的不等于)
917        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    /// S3: IN/NOT IN → 数组参数。
926    #[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        // IN 值非数组 → 显式报错
944        let err = filter_to_cypher(&MetadataFilter::field("tags", FilterOp::In, "oops"), "meta");
945        assert!(matches!(err, Err(VectorStoreError::UnsupportedFilter(_))));
946    }
947
948    /// S3: AND/OR 组合 → 括号 + 参数名继续递增不冲突。
949    #[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    /// S3: 不可表达的标量比较(对象值)显式报错。
969    #[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}