use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock};
use crate::error::{NopalError, Result};
use crate::types::{Edge, Node, NodeId, PropertyValue};
use super::Graph;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpsertOutcome {
Created,
Updated,
Unchanged,
}
impl UpsertOutcome {
pub fn as_str(&self) -> &'static str {
match self {
UpsertOutcome::Created => "created",
UpsertOutcome::Updated => "updated",
UpsertOutcome::Unchanged => "unchanged",
}
}
}
#[derive(Debug, Clone)]
pub struct LinkSpec {
pub edge_type: String,
pub target_label: String,
pub target_key: String,
pub target_key_value: PropertyValue,
pub props: HashMap<String, PropertyValue>,
pub create_target_stub: bool,
}
#[derive(Debug, Clone)]
pub struct UpsertRequest {
pub label: String,
pub key: String,
pub props: HashMap<String, PropertyValue>,
pub embedding: Option<(Vec<f32>, String)>,
pub links: Vec<LinkSpec>,
}
fn key_locks() -> &'static Mutex<HashMap<u64, Arc<tokio::sync::Mutex<()>>>> {
static LOCKS: OnceLock<Mutex<HashMap<u64, Arc<tokio::sync::Mutex<()>>>>> = OnceLock::new();
LOCKS.get_or_init(|| Mutex::new(HashMap::new()))
}
fn key_lock_for(label: &str, key: &str, value: &PropertyValue) -> Arc<tokio::sync::Mutex<()>> {
let mut h = DefaultHasher::new();
label.hash(&mut h);
key.hash(&mut h);
format!("{value:?}").hash(&mut h);
let id = h.finish();
let mut map = key_locks().lock().unwrap();
map.entry(id).or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))).clone()
}
impl Graph {
pub async fn upsert_node(&self, req: UpsertRequest) -> Result<(UpsertOutcome, NodeId)> {
let key_value = req.props.get(&req.key).cloned().ok_or_else(|| {
NopalError::Custom(format!(
"upsert: key '{}' missing from props for label '{}'",
req.key, req.label
))
})?;
let lock = key_lock_for(&req.label, &req.key, &key_value);
let _guard = lock.lock().await;
let mut tx = self.begin_transaction().await?;
let existing = tx
.get_nodes_by_label_and_property(&req.label, &req.key, &key_value)
.await?;
let (outcome, node_id, stale_index_entries) = match existing.len() {
0 => {
let node = Node::with_id(NodeId::new_v4(), req.label.clone())
.with_properties(req.props.clone());
let id = node.id;
tx.add_node(node).await?;
(UpsertOutcome::Created, id, Vec::new())
}
1 => {
let old = &existing[0];
let id = old.id;
let props_same = old.properties == req.props;
if props_same {
(UpsertOutcome::Unchanged, id, Vec::new())
} else {
let stale: Vec<(String, PropertyValue)> = old
.properties
.iter()
.filter(|(k, v)| req.props.get(*k) != Some(*v))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let node = Node::with_id(id, req.label.clone())
.with_properties(req.props.clone());
tx.add_node(node).await?;
(UpsertOutcome::Updated, id, stale)
}
}
n => {
return Err(NopalError::AmbiguousUpsertKey(format!(
"{n} nodes match {}.{}={:?}; deduplicate before upserting",
req.label, req.key, key_value
)));
}
};
let mut links_added = 0usize;
let existing_edges = if outcome == UpsertOutcome::Created {
Vec::new()
} else {
self.get_outgoing_edges(node_id).await?
};
for link in &req.links {
let target_id = self
.resolve_or_stub_target(&mut tx, link)
.await?;
let already = existing_edges
.iter()
.any(|e| e.edge_type == link.edge_type && e.target == target_id);
if !already {
let mut edge = Edge::new(node_id, target_id, link.edge_type.clone());
edge.properties = link.props.clone();
tx.add_edge(edge)?;
links_added += 1;
}
}
let embedding_changed = self.embedding_differs(node_id, &req.embedding).await;
if outcome == UpsertOutcome::Unchanged && links_added == 0 && !embedding_changed {
tx.rollback_async().await?;
return Ok((UpsertOutcome::Unchanged, node_id));
}
tx.commit().await?;
for (prop, old_val) in &stale_index_entries {
self.storage_remove_property_index(prop, old_val, node_id).await?;
}
#[cfg(feature = "embeddings")]
if let Some((vector, model)) = &req.embedding
&& embedding_changed
{
self.add_node_embedding(node_id, vector.clone(), model).await?;
}
let final_outcome = if outcome == UpsertOutcome::Unchanged {
UpsertOutcome::Updated } else {
outcome
};
Ok((final_outcome, node_id))
}
pub async fn upsert_batch(
&self,
reqs: Vec<UpsertRequest>,
) -> Result<Vec<(UpsertOutcome, NodeId)>> {
let mut out = Vec::with_capacity(reqs.len());
for req in reqs {
out.push(self.upsert_node(req).await?);
}
Ok(out)
}
pub async fn delete_node_by_key(
&self,
label: &str,
key: &str,
value: &PropertyValue,
) -> Result<Option<NodeId>> {
let lock = key_lock_for(label, key, value);
let _guard = lock.lock().await;
let tx = self.begin_transaction().await?;
let existing = tx.get_nodes_by_label_and_property(label, key, value).await?;
let id = match existing.len() {
0 => return Ok(None),
1 => existing[0].id,
n => {
return Err(NopalError::AmbiguousUpsertKey(format!(
"{n} nodes match {label}.{key}={value:?}; deduplicate before deleting"
)));
}
};
tx.rollback_async().await?;
self.delete_node(id).await?;
Ok(Some(id))
}
async fn resolve_or_stub_target(
&self,
tx: &mut crate::transaction::Transaction,
link: &LinkSpec,
) -> Result<NodeId> {
let found = tx
.get_nodes_by_label_and_property(
&link.target_label,
&link.target_key,
&link.target_key_value,
)
.await?;
match found.len() {
0 => {
if link.create_target_stub {
let stub = Node::with_id(NodeId::new_v4(), link.target_label.clone())
.with_property(link.target_key.clone(), link.target_key_value.clone());
let id = stub.id;
tx.add_node(stub).await?;
Ok(id)
} else {
Err(NopalError::NodeNotFound(format!(
"link target {}.{}={:?} not found (set create_target_stub to create it)",
link.target_label, link.target_key, link.target_key_value
)))
}
}
_ => Ok(found[0].id),
}
}
#[allow(unused_variables)]
async fn embedding_differs(
&self,
node_id: NodeId,
embedding: &Option<(Vec<f32>, String)>,
) -> bool {
#[cfg(feature = "embeddings")]
{
if let Some((vector, model)) = embedding {
return match self.get_node_embedding(node_id, model).await {
Ok(stored) => stored.vector != *vector,
Err(_) => true,
};
}
}
false
}
}