168 lines
6.5 KiB
Rust
168 lines
6.5 KiB
Rust
//! chrs-graph library implementation using Dolt for graph persistence.
|
||
|
||
use chrono::Utc;
|
||
use serde_json::Value;
|
||
use std::{path::Path, process::Command};
|
||
use thiserror::Error;
|
||
use uuid::Uuid;
|
||
|
||
/// Enumeration of possible errors that can arise while interacting with the `DoltGraph`.
|
||
///
|
||
/// Each variant wraps an underlying error source, making it easier for callers to
|
||
/// understand the failure context and decide on remedial actions.
|
||
#[derive(Error, Debug)]
|
||
pub enum GraphError {
|
||
/// Propagates I/O errors from the standard library (e.g., filesystem access).
|
||
#[error("IO error: {0}")]
|
||
Io(#[from] std::io::Error),
|
||
/// Represents a failure when executing a Dolt command.
|
||
#[error("Command failed: {0}")]
|
||
CommandFailed(String),
|
||
/// Propagates JSON (de)serialization errors from `serde_json`.
|
||
#[error("Serde JSON error: {0}")]
|
||
SerdeJson(#[from] serde_json::Error),
|
||
/// A generic catch‑all for errors that don't fit the other categories.
|
||
#[error("Other error: {0}")]
|
||
Other(String),
|
||
}
|
||
|
||
/// Wrapper around a Dolt repository that stores graph data.
|
||
///
|
||
/// The `DoltGraph` type encapsulates a path to a Dolt repo and provides high‑level
|
||
/// operations such as initializing the repo, committing changes, creating tables, and
|
||
/// inserting nodes expressed as JSON objects.
|
||
///
|
||
/// # Architectural Rationale
|
||
/// Dolt offers a Git‑like version‑controlled SQL database, which aligns well with CHORUS's
|
||
/// need for an immutable, query‑able history of graph mutations. By wrapping Dolt commands in
|
||
/// this struct we isolate the rest of the codebase from the command‑line interface, making the
|
||
/// graph layer portable and easier to test.
|
||
pub struct DoltGraph {
|
||
/// Filesystem path to the root of the Dolt repository.
|
||
pub repo_path: std::path::PathBuf,
|
||
}
|
||
|
||
impl DoltGraph {
|
||
/// Initialise (or open) a Dolt repository at the given `path`.
|
||
///
|
||
/// If the directory does not already contain a `.dolt` sub‑directory, the function runs
|
||
/// `dolt init` to create a new repository. Errors from the underlying command are wrapped in
|
||
/// `GraphError::CommandFailed`.
|
||
pub fn init(path: &Path) -> Result<Self, GraphError> {
|
||
if !path.join(".dolt").exists() {
|
||
let status = Command::new("dolt")
|
||
.arg("init")
|
||
.current_dir(path)
|
||
.status()?;
|
||
if !status.success() {
|
||
return Err(GraphError::CommandFailed(format!(
|
||
"dolt init failed with status {:?}",
|
||
status
|
||
)));
|
||
}
|
||
}
|
||
Ok(Self {
|
||
repo_path: path.to_path_buf(),
|
||
})
|
||
}
|
||
|
||
/// Execute a Dolt command with the specified arguments.
|
||
///
|
||
/// This helper centralises command execution and error handling. It runs `dolt` with the
|
||
/// provided argument slice, captures stdout/stderr, and returns `GraphError::CommandFailed`
|
||
/// when the command exits with a non‑zero status.
|
||
fn run_cmd(&self, args: &[&str]) -> Result<(), GraphError> {
|
||
let output = Command::new("dolt")
|
||
.args(args)
|
||
.current_dir(&self.repo_path)
|
||
.output()?;
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
return Err(GraphError::CommandFailed(stderr.to_string()));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Stage all changes and commit them with the provided `message`.
|
||
///
|
||
/// The method first runs `dolt add -A` to stage modifications, then `dolt commit -m`.
|
||
/// Any failure in these steps propagates as a `GraphError`.
|
||
pub fn commit(&self, message: &str) -> Result<(), GraphError> {
|
||
self.run_cmd(&["add", "-A"])?;
|
||
self.run_cmd(&["commit", "-m", message])?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Create a SQL table within the Dolt repository.
|
||
///
|
||
/// `schema` should be a comma‑separated column definition list (e.g., `"id INT PRIMARY KEY, name TEXT"`).
|
||
/// If the table already exists, the function treats it as a no‑op and returns `Ok(())`.
|
||
pub fn create_table(&self, table_name: &str, schema: &str) -> Result<(), GraphError> {
|
||
let query = format!("CREATE TABLE {} ({})", table_name, schema);
|
||
if let Err(e) = self.run_cmd(&["sql", "-q", &query]) {
|
||
if e.to_string().contains("already exists") {
|
||
// Table is already present – not an error for our use‑case.
|
||
return Ok(());
|
||
}
|
||
return Err(e);
|
||
}
|
||
self.commit(&format!("Create table {}", table_name))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Insert a node represented by a JSON object into the specified `table`.
|
||
///
|
||
/// The JSON `data` must be an object where keys correspond to column names. Supported value
|
||
/// types are strings, numbers, booleans, and null. Complex JSON structures are rejected because
|
||
/// they cannot be directly mapped to SQL scalar columns.
|
||
pub fn insert_node(&self, table: &str, data: Value) -> Result<(), GraphError> {
|
||
let obj = data
|
||
.as_object()
|
||
.ok_or_else(|| GraphError::Other("Data must be a JSON object".into()))?;
|
||
let columns: Vec<String> = obj.keys().cloned().collect();
|
||
let mut values: Vec<String> = Vec::new();
|
||
for key in &columns {
|
||
let v = &obj[key];
|
||
let sql_val = match v {
|
||
Value::String(s) => format!("'{}'", s.replace('\'', "''")),
|
||
Value::Number(n) => n.to_string(),
|
||
Value::Bool(b) => {
|
||
if *b {
|
||
"TRUE".into()
|
||
} else {
|
||
"FALSE".into()
|
||
}
|
||
}
|
||
Value::Null => "NULL".into(),
|
||
_ => return Err(GraphError::Other("Unsupported JSON value type".into())),
|
||
};
|
||
values.push(sql_val);
|
||
}
|
||
let query = format!(
|
||
"INSERT INTO {} ({}) VALUES ({})",
|
||
table,
|
||
columns.join(", "),
|
||
values.join(", ")
|
||
);
|
||
self.run_cmd(&["sql", "-q", &query])?;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use tempfile::TempDir;
|
||
|
||
#[test]
|
||
fn test_init_create_table_and_commit() {
|
||
let dir = TempDir::new().unwrap();
|
||
// Initialise a Dolt repository in a temporary directory.
|
||
let graph = DoltGraph::init(dir.path()).expect("init failed");
|
||
// Create a simple `nodes` table.
|
||
graph
|
||
.create_table("nodes", "id INT PRIMARY KEY, name TEXT")
|
||
.expect("create table failed");
|
||
}
|
||
}
|