use std::collections::{HashMap, HashSet};
use nodedb_physical::physical_plan::UpdateValue;
use nodedb_physical::physical_task::PhysicalTask;
use super::extract::resolve_edge_label;
use super::routed::{EdgeRouteCtx, push_edge_delete, push_edge_put};
use crate::control::planner::calvin::preexec::ScannedEdge;
use crate::control::state::SharedState;
use crate::types::{DatabaseId, TenantId, TraceId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FieldUpdate {
Unchanged,
Set(String),
Cleared,
}
#[derive(Debug, Clone, PartialEq)]
pub enum WeightUpdate {
Unchanged,
Set(f64),
Cleared,
}
#[derive(Debug, Clone, PartialEq)]
pub struct EdgeFieldOverrides {
pub from: FieldUpdate,
pub to: FieldUpdate,
pub label: FieldUpdate,
pub weight: WeightUpdate,
}
pub fn parse_edge_field_overrides(
updates: &[(String, UpdateValue)],
) -> crate::Result<EdgeFieldOverrides> {
let mut overrides = EdgeFieldOverrides {
from: FieldUpdate::Unchanged,
to: FieldUpdate::Unchanged,
label: FieldUpdate::Unchanged,
weight: WeightUpdate::Unchanged,
};
for (field, val) in updates {
if field == "weight" {
overrides.weight = match val {
UpdateValue::Literal(bytes) => match decode_literal_weight(bytes) {
Some(w) => WeightUpdate::Set(w),
None => WeightUpdate::Cleared,
},
UpdateValue::Expr(_) => {
return Err(crate::Error::BadRequest {
detail: "expression updates to the reserved edge field 'weight' are not \
supported on edge-bearing collections; use a literal value"
.to_string(),
});
}
};
continue;
}
let slot = match field.as_str() {
"_from" => &mut overrides.from,
"_to" => &mut overrides.to,
"_type" => &mut overrides.label,
_ => continue,
};
*slot = match val {
UpdateValue::Literal(bytes) => match decode_literal_string(bytes) {
Some(s) => FieldUpdate::Set(s),
None => FieldUpdate::Cleared,
},
UpdateValue::Expr(_) => {
return Err(crate::Error::BadRequest {
detail: format!(
"expression updates to reserved edge fields (_from, _to, _type) \
are not supported on edge-bearing collections (field '{field}'); \
use a literal value"
),
});
}
};
}
Ok(overrides)
}
fn decode_literal_string(bytes: &[u8]) -> Option<String> {
let decoded = rmpv::decode::read_value(&mut &bytes[..]).ok()?;
decoded.as_str().map(str::to_string)
}
fn decode_literal_weight(bytes: &[u8]) -> Option<f64> {
let decoded = rmpv::decode::read_value(&mut &bytes[..]).ok()?;
match decoded {
rmpv::Value::F64(f) => Some(f),
rmpv::Value::F32(f) => Some(f as f64),
rmpv::Value::Integer(i) => i.as_f64(),
_ => None,
}
.filter(|w| w.is_finite())
}
type EdgeIdentity = (String, String, String);
pub struct EdgeUpdateCtx<'a> {
pub state: &'a SharedState,
pub tenant_id: TenantId,
pub database_id: DatabaseId,
pub trace_id: TraceId,
pub collection: &'a str,
}
pub async fn append_implicit_edge_update_tasks(
ctx: EdgeUpdateCtx<'_>,
out: &mut Vec<PhysicalTask>,
edges: &[ScannedEdge],
all_surrogates: &[u32],
overrides: &EdgeFieldOverrides,
) -> crate::Result<()> {
let EdgeUpdateCtx {
state,
tenant_id,
database_id,
trace_id,
collection,
} = ctx;
let old_by_surrogate: HashMap<u32, &ScannedEdge> =
edges.iter().map(|e| (e.surrogate, e)).collect();
let mut deleted: HashSet<EdgeIdentity> = HashSet::new();
let mut put: HashSet<EdgeIdentity> = HashSet::new();
for old in edges {
let new_from = apply_override(&overrides.from, Some(old.from.as_str()));
let new_to = apply_override(&overrides.to, Some(old.to.as_str()));
let new_label_raw = match &overrides.label {
FieldUpdate::Unchanged => old.label.clone(),
FieldUpdate::Set(s) => Some(s.clone()),
FieldUpdate::Cleared => None,
};
let new_weight: Option<f64> = match &overrides.weight {
WeightUpdate::Unchanged => old.weight,
WeightUpdate::Set(w) => Some(*w),
WeightUpdate::Cleared => None,
};
let old_identity: EdgeIdentity = (
old.from.clone(),
old.to.clone(),
resolve_edge_label(old.label.as_deref()),
);
let new_identity: Option<EdgeIdentity> = match (new_from, new_to) {
(Some(f), Some(t)) => Some((f, t, resolve_edge_label(new_label_raw.as_deref()))),
_ => None,
};
if new_identity.as_ref() == Some(&old_identity) {
let weight_changed = !matches!(overrides.weight, WeightUpdate::Unchanged);
if weight_changed && deleted.insert(old_identity.clone()) {
push_edge_delete(
EdgeRouteCtx {
state,
tenant_id,
database_id,
trace_id,
collection,
src: &old_identity.0,
dst: &old_identity.1,
},
out,
old_identity.2.clone(),
)
.await?;
put.insert(old_identity.clone());
push_edge_put(
EdgeRouteCtx {
state,
tenant_id,
database_id,
trace_id,
collection,
src: &old_identity.0,
dst: &old_identity.1,
},
out,
old_identity.2.clone(),
new_weight,
)
.await?;
}
continue;
}
if deleted.insert(old_identity.clone()) {
push_edge_delete(
EdgeRouteCtx {
state,
tenant_id,
database_id,
trace_id,
collection,
src: &old_identity.0,
dst: &old_identity.1,
},
out,
old_identity.2.clone(),
)
.await?;
}
if let Some(new_identity) = new_identity
&& put.insert(new_identity.clone())
{
push_edge_put(
EdgeRouteCtx {
state,
tenant_id,
database_id,
trace_id,
collection,
src: &new_identity.0,
dst: &new_identity.1,
},
out,
new_identity.2.clone(),
new_weight,
)
.await?;
}
}
if let (FieldUpdate::Set(f), FieldUpdate::Set(t)) = (&overrides.from, &overrides.to) {
let new_label_raw = match &overrides.label {
FieldUpdate::Set(s) => Some(s.as_str()),
FieldUpdate::Cleared | FieldUpdate::Unchanged => None,
};
let identity: EdgeIdentity = (f.clone(), t.clone(), resolve_edge_label(new_label_raw));
let new_weight: Option<f64> = match &overrides.weight {
WeightUpdate::Set(w) => Some(*w),
WeightUpdate::Unchanged | WeightUpdate::Cleared => None,
};
let has_new_edge_doc = all_surrogates
.iter()
.any(|s| !old_by_surrogate.contains_key(s));
if has_new_edge_doc && put.insert(identity.clone()) {
push_edge_put(
EdgeRouteCtx {
state,
tenant_id,
database_id,
trace_id,
collection,
src: &identity.0,
dst: &identity.1,
},
out,
identity.2.clone(),
new_weight,
)
.await?;
}
}
Ok(())
}
fn apply_override(update: &FieldUpdate, current: Option<&str>) -> Option<String> {
match update {
FieldUpdate::Unchanged => current.map(str::to_string),
FieldUpdate::Set(s) => Some(s.clone()),
FieldUpdate::Cleared => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lit_str(s: &str) -> UpdateValue {
let mut buf = Vec::new();
rmpv::encode::write_value(&mut buf, &rmpv::Value::String(s.into())).expect("encode");
UpdateValue::Literal(buf)
}
fn lit_null() -> UpdateValue {
let mut buf = Vec::new();
rmpv::encode::write_value(&mut buf, &rmpv::Value::Nil).expect("encode");
UpdateValue::Literal(buf)
}
fn lit_f64(w: f64) -> UpdateValue {
let mut buf = Vec::new();
rmpv::encode::write_value(&mut buf, &rmpv::Value::F64(w)).expect("encode");
UpdateValue::Literal(buf)
}
fn lit_int(i: i64) -> UpdateValue {
let mut buf = Vec::new();
rmpv::encode::write_value(&mut buf, &rmpv::Value::Integer(i.into())).expect("encode");
UpdateValue::Literal(buf)
}
#[test]
fn parse_set_endpoints_and_label() {
let updates = vec![
("_from".to_string(), lit_str("x")),
("_to".to_string(), lit_str("y")),
("_type".to_string(), lit_str("ROAD")),
("other".to_string(), lit_str("z")),
];
let ov = parse_edge_field_overrides(&updates).expect("parse");
assert_eq!(ov.from, FieldUpdate::Set("x".to_string()));
assert_eq!(ov.to, FieldUpdate::Set("y".to_string()));
assert_eq!(ov.label, FieldUpdate::Set("ROAD".to_string()));
assert_eq!(ov.weight, WeightUpdate::Unchanged);
}
#[test]
fn parse_null_is_cleared_and_absent_is_unchanged() {
let updates = vec![("_from".to_string(), lit_null())];
let ov = parse_edge_field_overrides(&updates).expect("parse");
assert_eq!(ov.from, FieldUpdate::Cleared);
assert_eq!(ov.to, FieldUpdate::Unchanged);
assert_eq!(ov.label, FieldUpdate::Unchanged);
assert_eq!(ov.weight, WeightUpdate::Unchanged);
}
#[test]
fn parse_weight_set_float_and_int() {
let ov =
parse_edge_field_overrides(&[("weight".to_string(), lit_f64(2.5))]).expect("parse");
assert_eq!(ov.weight, WeightUpdate::Set(2.5));
let ov = parse_edge_field_overrides(&[("weight".to_string(), lit_int(3))]).expect("parse");
assert_eq!(ov.weight, WeightUpdate::Set(3.0));
}
#[test]
fn parse_weight_null_or_nonnumeric_is_cleared() {
let ov = parse_edge_field_overrides(&[("weight".to_string(), lit_null())]).expect("parse");
assert_eq!(ov.weight, WeightUpdate::Cleared);
let ov =
parse_edge_field_overrides(&[("weight".to_string(), lit_str("heavy"))]).expect("parse");
assert_eq!(ov.weight, WeightUpdate::Cleared);
}
#[test]
fn parse_weight_expr_is_rejected() {
use nodedb_query::expr::SqlExpr;
let updates = vec![(
"weight".to_string(),
UpdateValue::Expr(SqlExpr::Column("other".to_string())),
)];
assert!(parse_edge_field_overrides(&updates).is_err());
}
#[test]
fn parse_expr_on_edge_field_is_rejected() {
use nodedb_query::expr::SqlExpr;
let updates = vec![(
"_to".to_string(),
UpdateValue::Expr(SqlExpr::Column("other".to_string())),
)];
assert!(parse_edge_field_overrides(&updates).is_err());
}
#[test]
fn parse_expr_on_non_edge_field_is_ignored() {
use nodedb_query::expr::SqlExpr;
let updates = vec![(
"score".to_string(),
UpdateValue::Expr(SqlExpr::Column("other".to_string())),
)];
let ov = parse_edge_field_overrides(&updates).expect("parse");
assert_eq!(ov.from, FieldUpdate::Unchanged);
}
#[test]
fn apply_override_semantics() {
assert_eq!(
apply_override(&FieldUpdate::Unchanged, Some("a")),
Some("a".to_string())
);
assert_eq!(
apply_override(&FieldUpdate::Set("b".to_string()), Some("a")),
Some("b".to_string())
);
assert_eq!(apply_override(&FieldUpdate::Cleared, Some("a")), None);
}
}