use crate::executor::{Params, Record, Value};
use cypherlite_core::{CypherLiteError, DatabaseConfig};
use cypherlite_storage::StorageEngine;
use std::collections::HashMap;
#[derive(Debug)]
pub struct QueryResult {
pub columns: Vec<String>,
pub rows: Vec<Row>,
}
#[derive(Debug)]
pub struct Row {
values: HashMap<String, Value>,
columns: Vec<String>,
}
impl Row {
pub fn new(values: HashMap<String, Value>, columns: Vec<String>) -> Self {
Self { values, columns }
}
pub fn get(&self, column: &str) -> Option<&Value> {
self.values.get(column)
}
pub fn get_as<T: FromValue>(&self, column: &str) -> Option<T> {
self.values.get(column).and_then(T::from_value)
}
pub fn columns(&self) -> &[String] {
&self.columns
}
}
pub trait FromValue: Sized {
fn from_value(value: &Value) -> Option<Self>;
}
impl FromValue for i64 {
fn from_value(value: &Value) -> Option<Self> {
match value {
Value::Int64(i) => Some(*i),
_ => None,
}
}
}
impl FromValue for f64 {
fn from_value(value: &Value) -> Option<Self> {
match value {
Value::Float64(f) => Some(*f),
_ => None,
}
}
}
impl FromValue for String {
fn from_value(value: &Value) -> Option<Self> {
match value {
Value::String(s) => Some(s.clone()),
_ => None,
}
}
}
impl FromValue for bool {
fn from_value(value: &Value) -> Option<Self> {
match value {
Value::Bool(b) => Some(*b),
_ => None,
}
}
}
pub struct CypherLite {
engine: StorageEngine,
#[cfg(feature = "plugin")]
scalar_functions:
cypherlite_core::plugin::PluginRegistry<dyn cypherlite_core::plugin::ScalarFunction>,
#[cfg(feature = "plugin")]
index_plugins:
cypherlite_core::plugin::PluginRegistry<dyn cypherlite_core::plugin::IndexPlugin>,
#[cfg(feature = "plugin")]
serializers: cypherlite_core::plugin::PluginRegistry<dyn cypherlite_core::plugin::Serializer>,
#[cfg(feature = "plugin")]
triggers: cypherlite_core::plugin::PluginRegistry<dyn cypherlite_core::plugin::Trigger>,
}
impl CypherLite {
pub fn open(config: DatabaseConfig) -> Result<Self, CypherLiteError> {
let engine = StorageEngine::open(config)?;
Ok(Self {
engine,
#[cfg(feature = "plugin")]
scalar_functions: cypherlite_core::plugin::PluginRegistry::new(),
#[cfg(feature = "plugin")]
index_plugins: cypherlite_core::plugin::PluginRegistry::new(),
#[cfg(feature = "plugin")]
serializers: cypherlite_core::plugin::PluginRegistry::new(),
#[cfg(feature = "plugin")]
triggers: cypherlite_core::plugin::PluginRegistry::new(),
})
}
pub fn execute(&mut self, query: &str) -> Result<QueryResult, CypherLiteError> {
self.execute_with_params(query, Params::new())
}
pub fn execute_with_params(
&mut self,
query: &str,
params: Params,
) -> Result<QueryResult, CypherLiteError> {
let ast = crate::parser::parse_query(query).map_err(|e| CypherLiteError::ParseError {
line: e.line,
column: e.column,
message: e.message,
})?;
let mut analyzer = crate::semantic::SemanticAnalyzer::new(self.engine.catalog_mut());
analyzer
.analyze(&ast)
.map_err(|e| CypherLiteError::SemanticError(e.message))?;
let plan = crate::planner::LogicalPlanner::new(self.engine.catalog_mut())
.plan(&ast)
.map_err(|e| CypherLiteError::ExecutionError(e.message))?;
let plan = crate::planner::optimize::optimize(plan);
let mut params = params;
if !params.contains_key("__query_start_ms__") {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
params.insert("__query_start_ms__".to_string(), Value::Int64(now_ms));
}
#[cfg(feature = "plugin")]
let scalar_fns: &dyn crate::executor::ScalarFnLookup = &self.scalar_functions;
#[cfg(not(feature = "plugin"))]
let scalar_fns: &dyn crate::executor::ScalarFnLookup = &();
#[cfg(feature = "plugin")]
let trigger_fns: &dyn crate::executor::TriggerLookup = &self.triggers;
#[cfg(not(feature = "plugin"))]
let trigger_fns: &dyn crate::executor::TriggerLookup = &();
let records =
crate::executor::execute(&plan, &mut self.engine, ¶ms, scalar_fns, trigger_fns)
.map_err(|e| CypherLiteError::ExecutionError(e.message))?;
let columns = extract_columns(&records);
let rows = records
.into_iter()
.map(|r| Row::new(r, columns.clone()))
.collect();
Ok(QueryResult { columns, rows })
}
pub fn engine(&self) -> &StorageEngine {
&self.engine
}
pub fn engine_mut(&mut self) -> &mut StorageEngine {
&mut self.engine
}
#[cfg(feature = "plugin")]
pub fn register_scalar_function(
&mut self,
func: Box<dyn cypherlite_core::plugin::ScalarFunction>,
) -> Result<(), CypherLiteError> {
self.scalar_functions
.register(func)
.map_err(|e| CypherLiteError::PluginError(e.to_string()))
}
#[cfg(feature = "plugin")]
pub fn list_scalar_functions(&self) -> Vec<(&str, &str)> {
self.scalar_functions
.list()
.filter_map(|name| self.scalar_functions.get(name).map(|f| (name, f.version())))
.collect()
}
#[cfg(feature = "plugin")]
pub fn register_index_plugin(
&mut self,
plugin: Box<dyn cypherlite_core::plugin::IndexPlugin>,
) -> Result<(), CypherLiteError> {
self.index_plugins
.register(plugin)
.map_err(|e| CypherLiteError::PluginError(e.to_string()))
}
#[cfg(feature = "plugin")]
pub fn list_index_plugins(&self) -> Vec<(&str, &str, &str)> {
self.index_plugins
.list()
.filter_map(|name| {
self.index_plugins
.get(name)
.map(|p| (name, p.version(), p.index_type()))
})
.collect()
}
#[cfg(feature = "plugin")]
pub fn get_index_plugin(
&self,
name: &str,
) -> Option<&dyn cypherlite_core::plugin::IndexPlugin> {
self.index_plugins.get(name)
}
#[cfg(feature = "plugin")]
pub fn get_index_plugin_mut(
&mut self,
name: &str,
) -> Option<&mut (dyn cypherlite_core::plugin::IndexPlugin + 'static)> {
self.index_plugins.get_mut(name)
}
#[cfg(feature = "plugin")]
pub fn register_serializer(
&mut self,
serializer: Box<dyn cypherlite_core::plugin::Serializer>,
) -> Result<(), CypherLiteError> {
self.serializers
.register(serializer)
.map_err(|e| CypherLiteError::PluginError(e.to_string()))
}
#[cfg(feature = "plugin")]
pub fn list_serializers(&self) -> Vec<(&str, &str)> {
self.serializers
.list()
.filter_map(|name| self.serializers.get(name).map(|s| (name, s.version())))
.collect()
}
#[cfg(feature = "plugin")]
pub fn export_data(&mut self, format: &str, query: &str) -> Result<Vec<u8>, CypherLiteError> {
if !self.has_serializer_format(format) {
return Err(CypherLiteError::UnsupportedFormat(format.to_string()));
}
let result = self.execute(query)?;
let data = rows_to_property_maps(&result.rows);
let serializer = self.find_serializer_by_format(format)?;
serializer.export(&data)
}
#[cfg(feature = "plugin")]
pub fn import_data(
&self,
format: &str,
bytes: &[u8],
) -> Result<Vec<HashMap<String, cypherlite_core::types::PropertyValue>>, CypherLiteError> {
let serializer = self.find_serializer_by_format(format)?;
serializer.import(bytes)
}
#[cfg(feature = "plugin")]
fn has_serializer_format(&self, format: &str) -> bool {
self.serializers.list().any(|name| {
self.serializers
.get(name)
.is_some_and(|s| s.format() == format)
})
}
#[cfg(feature = "plugin")]
fn find_serializer_by_format(
&self,
format: &str,
) -> Result<&dyn cypherlite_core::plugin::Serializer, CypherLiteError> {
for name in self.serializers.list() {
if let Some(s) = self.serializers.get(name) {
if s.format() == format {
return Ok(s);
}
}
}
Err(CypherLiteError::UnsupportedFormat(format.to_string()))
}
#[cfg(feature = "plugin")]
pub fn register_trigger(
&mut self,
trigger: Box<dyn cypherlite_core::plugin::Trigger>,
) -> Result<(), CypherLiteError> {
self.triggers
.register(trigger)
.map_err(|e| CypherLiteError::PluginError(e.to_string()))
}
#[cfg(feature = "plugin")]
pub fn list_triggers(&self) -> Vec<(&str, &str)> {
self.triggers
.list()
.filter_map(|name| self.triggers.get(name).map(|t| (name, t.version())))
.collect()
}
pub fn begin(&mut self) -> Transaction<'_> {
Transaction {
db: self,
committed: false,
}
}
}
#[cfg(feature = "plugin")]
fn rows_to_property_maps(
rows: &[Row],
) -> Vec<HashMap<String, cypherlite_core::types::PropertyValue>> {
use cypherlite_core::types::PropertyValue;
rows.iter()
.map(|row| {
row.columns()
.iter()
.filter_map(|col| {
row.get(col).and_then(|v| {
PropertyValue::try_from(v.clone())
.ok()
.map(|pv| (col.clone(), pv))
})
})
.collect()
})
.collect()
}
fn extract_columns(records: &[Record]) -> Vec<String> {
if records.is_empty() {
return vec![];
}
let mut cols: Vec<String> = records[0].keys().cloned().collect();
cols.sort(); cols
}
pub struct Transaction<'a> {
db: &'a mut CypherLite,
committed: bool,
}
impl<'a> Transaction<'a> {
pub fn execute(&mut self, query: &str) -> Result<QueryResult, CypherLiteError> {
self.db.execute(query)
}
pub fn execute_with_params(
&mut self,
query: &str,
params: Params,
) -> Result<QueryResult, CypherLiteError> {
self.db.execute_with_params(query, params)
}
pub fn commit(mut self) -> Result<(), CypherLiteError> {
self.committed = true;
Ok(())
}
pub fn rollback(mut self) -> Result<(), CypherLiteError> {
self.committed = true; Ok(())
}
}
impl<'a> Drop for Transaction<'a> {
fn drop(&mut self) {
if !self.committed {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use cypherlite_core::SyncMode;
use tempfile::tempdir;
fn test_config(dir: &std::path::Path) -> DatabaseConfig {
DatabaseConfig {
path: dir.join("test.cyl"),
wal_sync_mode: SyncMode::Normal,
..Default::default()
}
}
#[test]
fn test_row_get_existing_column() {
let mut values = HashMap::new();
values.insert("name".to_string(), Value::String("Alice".into()));
let row = Row::new(values, vec!["name".to_string()]);
assert_eq!(row.get("name"), Some(&Value::String("Alice".into())));
}
#[test]
fn test_row_get_missing_column() {
let row = Row::new(HashMap::new(), vec![]);
assert_eq!(row.get("missing"), None);
}
#[test]
fn test_row_get_as_i64() {
let mut values = HashMap::new();
values.insert("age".to_string(), Value::Int64(30));
let row = Row::new(values, vec!["age".to_string()]);
assert_eq!(row.get_as::<i64>("age"), Some(30));
}
#[test]
fn test_row_get_as_f64() {
let mut values = HashMap::new();
values.insert("score".to_string(), Value::Float64(3.15));
let row = Row::new(values, vec!["score".to_string()]);
assert_eq!(row.get_as::<f64>("score"), Some(3.15));
}
#[test]
fn test_row_get_as_string() {
let mut values = HashMap::new();
values.insert("name".to_string(), Value::String("Bob".into()));
let row = Row::new(values, vec!["name".to_string()]);
assert_eq!(row.get_as::<String>("name"), Some("Bob".to_string()));
}
#[test]
fn test_row_get_as_bool() {
let mut values = HashMap::new();
values.insert("active".to_string(), Value::Bool(true));
let row = Row::new(values, vec!["active".to_string()]);
assert_eq!(row.get_as::<bool>("active"), Some(true));
}
#[test]
fn test_row_get_as_wrong_type() {
let mut values = HashMap::new();
values.insert("age".to_string(), Value::String("thirty".into()));
let row = Row::new(values, vec!["age".to_string()]);
assert_eq!(row.get_as::<i64>("age"), None);
}
#[test]
fn test_row_columns() {
let row = Row::new(HashMap::new(), vec!["a".to_string(), "b".to_string()]);
assert_eq!(row.columns(), &["a".to_string(), "b".to_string()]);
}
#[test]
fn test_query_result_empty() {
let result = QueryResult {
columns: vec![],
rows: vec![],
};
assert!(result.rows.is_empty());
assert!(result.columns.is_empty());
}
#[test]
fn test_from_value_null_returns_none() {
assert_eq!(i64::from_value(&Value::Null), None);
assert_eq!(f64::from_value(&Value::Null), None);
assert_eq!(String::from_value(&Value::Null), None);
assert_eq!(bool::from_value(&Value::Null), None);
}
#[test]
fn test_extract_columns_empty_records() {
let records: Vec<Record> = vec![];
assert!(extract_columns(&records).is_empty());
}
#[test]
fn test_extract_columns_deterministic_order() {
let mut r = Record::new();
r.insert("b".to_string(), Value::Int64(1));
r.insert("a".to_string(), Value::Int64(2));
let cols = extract_columns(&[r]);
assert_eq!(cols, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn test_cypherlite_open() {
let dir = tempdir().expect("tempdir");
let db = CypherLite::open(test_config(dir.path()));
assert!(db.is_ok());
}
#[test]
fn test_cypherlite_engine_accessors() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
assert_eq!(db.engine().node_count(), 0);
assert_eq!(db.engine_mut().edge_count(), 0);
}
#[test]
fn test_transaction_commit() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
let tx = db.begin();
assert!(tx.commit().is_ok());
}
#[test]
fn test_transaction_rollback() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
let tx = db.begin();
assert!(tx.rollback().is_ok());
}
#[test]
fn test_transaction_auto_rollback_on_drop() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
{
let _tx = db.begin();
}
}
#[test]
fn int_t001_create_then_match() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
db.execute("CREATE (n:Person {name: 'Alice', age: 30})")
.expect("create");
let result = db
.execute("MATCH (n:Person) RETURN n.name, n.age")
.expect("match");
assert_eq!(result.rows.len(), 1);
assert_eq!(
result.rows[0].get_as::<String>("n.name"),
Some("Alice".to_string())
);
assert_eq!(result.rows[0].get_as::<i64>("n.age"), Some(30));
}
#[test]
fn int_t002_parameter_binding() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
db.execute("CREATE (n:Person {name: 'Alice'})")
.expect("create");
let mut params = Params::new();
params.insert("name".to_string(), Value::String("Alice".into()));
let result = db
.execute_with_params(
"MATCH (n:Person) WHERE n.name = $name RETURN n.name",
params,
)
.expect("match with params");
assert_eq!(result.rows.len(), 1);
assert_eq!(
result.rows[0].get_as::<String>("n.name"),
Some("Alice".to_string())
);
}
#[test]
fn int_t003_transaction_commit() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
{
let mut tx = db.begin();
tx.execute("CREATE (n:Person {name: 'Bob'})")
.expect("create in tx");
tx.commit().expect("commit");
}
let result = db
.execute("MATCH (n:Person) RETURN n.name")
.expect("match after commit");
assert_eq!(result.rows.len(), 1);
assert_eq!(
result.rows[0].get_as::<String>("n.name"),
Some("Bob".to_string())
);
}
#[test]
fn int_t004_invalid_cypher_parse_error() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
let result = db.execute("INVALID QUERY @#$");
assert!(result.is_err());
let err = result.expect_err("should fail");
assert!(
matches!(err, CypherLiteError::ParseError { .. }),
"expected ParseError, got: {err}"
);
}
#[test]
fn int_t005_match_nonexistent_label_empty() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
let result = db
.execute("MATCH (n:NonExistent) RETURN n")
.expect("should succeed with empty result");
assert!(result.rows.is_empty());
}
#[test]
fn int_t006_set_then_match() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
db.execute("CREATE (n:Person {name: 'Alice', age: 25})")
.expect("create");
db.execute("MATCH (n:Person) SET n.age = 30").expect("set");
let result = db
.execute("MATCH (n:Person) RETURN n.age")
.expect("match after set");
assert_eq!(result.rows.len(), 1);
assert_eq!(result.rows[0].get_as::<i64>("n.age"), Some(30));
}
#[test]
fn int_t007_detach_delete() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
db.execute("CREATE (a:Person {name: 'Alice'})-[:KNOWS]->(b:Person {name: 'Bob'})")
.expect("create");
let result = db
.execute("MATCH (n:Person) RETURN n.name")
.expect("match before delete");
assert_eq!(result.rows.len(), 2);
db.execute("MATCH (n:Person) DETACH DELETE n")
.expect("detach delete");
let result = db
.execute("MATCH (n:Person) RETURN n.name")
.expect("match after delete");
assert!(result.rows.is_empty());
}
#[test]
fn ac_001_match_return_three_persons() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
db.execute("CREATE (n:Person {name: 'Alice'})").expect("c1");
db.execute("CREATE (n:Person {name: 'Bob'})").expect("c2");
db.execute("CREATE (n:Person {name: 'Charlie'})")
.expect("c3");
let result = db.execute("MATCH (n:Person) RETURN n.name").expect("match");
assert_eq!(result.rows.len(), 3);
let mut names: Vec<String> = result
.rows
.iter()
.filter_map(|r| r.get_as::<String>("n.name"))
.collect();
names.sort();
assert_eq!(names, vec!["Alice", "Bob", "Charlie"]);
}
#[test]
fn ac_002_create_then_match_verify() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
db.execute("CREATE (a:Person {name: 'Alice'})")
.expect("create");
let result = db.execute("MATCH (n:Person) RETURN n.name").expect("match");
assert_eq!(result.rows.len(), 1);
assert_eq!(
result.rows[0].get_as::<String>("n.name"),
Some("Alice".to_string())
);
}
#[test]
fn ac_003_create_relationship_then_traverse() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
db.execute("CREATE (a:Person {name: 'Alice'})-[:KNOWS]->(b:Person {name: 'Bob'})")
.expect("create relationship");
let result = db
.execute("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN b.name")
.expect("traverse");
assert_eq!(result.rows.len(), 1);
assert_eq!(
result.rows[0].get_as::<String>("b.name"),
Some("Bob".to_string())
);
}
#[test]
fn ac_004_where_filter() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
db.execute("CREATE (n:Person {name: 'Alice', age: 30})")
.expect("c1");
db.execute("CREATE (n:Person {name: 'Bob', age: 25})")
.expect("c2");
db.execute("CREATE (n:Person {name: 'Charlie', age: 35})")
.expect("c3");
let result = db
.execute("MATCH (n:Person) WHERE n.age > 28 RETURN n.name")
.expect("filter");
assert_eq!(result.rows.len(), 2);
let mut names: Vec<String> = result
.rows
.iter()
.filter_map(|r| r.get_as::<String>("n.name"))
.collect();
names.sort();
assert_eq!(names, vec!["Alice", "Charlie"]);
}
#[test]
fn ac_006_syntax_error_detection() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
let result = db.execute("MATCH (n:Person RETURN n");
assert!(result.is_err());
let err = result.expect_err("should fail");
match err {
CypherLiteError::ParseError {
line,
column,
message,
} => {
assert!(line >= 1, "line should be >= 1, got {line}");
assert!(column >= 1, "column should be >= 1, got {column}");
assert!(!message.is_empty(), "error message should not be empty");
}
other => panic!("expected ParseError, got: {other}"),
}
}
#[test]
fn ac_007_semantic_error() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
let result = db.execute("MATCH (n:Person) RETURN m.name");
assert!(result.is_err());
let err = result.expect_err("should fail");
assert!(
matches!(err, CypherLiteError::SemanticError(_)),
"expected SemanticError, got: {err}"
);
}
#[test]
fn ac_010_null_handling() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
db.execute("CREATE (n:Person {name: 'Alice', email: 'alice@example.com'})")
.expect("c1");
db.execute("CREATE (n:Person {name: 'Bob'})").expect("c2");
let result = db
.execute("MATCH (n:Person) RETURN n.name, n.email")
.expect("match");
assert_eq!(result.rows.len(), 2);
let mut found_null = false;
let mut found_email = false;
for row in &result.rows {
match row.get("n.email") {
Some(Value::String(s)) if !s.is_empty() => found_email = true,
Some(Value::Null) | None => found_null = true,
_ => {}
}
}
assert!(found_email, "should find at least one row with email");
assert!(found_null, "should find at least one row with null email");
}
#[test]
fn ac_010_is_not_null_filter() {
let dir = tempdir().expect("tempdir");
let mut db = CypherLite::open(test_config(dir.path())).expect("open");
db.execute("CREATE (n:Person {name: 'Alice', email: 'alice@example.com'})")
.expect("c1");
db.execute("CREATE (n:Person {name: 'Bob'})").expect("c2");
let result = db
.execute("MATCH (n:Person) WHERE n.email IS NOT NULL RETURN n.name")
.expect("filter not null");
assert_eq!(result.rows.len(), 1);
assert_eq!(
result.rows[0].get_as::<String>("n.name"),
Some("Alice".to_string())
);
}
}