Files
CHORUS/chrs-graph/src/lib.rs

168 lines
6.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 catchall 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 highlevel
/// operations such as initializing the repo, committing changes, creating tables, and
/// inserting nodes expressed as JSON objects.
///
/// # Architectural Rationale
/// Dolt offers a Gitlike versioncontrolled SQL database, which aligns well with CHORUS's
/// need for an immutable, queryable history of graph mutations. By wrapping Dolt commands in
/// this struct we isolate the rest of the codebase from the commandline 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` subdirectory, 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 nonzero 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 commaseparated column definition list (e.g., `"id INT PRIMARY KEY, name TEXT"`).
/// If the table already exists, the function treats it as a noop 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 usecase.
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");
}
}