use std::sync::Arc;
use tokio::sync::Mutex as AsyncMutex;
use crate::database::graph::{
GraphConnection, GraphExecResult, GraphQueryResult, GraphRow, GraphTransaction, GraphValue,
};
use crate::foundation::{DbError, DbResult};
#[derive(Clone)]
pub struct Neo4jConnection {
graph: Option<Arc<neo4rs::Graph>>,
}
impl Neo4jConnection {
pub async fn new(uri: &str, user: &str, password: &str) -> DbResult<Self> {
let graph = neo4rs::Graph::new(uri, user, password)
.await
.map_err(|e| DbError::Connection(sea_orm::DbErr::Custom(format!("neo4j connect: {e}"))))?;
Ok(Self {
graph: Some(Arc::new(graph)),
})
}
pub fn parse_url(url: &str) -> DbResult<(String, String, String)> {
match url::Url::parse(url) {
Ok(parsed) => {
let scheme = parsed.scheme();
let host = parsed.host_str().unwrap_or("localhost");
let port = parsed.port().unwrap_or(7687);
let uri = format!("{scheme}://{host}:{port}");
let user = parsed.username().to_string();
let password = parsed.password().unwrap_or("").to_string();
if user.is_empty() {
let env_user = std::env::var("NEO4J_USER").map_err(|_| {
DbError::Connection(sea_orm::DbErr::Custom(
"neo4j URL has no credentials and NEO4J_USER env var is not set".to_string(),
))
})?;
let env_pass = std::env::var("NEO4J_PASSWORD").map_err(|_| {
DbError::Connection(sea_orm::DbErr::Custom(
"neo4j URL has no credentials and NEO4J_PASSWORD env var is not set".to_string(),
))
})?;
Ok((uri, env_user, env_pass))
} else {
Ok((uri, user, password))
}
}
Err(_) => {
let env_user = std::env::var("NEO4J_USER").map_err(|_| {
DbError::Connection(sea_orm::DbErr::Custom(
"neo4j URL is not a valid URL and NEO4J_USER env var is not set".to_string(),
))
})?;
let env_pass = std::env::var("NEO4J_PASSWORD").map_err(|_| {
DbError::Connection(sea_orm::DbErr::Custom(
"neo4j URL is not a valid URL and NEO4J_PASSWORD env var is not set".to_string(),
))
})?;
Ok((url.to_string(), env_user, env_pass))
}
}
}
#[cfg(test)]
pub(crate) fn new_placeholder() -> Self {
Self { graph: None }
}
}
impl std::fmt::Debug for Neo4jConnection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Neo4jConnection")
.field("connected", &self.graph.is_some())
.finish()
}
}
#[async_trait::async_trait]
impl GraphConnection for Neo4jConnection {
async fn execute_cypher(&self, cypher: &str) -> DbResult<GraphExecResult> {
let graph = self
.graph
.as_ref()
.ok_or_else(|| DbError::Config("Neo4jConnection not connected (placeholder)".to_string()))?;
let mut stream = graph
.execute(neo4rs::query(cypher))
.await
.map_err(|e| DbError::Connection(sea_orm::DbErr::Custom(format!("neo4j execute: {e}"))))?;
let mut rows = Vec::new();
while let Some(row) = stream
.next()
.await
.map_err(|e| DbError::Connection(sea_orm::DbErr::Custom(format!("neo4j row fetch: {e}"))))?
{
let json_val: serde_json::Value = row
.to::<serde_json::Value>()
.map_err(|e| DbError::Connection(sea_orm::DbErr::Custom(format!("neo4j row deserialize: {e}"))))?;
let columns = match json_val {
serde_json::Value::Object(map) => map
.into_iter()
.map(|(name, val)| (name, GraphValue::Scalar(val)))
.collect(),
other => vec![("value".to_string(), GraphValue::Scalar(other))],
};
rows.push(GraphRow { columns });
}
Ok(GraphExecResult::Query(GraphQueryResult { rows, rows_affected: 0 }))
}
async fn health_check(&self) -> DbResult<()> {
let result = self.execute_cypher("RETURN 1").await?;
match result {
GraphExecResult::Query(q) if !q.rows.is_empty() => Ok(()),
GraphExecResult::Query(_) => Err(DbError::Connection(sea_orm::DbErr::Custom(
"neo4j health check returned no rows".to_string(),
))),
GraphExecResult::Write { .. } => Ok(()),
}
}
async fn begin_graph_txn(&self) -> DbResult<Box<dyn GraphTransaction + Send>> {
let graph = self
.graph
.as_ref()
.ok_or_else(|| DbError::Config("Neo4jConnection not connected (placeholder)".to_string()))?;
let txn = graph
.start_txn()
.await
.map_err(|e| DbError::Connection(sea_orm::DbErr::Custom(format!("neo4j start_txn: {e}"))))?;
Ok(Box::new(Neo4jTransaction {
txn: AsyncMutex::new(Some(txn)),
}))
}
fn backend_name(&self) -> &'static str {
"neo4j"
}
}
pub struct Neo4jTransaction {
txn: AsyncMutex<Option<neo4rs::Txn>>,
}
impl Drop for Neo4jTransaction {
fn drop(&mut self) {
if let Ok(mut guard) = self.txn.try_lock() {
if let Some(txn) = guard.take() {
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let _ = txn.rollback().await;
});
}
}
}
}
}
#[async_trait::async_trait]
impl GraphTransaction for Neo4jTransaction {
async fn commit(self: Box<Self>) -> DbResult<()> {
let mut guard = self.txn.lock().await;
let txn = guard.take().ok_or_else(|| {
DbError::Connection(sea_orm::DbErr::Custom("neo4j transaction already consumed".to_string()))
})?;
drop(guard);
txn.commit()
.await
.map_err(|e| DbError::Connection(sea_orm::DbErr::Custom(format!("neo4j commit: {e}"))))
}
async fn rollback(self: Box<Self>) -> DbResult<()> {
let mut guard = self.txn.lock().await;
let txn = guard.take().ok_or_else(|| {
DbError::Connection(sea_orm::DbErr::Custom("neo4j transaction already consumed".to_string()))
})?;
drop(guard);
txn.rollback()
.await
.map_err(|e| DbError::Connection(sea_orm::DbErr::Custom(format!("neo4j rollback: {e}"))))
}
async fn execute_cypher(&self, cypher: &str) -> DbResult<GraphExecResult> {
let mut guard = self.txn.lock().await;
let txn = guard.as_mut().ok_or_else(|| {
DbError::Connection(sea_orm::DbErr::Custom("neo4j transaction already consumed".to_string()))
})?;
let mut stream = txn
.execute(neo4rs::query(cypher))
.await
.map_err(|e| DbError::Connection(sea_orm::DbErr::Custom(format!("neo4j txn execute: {e}"))))?;
let mut rows = Vec::new();
while let Some(row) = stream
.next(&mut *txn)
.await
.map_err(|e| DbError::Connection(sea_orm::DbErr::Custom(format!("neo4j txn row fetch: {e}"))))?
{
let json_val: serde_json::Value = row
.to::<serde_json::Value>()
.map_err(|e| DbError::Connection(sea_orm::DbErr::Custom(format!("neo4j txn row deserialize: {e}"))))?;
let columns = match json_val {
serde_json::Value::Object(map) => map
.into_iter()
.map(|(name, val)| (name, GraphValue::Scalar(val)))
.collect(),
other => vec![("value".to_string(), GraphValue::Scalar(other))],
};
rows.push(GraphRow { columns });
}
Ok(GraphExecResult::Query(GraphQueryResult { rows, rows_affected: 0 }))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_url_neo4j_with_credentials() {
let (uri, user, pass) = Neo4jConnection::parse_url("neo4j://user:pass@localhost:7687")
.expect("parse_url with credentials should succeed");
assert_eq!(uri, "neo4j://localhost:7687");
assert_eq!(user, "user");
assert_eq!(pass, "pass");
}
#[test]
fn test_parse_url_neo4j_plus_s_with_credentials() {
let (uri, user, pass) = Neo4jConnection::parse_url("neo4j+s://admin:secret@host:7687")
.expect("parse_url with credentials should succeed");
assert_eq!(uri, "neo4j+s://host:7687");
assert_eq!(user, "admin");
assert_eq!(pass, "secret");
}
#[test]
fn test_parse_url_neo4j_no_credentials_returns_error() {
let result = Neo4jConnection::parse_url("neo4j://localhost:7687");
assert!(result.is_err(), "parse_url without credentials should error");
}
#[test]
fn test_parse_url_neo4j_default_port() {
let (uri, _, _) = Neo4jConnection::parse_url("neo4j://user:pass@localhost")
.expect("parse_url with credentials should succeed");
assert_eq!(uri, "neo4j://localhost:7687");
}
#[test]
fn test_parse_url_invalid_returns_error() {
let result = Neo4jConnection::parse_url("not_a_url");
assert!(result.is_err(), "parse_url with invalid URL should error");
}
#[test]
fn test_parse_url_password_with_special_chars() {
let (uri, user, pass) = Neo4jConnection::parse_url("neo4j://user:p%40ss@host:7687")
.expect("parse_url with credentials should succeed");
assert_eq!(uri, "neo4j://host:7687");
assert_eq!(user, "user");
assert_eq!(pass, "p%40ss");
}
#[test]
fn test_neo4j_backend_name() {
let conn = Neo4jConnection::new_placeholder();
assert_eq!(conn.backend_name(), "neo4j");
}
#[test]
fn test_neo4j_debug_format_placeholder() {
let conn = Neo4jConnection::new_placeholder();
let debug_str = format!("{:?}", conn);
assert!(
debug_str.contains("Neo4jConnection"),
"Debug should contain 'Neo4jConnection': {debug_str}"
);
assert!(
debug_str.contains("connected: false"),
"Debug should show connected: false for placeholder: {debug_str}"
);
}
#[test]
fn test_neo4j_clone_preserves_backend_name() {
let conn = Neo4jConnection::new_placeholder();
let cloned = conn.clone();
assert_eq!(conn.backend_name(), cloned.backend_name());
}
#[tokio::test]
async fn test_neo4j_placeholder_execute_cypher_returns_error() {
let conn = Neo4jConnection::new_placeholder();
let result = conn.execute_cypher("RETURN 1").await;
assert!(result.is_err(), "placeholder should return error");
let err = result.unwrap_err();
match err {
DbError::Config(msg) => assert!(
msg.contains("not connected"),
"error should mention 'not connected': {msg}"
),
other => panic!("expected DbError::Config, got {other:?}"),
}
}
#[tokio::test]
async fn test_neo4j_placeholder_health_check_returns_error() {
let conn = Neo4jConnection::new_placeholder();
let result = conn.health_check().await;
assert!(result.is_err(), "placeholder should return error");
}
#[tokio::test]
async fn test_neo4j_placeholder_begin_graph_txn_returns_error() {
let conn = Neo4jConnection::new_placeholder();
let result = conn.begin_graph_txn().await;
assert!(result.is_err(), "placeholder should return error");
}
fn neo4j_test_connection() -> Option<Neo4jConnection> {
let url = std::env::var("NEO4J_URL").ok()?;
let (uri, user, password) = Neo4jConnection::parse_url(&url).ok()?;
let rt = tokio::runtime::Runtime::new().ok()?;
rt.block_on(async { Neo4jConnection::new(&uri, &user, &password).await })
.ok()
}
#[tokio::test]
#[ignore = "需要 Neo4j 服务器,设置 NEO4J_URL/NEO4J_USER/NEO4J_PASSWORD 环境变量后运行"]
async fn test_neo4j_execute_return_1() {
let conn = neo4j_test_connection().expect("NEO4J_URL not set or connection failed");
let result = conn
.execute_cypher("RETURN 1 AS n")
.await
.expect("execute_cypher RETURN 1 should succeed");
match result {
GraphExecResult::Query(q) => {
assert_eq!(q.rows.len(), 1, "should return 1 row");
let value = &q.rows[0].columns[0].1;
match value {
GraphValue::Scalar(s) => assert_eq!(s, &serde_json::json!(1), "should return scalar 1"),
other => panic!("expected Scalar, got {other:?}"),
}
}
GraphExecResult::Write { .. } => panic!("expected Query variant"),
}
}
#[tokio::test]
#[ignore = "需要 Neo4j 服务器,设置 NEO4J_URL/NEO4J_USER/NEO4J_PASSWORD 环境变量后运行"]
async fn test_neo4j_health_check() {
let conn = neo4j_test_connection().expect("NEO4J_URL not set or connection failed");
conn.health_check()
.await
.expect("health check should pass on connected Neo4j");
}
#[tokio::test]
#[ignore = "需要 Neo4j 服务器,设置 NEO4J_URL/NEO4J_USER/NEO4J_PASSWORD 环境变量后运行"]
async fn test_neo4j_txn_commit() {
let conn = neo4j_test_connection().expect("NEO4J_URL not set or connection failed");
let _ = conn.execute_cypher("MATCH (n:T029Test) DETACH DELETE n").await;
let txn = conn.begin_graph_txn().await.expect("begin_graph_txn should succeed");
txn.execute_cypher("CREATE (n:T029Test {name: 'Alice'})")
.await
.expect("create in txn should succeed");
txn.commit().await.expect("commit should succeed");
let result = conn
.execute_cypher("MATCH (n:T029Test) RETURN n.name AS name")
.await
.expect("match after commit should succeed");
match result {
GraphExecResult::Query(q) => {
assert_eq!(q.rows.len(), 1, "should see 1 node after commit");
let name = &q.rows[0].columns[0].1;
match name {
GraphValue::Scalar(serde_json::Value::String(s)) => {
assert_eq!(s, "Alice", "node name should be Alice")
}
other => panic!("expected String Scalar, got {other:?}"),
}
}
GraphExecResult::Write { .. } => panic!("expected Query variant"),
}
let _ = conn.execute_cypher("MATCH (n:T029Test) DETACH DELETE n").await;
}
#[tokio::test]
#[ignore = "需要 Neo4j 服务器,设置 NEO4J_URL/NEO4J_USER/NEO4J_PASSWORD 环境变量后运行"]
async fn test_neo4j_txn_rollback() {
let conn = neo4j_test_connection().expect("NEO4J_URL not set or connection failed");
let _ = conn.execute_cypher("MATCH (n:T029Test) DETACH DELETE n").await;
let txn = conn.begin_graph_txn().await.expect("begin_graph_txn should succeed");
txn.execute_cypher("CREATE (n:T029Test {name: 'Bob'})")
.await
.expect("create in txn should succeed");
txn.rollback().await.expect("rollback should succeed");
let result = conn
.execute_cypher("MATCH (n:T029Test) RETURN n.name AS name")
.await
.expect("match after rollback should succeed");
match result {
GraphExecResult::Query(q) => {
assert_eq!(q.rows.len(), 0, "should see 0 nodes after rollback")
}
GraphExecResult::Write { .. } => panic!("expected Query variant"),
}
}
}