use ahash::AHashMap;
use serde_json::Value;
use crate::error::Error;
use crate::schema::{NodeId, NodeRecord};
use crate::storage::{lmdb::Storage, props};
pub(crate) enum PropColumn {
Int(Vec<Option<i64>>),
Float(Vec<Option<f64>>),
Bool(Vec<Option<bool>>),
Str {
dict: Vec<String>,
lookup: AHashMap<String, u32>,
idx: Vec<u32>,
},
Json(Vec<Option<Value>>),
}
const STR_NULL: u32 = u32::MAX;
#[derive(PartialEq, Clone, Copy)]
enum Kind {
Null,
Int,
Float,
Bool,
Str,
Other,
}
fn kind_of(v: &Value) -> Kind {
match v {
Value::Null => Kind::Null,
Value::Bool(_) => Kind::Bool,
Value::Number(n) => {
if n.is_i64() || (n.is_u64() && n.as_i64().is_some()) {
Kind::Int
} else if n.is_f64() {
Kind::Float
} else {
Kind::Other
}
}
Value::String(_) => Kind::Str,
_ => Kind::Other,
}
}
impl PropColumn {
fn from_values(values: Vec<Option<Value>>) -> Self {
let mut kind = Kind::Null;
for v in values.iter().flatten() {
let k = kind_of(v);
if k == Kind::Null {
continue;
}
if kind == Kind::Null {
kind = k;
} else if kind != k {
kind = Kind::Other;
break;
}
}
match kind {
Kind::Int => Self::Int(
values
.into_iter()
.map(|v| v.and_then(|v| v.as_i64()))
.collect(),
),
Kind::Float => Self::Float(
values
.into_iter()
.map(|v| v.and_then(|v| v.as_f64()))
.collect(),
),
Kind::Bool => Self::Bool(
values
.into_iter()
.map(|v| v.and_then(|v| v.as_bool()))
.collect(),
),
Kind::Str => {
let mut dict = Vec::new();
let mut lookup: AHashMap<String, u32> = AHashMap::new();
let mut idx = Vec::with_capacity(values.len());
for v in values {
match v {
Some(Value::String(s)) => idx.push(intern(&mut dict, &mut lookup, s)),
_ => idx.push(STR_NULL),
}
}
Self::Str { dict, lookup, idx }
}
Kind::Null | Kind::Other => Self::Json(
values
.into_iter()
.map(|v| v.filter(|v| !v.is_null()))
.collect(),
),
}
}
fn len(&self) -> usize {
match self {
Self::Int(v) => v.len(),
Self::Float(v) => v.len(),
Self::Bool(v) => v.len(),
Self::Str { idx, .. } => idx.len(),
Self::Json(v) => v.len(),
}
}
fn grow(&mut self, len: usize) {
match self {
Self::Int(v) => v.resize(len, None),
Self::Float(v) => v.resize(len, None),
Self::Bool(v) => v.resize(len, None),
Self::Str { idx, .. } => idx.resize(len, STR_NULL),
Self::Json(v) => v.resize(len, None),
}
}
fn clear(&mut self, dense: usize) {
match self {
Self::Int(v) => v[dense] = None,
Self::Float(v) => v[dense] = None,
Self::Bool(v) => v[dense] = None,
Self::Str { idx, .. } => idx[dense] = STR_NULL,
Self::Json(v) => v[dense] = None,
}
}
fn set(&mut self, dense: usize, value: Value) {
match (&mut *self, kind_of(&value)) {
(_, Kind::Null) => self.clear(dense),
(Self::Int(v), Kind::Int) => v[dense] = value.as_i64(),
(Self::Float(v), Kind::Float) => v[dense] = value.as_f64(),
(Self::Bool(v), Kind::Bool) => v[dense] = value.as_bool(),
(Self::Str { dict, lookup, idx }, Kind::Str) => {
if let Value::String(s) = value {
idx[dense] = intern(dict, lookup, s);
}
}
(Self::Json(v), _) => v[dense] = Some(value),
_ => {
self.degrade_to_json();
self.set(dense, value);
}
}
}
fn degrade_to_json(&mut self) {
let json = |col: &Self| -> Vec<Option<Value>> {
(0..col.len()).map(|d| col.get_json_opt(d)).collect()
};
*self = Self::Json(json(self));
}
pub(crate) fn get_json_opt(&self, dense: usize) -> Option<Value> {
match self {
Self::Int(v) => v[dense].map(Value::from),
Self::Float(v) => v[dense].map(Value::from),
Self::Bool(v) => v[dense].map(Value::from),
Self::Str { dict, idx, .. } => match idx[dense] {
STR_NULL => None,
i => Some(Value::String(dict[i as usize].clone())),
},
Self::Json(v) => v[dense].clone(),
}
}
}
fn intern(dict: &mut Vec<String>, lookup: &mut AHashMap<String, u32>, s: String) -> u32 {
if let Some(&i) = lookup.get(&s) {
return i;
}
let i = dict.len() as u32;
dict.push(s.clone());
lookup.insert(s, i);
i
}
pub(crate) struct PropStats {
pub(crate) min: Value,
pub(crate) max: Value,
pub(crate) histogram: crate::histogram::Histogram,
pub(crate) mcvs: Vec<(Value, u64)>,
}
const MCV_LIMIT: usize = 8;
const HISTOGRAM_BUCKETS: usize = 10;
impl PropStats {
pub(crate) fn equality_selectivity(&self, value: &Value) -> f64 {
for (v, count) in &self.mcvs {
if v == value {
return *count as f64 / self.histogram.total_rows as f64;
}
}
self.histogram.estimate_equality_selectivity(value)
}
}
pub(crate) struct PropColumns {
pub(crate) id_to_dense: AHashMap<NodeId, u32>,
pub(crate) dense_to_id: Vec<NodeId>,
pub(crate) cols: AHashMap<String, PropColumn>,
stats: AHashMap<String, Option<PropStats>>,
}
impl PropColumns {
fn build(storage: &Storage) -> Result<Self, Error> {
let rtxn = storage.env.read_txn()?;
let mut dense_to_id = Vec::new();
let mut decoded: Vec<Value> = Vec::new();
for entry in storage.nodes.iter(&rtxn)? {
let (id, bytes) = entry?;
let rec: NodeRecord = props::decode(bytes)?;
dense_to_id.push(id);
decoded.push(props::decode(&rec.props)?);
}
let n = dense_to_id.len();
let id_to_dense: AHashMap<NodeId, u32> = dense_to_id
.iter()
.enumerate()
.map(|(i, &id)| (id, i as u32))
.collect();
let mut values: AHashMap<String, Vec<Option<Value>>> = AHashMap::new();
for (dense, json) in decoded.into_iter().enumerate() {
if let Value::Object(map) = json {
for (k, v) in map {
let col = values.entry(k).or_insert_with(|| vec![None; n]);
col[dense] = Some(v);
}
}
}
let cols: AHashMap<String, PropColumn> = values
.into_iter()
.map(|(k, v)| (k, PropColumn::from_values(v)))
.collect();
Ok(Self {
id_to_dense,
dense_to_id,
cols,
stats: AHashMap::new(),
})
}
pub(crate) fn prop_stats(&mut self, prop: &str) -> Option<&PropStats> {
if !self.stats.contains_key(prop) {
let computed = self.cols.get(prop).and_then(compute_prop_stats);
self.stats.insert(prop.to_string(), computed);
}
self.stats.get(prop).and_then(|s| s.as_ref())
}
pub(crate) fn props_table(
&self,
ids: &[NodeId],
props: &[&str],
) -> Result<Vec<Vec<Value>>, Error> {
let cols: Vec<Option<&PropColumn>> = props.iter().map(|p| self.cols.get(*p)).collect();
let mut out = Vec::with_capacity(ids.len());
for &id in ids {
let dense = *self
.id_to_dense
.get(&id)
.ok_or_else(|| Error::NodeNotFound(id))? as usize;
out.push(
cols.iter()
.map(|c| c.and_then(|c| c.get_json_opt(dense)).unwrap_or(Value::Null))
.collect(),
);
}
Ok(out)
}
pub(crate) fn prop_column(&self, ids: &[NodeId], prop: &str) -> Result<Vec<Value>, Error> {
let col = self.cols.get(prop);
let mut out = Vec::with_capacity(ids.len());
for &id in ids {
let dense = *self
.id_to_dense
.get(&id)
.ok_or_else(|| Error::NodeNotFound(id))? as usize;
out.push(
col.and_then(|c| c.get_json_opt(dense))
.unwrap_or(Value::Null),
);
}
Ok(out)
}
pub(crate) fn group_codes(
&self,
ids: &[NodeId],
prop: &str,
) -> Result<(Vec<u32>, Vec<Value>), Error> {
let mut codes = Vec::with_capacity(ids.len());
let mut reps: Vec<Value> = Vec::new();
let Some(col) = self.cols.get(prop) else {
for &id in ids {
if !self.id_to_dense.contains_key(&id) {
return Err(Error::NodeNotFound(id));
}
}
if !ids.is_empty() {
reps.push(Value::Null);
codes.resize(ids.len(), 0);
}
return Ok((codes, reps));
};
let mut null_code: Option<u32> = None;
let mut intern_null = |reps: &mut Vec<Value>| -> u32 {
*null_code.get_or_insert_with(|| {
reps.push(Value::Null);
(reps.len() - 1) as u32
})
};
match col {
PropColumn::Int(v) => {
let mut seen: AHashMap<i64, u32> = AHashMap::new();
for &id in ids {
let dense = *self
.id_to_dense
.get(&id)
.ok_or_else(|| Error::NodeNotFound(id))?
as usize;
codes.push(match v[dense] {
None => intern_null(&mut reps),
Some(n) => *seen.entry(n).or_insert_with(|| {
reps.push(Value::from(n));
(reps.len() - 1) as u32
}),
});
}
}
PropColumn::Float(v) => {
let mut seen: AHashMap<u64, u32> = AHashMap::new();
for &id in ids {
let dense = *self
.id_to_dense
.get(&id)
.ok_or_else(|| Error::NodeNotFound(id))?
as usize;
codes.push(match v[dense] {
None => intern_null(&mut reps),
Some(f) => *seen.entry(f.to_bits()).or_insert_with(|| {
reps.push(Value::from(f));
(reps.len() - 1) as u32
}),
});
}
}
PropColumn::Bool(v) => {
let mut seen: [Option<u32>; 2] = [None, None];
for &id in ids {
let dense = *self
.id_to_dense
.get(&id)
.ok_or_else(|| Error::NodeNotFound(id))?
as usize;
codes.push(match v[dense] {
None => intern_null(&mut reps),
Some(b) => *seen[b as usize].get_or_insert_with(|| {
reps.push(Value::from(b));
(reps.len() - 1) as u32
}),
});
}
}
PropColumn::Str { dict, idx, .. } => {
let mut dict_code: Vec<u32> = vec![u32::MAX; dict.len()];
for &id in ids {
let dense = *self
.id_to_dense
.get(&id)
.ok_or_else(|| Error::NodeNotFound(id))?
as usize;
codes.push(match idx[dense] {
STR_NULL => intern_null(&mut reps),
i => {
if dict_code[i as usize] == u32::MAX {
reps.push(Value::String(dict[i as usize].clone()));
dict_code[i as usize] = (reps.len() - 1) as u32;
}
dict_code[i as usize]
}
});
}
}
PropColumn::Json(v) => {
let mut seen: AHashMap<String, u32> = AHashMap::new();
for &id in ids {
let dense = *self
.id_to_dense
.get(&id)
.ok_or_else(|| Error::NodeNotFound(id))?
as usize;
codes.push(match &v[dense] {
None => intern_null(&mut reps),
Some(val) => *seen.entry(val.to_string()).or_insert_with(|| {
reps.push(val.clone());
(reps.len() - 1) as u32
}),
});
}
}
}
Ok((codes, reps))
}
fn patch(&mut self, storage: &Storage, touched: &[NodeId]) -> Result<(), Error> {
if !touched.is_empty() {
self.stats.clear();
}
let rtxn = storage.env.read_txn()?;
for &id in touched {
let bytes = match storage.nodes.get(&rtxn, &id)? {
Some(b) => b,
None => continue,
};
let rec: NodeRecord = props::decode(bytes)?;
let json: Value = props::decode(&rec.props)?;
let dense = match self.id_to_dense.get(&id) {
Some(&d) => d as usize,
None => {
let d = self.dense_to_id.len();
self.dense_to_id.push(id);
self.id_to_dense.insert(id, d as u32);
d
}
};
let n = self.dense_to_id.len();
for col in self.cols.values_mut() {
col.grow(n);
col.clear(dense);
}
if let Value::Object(map) = json {
for (k, v) in map {
let col = self.cols.entry(k).or_insert_with(|| {
let mut c = PropColumn::Json(Vec::new());
c.grow(n);
c
});
col.grow(n);
col.set(dense, v);
}
}
}
Ok(())
}
}
fn sorted_non_null_values(col: &PropColumn) -> Option<Vec<Value>> {
let mut vals: Vec<Value> = match col {
PropColumn::Int(v) => v.iter().flatten().map(|&x| Value::from(x)).collect(),
PropColumn::Float(v) => v
.iter()
.flatten()
.filter(|x| !x.is_nan())
.map(|&x| Value::from(x))
.collect(),
PropColumn::Bool(v) => v.iter().flatten().map(|&x| Value::Bool(x)).collect(),
PropColumn::Str { dict, idx, .. } => idx
.iter()
.filter(|&&i| i != STR_NULL)
.map(|&i| Value::String(dict[i as usize].clone()))
.collect(),
PropColumn::Json(_) => return None,
};
vals.sort_unstable_by(|a, b| {
crate::histogram::compare_values(a, b).unwrap_or(std::cmp::Ordering::Equal)
});
Some(vals)
}
fn compute_prop_stats(col: &PropColumn) -> Option<PropStats> {
let vals = sorted_non_null_values(col)?;
let (min, max) = match (vals.first(), vals.last()) {
(Some(mn), Some(mx)) => (mn.clone(), mx.clone()),
_ => return None,
};
let histogram = crate::histogram::Histogram::build(&vals, HISTOGRAM_BUCKETS);
let mut runs: Vec<(Value, u64)> = Vec::new();
for v in &vals {
match runs.last_mut() {
Some((last, count)) if last == v => *count += 1,
_ => runs.push((v.clone(), 1)),
}
}
runs.sort_by(|a, b| b.1.cmp(&a.1));
runs.truncate(MCV_LIMIT);
Some(PropStats {
min,
max,
histogram,
mcvs: runs,
})
}
#[derive(Default)]
struct ColumnsDelta {
touched: Vec<NodeId>,
force_full: bool,
}
#[derive(Default)]
pub(crate) struct ColumnsCache {
columns: parking_lot::RwLock<Option<PropColumns>>,
pending: parking_lot::Mutex<ColumnsDelta>,
}
impl ColumnsCache {
pub(crate) fn record_touched(&self, id: NodeId) {
let mut p = self.pending.lock();
if !p.force_full {
p.touched.push(id);
}
}
pub(crate) fn record_touched_many(&self, ids: &[NodeId]) {
let mut p = self.pending.lock();
if !p.force_full {
p.touched.extend_from_slice(ids);
}
}
pub(crate) fn record_force_full(&self) {
let mut p = self.pending.lock();
p.force_full = true;
p.touched.clear();
}
pub(crate) fn with_fresh<T>(
&self,
storage: &Storage,
f: impl FnOnce(&PropColumns) -> T,
) -> Result<T, Error> {
loop {
{
let guard = self.columns.read();
if let Some(cols) = guard.as_ref() {
let p = self.pending.lock();
if p.touched.is_empty() && !p.force_full {
drop(p);
return Ok(f(cols));
}
}
}
let mut guard = self.columns.write();
let delta = std::mem::take(&mut *self.pending.lock());
match guard.as_mut() {
Some(cols) if !delta.force_full => cols.patch(storage, &delta.touched)?,
_ => *guard = Some(PropColumns::build(storage)?),
}
}
}
pub(crate) fn with_fresh_mut<T>(
&self,
storage: &Storage,
f: impl FnOnce(&mut PropColumns) -> T,
) -> Result<T, Error> {
let mut guard = self.columns.write();
loop {
let delta = std::mem::take(&mut *self.pending.lock());
if delta.touched.is_empty() && !delta.force_full {
if let Some(cols) = guard.as_mut() {
return Ok(f(cols));
}
}
match guard.as_mut() {
Some(cols) if !delta.force_full => cols.patch(storage, &delta.touched)?,
_ => *guard = Some(PropColumns::build(storage)?),
}
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use tempfile::TempDir;
use crate::Graph;
fn open_tmp() -> (TempDir, Graph) {
let dir = TempDir::new().unwrap();
let g = Graph::open(dir.path(), 1).unwrap();
(dir, g)
}
#[test]
fn typed_values_round_trip_exactly() {
let (_dir, g) = open_tmp();
let a = g
.add_node(
"N",
&json!({ "i": 42, "f": 1.5, "s": "hello", "b": true, "arr": [1, 2] }),
)
.unwrap();
assert_eq!(g.node_prop_json(a, "i").unwrap(), Some(json!(42)));
assert_eq!(g.node_prop_json(a, "f").unwrap(), Some(json!(1.5)));
assert_eq!(g.node_prop_json(a, "s").unwrap(), Some(json!("hello")));
assert_eq!(g.node_prop_json(a, "b").unwrap(), Some(json!(true)));
assert_eq!(g.node_prop_json(a, "arr").unwrap(), Some(json!([1, 2])));
}
#[test]
fn missing_property_is_null_and_missing_node_is_none() {
let (_dir, g) = open_tmp();
let a = g.add_node("N", &json!({ "x": 1 })).unwrap();
assert_eq!(
g.node_prop_json(a, "nope").unwrap(),
Some(serde_json::Value::Null)
);
assert_eq!(g.node_prop_json(a + 999, "x").unwrap(), None);
}
#[test]
fn mixed_kind_property_keeps_exact_values() {
let (_dir, g) = open_tmp();
let a = g.add_node("N", &json!({ "v": 1 })).unwrap();
let b = g.add_node("N", &json!({ "v": "one" })).unwrap();
assert_eq!(g.node_prop_json(a, "v").unwrap(), Some(json!(1)));
assert_eq!(g.node_prop_json(b, "v").unwrap(), Some(json!("one")));
}
#[test]
fn update_node_is_visible_and_can_remove_and_degrade() {
let (_dir, g) = open_tmp();
let a = g.add_node("N", &json!({ "x": 1, "y": 2 })).unwrap();
assert_eq!(g.node_prop_json(a, "x").unwrap(), Some(json!(1)));
g.update_node(a, &json!({ "x": "now a string" })).unwrap();
assert_eq!(
g.node_prop_json(a, "x").unwrap(),
Some(json!("now a string"))
);
assert_eq!(
g.node_prop_json(a, "y").unwrap(),
Some(serde_json::Value::Null)
);
}
#[test]
fn nodes_added_after_first_build_are_visible() {
let (_dir, g) = open_tmp();
let a = g.add_node("N", &json!({ "x": 1 })).unwrap();
assert_eq!(g.node_prop_json(a, "x").unwrap(), Some(json!(1)));
let b = g.add_node("N", &json!({ "x": 2, "fresh": "yes" })).unwrap();
assert_eq!(g.node_prop_json(b, "x").unwrap(), Some(json!(2)));
assert_eq!(g.node_prop_json(b, "fresh").unwrap(), Some(json!("yes")));
assert_eq!(
g.node_prop_json(a, "fresh").unwrap(),
Some(serde_json::Value::Null)
);
}
#[test]
fn delete_node_forces_rebuild() {
let (_dir, g) = open_tmp();
let a = g.add_node("N", &json!({ "x": 1 })).unwrap();
let b = g.add_node("N", &json!({ "x": 2 })).unwrap();
assert_eq!(g.node_prop_json(a, "x").unwrap(), Some(json!(1)));
g.delete_node(a).unwrap();
assert_eq!(g.node_prop_json(a, "x").unwrap(), None);
assert_eq!(g.node_prop_json(b, "x").unwrap(), Some(json!(2)));
}
#[test]
fn props_table_gathers_rows_in_input_order() {
let (_dir, g) = open_tmp();
let a = g
.add_node("N", &json!({ "name": "ada", "age": 36, "city": "london" }))
.unwrap();
let b = g
.add_node("N", &json!({ "name": "bob", "age": 4 }))
.unwrap();
let table = g
.node_props_json_table(&[b, a, b], &["name", "age", "city"])
.unwrap();
assert_eq!(
table,
vec![
vec![json!("bob"), json!(4), serde_json::Value::Null],
vec![json!("ada"), json!(36), json!("london")],
vec![json!("bob"), json!(4), serde_json::Value::Null],
]
);
let table = g.node_props_json_table(&[a], &["nope"]).unwrap();
assert_eq!(table, vec![vec![serde_json::Value::Null]]);
assert!(g.node_props_json_table(&[], &["name"]).unwrap().is_empty());
assert_eq!(
g.node_props_json_table(&[a], &[]).unwrap(),
vec![Vec::<serde_json::Value>::new()]
);
}
#[test]
fn group_codes_match_value_identity() {
let (_dir, g) = open_tmp();
let vals = [
json!({ "v": 1 }),
json!({ "v": "1" }),
json!({ "v": 1.0 }),
json!({ "v": true }),
json!({}),
json!({ "v": 1 }),
json!({ "v": "1" }),
];
let ids: Vec<_> = vals.iter().map(|p| g.add_node("N", p).unwrap()).collect();
let (codes, reps) = g.node_prop_group_codes(&ids, "v").unwrap();
assert_eq!(codes.len(), ids.len());
assert_eq!(codes[0], codes[5]);
assert_eq!(codes[1], codes[6]);
let distinct: std::collections::HashSet<u32> = codes.iter().copied().collect();
assert_eq!(distinct.len(), 5);
for (i, &c) in codes.iter().enumerate() {
let expected = vals[i].get("v").cloned().unwrap_or(serde_json::Value::Null);
assert_eq!(reps[c as usize], expected, "representative for row {i}");
}
}
#[test]
fn group_codes_cover_typed_columns_and_unknown_props() {
let (_dir, g) = open_tmp();
let a = g.add_node("N", &json!({ "s": "x", "i": 7 })).unwrap();
let b = g.add_node("N", &json!({ "s": "y", "i": 7 })).unwrap();
let c = g.add_node("N", &json!({ "s": "x" })).unwrap();
let (codes, reps) = g.node_prop_group_codes(&[a, b, c, a], "s").unwrap();
assert_eq!(codes[0], codes[2]);
assert_eq!(codes[0], codes[3]);
assert_ne!(codes[0], codes[1]);
assert_eq!(reps[codes[0] as usize], json!("x"));
let (codes, reps) = g.node_prop_group_codes(&[a, b, c], "i").unwrap();
assert_eq!(codes[0], codes[1]);
assert_ne!(codes[0], codes[2]);
assert_eq!(reps[codes[2] as usize], serde_json::Value::Null);
let (codes, reps) = g.node_prop_group_codes(&[a, b], "nope").unwrap();
assert_eq!(codes, vec![0, 0]);
assert_eq!(reps, vec![serde_json::Value::Null]);
assert!(g.node_prop_group_codes(&[a + 999], "s").is_err());
}
#[test]
fn prop_column_gathers_in_input_order() {
let (_dir, g) = open_tmp();
let a = g
.add_node("N", &json!({ "name": "ada", "age": 36 }))
.unwrap();
let b = g.add_node("N", &json!({ "name": "bob" })).unwrap();
let col = g.node_prop_json_column(&[b, a, b], "age").unwrap();
assert_eq!(
col,
vec![serde_json::Value::Null, json!(36), serde_json::Value::Null]
);
let col = g.node_prop_json_column(&[a, b], "nope").unwrap();
assert_eq!(col, vec![serde_json::Value::Null; 2]);
assert!(g.node_prop_json_column(&[], "age").unwrap().is_empty());
let err = g.node_prop_json_column(&[a + 999], "age").unwrap_err();
assert!(matches!(err, crate::error::Error::NodeNotFound(id) if id == a + 999));
}
#[test]
fn props_table_errors_on_missing_node() {
let (_dir, g) = open_tmp();
let a = g.add_node("N", &json!({ "x": 1 })).unwrap();
let err = g.node_props_json_table(&[a, a + 999], &["x"]).unwrap_err();
assert!(matches!(err, crate::error::Error::NodeNotFound(id) if id == a + 999));
}
#[test]
fn props_table_sees_committed_writes_immediately() {
let (_dir, g) = open_tmp();
let a = g.add_node("N", &json!({ "x": 1 })).unwrap();
let table = g.node_props_json_table(&[a], &["x"]).unwrap();
assert_eq!(table, vec![vec![json!(1)]]);
g.update_node(a, &json!({ "x": 2 })).unwrap();
let table = g.node_props_json_table(&[a], &["x"]).unwrap();
assert_eq!(table, vec![vec![json!(2)]]);
}
#[test]
fn batch_transaction_writes_are_visible() {
let (_dir, g) = open_tmp();
let a = g.add_node("N", &json!({ "x": 1 })).unwrap();
assert_eq!(g.node_prop_json(a, "x").unwrap(), Some(json!(1)));
let b = g
.update(|txn| {
txn.update_node(a, &json!({ "x": 10 }))?;
txn.add_node("N", &json!({ "x": 20 }))
})
.unwrap();
assert_eq!(g.node_prop_json(a, "x").unwrap(), Some(json!(10)));
assert_eq!(g.node_prop_json(b, "x").unwrap(), Some(json!(20)));
}
#[test]
fn prop_column_min_max_bounds() {
let (_dir, g) = open_tmp();
let _a = g
.add_node(
"N",
&json!({ "age": 30, "weight": 70.5, "active": true, "name": "ada" }),
)
.unwrap();
let _b = g
.add_node(
"N",
&json!({ "age": 40, "weight": 80.2, "active": false, "name": "bob" }),
)
.unwrap();
let c = g
.add_node(
"N",
&json!({ "age": 20, "weight": 60.1, "active": true, "name": "charlie" }),
)
.unwrap();
let (min_age, max_age) = g.node_prop_min_max("age").unwrap().unwrap();
assert_eq!(min_age, json!(20));
assert_eq!(max_age, json!(40));
let (min_w, max_w) = g.node_prop_min_max("weight").unwrap().unwrap();
assert_eq!(min_w, json!(60.1));
assert_eq!(max_w, json!(80.2));
let (min_act, max_act) = g.node_prop_min_max("active").unwrap().unwrap();
assert_eq!(min_act, json!(false));
assert_eq!(max_act, json!(true));
let (min_name, max_name) = g.node_prop_min_max("name").unwrap().unwrap();
assert_eq!(min_name, json!("ada"));
assert_eq!(max_name, json!("charlie"));
assert!(g.node_prop_min_max("nope").unwrap().is_none());
g.update_node(c, &json!({ "age": 50, "weight": 90.0 }))
.unwrap();
let (min_age, max_age) = g.node_prop_min_max("age").unwrap().unwrap();
assert_eq!(min_age, json!(30));
assert_eq!(max_age, json!(50));
}
#[test]
fn prop_stats_refresh_when_update_removes_the_property() {
let (_dir, g) = open_tmp();
let a = g.add_node("N", &json!({ "age": 10 })).unwrap();
let _b = g.add_node("N", &json!({ "age": 99 })).unwrap();
let (_, max_age) = g.node_prop_min_max("age").unwrap().unwrap();
assert_eq!(max_age, json!(99));
g.update_node(a + 1, &json!({ "renamed": 1 })).unwrap();
let (min_age, max_age) = g.node_prop_min_max("age").unwrap().unwrap();
assert_eq!(min_age, json!(10));
assert_eq!(max_age, json!(10));
}
#[test]
fn equality_selectivity_uses_most_common_values() {
let (_dir, g) = open_tmp();
for _ in 0..90 {
g.add_node("N", &json!({ "team": "blue" })).unwrap();
}
for i in 0..10 {
g.add_node("N", &json!({ "team": format!("t{i}") }))
.unwrap();
}
let sel = g
.estimate_equality_selectivity("team", &json!("blue"))
.unwrap()
.unwrap();
assert!((sel - 0.9).abs() < 1e-9, "got {sel}");
let sel = g
.estimate_equality_selectivity("team", &json!("zzz"))
.unwrap()
.unwrap();
assert_eq!(sel, 0.0);
assert!(
g.estimate_equality_selectivity("nope", &json!(1))
.unwrap()
.is_none()
);
}
#[test]
fn range_selectivity_estimates_fraction() {
let (_dir, g) = open_tmp();
for i in 0..100 {
g.add_node("N", &json!({ "age": i })).unwrap();
}
let sel = g
.estimate_range_selectivity("age", Some(&json!(50)), None)
.unwrap()
.unwrap();
assert!((sel - 0.5).abs() < 0.05, "got {sel}");
let sel = g
.estimate_range_selectivity("age", None, Some(&json!(1000)))
.unwrap()
.unwrap();
assert!((sel - 1.0).abs() < 1e-9, "got {sel}");
}
}