use std::sync::Arc;
use khive_storage::graph::{SymmetricEdgeUpdateOutcome, SymmetricEdgeUpdateRequest};
use khive_storage::{SqlStatement, SqlValue, StorageCapability, StorageError, StorageResult};
use khive_types::EdgeRelation;
use rusqlite::OptionalExtension;
use uuid::Uuid;
use super::{map_sqlite_err, SqlGraphStore};
use crate::pool::RuntimeWriteOperation;
use crate::SqliteError;
pub const EDGE_SYMMETRIC_CONFLICT_PROBE_SQL: &str = "SELECT id FROM graph_edges \
WHERE namespace = ?1 AND source_id = ?2 AND target_id = ?3 \
AND relation = ?4 AND id != ?5";
pub const EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL: &str =
"DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2";
pub const EDGE_SYMMETRIC_DELETE_NONCANONICAL_GUARDED_SQL: &str =
"DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2 \
AND updated_at = ?3 AND deleted_at IS ?4";
pub const EDGE_SYMMETRIC_UPDATE_INPLACE_SQL: &str = "UPDATE graph_edges SET \
source_id = ?1, target_id = ?2, relation = ?3, \
weight = ?4, updated_at = ?5, metadata = ?6 \
WHERE namespace = ?7 AND id = ?8 \
AND updated_at = ?9 AND deleted_at IS ?10 \
AND ?5 > updated_at";
pub fn edge_symmetric_conflict_probe_statement(
namespace: &str,
canon_src: Uuid,
canon_tgt: Uuid,
relation: EdgeRelation,
exclude_id: Uuid,
) -> SqlStatement {
SqlStatement {
sql: EDGE_SYMMETRIC_CONFLICT_PROBE_SQL.to_string(),
params: vec![
SqlValue::Text(namespace.to_string()),
SqlValue::Text(canon_src.to_string()),
SqlValue::Text(canon_tgt.to_string()),
SqlValue::Text(relation.to_string()),
SqlValue::Text(exclude_id.to_string()),
],
label: Some("edge-symmetric-conflict-probe".to_string()),
}
}
pub fn edge_symmetric_delete_noncanonical_statement(namespace: &str, id: Uuid) -> SqlStatement {
SqlStatement {
sql: EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL.to_string(),
params: vec![
SqlValue::Text(namespace.to_string()),
SqlValue::Text(id.to_string()),
],
label: Some("edge-symmetric-delete-noncanonical".to_string()),
}
}
#[allow(clippy::too_many_arguments)]
pub fn edge_symmetric_update_inplace_statement(
namespace: &str,
id: Uuid,
canon_src: Uuid,
canon_tgt: Uuid,
relation: EdgeRelation,
weight: f64,
updated_at_micros: i64,
metadata: Option<&str>,
expected_updated_at_micros: i64,
expected_deleted_at_micros: Option<i64>,
) -> SqlStatement {
SqlStatement {
sql: EDGE_SYMMETRIC_UPDATE_INPLACE_SQL.to_string(),
params: vec![
SqlValue::Text(canon_src.to_string()),
SqlValue::Text(canon_tgt.to_string()),
SqlValue::Text(relation.to_string()),
SqlValue::Float(weight),
SqlValue::Integer(updated_at_micros),
match metadata {
Some(m) => SqlValue::Text(m.to_string()),
None => SqlValue::Null,
},
SqlValue::Text(namespace.to_string()),
SqlValue::Text(id.to_string()),
SqlValue::Integer(expected_updated_at_micros),
match expected_deleted_at_micros {
Some(value) => SqlValue::Integer(value),
None => SqlValue::Null,
},
],
label: Some("edge-symmetric-update-inplace".to_string()),
}
}
impl SqlGraphStore {
pub(super) async fn update_symmetric_edge(
&self,
request: SymmetricEdgeUpdateRequest,
) -> StorageResult<SymmetricEdgeUpdateOutcome> {
let ns = request.namespace;
let edge_id_str = request.id.to_string();
let canon_src_str = request.source_id.to_string();
let canon_tgt_str = request.target_id.to_string();
let relation_str = request.relation.to_string();
let weight = request.weight;
let metadata = request
.metadata
.as_ref()
.map(|v| serde_json::to_string(v).unwrap_or_default());
let expected_updated_at_micros = request.expected_updated_at_micros;
let expected_deleted_at_micros = request.expected_deleted_at_micros;
let pool = Arc::clone(&self.pool);
let writer_task =
pool.writer_task_for_runtime_write(RuntimeWriteOperation::UpdateSymmetricEdge)?;
let dml = move |conn: &rusqlite::Connection| {
update_edge_symmetric_dml(
conn,
&ns,
&edge_id_str,
&canon_src_str,
&canon_tgt_str,
&relation_str,
weight,
metadata,
expected_updated_at_micros,
expected_deleted_at_micros,
)
};
if let Some(writer_task) = writer_task {
writer_task
.send_at(
move |conn| {
dml(conn).map_err(|error| {
StorageError::driver(StorageCapability::Graph, "update_edge", error)
})
},
"update_edge",
)
.await
} else {
tokio::task::spawn_blocking(move || {
let guard = pool.writer()?;
guard.transaction(dml)
})
.await
.map_err(|error| StorageError::driver(StorageCapability::Graph, "update_edge", error))?
.map_err(|error| map_sqlite_err(error, "update_edge"))
}
}
}
#[allow(clippy::too_many_arguments)]
fn update_edge_symmetric_dml(
conn: &rusqlite::Connection,
ns: &str,
edge_id_str: &str,
canon_src_str: &str,
canon_tgt_str: &str,
relation_str: &str,
weight: f64,
metadata: Option<String>,
expected_updated_at_micros: i64,
expected_deleted_at_micros: Option<i64>,
) -> Result<SymmetricEdgeUpdateOutcome, SqliteError> {
let minimum_updated_at_micros = expected_updated_at_micros.checked_add(1).ok_or_else(|| {
SqliteError::InvalidData(format!(
"update_edge: edge {edge_id_str} updated_at is already at i64::MAX \
and cannot advance"
))
})?;
let now_ts = chrono::Utc::now()
.timestamp_micros()
.max(minimum_updated_at_micros);
let conflict_id: Option<String> = conn
.query_row(
EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
rusqlite::params![
&ns,
&canon_src_str,
&canon_tgt_str,
&relation_str,
&edge_id_str
],
|row| row.get(0),
)
.optional()
.map_err(SqliteError::Rusqlite)?;
if let Some(existing_id) = conflict_id {
let affected = conn
.execute(
EDGE_SYMMETRIC_DELETE_NONCANONICAL_GUARDED_SQL,
rusqlite::params![
&ns,
&edge_id_str,
expected_updated_at_micros,
expected_deleted_at_micros,
],
)
.map_err(SqliteError::Rusqlite)?;
if affected == 0 {
return Ok(SymmetricEdgeUpdateOutcome::Stale);
}
Ok(SymmetricEdgeUpdateOutcome::Absorbed(existing_id))
} else {
let affected = conn
.execute(
EDGE_SYMMETRIC_UPDATE_INPLACE_SQL,
rusqlite::params![
&canon_src_str,
&canon_tgt_str,
&relation_str,
weight,
now_ts,
metadata,
&ns,
&edge_id_str,
expected_updated_at_micros,
expected_deleted_at_micros,
],
)
.map_err(SqliteError::Rusqlite)?;
if affected == 0 {
return Ok(SymmetricEdgeUpdateOutcome::Stale);
}
Ok(SymmetricEdgeUpdateOutcome::Updated)
}
}
#[cfg(test)]
#[path = "symmetric_update/tests.rs"]
mod tests;