use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct BatchTriple {
pub subject: String,
pub predicate: String,
pub object: String,
pub graph: Option<String>,
}
impl BatchTriple {
pub fn new(
subject: impl Into<String>,
predicate: impl Into<String>,
object: impl Into<String>,
) -> Self {
Self {
subject: subject.into(),
predicate: predicate.into(),
object: object.into(),
graph: None,
}
}
pub fn quad(
subject: impl Into<String>,
predicate: impl Into<String>,
object: impl Into<String>,
graph: impl Into<String>,
) -> Self {
Self {
subject: subject.into(),
predicate: predicate.into(),
object: object.into(),
graph: Some(graph.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BatchOperation {
Insert(BatchTriple),
Delete(BatchTriple),
}
impl BatchOperation {
pub fn triple(&self) -> &BatchTriple {
match self {
BatchOperation::Insert(t) | BatchOperation::Delete(t) => t,
}
}
pub fn is_insert(&self) -> bool {
matches!(self, BatchOperation::Insert(_))
}
pub fn is_delete(&self) -> bool {
matches!(self, BatchOperation::Delete(_))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatchStatus {
Pending,
Committed,
RolledBack,
}
#[derive(Debug, Clone)]
pub struct WalBatchEntry {
pub batch_id: u64,
pub operations: Vec<BatchOperation>,
pub checksum: u32,
}
#[derive(Debug, Clone)]
pub struct WriteBatchConfig {
pub max_batch_size: usize,
}
impl Default for WriteBatchConfig {
fn default() -> Self {
Self {
max_batch_size: 100_000,
}
}
}
impl WriteBatchConfig {
pub fn with_max_size(max_batch_size: usize) -> Self {
Self { max_batch_size }
}
pub fn unlimited() -> Self {
Self { max_batch_size: 0 }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BatchError {
BatchSizeLimitReached {
current: usize,
max: usize,
},
InvalidStatus {
expected: BatchStatus,
actual: BatchStatus,
},
MergeConflict {
description: String,
},
Other(String),
}
impl std::fmt::Display for BatchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BatchError::BatchSizeLimitReached { current, max } => {
write!(f, "batch size limit reached: {current}/{max}")
}
BatchError::InvalidStatus { expected, actual } => {
write!(
f,
"invalid batch status: expected {expected:?}, got {actual:?}"
)
}
BatchError::MergeConflict { description } => {
write!(f, "merge conflict: {description}")
}
BatchError::Other(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for BatchError {}
pub type BatchResult<T> = std::result::Result<T, BatchError>;
#[derive(Debug, Clone, Default)]
pub struct BatchStats {
pub inserts: usize,
pub deletes: usize,
pub status: Option<BatchStatus>,
}
pub struct WriteBatch {
batch_id: u64,
operations: Vec<BatchOperation>,
status: BatchStatus,
config: WriteBatchConfig,
wal_entries: Vec<WalBatchEntry>,
next_batch_id: u64,
}
impl WriteBatch {
pub fn new(batch_id: u64) -> Self {
Self {
batch_id,
operations: Vec::new(),
status: BatchStatus::Pending,
config: WriteBatchConfig::default(),
wal_entries: Vec::new(),
next_batch_id: batch_id + 1,
}
}
pub fn with_config(batch_id: u64, config: WriteBatchConfig) -> Self {
Self {
batch_id,
operations: Vec::new(),
status: BatchStatus::Pending,
config,
wal_entries: Vec::new(),
next_batch_id: batch_id + 1,
}
}
pub fn id(&self) -> u64 {
self.batch_id
}
pub fn status(&self) -> BatchStatus {
self.status
}
pub fn len(&self) -> usize {
self.operations.len()
}
pub fn is_empty(&self) -> bool {
self.operations.is_empty()
}
pub fn stats(&self) -> BatchStats {
let inserts = self.operations.iter().filter(|o| o.is_insert()).count();
let deletes = self.operations.iter().filter(|o| o.is_delete()).count();
BatchStats {
inserts,
deletes,
status: Some(self.status),
}
}
pub fn operations(&self) -> &[BatchOperation] {
&self.operations
}
pub fn wal_entries(&self) -> &[WalBatchEntry] {
&self.wal_entries
}
pub fn insert(&mut self, triple: BatchTriple) -> BatchResult<()> {
self.ensure_pending()?;
self.check_size_limit()?;
self.operations.push(BatchOperation::Insert(triple));
Ok(())
}
pub fn delete(&mut self, triple: BatchTriple) -> BatchResult<()> {
self.ensure_pending()?;
self.check_size_limit()?;
self.operations.push(BatchOperation::Delete(triple));
Ok(())
}
pub fn update(&mut self, old: BatchTriple, new: BatchTriple) -> BatchResult<()> {
self.ensure_pending()?;
if self.config.max_batch_size > 0 && self.operations.len() + 2 > self.config.max_batch_size
{
return Err(BatchError::BatchSizeLimitReached {
current: self.operations.len(),
max: self.config.max_batch_size,
});
}
self.operations.push(BatchOperation::Delete(old));
self.operations.push(BatchOperation::Insert(new));
Ok(())
}
pub fn insert_many(&mut self, triples: Vec<BatchTriple>) -> BatchResult<()> {
self.ensure_pending()?;
if self.config.max_batch_size > 0
&& self.operations.len() + triples.len() > self.config.max_batch_size
{
return Err(BatchError::BatchSizeLimitReached {
current: self.operations.len(),
max: self.config.max_batch_size,
});
}
for t in triples {
self.operations.push(BatchOperation::Insert(t));
}
Ok(())
}
pub fn delete_many(&mut self, triples: Vec<BatchTriple>) -> BatchResult<()> {
self.ensure_pending()?;
if self.config.max_batch_size > 0
&& self.operations.len() + triples.len() > self.config.max_batch_size
{
return Err(BatchError::BatchSizeLimitReached {
current: self.operations.len(),
max: self.config.max_batch_size,
});
}
for t in triples {
self.operations.push(BatchOperation::Delete(t));
}
Ok(())
}
pub fn commit(&mut self) -> BatchResult<WalBatchEntry> {
self.ensure_pending()?;
let checksum = compute_operations_checksum(&self.operations);
let entry = WalBatchEntry {
batch_id: self.batch_id,
operations: self.operations.clone(),
checksum,
};
self.wal_entries.push(entry.clone());
self.status = BatchStatus::Committed;
Ok(entry)
}
pub fn rollback(&mut self) -> BatchResult<()> {
self.ensure_pending()?;
self.operations.clear();
self.status = BatchStatus::RolledBack;
Ok(())
}
pub fn merge(&mut self, other: &WriteBatch) -> BatchResult<()> {
self.ensure_pending()?;
if other.status != BatchStatus::Pending {
return Err(BatchError::InvalidStatus {
expected: BatchStatus::Pending,
actual: other.status,
});
}
let mut intent_map: HashMap<&BatchTriple, bool> = HashMap::new();
for op in &self.operations {
intent_map.insert(op.triple(), op.is_insert());
}
for op in &other.operations {
if let Some(&existing_is_insert) = intent_map.get(op.triple()) {
if existing_is_insert != op.is_insert() {
return Err(BatchError::MergeConflict {
description: format!(
"({}, {}, {})",
op.triple().subject,
op.triple().predicate,
op.triple().object,
),
});
}
}
}
let combined = self.operations.len() + other.operations.len();
if self.config.max_batch_size > 0 && combined > self.config.max_batch_size {
return Err(BatchError::BatchSizeLimitReached {
current: combined,
max: self.config.max_batch_size,
});
}
self.operations.extend(other.operations.iter().cloned());
Ok(())
}
pub fn serialize(&self) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(&self.batch_id.to_le_bytes());
buf.extend_from_slice(&(self.operations.len() as u32).to_le_bytes());
for op in &self.operations {
let (op_byte, triple) = match op {
BatchOperation::Insert(t) => (0u8, t),
BatchOperation::Delete(t) => (1u8, t),
};
buf.push(op_byte);
write_string(&mut buf, &triple.subject);
write_string(&mut buf, &triple.predicate);
write_string(&mut buf, &triple.object);
match &triple.graph {
Some(g) => {
buf.push(1);
write_string(&mut buf, g);
}
None => {
buf.push(0);
}
}
}
buf
}
pub fn deserialize(data: &[u8]) -> BatchResult<Self> {
if data.len() < 12 {
return Err(BatchError::Other("data too short for header".to_string()));
}
let batch_id = u64::from_le_bytes(
data[0..8]
.try_into()
.map_err(|_| BatchError::Other("bad batch_id bytes".to_string()))?,
);
let op_count = u32::from_le_bytes(
data[8..12]
.try_into()
.map_err(|_| BatchError::Other("bad op count bytes".to_string()))?,
) as usize;
let mut cursor = 12;
let mut operations = Vec::with_capacity(op_count);
for _ in 0..op_count {
if cursor >= data.len() {
return Err(BatchError::Other("unexpected end of data".to_string()));
}
let op_byte = data[cursor];
cursor += 1;
let subject = read_string(data, &mut cursor)?;
let predicate = read_string(data, &mut cursor)?;
let object = read_string(data, &mut cursor)?;
if cursor >= data.len() {
return Err(BatchError::Other(
"unexpected end of data (graph flag)".to_string(),
));
}
let graph_flag = data[cursor];
cursor += 1;
let graph = if graph_flag == 1 {
Some(read_string(data, &mut cursor)?)
} else {
None
};
let triple = BatchTriple {
subject,
predicate,
object,
graph,
};
let op = if op_byte == 0 {
BatchOperation::Insert(triple)
} else {
BatchOperation::Delete(triple)
};
operations.push(op);
}
let mut batch = WriteBatch::new(batch_id);
batch.operations = operations;
Ok(batch)
}
pub fn next_batch(&mut self) -> WriteBatch {
let id = self.next_batch_id;
self.next_batch_id += 1;
WriteBatch::new(id)
}
fn ensure_pending(&self) -> BatchResult<()> {
if self.status != BatchStatus::Pending {
return Err(BatchError::InvalidStatus {
expected: BatchStatus::Pending,
actual: self.status,
});
}
Ok(())
}
fn check_size_limit(&self) -> BatchResult<()> {
if self.config.max_batch_size > 0 && self.operations.len() >= self.config.max_batch_size {
return Err(BatchError::BatchSizeLimitReached {
current: self.operations.len(),
max: self.config.max_batch_size,
});
}
Ok(())
}
}
fn write_string(buf: &mut Vec<u8>, s: &str) {
let bytes = s.as_bytes();
buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
buf.extend_from_slice(bytes);
}
fn read_string(data: &[u8], cursor: &mut usize) -> BatchResult<String> {
if *cursor + 4 > data.len() {
return Err(BatchError::Other(
"unexpected end of data (string len)".to_string(),
));
}
let len = u32::from_le_bytes(
data[*cursor..*cursor + 4]
.try_into()
.map_err(|_| BatchError::Other("bad string len bytes".to_string()))?,
) as usize;
*cursor += 4;
if *cursor + len > data.len() {
return Err(BatchError::Other(
"unexpected end of data (string body)".to_string(),
));
}
let s = String::from_utf8(data[*cursor..*cursor + len].to_vec())
.map_err(|e| BatchError::Other(format!("invalid utf-8: {e}")))?;
*cursor += len;
Ok(s)
}
fn compute_operations_checksum(operations: &[BatchOperation]) -> u32 {
let mut hash: u32 = 0x811c_9dc5;
for op in operations {
let discriminant: u8 = if op.is_insert() { 0 } else { 1 };
hash ^= discriminant as u32;
hash = hash.wrapping_mul(0x0100_0193);
for byte in op.triple().subject.as_bytes() {
hash ^= *byte as u32;
hash = hash.wrapping_mul(0x0100_0193);
}
for byte in op.triple().predicate.as_bytes() {
hash ^= *byte as u32;
hash = hash.wrapping_mul(0x0100_0193);
}
for byte in op.triple().object.as_bytes() {
hash ^= *byte as u32;
hash = hash.wrapping_mul(0x0100_0193);
}
if let Some(g) = &op.triple().graph {
for byte in g.as_bytes() {
hash ^= *byte as u32;
hash = hash.wrapping_mul(0x0100_0193);
}
}
}
hash
}
pub fn verify_wal_entry(entry: &WalBatchEntry) -> bool {
compute_operations_checksum(&entry.operations) == entry.checksum
}
#[cfg(test)]
mod tests {
use super::*;
fn triple(s: &str, p: &str, o: &str) -> BatchTriple {
BatchTriple::new(s, p, o)
}
fn quad(s: &str, p: &str, o: &str, g: &str) -> BatchTriple {
BatchTriple::quad(s, p, o, g)
}
#[test]
fn test_triple_new() {
let t = triple("s", "p", "o");
assert_eq!(t.subject, "s");
assert_eq!(t.predicate, "p");
assert_eq!(t.object, "o");
assert!(t.graph.is_none());
}
#[test]
fn test_quad_new() {
let t = quad("s", "p", "o", "g");
assert_eq!(t.graph, Some("g".to_string()));
}
#[test]
fn test_operation_is_insert() {
let op = BatchOperation::Insert(triple("s", "p", "o"));
assert!(op.is_insert());
assert!(!op.is_delete());
}
#[test]
fn test_operation_is_delete() {
let op = BatchOperation::Delete(triple("s", "p", "o"));
assert!(op.is_delete());
assert!(!op.is_insert());
}
#[test]
fn test_operation_triple_ref() {
let t = triple("s", "p", "o");
let op = BatchOperation::Insert(t.clone());
assert_eq!(op.triple(), &t);
}
#[test]
fn test_new_batch_is_pending() {
let b = WriteBatch::new(1);
assert_eq!(b.status(), BatchStatus::Pending);
assert!(b.is_empty());
assert_eq!(b.len(), 0);
}
#[test]
fn test_batch_id() {
let b = WriteBatch::new(42);
assert_eq!(b.id(), 42);
}
#[test]
fn test_insert_increments_len() {
let mut b = WriteBatch::new(1);
b.insert(triple("s", "p", "o")).ok();
assert_eq!(b.len(), 1);
assert!(!b.is_empty());
}
#[test]
fn test_delete_increments_len() {
let mut b = WriteBatch::new(1);
b.delete(triple("s", "p", "o")).ok();
assert_eq!(b.len(), 1);
}
#[test]
fn test_insert_many() {
let mut b = WriteBatch::new(1);
let triples = vec![
triple("s1", "p", "o"),
triple("s2", "p", "o"),
triple("s3", "p", "o"),
];
b.insert_many(triples).ok();
assert_eq!(b.len(), 3);
assert!(b.operations().iter().all(|o| o.is_insert()));
}
#[test]
fn test_delete_many() {
let mut b = WriteBatch::new(1);
let triples = vec![triple("s1", "p", "o"), triple("s2", "p", "o")];
b.delete_many(triples).ok();
assert_eq!(b.len(), 2);
assert!(b.operations().iter().all(|o| o.is_delete()));
}
#[test]
fn test_update_produces_delete_then_insert() {
let mut b = WriteBatch::new(1);
b.update(triple("old_s", "p", "o"), triple("new_s", "p", "o"))
.ok();
assert_eq!(b.len(), 2);
assert!(b.operations()[0].is_delete());
assert!(b.operations()[1].is_insert());
}
#[test]
fn test_commit_transitions_to_committed() {
let mut b = WriteBatch::new(1);
b.insert(triple("s", "p", "o")).ok();
let _entry = b.commit();
assert_eq!(b.status(), BatchStatus::Committed);
}
#[test]
fn test_commit_produces_wal_entry() {
let mut b = WriteBatch::new(1);
b.insert(triple("s", "p", "o")).ok();
let entry = b.commit().expect("commit should succeed");
assert_eq!(entry.batch_id, 1);
assert_eq!(entry.operations.len(), 1);
}
#[test]
fn test_commit_wal_entry_stored() {
let mut b = WriteBatch::new(1);
b.insert(triple("s", "p", "o")).ok();
b.commit().ok();
assert_eq!(b.wal_entries().len(), 1);
}
#[test]
fn test_commit_on_committed_fails() {
let mut b = WriteBatch::new(1);
b.insert(triple("s", "p", "o")).ok();
b.commit().ok();
let result = b.commit();
assert!(result.is_err());
}
#[test]
fn test_rollback_transitions_to_rolledback() {
let mut b = WriteBatch::new(1);
b.insert(triple("s", "p", "o")).ok();
b.rollback().ok();
assert_eq!(b.status(), BatchStatus::RolledBack);
}
#[test]
fn test_rollback_clears_operations() {
let mut b = WriteBatch::new(1);
b.insert(triple("s", "p", "o")).ok();
b.rollback().ok();
assert!(b.is_empty());
}
#[test]
fn test_rollback_on_committed_fails() {
let mut b = WriteBatch::new(1);
b.commit().ok();
let result = b.rollback();
assert!(result.is_err());
}
#[test]
fn test_insert_after_rollback_fails() {
let mut b = WriteBatch::new(1);
b.rollback().ok();
let result = b.insert(triple("s", "p", "o"));
assert!(result.is_err());
}
#[test]
fn test_size_limit_enforced_on_insert() {
let config = WriteBatchConfig::with_max_size(2);
let mut b = WriteBatch::with_config(1, config);
b.insert(triple("s1", "p", "o")).ok();
b.insert(triple("s2", "p", "o")).ok();
let result = b.insert(triple("s3", "p", "o"));
assert!(result.is_err());
}
#[test]
fn test_size_limit_enforced_on_delete() {
let config = WriteBatchConfig::with_max_size(1);
let mut b = WriteBatch::with_config(1, config);
b.delete(triple("s1", "p", "o")).ok();
let result = b.delete(triple("s2", "p", "o"));
assert!(result.is_err());
}
#[test]
fn test_size_limit_enforced_on_insert_many() {
let config = WriteBatchConfig::with_max_size(2);
let mut b = WriteBatch::with_config(1, config);
let triples = vec![
triple("a", "b", "c"),
triple("d", "e", "f"),
triple("g", "h", "i"),
];
let result = b.insert_many(triples);
assert!(result.is_err());
}
#[test]
fn test_size_limit_enforced_on_update() {
let config = WriteBatchConfig::with_max_size(1);
let mut b = WriteBatch::with_config(1, config);
let result = b.update(triple("old", "p", "o"), triple("new", "p", "o"));
assert!(result.is_err());
}
#[test]
fn test_unlimited_config_allows_many() {
let config = WriteBatchConfig::unlimited();
let mut b = WriteBatch::with_config(1, config);
for i in 0..1000 {
b.insert(triple(&format!("s{i}"), "p", "o")).ok();
}
assert_eq!(b.len(), 1000);
}
#[test]
fn test_merge_compatible_batches() {
let mut b1 = WriteBatch::new(1);
b1.insert(triple("s1", "p", "o")).ok();
let mut b2 = WriteBatch::new(2);
b2.insert(triple("s2", "p", "o")).ok();
b1.merge(&b2).ok();
assert_eq!(b1.len(), 2);
}
#[test]
fn test_merge_conflict_detected() {
let mut b1 = WriteBatch::new(1);
b1.insert(triple("s", "p", "o")).ok();
let mut b2 = WriteBatch::new(2);
b2.delete(triple("s", "p", "o")).ok();
let result = b1.merge(&b2);
assert!(result.is_err());
}
#[test]
fn test_merge_same_intent_succeeds() {
let mut b1 = WriteBatch::new(1);
b1.insert(triple("s", "p", "o")).ok();
let mut b2 = WriteBatch::new(2);
b2.insert(triple("s", "p", "o")).ok();
assert!(b1.merge(&b2).is_ok());
}
#[test]
fn test_merge_committed_other_fails() {
let mut b1 = WriteBatch::new(1);
let mut b2 = WriteBatch::new(2);
b2.commit().ok();
let result = b1.merge(&b2);
assert!(result.is_err());
}
#[test]
fn test_merge_respects_size_limit() {
let config = WriteBatchConfig::with_max_size(3);
let mut b1 = WriteBatch::with_config(1, config);
b1.insert(triple("s1", "p", "o")).ok();
b1.insert(triple("s2", "p", "o")).ok();
let mut b2 = WriteBatch::new(2);
b2.insert(triple("s3", "p", "o")).ok();
b2.insert(triple("s4", "p", "o")).ok();
let result = b1.merge(&b2);
assert!(result.is_err());
}
#[test]
fn test_stats_insert_count() {
let mut b = WriteBatch::new(1);
b.insert(triple("s1", "p", "o")).ok();
b.insert(triple("s2", "p", "o")).ok();
let s = b.stats();
assert_eq!(s.inserts, 2);
assert_eq!(s.deletes, 0);
}
#[test]
fn test_stats_delete_count() {
let mut b = WriteBatch::new(1);
b.delete(triple("s1", "p", "o")).ok();
let s = b.stats();
assert_eq!(s.inserts, 0);
assert_eq!(s.deletes, 1);
}
#[test]
fn test_stats_mixed() {
let mut b = WriteBatch::new(1);
b.insert(triple("s1", "p", "o")).ok();
b.delete(triple("s2", "p", "o")).ok();
b.insert(triple("s3", "p", "o")).ok();
let s = b.stats();
assert_eq!(s.inserts, 2);
assert_eq!(s.deletes, 1);
assert_eq!(s.status, Some(BatchStatus::Pending));
}
#[test]
fn test_wal_entry_checksum_valid() {
let mut b = WriteBatch::new(1);
b.insert(triple("s", "p", "o")).ok();
let entry = b.commit().expect("commit ok");
assert!(verify_wal_entry(&entry));
}
#[test]
fn test_wal_entry_checksum_corrupted_fails() {
let mut b = WriteBatch::new(1);
b.insert(triple("s", "p", "o")).ok();
let mut entry = b.commit().expect("commit ok");
entry.checksum = entry.checksum.wrapping_add(1);
assert!(!verify_wal_entry(&entry));
}
#[test]
fn test_wal_checksum_deterministic() {
let ops1 = vec![BatchOperation::Insert(triple("s", "p", "o"))];
let ops2 = vec![BatchOperation::Insert(triple("s", "p", "o"))];
assert_eq!(
compute_operations_checksum(&ops1),
compute_operations_checksum(&ops2)
);
}
#[test]
fn test_wal_checksum_differs_for_different_ops() {
let ops1 = vec![BatchOperation::Insert(triple("s1", "p", "o"))];
let ops2 = vec![BatchOperation::Insert(triple("s2", "p", "o"))];
assert_ne!(
compute_operations_checksum(&ops1),
compute_operations_checksum(&ops2)
);
}
#[test]
fn test_serialize_deserialize_roundtrip() {
let mut b = WriteBatch::new(42);
b.insert(triple(
"http://example.org/s",
"http://example.org/p",
"hello",
))
.ok();
b.delete(triple(
"http://example.org/old",
"http://example.org/p",
"world",
))
.ok();
let data = b.serialize();
let restored = WriteBatch::deserialize(&data).expect("deserialize ok");
assert_eq!(restored.id(), 42);
assert_eq!(restored.len(), 2);
assert!(restored.operations()[0].is_insert());
assert!(restored.operations()[1].is_delete());
}
#[test]
fn test_serialize_deserialize_quad() {
let mut b = WriteBatch::new(1);
b.insert(quad("s", "p", "o", "g")).ok();
let data = b.serialize();
let restored = WriteBatch::deserialize(&data).expect("deserialize ok");
assert_eq!(
restored.operations()[0].triple().graph,
Some("g".to_string())
);
}
#[test]
fn test_deserialize_empty_data_fails() {
let result = WriteBatch::deserialize(&[]);
assert!(result.is_err());
}
#[test]
fn test_deserialize_truncated_data_fails() {
let result = WriteBatch::deserialize(&[0u8; 5]);
assert!(result.is_err());
}
#[test]
fn test_serialize_empty_batch() {
let b = WriteBatch::new(1);
let data = b.serialize();
let restored = WriteBatch::deserialize(&data).expect("deserialize ok");
assert!(restored.is_empty());
}
#[test]
fn test_next_batch_increments_id() {
let mut b = WriteBatch::new(10);
let b2 = b.next_batch();
assert_eq!(b2.id(), 11);
let b3 = b.next_batch();
assert_eq!(b3.id(), 12);
}
#[test]
fn test_commit_empty_batch_succeeds() {
let mut b = WriteBatch::new(1);
let entry = b.commit().expect("commit ok");
assert!(entry.operations.is_empty());
}
#[test]
fn test_insert_after_commit_fails() {
let mut b = WriteBatch::new(1);
b.commit().ok();
let result = b.insert(triple("s", "p", "o"));
assert!(result.is_err());
}
#[test]
fn test_delete_after_commit_fails() {
let mut b = WriteBatch::new(1);
b.commit().ok();
let result = b.delete(triple("s", "p", "o"));
assert!(result.is_err());
}
#[test]
fn test_delete_many_after_commit_fails() {
let mut b = WriteBatch::new(1);
b.commit().ok();
let result = b.delete_many(vec![triple("s", "p", "o")]);
assert!(result.is_err());
}
#[test]
fn test_insert_many_after_commit_fails() {
let mut b = WriteBatch::new(1);
b.commit().ok();
let result = b.insert_many(vec![triple("s", "p", "o")]);
assert!(result.is_err());
}
#[test]
fn test_update_after_commit_fails() {
let mut b = WriteBatch::new(1);
b.commit().ok();
let result = b.update(triple("old", "p", "o"), triple("new", "p", "o"));
assert!(result.is_err());
}
#[test]
fn test_merge_into_committed_fails() {
let mut b1 = WriteBatch::new(1);
b1.commit().ok();
let b2 = WriteBatch::new(2);
let result = b1.merge(&b2);
assert!(result.is_err());
}
#[test]
fn test_default_config() {
let config = WriteBatchConfig::default();
assert_eq!(config.max_batch_size, 100_000);
}
#[test]
fn test_batch_status_in_stats() {
let mut b = WriteBatch::new(1);
assert_eq!(b.stats().status, Some(BatchStatus::Pending));
b.commit().ok();
assert_eq!(b.stats().status, Some(BatchStatus::Committed));
}
#[test]
fn test_rollback_status_in_stats() {
let mut b = WriteBatch::new(1);
b.rollback().ok();
assert_eq!(b.stats().status, Some(BatchStatus::RolledBack));
}
}