use nodedb::bridge::envelope::Status;
use nodedb_physical::physical_plan::{DocumentOp, EnforcementOptions, PhysicalPlan, StorageMode};
use nodedb_types::columnar::{ColumnDef, ColumnType, StrictSchema};
use crate::helpers::{TestCtx, make_ctx};
fn msgpack_map(fields: &[(&str, &str)]) -> Vec<u8> {
let mut map = serde_json::Map::new();
for (k, v) in fields {
map.insert(k.to_string(), serde_json::Value::String(v.to_string()));
}
nodedb_types::json_to_msgpack(&serde_json::Value::Object(map)).unwrap()
}
fn register_schemaless_bitemporal(ctx: &mut TestCtx, collection: &str) {
let resp = crate::helpers::send_raw(
&mut ctx.core,
&mut ctx.tx,
&mut ctx.rx,
PhysicalPlan::Document(DocumentOp::Register {
collection: collection.into(),
indexes: Vec::new(),
crdt_enabled: false,
storage_mode: StorageMode::Schemaless,
enforcement: Box::new(EnforcementOptions::default()),
bitemporal: true,
conflict_policy: None,
}),
);
assert_eq!(resp.status, Status::Ok, "register schemaless bitemporal");
}
fn register_strict_bitemporal(ctx: &mut TestCtx, collection: &str) {
let resp = crate::helpers::send_raw(
&mut ctx.core,
&mut ctx.tx,
&mut ctx.rx,
PhysicalPlan::Document(DocumentOp::Register {
collection: collection.into(),
indexes: Vec::new(),
crdt_enabled: false,
storage_mode: StorageMode::Strict {
schema: StrictSchema::new_bitemporal(vec![
ColumnDef::required("id", ColumnType::String).with_primary_key(),
ColumnDef::required("score", ColumnType::String),
])
.expect("build bitemporal strict schema"),
},
enforcement: Box::new(EnforcementOptions::default()),
bitemporal: true,
conflict_policy: None,
}),
);
assert_eq!(resp.status, Status::Ok, "register strict bitemporal");
}
fn put(ctx: &mut TestCtx, collection: &str, doc_id: &str, value: Vec<u8>, surrogate: u32) {
let resp = crate::helpers::send_raw(
&mut ctx.core,
&mut ctx.tx,
&mut ctx.rx,
PhysicalPlan::Document(DocumentOp::PointPut {
collection: collection.into(),
document_id: doc_id.into(),
value,
surrogate: nodedb_types::Surrogate::new(surrogate),
pk_bytes: doc_id.as_bytes().to_vec(),
}),
);
assert_eq!(resp.status, Status::Ok, "PointPut {doc_id}");
}
fn delete(ctx: &mut TestCtx, collection: &str, doc_id: &str, surrogate: u32) {
let resp = crate::helpers::send_raw(
&mut ctx.core,
&mut ctx.tx,
&mut ctx.rx,
PhysicalPlan::Document(DocumentOp::PointDelete {
collection: collection.into(),
document_id: doc_id.into(),
surrogate: nodedb_types::Surrogate::new(surrogate),
pk_bytes: doc_id.as_bytes().to_vec(),
returning: None,
}),
);
assert_eq!(resp.status, Status::Ok, "PointDelete {doc_id}");
}
fn range_scan_scores(
ctx: &mut TestCtx,
collection: &str,
lower: Option<&[u8]>,
upper: Option<&[u8]>,
) -> Vec<String> {
let resp = crate::helpers::send_raw(
&mut ctx.core,
&mut ctx.tx,
&mut ctx.rx,
PhysicalPlan::Document(DocumentOp::RangeScan {
collection: collection.into(),
field: "score".into(),
lower: lower.map(|b| b.to_vec()),
upper: upper.map(|b| b.to_vec()),
limit: 100,
}),
);
assert_eq!(resp.status, Status::Ok, "RangeScan status");
let json = nodedb::data::executor::response_codec::decode_payload_to_json(&resp.payload);
let rows: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap_or_else(|_| Vec::new());
rows.iter()
.filter_map(|row| {
row.get("data")
.and_then(|d| d.get("score"))
.and_then(|s| s.as_str())
.map(|s| s.to_string())
})
.collect()
}
fn seed_four(ctx: &mut TestCtx, collection: &str) {
let rows = [("d1", "010"), ("d2", "020"), ("d3", "030"), ("d4", "040")];
for (i, (id, score)) in rows.iter().enumerate() {
let value = msgpack_map(&[("id", id), ("score", score)]);
put(ctx, collection, id, value, (i + 1) as u32);
}
}
fn assert_range_basics(ctx: &mut TestCtx, collection: &str) {
let scores = range_scan_scores(ctx, collection, Some(b"020"), Some(b"040"));
assert_eq!(
scores,
vec!["020".to_string(), "030".to_string()],
"bitemporal range [020,040) must return the two in-range CURRENT rows \
(empty here = the bug)"
);
let all = range_scan_scores(ctx, collection, None, None);
assert_eq!(
all,
vec![
"010".to_string(),
"020".to_string(),
"030".to_string(),
"040".to_string()
],
"unbounded bitemporal range scan must return all current rows"
);
}
#[test]
fn schemaless_bitemporal_range_scan_returns_in_range_rows() {
let mut ctx = make_ctx();
register_schemaless_bitemporal(&mut ctx, "events");
seed_four(&mut ctx, "events");
assert_range_basics(&mut ctx, "events");
}
#[test]
fn strict_bitemporal_range_scan_returns_in_range_rows() {
let mut ctx = make_ctx();
register_strict_bitemporal(&mut ctx, "events");
seed_four(&mut ctx, "events");
assert_range_basics(&mut ctx, "events");
}
#[test]
fn schemaless_bitemporal_update_moves_row_in_range() {
let mut ctx = make_ctx();
register_schemaless_bitemporal(&mut ctx, "events");
seed_four(&mut ctx, "events");
put(
&mut ctx,
"events",
"d4",
msgpack_map(&[("id", "d4"), ("score", "025")]),
4,
);
let scores = range_scan_scores(&mut ctx, "events", Some(b"020"), Some(b"035"));
assert_eq!(
scores,
vec!["020".to_string(), "025".to_string(), "030".to_string()],
"range scan must reflect only the CURRENT version after UPDATE"
);
put(
&mut ctx,
"events",
"d2",
msgpack_map(&[("id", "d2"), ("score", "099")]),
2,
);
let scores = range_scan_scores(&mut ctx, "events", Some(b"020"), Some(b"035"));
assert_eq!(
scores,
vec!["025".to_string(), "030".to_string()],
"an updated-out-of-range row must not appear"
);
}
#[test]
fn strict_bitemporal_update_moves_row_in_range() {
let mut ctx = make_ctx();
register_strict_bitemporal(&mut ctx, "events");
seed_four(&mut ctx, "events");
put(
&mut ctx,
"events",
"d4",
msgpack_map(&[("id", "d4"), ("score", "025")]),
4,
);
let scores = range_scan_scores(&mut ctx, "events", Some(b"020"), Some(b"035"));
assert_eq!(
scores,
vec!["020".to_string(), "025".to_string(), "030".to_string()],
"strict range scan must reflect only the CURRENT version after UPDATE"
);
}
#[test]
fn schemaless_bitemporal_delete_excludes_tombstoned_row() {
let mut ctx = make_ctx();
register_schemaless_bitemporal(&mut ctx, "events");
seed_four(&mut ctx, "events");
delete(&mut ctx, "events", "d3", 3);
let scores = range_scan_scores(&mut ctx, "events", Some(b"020"), Some(b"040"));
assert_eq!(
scores,
vec!["020".to_string()],
"deleted in-range row must be excluded (tombstone respected)"
);
}
#[test]
fn strict_bitemporal_delete_excludes_tombstoned_row() {
let mut ctx = make_ctx();
register_strict_bitemporal(&mut ctx, "events");
seed_four(&mut ctx, "events");
delete(&mut ctx, "events", "d3", 3);
let scores = range_scan_scores(&mut ctx, "events", Some(b"020"), Some(b"040"));
assert_eq!(
scores,
vec!["020".to_string()],
"deleted in-range strict row must be excluded (tombstone respected)"
);
}