use crate::cypher::{
execute_cypher, executor::ExecContext, parse_cache::ParseCache, plan_cache::PlanCache,
record::NamedRecord, ExecCaches,
};
use crate::edge;
use crate::fts;
use crate::index;
use crate::node;
use crate::types::{
validate_properties, Direction, GraphError, Node, NodeId, Properties, Result, Value,
};
use std::sync::atomic::{AtomicU64, Ordering};
macro_rules! impl_read_ops {
($ty:ident, $read_only:expr) => {
impl<'a> $ty<'a> {
pub fn get_node(&self, id: NodeId) -> Result<Node> {
node::get_node(&self.tx, id)
}
pub fn node_exists(&self, id: NodeId) -> Result<bool> {
node::node_exists(&self.tx, id)
}
pub fn get_neighbors(
&self,
id: NodeId,
label: &str,
direction: Direction,
) -> Result<Vec<NodeId>> {
edge::get_neighbors(&self.tx, id, label, direction)
}
pub fn get_edge_properties(
&self,
src: NodeId,
dst: NodeId,
label: &str,
) -> Result<Properties> {
edge::get_edge_properties(&self.tx, src, dst, label)
}
pub fn find_nodes_by_label(&self, label: &str) -> Result<Vec<Node>> {
node::find_nodes_by_label(&self.tx, label)
}
pub fn index_lookup(
&self,
label: &str,
property: &str,
value: &Value,
) -> Result<Vec<NodeId>> {
index::index_lookup(&self.tx, label, property, value)
}
pub fn traverse(
&self,
start: NodeId,
label: &str,
direction: Direction,
min_hops: u32,
max_hops: u32,
) -> Result<Vec<NodeId>> {
edge::traverse(&self.tx, start, label, direction, min_hops, max_hops, None)
}
pub fn traverse_with_depth(
&self,
start: NodeId,
label: &str,
direction: Direction,
min_hops: u32,
max_hops: u32,
) -> Result<Vec<(NodeId, u32)>> {
edge::traverse_with_depth(
&self.tx, start, label, direction, min_hops, max_hops, None,
)
}
pub fn query(&self, cypher: &str) -> Result<Vec<NamedRecord>> {
self.query_with_params(cypher, None)
}
pub fn query_with_params(
&self,
cypher: &str,
params: Option<&std::collections::HashMap<String, Value>>,
) -> Result<Vec<NamedRecord>> {
let ctx = ExecContext {
max_result_rows: self.max_result_rows,
max_traversal_depth: self.max_traversal_depth,
max_traversal_work: self.max_traversal_work,
require_read_only: $read_only,
..Default::default()
};
execute_cypher(&self.tx, cypher, params, ctx, self.exec_caches())
}
pub fn query_with_procedures(
&self,
cypher: &str,
params: Option<&std::collections::HashMap<String, Value>>,
procedures: &crate::cypher::procedure::ProcedureRegistry,
) -> Result<Vec<NamedRecord>> {
let ctx = ExecContext {
max_result_rows: self.max_result_rows,
max_traversal_depth: self.max_traversal_depth,
max_traversal_work: self.max_traversal_work,
procedures: procedures.clone(),
require_read_only: $read_only,
..Default::default()
};
execute_cypher(&self.tx, cypher, params, ctx, self.exec_caches())
}
}
};
}
pub struct ReadTransaction<'a> {
tx: rusqlite::Transaction<'a>,
max_result_rows: usize,
max_traversal_depth: u32,
max_traversal_work: u64,
parse_cache: &'a ParseCache,
plan_cache: &'a PlanCache,
schema_epoch: &'a AtomicU64,
}
impl<'a> ReadTransaction<'a> {
pub(crate) fn new(
tx: rusqlite::Transaction<'a>,
max_result_rows: usize,
max_traversal_depth: u32,
max_traversal_work: u64,
parse_cache: &'a ParseCache,
plan_cache: &'a PlanCache,
schema_epoch: &'a AtomicU64,
) -> Self {
Self {
tx,
max_result_rows,
max_traversal_depth,
max_traversal_work,
parse_cache,
plan_cache,
schema_epoch,
}
}
fn exec_caches(&self) -> ExecCaches<'_> {
ExecCaches {
parse: Some(self.parse_cache),
plan: Some(self.plan_cache),
schema_epoch: Some(self.schema_epoch),
}
}
pub fn commit(self) -> Result<()> {
self.tx.commit()?;
Ok(())
}
pub fn rollback(self) -> Result<()> {
self.tx.rollback()?;
Ok(())
}
}
impl_read_ops!(ReadTransaction, true);
pub struct WriteTransaction<'a> {
tx: rusqlite::Transaction<'a>,
max_property_value_bytes: usize,
max_name_bytes: usize,
max_result_rows: usize,
max_traversal_depth: u32,
max_traversal_work: u64,
parse_cache: &'a ParseCache,
plan_cache: &'a PlanCache,
schema_epoch: &'a AtomicU64,
}
impl<'a> WriteTransaction<'a> {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
tx: rusqlite::Transaction<'a>,
max_property_value_bytes: usize,
max_name_bytes: usize,
max_result_rows: usize,
max_traversal_depth: u32,
max_traversal_work: u64,
parse_cache: &'a ParseCache,
plan_cache: &'a PlanCache,
schema_epoch: &'a AtomicU64,
) -> Self {
Self {
tx,
max_property_value_bytes,
max_name_bytes,
max_result_rows,
max_traversal_depth,
max_traversal_work,
parse_cache,
plan_cache,
schema_epoch,
}
}
fn exec_caches(&self) -> ExecCaches<'_> {
ExecCaches {
parse: Some(self.parse_cache),
plan: Some(self.plan_cache),
schema_epoch: Some(self.schema_epoch),
}
}
pub fn create_node(&self, label: &str, properties: Properties) -> Result<NodeId> {
let labels = if label.is_empty() {
vec![]
} else {
vec![label.to_string()]
};
self.create_node_with_labels(&labels, properties)
}
pub fn create_node_with_labels(
&self,
labels: &[String],
properties: Properties,
) -> Result<NodeId> {
let primary_label = labels.first().map(|s| s.as_str()).unwrap_or("");
validate_properties(
primary_label,
&properties,
self.max_name_bytes,
self.max_property_value_bytes,
)?;
let id = node::create_node(&self.tx, labels, properties.clone())?;
index::update_indexes_for_node(&self.tx, id, labels, None, &properties)?;
fts::update_fts_for_node(&self.tx, id, labels, None, &properties)?;
Ok(id)
}
pub fn delete_node(&self, id: NodeId) -> Result<()> {
let n = node::get_node(&self.tx, id)?;
index::remove_indexes_for_node(&self.tx, id, &n.labels, &n.properties)?;
fts::remove_fts_for_node(&self.tx, id, &n.labels, &n.properties)?;
node::delete_node(&self.tx, id)
}
pub fn set_node_property(&self, id: NodeId, key: &str, value: Value) -> Result<()> {
let props = std::collections::HashMap::from([(key.to_string(), value.clone())]);
validate_properties(
"_",
&props,
self.max_name_bytes,
self.max_property_value_bytes,
)?;
let old = node::get_node(&self.tx, id)?;
node::set_node_property(&self.tx, id, key, value.clone())?;
let mut new_props = old.properties.clone();
new_props.insert(key.to_string(), value);
index::update_indexes_for_node(
&self.tx,
id,
&old.labels,
Some(&old.properties),
&new_props,
)?;
fts::update_fts_for_node(&self.tx, id, &old.labels, Some(&old.properties), &new_props)?;
Ok(())
}
pub fn remove_node_property(&self, id: NodeId, key: &str) -> Result<()> {
let old = node::get_node(&self.tx, id)?;
node::remove_node_property(&self.tx, id, key)?;
let mut new_props = old.properties.clone();
new_props.remove(key);
index::update_indexes_for_node(
&self.tx,
id,
&old.labels,
Some(&old.properties),
&new_props,
)?;
fts::update_fts_for_node(&self.tx, id, &old.labels, Some(&old.properties), &new_props)?;
Ok(())
}
pub fn create_edge(
&self,
src: NodeId,
dst: NodeId,
label: &str,
properties: Properties,
) -> Result<()> {
validate_properties(
label,
&properties,
self.max_name_bytes,
self.max_property_value_bytes,
)?;
if !node::node_exists(&self.tx, src)? {
return Err(GraphError::NodeNotFound {
id: src,
hint: None,
});
}
if !node::node_exists(&self.tx, dst)? {
return Err(GraphError::NodeNotFound {
id: dst,
hint: None,
});
}
edge::create_edge(&self.tx, src, dst, label, properties)
}
pub fn delete_edge(&self, src: NodeId, dst: NodeId, label: &str) -> Result<()> {
edge::delete_edge(&self.tx, src, dst, label)
}
pub fn create_index(&self, label: &str, property: &str) -> Result<()> {
index::create_index(&self.tx, label, property)?;
self.schema_epoch.fetch_add(1, Ordering::AcqRel);
Ok(())
}
pub fn drop_index(&self, label: &str, property: &str) -> Result<()> {
index::drop_index(&self.tx, label, property)?;
self.schema_epoch.fetch_add(1, Ordering::AcqRel);
Ok(())
}
pub fn create_composite_index(&self, label: &str, properties: &[&str]) -> Result<()> {
index::create_composite_index(&self.tx, label, properties)?;
self.schema_epoch.fetch_add(1, Ordering::AcqRel);
Ok(())
}
pub fn drop_composite_index(&self, label: &str, properties: &[&str]) -> Result<()> {
index::drop_composite_index(&self.tx, label, properties)?;
self.schema_epoch.fetch_add(1, Ordering::AcqRel);
Ok(())
}
pub fn create_fulltext_index(&self, label: &str, property: &str) -> Result<()> {
fts::create_fulltext_index(&self.tx, label, property)?;
self.schema_epoch.fetch_add(1, Ordering::AcqRel);
Ok(())
}
pub fn create_fulltext_index_ci(&self, label: &str, property: &str) -> Result<()> {
fts::create_fulltext_index_ci(&self.tx, label, property)?;
self.schema_epoch.fetch_add(1, Ordering::AcqRel);
Ok(())
}
pub fn create_fulltext_index_word(&self, label: &str, property: &str) -> Result<()> {
fts::create_fulltext_index_word(&self.tx, label, property)?;
self.schema_epoch.fetch_add(1, Ordering::AcqRel);
Ok(())
}
pub fn create_fulltext_index_word_multi(
&self,
label: &str,
properties: &[String],
) -> Result<()> {
fts::create_fulltext_index_word_multi(&self.tx, label, properties)?;
self.schema_epoch.fetch_add(1, Ordering::AcqRel);
Ok(())
}
pub fn drop_fulltext_index(&self, label: &str, property: &str) -> Result<()> {
fts::drop_fulltext_index(&self.tx, label, property)?;
self.schema_epoch.fetch_add(1, Ordering::AcqRel);
Ok(())
}
pub fn commit(self) -> Result<()> {
self.tx.commit()?;
Ok(())
}
pub fn rollback(self) -> Result<()> {
self.tx.rollback()?;
Ok(())
}
}
impl_read_ops!(WriteTransaction, false);
pub struct WriteTxGuard<'a> {
inner: Option<WriteTransaction<'a>>,
}
pub struct ReadTxGuard<'a> {
inner: Option<ReadTransaction<'a>>,
}
impl<'a> WriteTxGuard<'a> {
pub(crate) fn new(tx: WriteTransaction<'a>) -> Self {
Self { inner: Some(tx) }
}
pub fn commit(mut self) -> Result<()> {
self.inner
.take()
.expect("WriteTxGuard already finalized")
.commit()
}
pub fn rollback(mut self) -> Result<()> {
self.inner
.take()
.expect("WriteTxGuard already finalized")
.rollback()
}
}
impl<'a> ReadTxGuard<'a> {
pub(crate) fn new(tx: ReadTransaction<'a>) -> Self {
Self { inner: Some(tx) }
}
pub fn commit(mut self) -> Result<()> {
self.inner
.take()
.expect("ReadTxGuard already finalized")
.commit()
}
pub fn rollback(mut self) -> Result<()> {
self.inner
.take()
.expect("ReadTxGuard already finalized")
.rollback()
}
}
impl<'a> std::ops::Deref for WriteTxGuard<'a> {
type Target = WriteTransaction<'a>;
fn deref(&self) -> &WriteTransaction<'a> {
self.inner
.as_ref()
.expect("WriteTxGuard accessed after commit or rollback")
}
}
impl<'a> std::ops::DerefMut for WriteTxGuard<'a> {
fn deref_mut(&mut self) -> &mut WriteTransaction<'a> {
self.inner
.as_mut()
.expect("WriteTxGuard accessed after commit or rollback")
}
}
impl<'a> std::ops::Deref for ReadTxGuard<'a> {
type Target = ReadTransaction<'a>;
fn deref(&self) -> &ReadTransaction<'a> {
self.inner
.as_ref()
.expect("ReadTxGuard accessed after commit or rollback")
}
}
impl<'a> std::ops::DerefMut for ReadTxGuard<'a> {
fn deref_mut(&mut self) -> &mut ReadTransaction<'a> {
self.inner
.as_mut()
.expect("ReadTxGuard accessed after commit or rollback")
}
}
impl Drop for WriteTxGuard<'_> {
fn drop(&mut self) {
if self.inner.is_some() {
tracing::warn!(
"WriteTxGuard dropped without commit or rollback; transaction will be rolled back. \
Call `tx.commit()` to persist writes, or `tx.rollback()` to silence this warning."
);
}
}
}
impl Drop for ReadTxGuard<'_> {
fn drop(&mut self) {
}
}
#[cfg(test)]
mod tx_guard_tests {
use crate::{Database, Value};
#[test]
fn explicit_commit_persists() {
let mut db = Database::open_memory().unwrap();
{
let tx = db.write_tx().unwrap();
tx.query("CREATE (:Person {name: 'Alice'})").unwrap();
tx.commit().unwrap();
}
let tx = db.read_tx().unwrap();
let rows = tx.query("MATCH (n:Person) RETURN n.name").unwrap();
assert_eq!(rows.len(), 1);
tx.commit().unwrap();
}
#[test]
fn forgot_commit_rolls_back() {
let mut db = Database::open_memory().unwrap();
{
let tx = db.write_tx().unwrap();
tx.query("CREATE (:Person {name: 'Bob'})").unwrap();
}
let tx = db.read_tx().unwrap();
let rows = tx.query("MATCH (n:Person) RETURN n.name").unwrap();
assert_eq!(rows.len(), 0);
tx.commit().unwrap();
}
#[test]
fn explicit_rollback_discards() {
let mut db = Database::open_memory().unwrap();
{
let tx = db.write_tx().unwrap();
tx.query("CREATE (:Person {name: 'Carol'})").unwrap();
tx.rollback().unwrap();
}
let tx = db.read_tx().unwrap();
let rows = tx.query("MATCH (n:Person) RETURN n.name").unwrap();
assert_eq!(rows.len(), 0);
tx.commit().unwrap();
}
#[test]
fn panic_mid_txn_rolls_back() {
let mut db = Database::open_memory().unwrap();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let tx = db.write_tx().unwrap();
tx.query("CREATE (:Person {name: 'Dan'})").unwrap();
panic!("simulated failure");
}));
assert!(result.is_err());
let tx = db.read_tx().unwrap();
let rows = tx.query("MATCH (n:Person) RETURN n.name").unwrap();
assert_eq!(rows.len(), 0);
tx.commit().unwrap();
}
#[test]
fn deref_exposes_typed_methods() {
let mut db = Database::open_memory().unwrap();
let tx = db.write_tx().unwrap();
let id = tx
.create_node("Person", std::collections::HashMap::new())
.unwrap();
let n = tx.get_node(id).unwrap();
assert_eq!(n.id, id);
tx.commit().unwrap();
}
#[test]
fn typed_guard_blocks_when_stateful_txn_active() {
let mut db = Database::open_memory().unwrap();
db.begin_write().unwrap();
assert!(db.write_tx().is_err());
assert!(db.read_tx().is_err());
db.rollback().unwrap();
}
#[test]
fn write_tx_create_and_drop_fulltext_index() {
let mut db = Database::open_memory().unwrap();
{
let tx = db.write_tx().unwrap();
tx.create_fulltext_index("Doc", "body").unwrap();
tx.commit().unwrap();
}
{
let tx = db.write_tx().unwrap();
tx.query("CREATE (n:Doc {body: 'hello world'})").unwrap();
tx.commit().unwrap();
}
{
let tx = db.write_tx().unwrap();
tx.drop_fulltext_index("Doc", "body").unwrap();
tx.commit().unwrap();
}
}
#[test]
fn write_tx_create_and_drop_fulltext_index_ci() {
let mut db = Database::open_memory().unwrap();
{
let tx = db.write_tx().unwrap();
tx.create_fulltext_index_ci("Doc", "body").unwrap();
tx.commit().unwrap();
}
{
let tx = db.write_tx().unwrap();
tx.query("CREATE (n:Doc {body: 'Hello World'})").unwrap();
tx.commit().unwrap();
}
{
let tx = db.read_tx().unwrap();
let rows = tx
.query("MATCH (n:Doc) WHERE n.body CONTAINS 'hello' RETURN n.body AS body")
.unwrap();
assert_eq!(rows.len(), 1);
}
{
let tx = db.write_tx().unwrap();
tx.drop_fulltext_index("Doc", "body").unwrap();
tx.commit().unwrap();
}
}
#[test]
fn write_tx_create_fulltext_index_word() {
let mut db = Database::open_memory().unwrap();
{
let tx = db.write_tx().unwrap();
tx.create_fulltext_index_word("Doc", "body").unwrap();
tx.commit().unwrap();
}
let rows = db
.execute("CALL db.indexes() YIELD label, property, kind RETURN kind")
.unwrap();
let kinds: Vec<String> = rows
.iter()
.map(|r| match r.get("kind").unwrap() {
Value::String(s) => s.clone(),
v => panic!("kind was {v:?}"),
})
.collect();
assert_eq!(kinds, vec!["fulltext_word".to_string()]);
}
#[test]
fn write_tx_create_fulltext_index_word_multi() {
let mut db = Database::open_memory().unwrap();
{
let tx = db.write_tx().unwrap();
tx.create_fulltext_index_word_multi(
"Article",
&["title".to_string(), "body".to_string()],
)
.unwrap();
tx.commit().unwrap();
}
let rows = db
.execute(
"CALL db.indexes() YIELD label, property, kind RETURN property ORDER BY property",
)
.unwrap();
let props: Vec<String> = rows
.iter()
.map(|r| match r.get("property").unwrap() {
Value::String(s) => s.clone(),
v => panic!("property was {v:?}"),
})
.collect();
assert_eq!(props, vec!["body".to_string(), "title".to_string()]);
}
}