#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use arrow::array::Array;
use chronix::chronix_query::{self, plan::FieldPredicate, plan::StatsOp};
use chronix::prelude::*;
use chronix::{Chronix, ParquetExportConfig, fields, tags};
use std::sync::Arc;
fn open(dir: &tempfile::TempDir) -> Arc<Chronix> {
let config = ChronixConfig::builder()
.data_dir(dir.path())
.build()
.unwrap();
Arc::new(Chronix::open(config).unwrap())
}
fn dec(text: &str) -> FieldValue {
FieldValue::Decimal(text.parse::<Decimal>().unwrap())
}
fn digits(batch: &arrow::record_batch::RecordBatch, column: &str, row: usize) -> Option<String> {
let arr = batch
.column_by_name(column)?
.as_any()
.downcast_ref::<arrow::array::Decimal128Array>()
.unwrap_or_else(|| panic!("'{column}' is not a decimal in {:?}", batch.schema()));
if arr.is_null(row) {
return None;
}
let scale = u8::try_from(arr.scale()).unwrap();
Some(Decimal::new(arr.value(row), scale).unwrap().to_string())
}
fn scan(db: &Chronix, measurement: &str) -> arrow::record_batch::RecordBatch {
let plan = db
.query()
.measurement(measurement)
.range(i64::MIN, i64::MAX)
.build()
.unwrap();
db.execute(&plan).unwrap()
}
fn seed_segments(db: &Chronix, count: i64, per_segment: i64) {
db.declare_field("meter", "z1nb", ColumnType::Decimal { scale: 4 })
.unwrap();
let key = SeriesKey::new("meter", tags! { "dev" => "main" }).unwrap();
for i in 0..count {
let value = Decimal::new(i128::from(10_000_000 + i), 4).unwrap();
db.insert(&Point::new(key.clone(), fields! { "z1nb" => value }, 1_000 + i).unwrap())
.unwrap();
if (i + 1) % per_segment == 0 {
db.flush().unwrap();
}
}
db.flush().unwrap();
}
#[test]
fn compaction_preserves_every_digit_and_the_column_type() {
let dir = tempfile::tempdir().unwrap();
let db = open(&dir);
seed_segments(&db, 40, 10);
let before = scan(&db, "meter");
let before_values: Vec<Option<String>> = (0..before.num_rows())
.map(|r| digits(&before, "z1nb", r))
.collect();
assert_eq!(before_values.len(), 40);
db.compact().unwrap();
let after = scan(&db, "meter");
let after_values: Vec<Option<String>> = (0..after.num_rows())
.map(|r| digits(&after, "z1nb", r))
.collect();
assert_eq!(
after_values, before_values,
"compaction rewrote the segments and changed the values"
);
assert_eq!(
after.schema().field_with_name("z1nb").unwrap().data_type(),
&arrow::datatypes::DataType::Decimal128(38, 4)
);
}
#[test]
fn a_decimal_column_survives_compaction_and_a_restart_together() {
let dir = tempfile::tempdir().unwrap();
{
let db = open(&dir);
seed_segments(&db, 40, 10);
db.compact().unwrap();
db.close().unwrap();
}
let db = open(&dir);
assert_eq!(
db.schema("meter")
.unwrap()
.column("z1nb")
.unwrap()
.column_type,
ColumnType::Decimal { scale: 4 }
);
let batch = scan(&db, "meter");
assert_eq!(batch.num_rows(), 40);
assert_eq!(digits(&batch, "z1nb", 0).as_deref(), Some("1000.0000"));
}
#[test]
fn a_predicate_on_a_decimal_column_never_prunes_a_matching_row() {
let dir = tempfile::tempdir().unwrap();
let db = open(&dir);
seed_segments(&db, 40, 10);
for (op, name, at_least) in [
(StatsOp::GtEq, ">=", 40_usize),
(StatsOp::Gt, ">", 39),
(StatsOp::LtEq, "<=", 1),
(StatsOp::Eq, "=", 1),
] {
let mut plan = db
.query()
.measurement("meter")
.range(i64::MIN, i64::MAX)
.build()
.unwrap();
chronix_query::plan::set_field_predicates(
&mut plan,
vec![FieldPredicate {
column: "z1nb".into(),
op,
value: 1000.0,
}],
);
let batch = db.execute(&plan).unwrap();
let kept = (0..batch.num_rows())
.filter_map(|r| digits(&batch, "z1nb", r))
.count();
assert!(
kept >= at_least,
"`z1nb {name} 1000.0` kept {kept} rows, fewer than the {at_least} that match — \
the zone map pruned a row group it should have read"
);
}
}
#[test]
fn a_predicate_far_outside_the_range_still_prunes() {
let dir = tempfile::tempdir().unwrap();
let db = open(&dir);
seed_segments(&db, 40, 10);
let mut plan = db
.query()
.measurement("meter")
.range(i64::MIN, i64::MAX)
.build()
.unwrap();
chronix_query::plan::set_field_predicates(
&mut plan,
vec![FieldPredicate {
column: "z1nb".into(),
op: StatsOp::Gt,
value: 9_999_999.0,
}],
);
let batch = db.execute(&plan).unwrap();
let matching = (0..batch.num_rows())
.filter_map(|r| digits(&batch, "z1nb", r))
.count();
assert_eq!(matching, 0, "nothing is above 9 999 999");
}
#[test]
fn a_decimal_column_exports_to_parquet_as_a_decimal() {
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
let dir = tempfile::tempdir().unwrap();
let db = open(&dir);
seed_segments(&db, 20, 20);
let out = dir.path().join("meter.parquet");
let plan = db
.query()
.measurement("meter")
.range(i64::MIN, i64::MAX)
.build()
.unwrap();
let result = db
.export_parquet(&plan, &out, &ParquetExportConfig::default())
.unwrap();
assert_eq!(result.rows_written, 20);
let file = std::fs::File::open(&out).unwrap();
let mut reader = ParquetRecordBatchReaderBuilder::try_new(file)
.unwrap()
.build()
.unwrap();
let batch = reader.next().unwrap().unwrap();
assert_eq!(
batch.schema().field_with_name("z1nb").unwrap().data_type(),
&arrow::datatypes::DataType::Decimal128(38, 4)
);
assert_eq!(digits(&batch, "z1nb", 0).as_deref(), Some("1000.0000"));
assert_eq!(digits(&batch, "z1nb", 19).as_deref(), Some("1000.0019"));
}
#[test]
fn a_decimal_reaches_the_change_stream_as_a_decimal() {
use chronix::chronix_streaming::cdc::{CdcEvent, SubscriptionFilter};
let dir = tempfile::tempdir().unwrap();
let db = open(&dir);
db.declare_field("meter", "z1nb", ColumnType::Decimal { scale: 4 })
.unwrap();
let sub = db.subscribe(SubscriptionFilter::default());
let key = SeriesKey::new("meter", tags! { "dev" => "main" }).unwrap();
db.insert(&Point::new(key, fields! { "z1nb" => dec("1234.5678") }, 1).unwrap())
.unwrap();
let mut sub = sub;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let event = loop {
if let Some(e) = sub.try_recv() {
break e;
}
assert!(
std::time::Instant::now() < deadline,
"no change event arrived"
);
std::thread::sleep(std::time::Duration::from_millis(10));
};
match event {
CdcEvent::PointWritten { fields, .. } => match fields.get("z1nb") {
Some(FieldValue::Decimal(d)) => assert_eq!(d.to_string(), "1234.5678"),
other => panic!("expected a decimal on the change stream, got {other:?}"),
},
other => panic!("expected a write event, got {other:?}"),
}
}
#[test]
fn promql_can_graph_a_decimal_series_and_says_it_is_a_float() {
let dir = tempfile::tempdir().unwrap();
let db = open(&dir);
seed_segments(&db, 5, 5);
let result = db
.promql("meter_z1nb", 1_004)
.expect("a decimal series must be selectable from PromQL");
let samples: Vec<f64> = match &result {
chronix::promql::ast::PromQLValue::Vector(series) => series
.iter()
.flat_map(|s| s.samples.iter().map(|p| p.value))
.collect(),
other => panic!("expected an instant vector, got {other:?}"),
};
assert_eq!(samples.len(), 1, "one series, one sample: {result:?}");
assert!(
(samples[0] - 1000.0004).abs() < 1e-9,
"got {samples:?} — the register should read back as its value"
);
}
#[test]
fn deleting_a_range_of_a_decimal_series_leaves_the_rest_exact() {
let dir = tempfile::tempdir().unwrap();
let db = open(&dir);
seed_segments(&db, 20, 20);
let request = db
.delete_builder()
.measurement("meter")
.range(1_000, 1_009)
.build()
.unwrap();
db.execute_delete(&request).unwrap();
let batch = scan(&db, "meter");
let remaining: Vec<String> = (0..batch.num_rows())
.filter_map(|r| digits(&batch, "z1nb", r))
.collect();
assert_eq!(remaining.len(), 10);
assert_eq!(remaining[0], "1000.0010");
assert_eq!(remaining[9], "1000.0019");
}