use std::collections::{HashMap, HashSet};
use arrow_array::{Array, RecordBatch};
use async_trait::async_trait;
use graphar_falkordb::{
BulkEdgeGroup, BulkNodeGroup, FalkorDbLoader,
cypher::{arrow_value_to_cypher, row_to_cypher_map},
loader::choose_bulk,
};
use crate::{
error::{BifrostError, Result},
mutation::{EdgeSet, GraphMutation, SinkReport, VertexDelete, VertexSet},
};
#[async_trait]
pub trait GraphSink: Send {
async fn apply(&mut self, mutation: &GraphMutation) -> Result<SinkReport>;
}
pub struct FalkorDbSink {
loader: FalkorDbLoader,
batch_size: usize,
indexed: HashSet<(String, String)>,
settled: HashSet<String>,
settled_edges: HashSet<String>,
}
impl FalkorDbSink {
pub async fn connect(url: &str, graph: impl Into<String>) -> Result<Self> {
Self::connect_with_identity(url, graph, None).await
}
pub async fn connect_with_identity(
url: &str,
graph: impl Into<String>,
tls: Option<graphar_falkordb::FalkorTls>,
) -> Result<Self> {
Ok(Self {
loader: FalkorDbLoader::connect_with_identity(url, graph, tls).await?,
batch_size: graphar_falkordb::DEFAULT_BATCH_SIZE,
indexed: HashSet::new(),
settled: HashSet::new(),
settled_edges: HashSet::new(),
})
}
pub fn with_batch_size(mut self, n: usize) -> Self {
self.batch_size = n.max(1);
self
}
async fn ensure_index(&mut self, label: &str, property: &str) -> Result<()> {
let key = (label.to_string(), property.to_string());
if self.indexed.contains(&key) {
return Ok(());
}
self.loader.create_index(label, property).await?;
self.indexed.insert(key);
Ok(())
}
async fn first_load_in_bulk(&mut self, mutation: &GraphMutation) -> Result<(HashSet<String>, usize)> {
let mut loaded = HashSet::new();
let mut created = 0;
if !mutation.deletes.is_empty() || !mutation.edge_deletes.is_empty() {
return Ok((loaded, created));
}
let mut by_label: Vec<(&str, Vec<&VertexSet>)> = Vec::new();
for set in &mutation.vertices {
match by_label.iter_mut().find(|(l, _)| *l == set.label) {
Some((_, sets)) => sets.push(set),
None => by_label.push((set.label.as_str(), vec![set])),
}
}
for (label, sets) in by_label {
if self.settled.contains(label) {
continue;
}
self.settled.insert(label.to_string());
if !self.loader.label_is_empty(label).await {
continue;
}
let Some(batch) = first_load_rows(&sets)? else { continue };
let n_rows = batch.num_rows();
match self
.loader
.bulk_insert_nodes_verbatim(&[BulkNodeGroup { label, batches: std::slice::from_ref(&batch) }])
.await
{
Ok(n) => {
tracing::info!(
target: "bifrost::sink",
event = "vertices_bulk_loaded",
label = %label,
rows = n_rows,
count = n,
"first load of an empty label: GRAPH.BULK",
);
self.ensure_index(label, &sets[0].id_column).await?;
loaded.insert(label.to_string());
created += n;
}
Err(e) => tracing::warn!(
target: "bifrost::sink",
event = "bulk_fallback",
label = %label,
error = %e,
"GRAPH.BULK refused the first load; MERGE instead (same graph)",
),
}
}
Ok((loaded, created))
}
async fn upsert_vertices(&mut self, set: &VertexSet) -> Result<usize> {
self.ensure_index(&set.label, &set.id_column).await?;
let rows = rows_as_cypher_maps(&set.batch)?;
let mut written = 0;
for chunk in rows.chunks(self.batch_size) {
let query = format!(
"UNWIND [{rows}] AS r MERGE (n:`{label}` {{`{id}`: r.`{id}`}}) SET n += r",
rows = chunk.join(", "),
label = set.label,
id = set.id_column,
);
self.loader.run_query(&query).await?;
written += chunk.len();
}
Ok(written)
}
async fn upsert_edges(&mut self, set: &EdgeSet) -> Result<usize> {
self.ensure_index(&set.src_label, &set.src_id_column).await?;
self.ensure_index(&set.dst_label, &set.dst_id_column).await?;
let rows = rows_as_cypher_maps(&set.batch)?;
let mut written = 0;
for chunk in rows.chunks(self.batch_size) {
self.loader
.run_query(&edge_upsert_query(set, chunk))
.await?;
written += chunk.len();
}
Ok(written)
}
async fn delete_vertices(
&mut self,
set: &VertexDelete,
upserted: &HashSet<(String, String)>,
) -> Result<usize> {
let ids = surviving_delete_ids(set, upserted)?;
if !ids.is_empty() {
self.ensure_index(&set.label, &set.id_column).await?;
}
let mut deleted = 0;
for chunk in ids.chunks(self.batch_size) {
self.loader
.run_query(&detach_delete_query(&set.label, &set.id_column, chunk))
.await?;
deleted += chunk.len();
}
Ok(deleted)
}
async fn delete_edges(&mut self, set: &EdgeSet, upserted: &HashSet<EdgeKey>, by_key: usize) -> Result<usize> {
let (rows, null_endpoint) = surviving_edge_deletes(set, upserted)?;
let skipped = null_endpoint.saturating_sub(by_key);
if skipped > 0 {
tracing::warn!(
target: "bifrost::sink",
event = "edge_deletes_skipped",
edge_type = %set.edge_type,
skipped,
"edge DELETE rows with a null src/dst and no edge key cannot name their edge",
);
}
if !rows.is_empty() {
self.ensure_index(&set.src_label, &set.src_id_column).await?;
self.ensure_index(&set.dst_label, &set.dst_id_column).await?;
}
let mut deleted = 0;
for chunk in rows.chunks(self.batch_size) {
self.loader.run_query(&edge_delete_query(set, chunk)).await?;
deleted += chunk.len();
}
Ok(deleted)
}
async fn first_load_edges_in_bulk(&mut self, set: &EdgeSet) -> Result<Option<(usize, EdgeSet)>> {
if !self.settled_edges.insert(set.edge_type.clone()) {
return Ok(None);
}
let n_existing = self
.loader
.run_query(&format!("MATCH ()-[e:`{}`]->() RETURN count(e)", set.edge_type))
.await
.ok()
.and_then(|v| reply_rows(&v).first().and_then(|r| r.first().cloned()))
.and_then(|v| if let NodeKey::Int(i) = v { Some(i) } else { None });
if n_existing != Some(0) {
return Ok(None);
}
let set = dedup_edges(set)?;
let (Some(src_keys), Some(dst_keys)) = (node_keys(&set.batch, "src"), node_keys(&set.batch, "dst")) else {
return Ok(None); };
let src_ids = self.node_ids(&set.src_label, &set.src_id_column).await?;
let dst_ids = self.node_ids(&set.dst_label, &set.dst_id_column).await?;
let (mut bulk_rows, mut s_ids, mut d_ids, mut merge_rows) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
for (row, (s, d)) in src_keys.iter().zip(&dst_keys).enumerate() {
match (s.as_ref().and_then(|k| src_ids.get(k)), d.as_ref().and_then(|k| dst_ids.get(k))) {
(Some(si), Some(di)) => {
bulk_rows.push(row as u32);
s_ids.push(*si);
d_ids.push(*di);
}
_ => merge_rows.push(row as u32),
}
}
if bulk_rows.is_empty() {
return Ok(None);
}
let bulk = with_endpoints(&take_rows(&set.batch, &bulk_rows)?, s_ids, d_ids)?;
let rest = EdgeSet { batch: take_rows(&set.batch, &merge_rows)?, ..set.clone() };
let group = BulkEdgeGroup {
src_label: &set.src_label,
edge_label: &set.edge_type,
dst_label: &set.dst_label,
batches: std::slice::from_ref(&bulk),
};
match self.loader.bulk_insert_edges_absolute(&[group]).await {
Ok(n) => {
tracing::info!(
target: "bifrost::sink",
event = "edges_bulk_loaded",
edge_type = %set.edge_type,
count = n,
left_for_merge = rest.batch.num_rows(),
"first load of an empty relationship type: GRAPH.BULK",
);
for k in &set.edge_key_props {
self.ensure_edge_index(&set.edge_type, k).await?;
}
Ok(Some((n, rest)))
}
Err(e) => {
tracing::warn!(
target: "bifrost::sink",
event = "bulk_fallback",
edge_type = %set.edge_type,
error = %e,
"GRAPH.BULK refused the first edge load; MERGE instead (same graph)",
);
Ok(Some((0, set)))
}
}
}
async fn node_ids(&mut self, label: &str, property: &str) -> Result<HashMap<NodeKey, i64>> {
let reply = self
.loader
.run_query(&format!("MATCH (n:`{label}`) WHERE n.`{property}` IS NOT NULL RETURN n.`{property}`, ID(n)"))
.await?;
Ok(reply_rows(&reply)
.into_iter()
.filter_map(|r| match (r.first(), r.get(1)) {
(Some(k), Some(NodeKey::Int(id))) => Some((k.clone(), *id)),
_ => None,
})
.collect())
}
async fn delete_edges_by_key(&mut self, set: &EdgeSet) -> Result<usize> {
let rows = key_only_edge_rows(set)?;
if rows.is_empty() {
return Ok(0);
}
for k in &set.edge_key_props {
self.ensure_edge_index(&set.edge_type, k).await?;
}
let mut deleted = 0;
for chunk in rows.chunks(self.batch_size) {
self.loader.run_query(&edge_delete_by_key_query(set, chunk)).await?;
deleted += chunk.len();
}
Ok(deleted)
}
async fn replace_outgoing(&mut self, set: &EdgeSet) -> Result<usize> {
let rows = replace_rows(set)?;
if rows.is_empty() {
return Ok(0);
}
self.ensure_index(&set.src_label, &set.src_id_column).await?;
for chunk in rows.chunks(self.batch_size) {
self.loader.run_query(&replace_outgoing_query(set, chunk)).await?;
}
Ok(0)
}
async fn ensure_edge_index(&mut self, edge_type: &str, property: &str) -> Result<()> {
let key = (format!("()-[{edge_type}]-()"), property.to_string());
if self.indexed.contains(&key) {
return Ok(());
}
let q = format!("CREATE INDEX FOR ()-[e:`{edge_type}`]-() ON (e.`{property}`)");
match self.loader.run_query(&q).await {
Ok(_) => {}
Err(e) if e.to_string().to_ascii_lowercase().contains("already indexed") => {}
Err(e) => return Err(e.into()),
}
self.indexed.insert(key);
Ok(())
}
async fn labels_all_empty(&mut self, sets: &[VertexSet]) -> bool {
for set in sets {
if !self.loader.label_is_empty(&set.label).await {
return false;
}
}
true
}
async fn bulk_load_vertices(&mut self, sets: &[VertexSet]) -> Result<usize> {
let node_groups: Vec<BulkNodeGroup> = sets
.iter()
.map(|set| BulkNodeGroup {
label: &set.label,
batches: std::slice::from_ref(&set.batch),
})
.collect();
let edge_groups: [BulkEdgeGroup; 0] = [];
let (nodes, _relations) = self
.loader
.bulk_insert_streamed(&node_groups, &edge_groups)
.await?;
Ok(nodes)
}
}
#[async_trait]
impl GraphSink for FalkorDbSink {
async fn apply(&mut self, mutation: &GraphMutation) -> Result<SinkReport> {
let mut report = SinkReport::default();
if bulk_lane_eligible(mutation)
&& choose_bulk(self.labels_all_empty(&mutation.vertices).await)
{
let n = self.bulk_load_vertices(&mutation.vertices).await?;
report.vertices_written += n;
tracing::info!(
target: "bifrost::sink",
event = "vertices_bulk_loaded",
count = n,
"GRAPH.BULK vertices (empty-graph fast lane)",
);
tracing::info!(
target: "bifrost::sink",
event = "sink_applied",
vertices = report.vertices_written,
edges = 0,
deleted = 0,
"graph mutation applied",
);
return Ok(report);
}
let upserted = upserted_vertex_keys(mutation)?;
let (bulk_loaded, created) = self.first_load_in_bulk(mutation).await?;
report.vertices_written += created;
for set in mutation.vertices.iter().filter(|s| !bulk_loaded.contains(&s.label)) {
let n = self.upsert_vertices(set).await?;
tracing::debug!(
target: "bifrost::sink",
event = "vertices_upserted",
label = %set.label,
count = n,
"MERGE vertices",
);
report.vertices_written += n;
}
let mut by_key = vec![0usize; mutation.edge_deletes.len()];
for (i, set) in mutation.edge_deletes.iter().enumerate() {
by_key[i] = self.delete_edges_by_key(set).await?;
report.deleted += by_key[i];
}
let deletes_nothing = mutation.deletes.is_empty() && mutation.edge_deletes.is_empty();
for set in &mutation.edges {
let replaced;
let set = if set.replace_outgoing && set.edge_key_props.is_empty() {
replaced = last_row_per_src(set)?;
report.deleted += self.replace_outgoing(&replaced).await?;
&replaced
} else {
set
};
let rest;
let set = if deletes_nothing {
match self.first_load_edges_in_bulk(set).await? {
Some((n, remaining)) => {
report.edges_written += n;
rest = remaining;
&rest
}
None => set,
}
} else {
set
};
if set.batch.num_rows() == 0 {
continue;
}
let n = self.upsert_edges(set).await?;
tracing::debug!(
target: "bifrost::sink",
event = "edges_upserted",
edge_type = %set.edge_type,
count = n,
"MERGE edges",
);
report.edges_written += n;
}
if !mutation.edge_deletes.is_empty() {
let upserted_edges = upserted_edge_keys(mutation)?;
for (i, set) in mutation.edge_deletes.iter().enumerate() {
let n = self.delete_edges(set, &upserted_edges, by_key[i]).await?;
tracing::debug!(
target: "bifrost::sink",
event = "edges_deleted",
edge_type = %set.edge_type,
count = n,
"DELETE edges",
);
report.deleted += n;
}
}
for set in &mutation.deletes {
let n = self.delete_vertices(set, &upserted).await?;
tracing::debug!(
target: "bifrost::sink",
event = "vertices_deleted",
label = %set.label,
count = n,
"DETACH DELETE vertices",
);
report.deleted += n;
}
tracing::info!(
target: "bifrost::sink",
event = "sink_applied",
vertices = report.vertices_written,
edges = report.edges_written,
deleted = report.deleted,
"graph mutation applied",
);
Ok(report)
}
}
fn edge_upsert_query(set: &EdgeSet, rows: &[String]) -> String {
let rel_key = edge_rel_key(set);
format!(
"UNWIND [{rows}] AS r \
MERGE (a:`{src_label}` {{`{src_id}`: r.src}}) \
MERGE (b:`{dst_label}` {{`{dst_id}`: r.dst}}) \
MERGE (a)-[e:`{etype}`{rel_key}]->(b) \
SET e += r.props",
rows = rows.join(", "),
src_label = set.src_label,
src_id = set.src_id_column,
dst_label = set.dst_label,
dst_id = set.dst_id_column,
etype = set.edge_type,
)
}
fn first_load_rows(sets: &[&VertexSet]) -> Result<Option<RecordBatch>> {
let schema = sets[0].batch.schema();
if sets.iter().any(|s| s.batch.schema() != schema || s.id_column != sets[0].id_column) {
return Ok(None);
}
let batch = arrow::compute::concat_batches(&schema, sets.iter().map(|s| &s.batch))?;
let idx = schema
.index_of(&sets[0].id_column)
.map_err(|_| BifrostError::ColumnNotFound(sets[0].id_column.clone()))?;
let ids = batch.column(idx).as_ref();
let mut last: HashMap<String, u32> = HashMap::with_capacity(batch.num_rows());
let mut order: Vec<String> = Vec::with_capacity(batch.num_rows());
for row in 0..batch.num_rows() {
if ids.is_null(row) {
continue;
}
let key = arrow_value_to_cypher(ids, row).map_err(BifrostError::Falkor)?;
if last.insert(key.clone(), row as u32).is_none() {
order.push(key);
}
}
if order.is_empty() {
return Ok(None);
}
let keep = arrow_array::UInt32Array::from(order.iter().map(|k| last[k]).collect::<Vec<_>>());
let cols = batch
.columns()
.iter()
.map(|c| arrow::compute::take(c.as_ref(), &keep, None))
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(Some(RecordBatch::try_new(schema, cols)?))
}
fn edge_rel_key(set: &EdgeSet) -> String {
if set.edge_key_props.is_empty() {
return String::new();
}
let parts: Vec<String> = set
.edge_key_props
.iter()
.map(|p| format!("`{p}`: r.props.`{p}`"))
.collect();
format!(" {{{}}}", parts.join(", "))
}
fn edge_delete_query(set: &EdgeSet, rows: &[String]) -> String {
format!(
"UNWIND [{rows}] AS r \
MATCH (a:`{src_label}` {{`{src_id}`: r.src}})-[e:`{etype}`{rel_key}]->(b:`{dst_label}` {{`{dst_id}`: r.dst}}) \
DELETE e",
rows = rows.join(", "),
src_label = set.src_label,
src_id = set.src_id_column,
dst_label = set.dst_label,
dst_id = set.dst_id_column,
etype = set.edge_type,
rel_key = edge_rel_key(set),
)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum NodeKey {
Int(i64),
Str(String),
}
fn reply_rows(v: &redis::Value) -> Vec<Vec<NodeKey>> {
let redis::Value::Array(parts) = v else { return Vec::new() };
let Some(redis::Value::Array(rows)) = parts.get(1) else { return Vec::new() };
rows.iter()
.filter_map(|r| match r {
redis::Value::Array(cells) => Some(
cells
.iter()
.filter_map(|c| match c {
redis::Value::Int(i) => Some(NodeKey::Int(*i)),
redis::Value::BulkString(b) => Some(NodeKey::Str(String::from_utf8_lossy(b).into_owned())),
redis::Value::SimpleString(s) => Some(NodeKey::Str(s.clone())),
_ => None,
})
.collect(),
),
_ => None,
})
.collect()
}
fn node_keys(batch: &RecordBatch, column: &str) -> Option<Vec<Option<NodeKey>>> {
use arrow_array::cast::AsArray;
let col = batch.column_by_name(column)?;
match col.data_type() {
arrow_schema::DataType::Int64 => {
let a = col.as_primitive::<arrow_array::types::Int64Type>();
Some((0..a.len()).map(|i| (!a.is_null(i)).then(|| NodeKey::Int(a.value(i)))).collect())
}
arrow_schema::DataType::Int32 => {
let a = col.as_primitive::<arrow_array::types::Int32Type>();
Some((0..a.len()).map(|i| (!a.is_null(i)).then(|| NodeKey::Int(a.value(i) as i64))).collect())
}
arrow_schema::DataType::Utf8 => {
let a = col.as_string::<i32>();
Some((0..a.len()).map(|i| (!a.is_null(i)).then(|| NodeKey::Str(a.value(i).to_string()))).collect())
}
_ => None,
}
}
fn dedup_edges(set: &EdgeSet) -> Result<EdgeSet> {
let (rows, _) = edge_rows_with_keys(set)?;
if rows.len() == set.batch.num_rows() && {
let mut seen = HashSet::new();
rows.iter().all(|(_, k)| seen.insert(k.clone()))
} {
return Ok(set.clone());
}
let batch = &set.batch;
let (src, dst) = (batch.column(batch.schema().index_of("src")?), batch.column(batch.schema().index_of("dst")?));
let key_cols: Vec<usize> = set.edge_key_props.iter().map(|k| batch.schema().index_of(k)).collect::<std::result::Result<_, _>>()?;
let lit = |c: &dyn Array, r: usize| arrow_value_to_cypher(c, r).map_err(BifrostError::Falkor);
let mut last: HashMap<String, u32> = HashMap::new();
let mut order: Vec<String> = Vec::new();
for r in 0..batch.num_rows() {
let mut k = format!("{}|{}", lit(src.as_ref(), r)?, lit(dst.as_ref(), r)?);
for c in &key_cols {
k.push('|');
k.push_str(&lit(batch.column(*c).as_ref(), r)?);
}
if last.insert(k.clone(), r as u32).is_none() {
order.push(k);
}
}
let keep: Vec<u32> = order.iter().map(|k| last[k]).collect();
Ok(EdgeSet { batch: take_rows(batch, &keep)?, ..set.clone() })
}
fn take_rows(batch: &RecordBatch, rows: &[u32]) -> Result<RecordBatch> {
let idx = arrow_array::UInt32Array::from(rows.to_vec());
let cols = batch
.columns()
.iter()
.map(|c| arrow::compute::take(c.as_ref(), &idx, None))
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(RecordBatch::try_new(batch.schema(), cols)?)
}
fn with_endpoints(batch: &RecordBatch, src: Vec<i64>, dst: Vec<i64>) -> Result<RecordBatch> {
let schema = batch.schema();
let mut fields = Vec::with_capacity(schema.fields().len());
let mut cols: Vec<arrow_array::ArrayRef> = Vec::with_capacity(schema.fields().len());
let (mut src, mut dst) = (Some(src), Some(dst));
for (i, f) in schema.fields().iter().enumerate() {
match f.name().as_str() {
"src" => {
fields.push(arrow_schema::Field::new("src", arrow_schema::DataType::Int64, false));
cols.push(std::sync::Arc::new(arrow_array::Int64Array::from(src.take().unwrap_or_default())));
}
"dst" => {
fields.push(arrow_schema::Field::new("dst", arrow_schema::DataType::Int64, false));
cols.push(std::sync::Arc::new(arrow_array::Int64Array::from(dst.take().unwrap_or_default())));
}
_ => {
fields.push(f.as_ref().clone());
cols.push(batch.column(i).clone());
}
}
}
Ok(RecordBatch::try_new(std::sync::Arc::new(arrow_schema::Schema::new(fields)), cols)?)
}
fn key_only_edge_rows(set: &EdgeSet) -> Result<Vec<String>> {
if set.edge_key_props.is_empty() {
return Ok(Vec::new());
}
let batch = &set.batch;
let schema = batch.schema();
let col = |name: &str| schema.index_of(name).map_err(|_| BifrostError::ColumnNotFound(name.to_string()));
let (src, dst) = (col("src")?, col("dst")?);
let keys: Vec<(&str, usize)> = set.edge_key_props.iter().map(|k| Ok((k.as_str(), col(k)?))).collect::<Result<_>>()?;
let mut out = Vec::new();
for row in 0..batch.num_rows() {
let endpoints = !batch.column(src).is_null(row) && !batch.column(dst).is_null(row);
if endpoints || keys.iter().any(|(_, i)| batch.column(*i).is_null(row)) {
continue;
}
let props: Vec<String> = keys
.iter()
.map(|(k, i)| Ok(format!("`{k}`: {}", arrow_value_to_cypher(batch.column(*i).as_ref(), row).map_err(BifrostError::Falkor)?)))
.collect::<Result<_>>()?;
out.push(format!("{{props: {{{}}}}}", props.join(", ")));
}
Ok(out)
}
fn edge_delete_by_key_query(set: &EdgeSet, rows: &[String]) -> String {
format!(
"UNWIND [{rows}] AS r MATCH (:`{src_label}`)-[e:`{etype}`{rel_key}]->(:`{dst_label}`) DELETE e",
rows = rows.join(", "),
src_label = set.src_label,
dst_label = set.dst_label,
etype = set.edge_type,
rel_key = edge_rel_key(set),
)
}
fn last_row_per_src(set: &EdgeSet) -> Result<EdgeSet> {
let batch = &set.batch;
let src = batch.column(batch.schema().index_of("src").map_err(|_| BifrostError::ColumnNotFound("src".into()))?);
let mut last: HashMap<String, u32> = HashMap::with_capacity(batch.num_rows());
let mut order: Vec<String> = Vec::with_capacity(batch.num_rows());
for row in 0..batch.num_rows() {
if src.is_null(row) {
continue;
}
let k = arrow_value_to_cypher(src.as_ref(), row).map_err(BifrostError::Falkor)?;
if last.insert(k.clone(), row as u32).is_none() {
order.push(k);
}
}
let keep = arrow_array::UInt32Array::from(order.iter().map(|k| last[k]).collect::<Vec<_>>());
let cols = batch
.columns()
.iter()
.map(|c| arrow::compute::take(c.as_ref(), &keep, None))
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(EdgeSet { batch: RecordBatch::try_new(batch.schema(), cols)?, ..set.clone() })
}
fn replace_rows(set: &EdgeSet) -> Result<Vec<String>> {
let batch = &set.batch;
let schema = batch.schema();
let col = |name: &str| schema.index_of(name).map_err(|_| BifrostError::ColumnNotFound(name.to_string()));
let (src, dst) = (batch.column(col("src")?).as_ref(), batch.column(col("dst")?).as_ref());
(0..batch.num_rows())
.filter(|r| !src.is_null(*r))
.map(|r| {
Ok(format!(
"{{src: {}, dst: {}}}",
arrow_value_to_cypher(src, r).map_err(BifrostError::Falkor)?,
arrow_value_to_cypher(dst, r).map_err(BifrostError::Falkor)?
))
})
.collect()
}
fn replace_outgoing_query(set: &EdgeSet, rows: &[String]) -> String {
format!(
"UNWIND [{rows}] AS r \
MATCH (a:`{src_label}` {{`{src_id}`: r.src}})-[e:`{etype}`]->(b:`{dst_label}`) \
WHERE r.dst IS NULL OR b.`{dst_id}` <> r.dst DELETE e",
rows = rows.join(", "),
src_label = set.src_label,
src_id = set.src_id_column,
dst_label = set.dst_label,
dst_id = set.dst_id_column,
etype = set.edge_type,
)
}
type EdgeKey = (String, String, String, String, String, String);
fn edge_rows_with_keys(set: &EdgeSet) -> Result<(Vec<(String, EdgeKey)>, usize)> {
let batch = &set.batch;
let schema = batch.schema();
let col = |name: &str| schema.index_of(name).map_err(|_| BifrostError::ColumnNotFound(name.to_string()));
let (src_idx, dst_idx) = (col("src")?, col("dst")?);
let key_idx: Vec<(&str, usize)> =
set.edge_key_props.iter().map(|k| Ok((k.as_str(), col(k)?))).collect::<Result<_>>()?;
let lit = |idx: usize, row: usize| arrow_value_to_cypher(batch.column(idx).as_ref(), row).map_err(BifrostError::Falkor);
let mut out = Vec::with_capacity(batch.num_rows());
let mut skipped = 0;
for row in 0..batch.num_rows() {
if batch.column(src_idx).is_null(row) || batch.column(dst_idx).is_null(row) {
skipped += 1;
continue;
}
let (src, dst) = (lit(src_idx, row)?, lit(dst_idx, row)?);
let props: Vec<String> = key_idx.iter().map(|(k, i)| Ok(format!("`{k}`: {}", lit(*i, row)?))).collect::<Result<_>>()?;
let props = props.join(", ");
let key = (
set.src_label.clone(),
set.edge_type.clone(),
set.dst_label.clone(),
src.clone(),
dst.clone(),
props.clone(),
);
out.push((format!("{{src: {src}, dst: {dst}, props: {{{props}}}}}"), key));
}
Ok((out, skipped))
}
fn upserted_edge_keys(mutation: &GraphMutation) -> Result<HashSet<EdgeKey>> {
let mut keys = HashSet::new();
for set in &mutation.edges {
keys.extend(edge_rows_with_keys(set)?.0.into_iter().map(|(_, k)| k));
}
Ok(keys)
}
fn surviving_edge_deletes(set: &EdgeSet, upserted: &HashSet<EdgeKey>) -> Result<(Vec<String>, usize)> {
let (rows, skipped) = edge_rows_with_keys(set)?;
Ok((rows.into_iter().filter(|(_, k)| !upserted.contains(k)).map(|(r, _)| r).collect(), skipped))
}
fn detach_delete_query(label: &str, id_column: &str, ids: &[String]) -> String {
format!(
"UNWIND [{ids}] AS r \
MATCH (n:`{label}` {{`{id}`: r}}) DETACH DELETE n",
ids = ids.join(", "),
id = id_column,
)
}
fn delete_ids_as_cypher(set: &VertexDelete) -> Result<Vec<String>> {
let schema = set.batch.schema();
let idx = schema
.index_of(&set.id_column)
.map_err(|_| BifrostError::ColumnNotFound(set.id_column.clone()))?;
let col = set.batch.column(idx).as_ref();
let mut ids = Vec::with_capacity(set.batch.num_rows());
for row in 0..set.batch.num_rows() {
ids.push(arrow_value_to_cypher(col, row).map_err(BifrostError::Falkor)?);
}
Ok(ids)
}
fn upserted_vertex_keys(mutation: &GraphMutation) -> Result<HashSet<(String, String)>> {
let mut keys = HashSet::new();
for set in &mutation.vertices {
let schema = set.batch.schema();
let Ok(idx) = schema.index_of(&set.id_column) else {
continue;
};
let col = set.batch.column(idx).as_ref();
for row in 0..set.batch.num_rows() {
let lit = arrow_value_to_cypher(col, row).map_err(BifrostError::Falkor)?;
keys.insert((set.label.clone(), lit));
}
}
Ok(keys)
}
fn surviving_delete_ids(
set: &VertexDelete,
upserted: &HashSet<(String, String)>,
) -> Result<Vec<String>> {
Ok(delete_ids_as_cypher(set)?
.into_iter()
.filter(|id| !upserted.contains(&(set.label.clone(), id.clone())))
.collect())
}
fn bulk_lane_eligible(mutation: &GraphMutation) -> bool {
if mutation.vertices.is_empty()
|| !mutation.edges.is_empty()
|| !mutation.deletes.is_empty()
|| !mutation.edge_deletes.is_empty()
{
return false;
}
let mut labels = HashSet::new();
for set in &mutation.vertices {
if set.batch.schema_ref().column_with_name("_gar_id").is_none() {
return false;
}
if !labels.insert(set.label.as_str()) {
return false;
}
}
true
}
pub(crate) fn rows_as_cypher_maps(batch: &RecordBatch) -> Result<Vec<String>> {
let schema = batch.schema();
let edge_endpoints = match (schema.index_of("src"), schema.index_of("dst")) {
(Ok(s), Ok(d)) => Some((s, d)),
_ => None,
};
let cols: Vec<(&str, &dyn Array)> = schema
.fields()
.iter()
.zip(batch.columns())
.map(|(f, c)| (f.name().as_str(), c.as_ref()))
.collect();
let mut rows = Vec::with_capacity(batch.num_rows());
for row in 0..batch.num_rows() {
if let Some((src_idx, dst_idx)) = edge_endpoints {
if batch.column(src_idx).is_null(row) || batch.column(dst_idx).is_null(row) {
continue;
}
let mut src = None;
let mut dst = None;
let mut props = Vec::new();
for (name, array) in &cols {
let val = arrow_value_to_cypher(*array, row).map_err(BifrostError::Falkor)?;
match *name {
"src" => src = Some(val),
"dst" => dst = Some(val),
_ => props.push(format!("`{name}`: {val}")),
}
}
rows.push(format!(
"{{src: {}, dst: {}, props: {{{}}}}}",
src.expect("src checked above"),
dst.expect("dst checked above"),
props.join(", "),
));
} else {
rows.push(row_to_cypher_map(&cols, row).map_err(BifrostError::Falkor)?);
}
}
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
use arrow_array::{Int64Array, StringArray};
use arrow_schema::{DataType, Field, Schema};
use std::sync::Arc;
#[test]
fn vertex_rows_render_as_flat_maps() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("user_id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(vec![7])),
Arc::new(StringArray::from(vec!["alice"])),
],
)
.unwrap();
let rows = rows_as_cypher_maps(&batch).unwrap();
assert_eq!(rows, vec!["{`user_id`: 7, `name`: 'alice'}"]);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"sink::vertex_rows_render_as_flat_maps",
rows == vec!["{`user_id`: 7, `name`: 'alice'}"],
&format!("cypher={}", rows[0]),
);
}
#[test]
fn edge_rows_separate_endpoints_from_props() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("src", DataType::Int64, false),
Field::new("dst", DataType::Int64, false),
Field::new("since", DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(vec![1])),
Arc::new(Int64Array::from(vec![2])),
Arc::new(StringArray::from(vec!["2024"])),
],
)
.unwrap();
let rows = rows_as_cypher_maps(&batch).unwrap();
assert_eq!(rows, vec!["{src: 1, dst: 2, props: {`since`: '2024'}}"]);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"sink::edge_rows_separate_endpoints_from_props",
rows == vec!["{src: 1, dst: 2, props: {`since`: '2024'}}"],
&format!("cypher={}", rows[0]),
);
}
#[test]
fn edge_rows_with_null_endpoint_are_dropped() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("src", DataType::Int64, false),
Field::new("dst", DataType::Int64, true), Field::new("since", DataType::Utf8, true),
])),
vec![
Arc::new(Int64Array::from(vec![1, 2, 3])),
Arc::new(Int64Array::from(vec![Some(10), None, Some(30)])),
Arc::new(StringArray::from(vec![Some("a"), None, Some("c")])),
],
)
.unwrap();
let rows = rows_as_cypher_maps(&batch).unwrap();
assert_eq!(
rows,
vec![
"{src: 1, dst: 10, props: {`since`: 'a'}}",
"{src: 3, dst: 30, props: {`since`: 'c'}}",
],
"null-endpoint edge row dropped (null FK = no edge)"
);
let src_null = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("src", DataType::Int64, true),
Field::new("dst", DataType::Int64, false),
])),
vec![
Arc::new(Int64Array::from(vec![None, Some(5)])),
Arc::new(Int64Array::from(vec![9, 6])),
],
)
.unwrap();
assert_eq!(
rows_as_cypher_maps(&src_null).unwrap(),
vec!["{src: 5, dst: 6, props: {}}"],
"null-src edge row dropped too"
);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"sink::edge_rows_with_null_endpoint_are_dropped",
rows.len() == 2,
&format!("kept={} of 3 edge rows (null FK = no edge)", rows.len()),
);
}
#[test]
fn detach_delete_query_matches_on_id_and_detaches() {
let q = detach_delete_query("Person", "user_id", &["7".into(), "8".into()]);
assert_eq!(
q,
"UNWIND [7, 8] AS r MATCH (n:`Person` {`user_id`: r}) DETACH DELETE n"
);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"sink::detach_delete_query_matches_on_id_and_detaches",
q == "UNWIND [7, 8] AS r MATCH (n:`Person` {`user_id`: r}) DETACH DELETE n",
&format!("query={q}"),
);
}
fn eset(edge_key_props: Vec<String>) -> EdgeSet {
EdgeSet {
src_label: "User".into(),
edge_type: "WORKS_AT".into(),
dst_label: "Company".into(),
src_id_column: "user_id".into(),
dst_id_column: "company_id".into(),
edge_key_props,
replace_outgoing: false,
batch: RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("src", DataType::Int64, false),
Field::new("dst", DataType::Int64, false),
])),
vec![
Arc::new(Int64Array::from(vec![1])),
Arc::new(Int64Array::from(vec![2])),
],
)
.unwrap(),
}
}
#[test]
fn edge_merge_key_controls_parallel_edges() {
let plain = edge_upsert_query(&eset(vec![]), &["{src: 1, dst: 2, props: {}}".into()]);
assert_eq!(
plain,
"UNWIND [{src: 1, dst: 2, props: {}}] AS r \
MERGE (a:`User` {`user_id`: r.src}) \
MERGE (b:`Company` {`company_id`: r.dst}) \
MERGE (a)-[e:`WORKS_AT`]->(b) \
SET e += r.props",
"no merge-key props ⇒ byte-identical to the historical collapsing MERGE",
);
let keyed = edge_upsert_query(
&eset(vec!["since".into(), "role".into()]),
&["{src: 1, dst: 2, props: {`since`: '2021', `role`: 'eng'}}".into()],
);
assert!(
keyed.contains(
"MERGE (a)-[e:`WORKS_AT` {`since`: r.props.`since`, `role`: r.props.`role`}]->(b)"
),
"merge-key props fold into the relationship pattern: {keyed}"
);
assert!(
keyed.ends_with("SET e += r.props"),
"props still written: {keyed}"
);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"sink::edge_merge_key_controls_parallel_edges",
plain.contains("-[e:`WORKS_AT`]->")
&& keyed.contains(
"-[e:`WORKS_AT` {`since`: r.props.`since`, `role`: r.props.`role`}]->",
),
"empty ⇒ collapse; keyed ⇒ parallel-edge merge key",
);
}
#[test]
fn delete_ids_read_only_the_id_column() {
let set = VertexDelete {
label: "Person".into(),
id_column: "user_id".into(),
batch: RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("user_id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(vec![7, 9])),
Arc::new(StringArray::from(vec!["stale", "gone"])),
],
)
.unwrap(),
};
let ids = delete_ids_as_cypher(&set).unwrap();
assert_eq!(ids, vec!["7", "9"]);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"sink::delete_ids_read_only_the_id_column",
ids == vec!["7", "9"],
&format!("ids={ids:?} (stale props ignored)"),
);
}
fn vset(label: &str, with_gar: bool) -> VertexSet {
let (schema, cols): (Schema, Vec<Arc<dyn Array>>) = if with_gar {
(
Schema::new(vec![
Field::new("_gar_id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
]),
vec![
Arc::new(Int64Array::from(vec![0, 1])),
Arc::new(StringArray::from(vec!["a", "b"])),
],
)
} else {
(
Schema::new(vec![
Field::new("user_id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
]),
vec![
Arc::new(Int64Array::from(vec![0, 1])),
Arc::new(StringArray::from(vec!["a", "b"])),
],
)
};
VertexSet {
label: label.into(),
id_column: if with_gar {
"_gar_id".into()
} else {
"user_id".into()
},
batch: RecordBatch::try_new(Arc::new(schema), cols).unwrap(),
}
}
#[test]
fn bulk_lane_eligibility_predicate() {
let ok = GraphMutation {
edge_deletes: Vec::new(),
vertices: vec![vset("Person", true), vset("Loan", true)],
edges: vec![],
deletes: vec![],
};
assert!(
bulk_lane_eligible(&ok),
"the demo's vertex-only `_gar_id` load qualifies"
);
assert!(!bulk_lane_eligible(&GraphMutation::default()));
let no_gar = GraphMutation {
edge_deletes: Vec::new(),
vertices: vec![vset("Person", false)],
edges: vec![],
deletes: vec![],
};
assert!(
!bulk_lane_eligible(&no_gar),
"missing `_gar_id` → MERGE (byte-identity)"
);
let dup = GraphMutation {
edge_deletes: Vec::new(),
vertices: vec![vset("Person", true), vset("Person", true)],
edges: vec![],
deletes: vec![],
};
assert!(!bulk_lane_eligible(&dup), "duplicate label → MERGE");
let with_edge = GraphMutation {
edge_deletes: Vec::new(),
vertices: vec![vset("Person", true)],
edges: vec![EdgeSet {
src_label: "Person".into(),
edge_type: "KNOWS".into(),
dst_label: "Person".into(),
src_id_column: "_gar_id".into(),
dst_id_column: "_gar_id".into(),
edge_key_props: vec![],
replace_outgoing: false,
batch: RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("src", DataType::Int64, false),
Field::new("dst", DataType::Int64, false),
])),
vec![
Arc::new(Int64Array::from(vec![0])),
Arc::new(Int64Array::from(vec![1])),
],
)
.unwrap(),
}],
deletes: vec![],
};
assert!(!bulk_lane_eligible(&with_edge), "edges present → MERGE");
let with_delete = GraphMutation {
edge_deletes: Vec::new(),
vertices: vec![vset("Person", true)],
edges: vec![],
deletes: vec![VertexDelete {
label: "Person".into(),
id_column: "_gar_id".into(),
batch: RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new(
"_gar_id",
DataType::Int64,
false,
)])),
vec![Arc::new(Int64Array::from(vec![0]))],
)
.unwrap(),
}],
};
assert!(!bulk_lane_eligible(&with_delete), "deletes present → MERGE");
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"sink::bulk_lane_eligibility_predicate",
bulk_lane_eligible(&ok)
&& !bulk_lane_eligible(&no_gar)
&& !bulk_lane_eligible(&dup)
&& !bulk_lane_eligible(&with_edge)
&& !bulk_lane_eligible(&with_delete),
"vertex-only + `_gar_id` + unique labels ⇒ bulk; else MERGE",
);
}
#[test]
fn in_place_update_does_not_delete_the_upserted_node() {
let upsert = VertexSet {
label: "Person".into(),
id_column: "user_id".into(),
batch: RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("user_id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(vec![7])),
Arc::new(StringArray::from(vec!["new-name"])),
],
)
.unwrap(),
};
let delete = VertexDelete {
label: "Person".into(),
id_column: "user_id".into(),
batch: RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new(
"user_id",
DataType::Int64,
false,
)])),
vec![Arc::new(Int64Array::from(vec![7, 9]))],
)
.unwrap(),
};
let mutation = GraphMutation {
edge_deletes: Vec::new(),
vertices: vec![upsert],
edges: vec![],
deletes: vec![delete.clone()],
};
let upserted = upserted_vertex_keys(&mutation).unwrap();
let surviving = surviving_delete_ids(&delete, &upserted).unwrap();
let pass = surviving == vec!["9"];
assert!(pass, "expected only id 9 to be deleted, got {surviving:?}");
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"sink::in_place_update_does_not_delete_the_upserted_node",
pass,
&format!("surviving_deletes={surviving:?} (id 7 upsert wins over delete)"),
);
}
fn knows(src: &[Option<i64>], dst: &[Option<i64>], id: &[i64]) -> EdgeSet {
EdgeSet {
src_label: "person".into(),
edge_type: "knows".into(),
dst_label: "person".into(),
src_id_column: "id".into(),
dst_id_column: "id".into(),
edge_key_props: vec!["id".into()],
replace_outgoing: false,
batch: RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("src", DataType::Int64, true),
Field::new("dst", DataType::Int64, true),
Field::new("id", DataType::Int64, false),
])),
vec![
Arc::new(Int64Array::from(src.to_vec())),
Arc::new(Int64Array::from(dst.to_vec())),
Arc::new(Int64Array::from(id.to_vec())),
],
)
.unwrap(),
}
}
#[test]
fn an_edge_delete_matches_its_endpoints_type_and_key() {
let set = knows(&[Some(1)], &[Some(2)], &[7]);
let (rows, skipped) = surviving_edge_deletes(&set, &HashSet::new()).unwrap();
assert_eq!((rows.as_slice(), skipped), (["{src: 1, dst: 2, props: {`id`: 7}}".to_string()].as_slice(), 0));
assert_eq!(
edge_delete_query(&set, &rows),
"UNWIND [{src: 1, dst: 2, props: {`id`: 7}}] AS r \
MATCH (a:`person` {`id`: r.src})-[e:`knows` {`id`: r.props.`id`}]->(b:`person` {`id`: r.dst}) DELETE e"
);
}
#[test]
fn an_edge_upserted_in_the_round_survives_and_a_null_endpoint_is_skipped() {
let upserts = GraphMutation { edges: vec![knows(&[Some(1)], &[Some(2)], &[7])], ..Default::default() };
let upserted = upserted_edge_keys(&upserts).unwrap();
let deletes = knows(&[Some(1), Some(1), None], &[Some(2), Some(3), None], &[7, 8, 9]);
let (rows, skipped) = surviving_edge_deletes(&deletes, &upserted).unwrap();
assert_eq!(rows, vec!["{src: 1, dst: 3, props: {`id`: 8}}".to_string()]);
assert_eq!(skipped, 1);
}
#[test]
fn an_edge_delete_keeps_the_bulk_lane_shut() {
let m = GraphMutation { edge_deletes: vec![knows(&[Some(1)], &[Some(2)], &[7])], ..Default::default() };
assert!(!bulk_lane_eligible(&m));
}
fn people_set(ids: &[Option<i64>], names: &[&str]) -> VertexSet {
VertexSet {
label: "Person".into(),
id_column: "id".into(),
batch: RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, true), Field::new("name", DataType::Utf8, false)])),
vec![Arc::new(Int64Array::from(ids.to_vec())), Arc::new(StringArray::from(names.to_vec()))],
)
.unwrap(),
}
}
#[test]
fn a_first_load_keeps_the_last_row_per_id_and_drops_null_ids() {
let a = people_set(&[Some(1), Some(2), None], &["a", "b", "nobody"]);
let b = people_set(&[Some(1), Some(3)], &["a2", "c"]);
let batch = first_load_rows(&[&a, &b]).unwrap().unwrap();
let ids: Vec<i64> = batch.column(0).as_any().downcast_ref::<Int64Array>().unwrap().values().to_vec();
let names: Vec<&str> = batch.column(1).as_any().downcast_ref::<StringArray>().unwrap().iter().flatten().collect();
assert_eq!(ids, vec![1, 2, 3], "first-seen order");
assert_eq!(names, vec!["a2", "b", "c"], "the last row of id 1 wins");
}
#[test]
fn a_first_load_of_mismatched_sets_is_left_to_merge() {
let a = people_set(&[Some(1)], &["a"]);
let mut b = people_set(&[Some(2)], &["b"]);
b.id_column = "name".into();
assert!(first_load_rows(&[&a, &b]).unwrap().is_none());
assert!(first_load_rows(&[&people_set(&[None], &["x"])]).unwrap().is_none(), "nothing left");
}
fn works_at(src: &[Option<i64>], dst: &[Option<i64>]) -> EdgeSet {
EdgeSet {
src_label: "User".into(),
edge_type: "WORKS_AT".into(),
dst_label: "Company".into(),
src_id_column: "user_id".into(),
dst_id_column: "id".into(),
edge_key_props: Vec::new(),
replace_outgoing: true,
batch: RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("src", DataType::Int64, true), Field::new("dst", DataType::Int64, true)])),
vec![Arc::new(Int64Array::from(src.to_vec())), Arc::new(Int64Array::from(dst.to_vec()))],
)
.unwrap(),
}
}
#[test]
fn a_replace_set_keeps_the_last_row_per_source_and_a_cleared_key() {
let set = last_row_per_src(&works_at(&[Some(1), Some(2), Some(1), None], &[Some(10), None, Some(20), Some(9)])).unwrap();
let rows = replace_rows(&set).unwrap();
assert_eq!(rows, vec!["{src: 1, dst: 20}".to_string(), "{src: 2, dst: null}".to_string()]);
assert_eq!(
replace_outgoing_query(&set, &rows[..1]),
"UNWIND [{src: 1, dst: 20}] AS r \
MATCH (a:`User` {`user_id`: r.src})-[e:`WORKS_AT`]->(b:`Company`) \
WHERE r.dst IS NULL OR b.`id` <> r.dst DELETE e"
);
}
#[test]
fn a_key_only_edge_delete_is_named_by_its_key() {
let set = knows(&[None, Some(1)], &[None, Some(2)], &[9, 7]);
let rows = key_only_edge_rows(&set).unwrap();
assert_eq!(rows, vec!["{props: {`id`: 9}}".to_string()]);
assert_eq!(
edge_delete_by_key_query(&set, &rows),
"UNWIND [{props: {`id`: 9}}] AS r MATCH (:`person`)-[e:`knows` {`id`: r.props.`id`}]->(:`person`) DELETE e"
);
assert!(key_only_edge_rows(&works_at(&[None], &[None])).unwrap().is_empty());
}
}