use radixdb_core::Value;
const STAT_VALUE_PREFIX: &str = "rdbs1:";
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
encoded.push(HEX[(byte >> 4) as usize] as char);
encoded.push(HEX[(byte & 0x0f) as usize] as char);
}
encoded
}
fn hex_decode(encoded: &str) -> Option<Vec<u8>> {
if !encoded.len().is_multiple_of(2) {
return None;
}
encoded
.as_bytes()
.chunks_exact(2)
.map(|pair| {
let high = (pair[0] as char).to_digit(16)?;
let low = (pair[1] as char).to_digit(16)?;
Some(((high << 4) | low) as u8)
})
.collect()
}
#[doc(hidden)]
pub fn encode_statistics_value(value: &Value) -> String {
match value {
Value::Null(data_type) => format!("{STAT_VALUE_PREFIX}n:{:02x}", *data_type as u8),
Value::Boolean(value) => format!("{STAT_VALUE_PREFIX}b:{}", u8::from(*value)),
Value::Integer(value) => format!("{STAT_VALUE_PREFIX}i:{value}"),
Value::Float(value) => format!("{STAT_VALUE_PREFIX}f:{:016x}", value.to_bits()),
Value::Text(value) => format!("{STAT_VALUE_PREFIX}s:{}", hex_encode(value.as_bytes())),
Value::Timestamp(value) => format!(
"{STAT_VALUE_PREFIX}t:{}:{}",
value.timestamp(),
value.timestamp_subsec_nanos()
),
Value::Extension(value) => {
format!("{STAT_VALUE_PREFIX}x:{}", hex_encode(value.as_ref()))
}
}
}
#[doc(hidden)]
pub fn decode_statistics_value(encoded: &str) -> Option<Value> {
let payload = encoded.strip_prefix(STAT_VALUE_PREFIX)?;
let (tag, value) = payload.split_once(':')?;
match tag {
"n" => {
let raw = u8::from_str_radix(value, 16).ok()?;
let data_type = radixdb_core::DataType::from_u8(raw)?;
Some(Value::Null(data_type))
}
"b" => match value {
"0" => Some(Value::Boolean(false)),
"1" => Some(Value::Boolean(true)),
_ => None,
},
"i" => value.parse().ok().map(Value::Integer),
"f" => u64::from_str_radix(value, 16)
.ok()
.map(|bits| Value::Float(f64::from_bits(bits))),
"s" => String::from_utf8(hex_decode(value)?).ok().map(Value::text),
"t" => {
let (seconds, nanos) = value.split_once(':')?;
let seconds = seconds.parse().ok()?;
let nanos = nanos.parse().ok()?;
chrono::DateTime::from_timestamp(seconds, nanos).map(Value::timestamp)
}
"x" => Some(Value::Extension(hex_decode(value)?.into())),
_ => None,
}
}
pub const SYS_TABLE_STATS: &str = "_sys_table_stats";
pub const SYS_COLUMN_STATS: &str = "_sys_column_stats";
pub const CREATE_TABLE_STATS_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS _sys_table_stats (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
table_name TEXT NOT NULL UNIQUE,
row_count INTEGER NOT NULL DEFAULT 0,
page_count INTEGER NOT NULL DEFAULT 0,
avg_row_size INTEGER NOT NULL DEFAULT 0,
last_analyzed TIMESTAMP
)
"#;
pub const CREATE_COLUMN_STATS_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS _sys_column_stats (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
table_name TEXT NOT NULL,
column_name TEXT NOT NULL,
null_count INTEGER NOT NULL DEFAULT 0,
distinct_count INTEGER NOT NULL DEFAULT 0,
min_value TEXT,
max_value TEXT,
avg_width INTEGER NOT NULL DEFAULT 0,
histogram TEXT
)
"#;
pub const DEFAULT_HISTOGRAM_BUCKETS: usize = 32;
#[derive(Debug, Clone)]
pub struct Histogram {
boundaries: Vec<Value>,
rows_per_bucket: u64,
bucket_counts: Vec<u64>,
upper_repeats: Vec<u64>,
total_rows: u64,
}
impl Histogram {
fn validate_parts(
boundaries: &[Value],
rows_per_bucket: u64,
bucket_counts: &[u64],
upper_repeats: &[u64],
total_rows: u64,
) -> bool {
let bucket_len = boundaries.len().saturating_sub(1);
if boundaries.len() < 2
|| rows_per_bucket == 0
|| total_rows == 0
|| bucket_counts.len() != bucket_len
|| upper_repeats.len() != bucket_len
|| bucket_counts.contains(&0)
|| upper_repeats
.iter()
.zip(bucket_counts)
.any(|(repeats, count)| repeats > count)
|| bucket_counts
.iter()
.try_fold(0u64, |sum, count| sum.checked_add(*count))
!= Some(total_rows)
{
return false;
}
let data_type = boundaries[0].data_type();
boundaries
.iter()
.all(|value| !value.is_null() && value.data_type() == data_type)
&& boundaries.windows(2).all(|pair| {
pair[0]
.compare(&pair[1])
.is_ok_and(|ordering| ordering != std::cmp::Ordering::Greater)
})
}
pub fn boundaries(&self) -> &[Value] {
&self.boundaries
}
pub fn total_rows(&self) -> u64 {
self.total_rows
}
pub fn from_sorted_values(values: &[Value], num_buckets: usize) -> Option<Self> {
Self::from_sorted_sample(
values,
num_buckets,
values.iter().filter(|v| !v.is_null()).count() as u64,
)
}
pub fn from_sorted_sample(
values: &[Value],
num_buckets: usize,
represented_rows: u64,
) -> Option<Self> {
if values.is_empty() || num_buckets == 0 {
return None;
}
let non_null_values: Vec<_> = values.iter().filter(|v| !v.is_null()).collect();
if non_null_values.is_empty() {
return None;
}
let data_type = non_null_values[0].data_type();
if non_null_values
.iter()
.any(|value| value.data_type() != data_type)
|| non_null_values.windows(2).any(|pair| {
!pair[0]
.compare(pair[1])
.is_ok_and(|ordering| ordering != std::cmp::Ordering::Greater)
})
{
return None;
}
let sample_rows = non_null_values.len() as u64;
let total_rows = represented_rows.max(sample_rows);
let rows_per_bucket = total_rows.div_ceil(num_buckets as u64).max(1);
let sample_target = sample_rows.div_ceil(num_buckets as u64).max(1);
let mut boundaries = vec![non_null_values[0].clone()];
let mut sample_counts = Vec::with_capacity(num_buckets);
let mut sample_upper_repeats = Vec::with_capacity(num_buckets);
let mut current_count = 0u64;
let mut last_run_count = 0u64;
let mut index = 0usize;
while index < non_null_values.len() {
let value = non_null_values[index];
let mut run_end = index + 1;
while run_end < non_null_values.len() && non_null_values[run_end] == value {
run_end += 1;
}
let run_count = (run_end - index) as u64;
current_count += run_count;
last_run_count = run_count;
if sample_counts.len() + 1 < num_buckets && current_count >= sample_target {
boundaries.push(value.clone());
sample_counts.push(current_count);
sample_upper_repeats.push(run_count);
current_count = 0;
}
index = run_end;
}
if current_count > 0 {
boundaries.push((*non_null_values.last().unwrap()).clone());
sample_counts.push(current_count);
sample_upper_repeats.push(last_run_count);
}
let mut bucket_counts = Vec::with_capacity(sample_counts.len());
let mut upper_repeats = Vec::with_capacity(sample_counts.len());
let mut assigned = 0u64;
let mut sample_assigned = 0u64;
for (index, sample_count) in sample_counts.iter().copied().enumerate() {
sample_assigned += sample_count;
let scaled_cumulative = if index + 1 == sample_counts.len() {
total_rows
} else {
((sample_assigned as u128 * total_rows as u128) / sample_rows as u128) as u64
};
bucket_counts.push(scaled_cumulative.saturating_sub(assigned));
assigned = scaled_cumulative;
let scaled_repeat = ((sample_upper_repeats[index] as u128 * total_rows as u128)
/ sample_rows as u128) as u64;
upper_repeats.push(scaled_repeat.max(1).min(bucket_counts[index]));
}
let histogram = Self {
boundaries,
rows_per_bucket,
bucket_counts,
upper_repeats,
total_rows,
};
Self::validate_parts(
&histogram.boundaries,
histogram.rows_per_bucket,
&histogram.bucket_counts,
&histogram.upper_repeats,
histogram.total_rows,
)
.then_some(histogram)
}
pub fn estimate_selectivity(&self, value: &Value, operator: HistogramOp) -> f64 {
if self.boundaries.is_empty() || self.total_rows == 0 {
return 0.5; }
let bucket_idx = self.find_bucket(value);
match operator {
HistogramOp::Equal => {
let upper = &self.boundaries[(bucket_idx + 1).min(self.boundaries.len() - 1)];
if value == upper && self.upper_repeat(bucket_idx) > 0 {
self.upper_repeat(bucket_idx) as f64 / self.total_rows as f64
} else {
1.0 / self.bucket_count(bucket_idx).max(1) as f64
}
}
HistogramOp::LessThan => self.cumulative_fraction(value, false),
HistogramOp::LessThanOrEqual => self.cumulative_fraction(value, true),
HistogramOp::GreaterThan => 1.0 - self.cumulative_fraction(value, true),
HistogramOp::GreaterThanOrEqual => 1.0 - self.cumulative_fraction(value, false),
}
}
fn find_bucket(&self, value: &Value) -> usize {
if self.boundaries.is_empty() {
return 0;
}
let bucket_count = self.boundaries.len().saturating_sub(1);
if bucket_count == 0 {
return 0;
}
let mut low = 0usize;
let mut high = bucket_count;
while low < high {
let mid = (low + high) / 2;
if &self.boundaries[mid + 1] < value {
low = mid + 1;
} else {
high = mid;
}
}
low.min(bucket_count - 1)
}
fn fraction_in_bucket(&self, value: &Value, bucket_idx: usize) -> f64 {
if bucket_idx >= self.boundaries.len().saturating_sub(1) {
return 1.0;
}
let lower = &self.boundaries[bucket_idx];
let upper = if bucket_idx + 1 < self.boundaries.len() {
&self.boundaries[bucket_idx + 1]
} else {
return 1.0;
};
match (lower, upper, value) {
(Value::Integer(lo), Value::Integer(hi), Value::Integer(v)) => {
if hi == lo {
if v < lo {
0.0
} else {
1.0
}
} else {
let numerator = *v as i128 - *lo as i128;
let denominator = *hi as i128 - *lo as i128;
(numerator as f64 / denominator as f64).clamp(0.0, 1.0)
}
}
(Value::Float(lo), Value::Float(hi), Value::Float(v)) => {
if (hi - lo).abs() < f64::EPSILON {
if v < lo {
0.0
} else {
1.0
}
} else {
let fraction = (v - lo) / (hi - lo);
if fraction.is_finite() {
fraction.clamp(0.0, 1.0)
} else {
0.5
}
}
}
_ if value <= lower => 0.0,
_ if value >= upper => 1.0,
_ => 0.5,
}
}
fn bucket_count(&self, bucket_idx: usize) -> u64 {
self.bucket_counts
.get(bucket_idx)
.copied()
.unwrap_or(self.rows_per_bucket)
}
fn upper_repeat(&self, bucket_idx: usize) -> u64 {
self.upper_repeats.get(bucket_idx).copied().unwrap_or(0)
}
fn cumulative_fraction(&self, value: &Value, inclusive: bool) -> f64 {
if self.boundaries.len() < 2 || self.total_rows == 0 {
return 0.5;
}
if value < &self.boundaries[0] {
return 0.0;
}
if value > self.boundaries.last().unwrap() {
return 1.0;
}
let bucket_idx = self.find_bucket(value);
let before: u128 = (0..bucket_idx)
.map(|index| self.bucket_count(index) as u128)
.sum();
let lower = &self.boundaries[bucket_idx];
let upper = &self.boundaries[bucket_idx + 1];
let bucket_count = self.bucket_count(bucket_idx);
let upper_repeat = self.upper_repeat(bucket_idx).min(bucket_count);
let within = if value == upper {
if inclusive {
bucket_count as f64
} else {
bucket_count.saturating_sub(upper_repeat) as f64
}
} else {
self.fraction_in_bucket(value, bucket_idx)
* bucket_count.saturating_sub(upper_repeat) as f64
};
let _ = lower;
let fraction = (before as f64 + within) / self.total_rows as f64;
if fraction.is_finite() {
fraction.clamp(0.0, 1.0)
} else {
0.5
}
}
pub fn estimate_range_selectivity(&self, low: &Value, high: &Value) -> f64 {
if self.boundaries.is_empty() || self.total_rows == 0 {
return 0.33; }
if low > high {
return 0.0;
}
(self.cumulative_fraction(high, true) - self.cumulative_fraction(low, false))
.clamp(0.0001, 1.0)
}
pub fn to_json(&self) -> String {
let boundary_strs: Vec<String> = self
.boundaries
.iter()
.map(encode_statistics_value)
.collect();
format!(
r#"{{"boundaries":[{}],"rows_per_bucket":{},"bucket_counts":[{}],"upper_repeats":[{}],"total_rows":{}}}"#,
boundary_strs
.iter()
.map(|s| format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")))
.collect::<Vec<_>>()
.join(","),
self.rows_per_bucket,
self.bucket_counts
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(","),
self.upper_repeats
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(","),
self.total_rows
)
}
pub fn from_json(json: &str) -> Option<Self> {
let json = json.trim();
if !json.starts_with('{') || !json.ends_with('}') {
return None;
}
let rows_per_bucket = extract_number(json, "rows_per_bucket")?;
let total_rows = extract_number(json, "total_rows")?;
let boundaries = extract_value_array(json, "boundaries")?;
let bucket_len = boundaries.len().saturating_sub(1);
let bucket_counts = extract_number_array(json, "bucket_counts").unwrap_or_else(|| {
let mut counts = vec![rows_per_bucket; bucket_len];
if let Some(last) = counts.last_mut() {
let assigned = rows_per_bucket.saturating_mul(bucket_len.saturating_sub(1) as u64);
*last = total_rows.saturating_sub(assigned).max(1);
}
counts
});
let upper_repeats =
extract_number_array(json, "upper_repeats").unwrap_or_else(|| vec![0; bucket_len]);
if bucket_counts.len() != bucket_len || upper_repeats.len() != bucket_len {
return None;
}
Self::validate_parts(
&boundaries,
rows_per_bucket,
&bucket_counts,
&upper_repeats,
total_rows,
)
.then_some(Self {
boundaries,
rows_per_bucket,
bucket_counts,
upper_repeats,
total_rows,
})
}
}
fn extract_number(json: &str, key: &str) -> Option<u64> {
let key_pattern = format!("\"{}\":", key);
let start = json.find(&key_pattern)? + key_pattern.len();
let rest = &json[start..];
let end = rest.find([',', '}'])?;
rest[..end].trim().parse().ok()
}
fn extract_number_array(json: &str, key: &str) -> Option<Vec<u64>> {
let key_pattern = format!("\"{}\":[", key);
let start = json.find(&key_pattern)? + key_pattern.len();
let rest = &json[start..];
let end = rest.find(']')?;
let content = rest[..end].trim();
if content.is_empty() {
return Some(Vec::new());
}
content
.split(',')
.map(|value| value.trim().parse().ok())
.collect()
}
fn extract_value_array(json: &str, key: &str) -> Option<Vec<Value>> {
let key_pattern = format!("\"{}\":[", key);
let start = json.find(&key_pattern)? + key_pattern.len();
let rest = &json[start..];
let end = rest.find(']')?;
let array_content = &rest[..end];
let mut values = Vec::new();
for item in array_content.split(',') {
let item = item.trim();
if item.is_empty() {
continue;
}
let item = item.trim_matches('"');
values.push(decode_statistics_value(item)?);
}
Some(values)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HistogramOp {
Equal,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual,
}
pub const DEFAULT_SAMPLE_SIZE: usize = 10000;
#[derive(Debug, Clone, Default)]
pub struct TableStats {
pub table_name: String,
pub row_count: u64,
pub page_count: u64,
pub avg_row_size: u64,
}
impl TableStats {
pub fn new(table_name: String) -> Self {
Self {
table_name,
row_count: 0,
page_count: 0,
avg_row_size: 0,
}
}
pub fn equality_selectivity(&self, distinct_count: u64) -> f64 {
if distinct_count > 0 {
1.0 / distinct_count as f64
} else if self.row_count > 0 {
1.0 / self.row_count as f64
} else {
0.1
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ColumnStats {
pub column_name: String,
pub null_count: u64,
pub distinct_count: u64,
pub min_value: Option<Value>,
pub max_value: Option<Value>,
pub avg_width: u32,
pub histogram: Option<String>,
}
impl ColumnStats {
pub fn new(column_name: String) -> Self {
Self {
column_name,
null_count: 0,
distinct_count: 0,
min_value: None,
max_value: None,
avg_width: 0,
histogram: None,
}
}
pub fn is_empty(&self) -> bool {
self.distinct_count == 0 && self.min_value.is_none() && self.max_value.is_none()
}
pub fn parsed_histogram(&self) -> Option<Histogram> {
self.histogram
.as_ref()
.and_then(|json| Histogram::from_json(json))
}
pub fn set_histogram(&mut self, histogram: &Histogram) {
self.histogram = Some(histogram.to_json());
}
}
pub struct SelectivityEstimator;
impl SelectivityEstimator {
pub fn equality(distinct_count: u64) -> f64 {
if distinct_count > 0 {
1.0 / distinct_count as f64
} else {
0.1 }
}
pub fn range() -> f64 {
0.33
}
pub fn range_with_histogram(col_stats: &ColumnStats, value: &Value, op: HistogramOp) -> f64 {
if let Some(histogram) = col_stats.parsed_histogram() {
return histogram.estimate_selectivity(value, op);
}
if let (Some(min_val), Some(max_val)) = (&col_stats.min_value, &col_stats.max_value) {
let fraction = Self::estimate_position(value, min_val, max_val);
return match op {
HistogramOp::Equal => 1.0 / col_stats.distinct_count.max(1) as f64,
HistogramOp::LessThan | HistogramOp::LessThanOrEqual => fraction,
HistogramOp::GreaterThan | HistogramOp::GreaterThanOrEqual => 1.0 - fraction,
};
}
match op {
HistogramOp::Equal => 0.1,
_ => 0.33,
}
}
fn estimate_position(value: &Value, min: &Value, max: &Value) -> f64 {
match (min, max, value) {
(Value::Integer(lo), Value::Integer(hi), Value::Integer(v)) => {
if hi == lo {
0.5
} else {
let numerator = *v as i128 - *lo as i128;
let denominator = *hi as i128 - *lo as i128;
(numerator as f64 / denominator as f64).clamp(0.0, 1.0)
}
}
(Value::Float(lo), Value::Float(hi), Value::Float(v)) => {
if (hi - lo).abs() < f64::EPSILON {
0.5
} else {
let fraction = (v - lo) / (hi - lo);
if fraction.is_finite() {
fraction.clamp(0.0, 1.0)
} else {
0.5
}
}
}
_ => 0.5, }
}
pub fn like(pattern: &str, distinct_count: u64) -> f64 {
if !pattern.starts_with('%') && pattern.ends_with('%') {
let prefix_len = pattern.len() - 1;
if distinct_count > 0 {
let prefix_selectivity = (26.0_f64).powi(-(prefix_len as i32));
return prefix_selectivity.max(1.0 / distinct_count as f64);
}
return 0.1;
}
if pattern.starts_with('%') {
return 0.25;
}
0.15 }
pub fn in_list(list_size: usize, distinct_count: u64) -> f64 {
if distinct_count > 0 {
(list_size as f64 / distinct_count as f64).min(1.0)
} else {
(list_size as f64 * 0.1).min(1.0)
}
}
pub fn is_null(null_count: u64, row_count: u64) -> f64 {
if row_count > 0 {
null_count as f64 / row_count as f64
} else {
0.01
}
}
pub fn is_not_null(null_count: u64, row_count: u64) -> f64 {
1.0 - Self::is_null(null_count, row_count)
}
pub fn join_cardinality(
left_rows: u64,
right_rows: u64,
left_distinct: u64,
right_distinct: u64,
) -> u64 {
let max_distinct = left_distinct.max(right_distinct).max(1);
let cardinality = left_rows as u128 * right_rows as u128 / max_distinct as u128;
cardinality.min(u64::MAX as u128) as u64
}
}
pub fn is_stats_table(table_name: &str) -> bool {
let lower = table_name.to_lowercase();
lower == SYS_TABLE_STATS || lower == SYS_COLUMN_STATS
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_table_stats_new() {
let stats = TableStats::new("test_table".to_string());
assert_eq!(stats.table_name, "test_table");
assert_eq!(stats.row_count, 0);
}
#[test]
fn test_equality_selectivity() {
let sel = SelectivityEstimator::equality(100);
assert!((sel - 0.01).abs() < 0.001);
let sel_default = SelectivityEstimator::equality(0);
assert!((sel_default - 0.1).abs() < 0.001);
}
#[test]
fn test_in_list_selectivity() {
let sel = SelectivityEstimator::in_list(2, 5);
assert!((sel - 0.4).abs() < 0.001);
let sel_large = SelectivityEstimator::in_list(10, 5);
assert!((sel_large - 1.0).abs() < 0.001);
}
#[test]
fn test_null_selectivity() {
let sel = SelectivityEstimator::is_null(100, 1000);
assert!((sel - 0.1).abs() < 0.001);
let sel_not_null = SelectivityEstimator::is_not_null(100, 1000);
assert!((sel_not_null - 0.9).abs() < 0.001);
}
#[test]
fn test_join_cardinality() {
let join_card = SelectivityEstimator::join_cardinality(10000, 1000, 1000, 1000);
assert_eq!(join_card, 10000);
}
#[test]
fn test_like_selectivity() {
let sel_prefix = SelectivityEstimator::like("abc%", 1000);
assert!(sel_prefix < 0.1);
let sel_suffix = SelectivityEstimator::like("%abc", 1000);
assert!((sel_suffix - 0.25).abs() < 0.001);
}
#[test]
fn test_is_stats_table() {
assert!(is_stats_table("_sys_table_stats"));
assert!(is_stats_table("_SYS_TABLE_STATS"));
assert!(is_stats_table("_sys_column_stats"));
assert!(!is_stats_table("users"));
assert!(!is_stats_table("_sys_other"));
}
#[test]
fn test_column_stats_is_empty() {
let stats = ColumnStats::new("test".to_string());
assert!(stats.is_empty());
let mut stats2 = ColumnStats::new("test".to_string());
stats2.distinct_count = 10;
assert!(!stats2.is_empty());
}
#[test]
fn test_histogram_from_sorted_values() {
let values: Vec<Value> = (0..100).map(Value::Integer).collect();
let histogram = Histogram::from_sorted_values(&values, 10).unwrap();
assert!(!histogram.boundaries.is_empty());
assert_eq!(histogram.total_rows, 100);
assert_eq!(histogram.rows_per_bucket, 10);
assert_eq!(histogram.boundaries[0], Value::Integer(0));
}
#[test]
fn test_histogram_selectivity_estimation() {
let values: Vec<Value> = (0..100).map(Value::Integer).collect();
let histogram = Histogram::from_sorted_values(&values, 10).unwrap();
let sel_lt_50 = histogram.estimate_selectivity(&Value::Integer(50), HistogramOp::LessThan);
assert!(
sel_lt_50 > 0.4 && sel_lt_50 < 0.6,
"Expected ~0.5, got {}",
sel_lt_50
);
let sel_lt_10 = histogram.estimate_selectivity(&Value::Integer(10), HistogramOp::LessThan);
assert!(
sel_lt_10 > 0.05 && sel_lt_10 < 0.2,
"Expected ~0.1, got {}",
sel_lt_10
);
let sel_gt_90 =
histogram.estimate_selectivity(&Value::Integer(90), HistogramOp::GreaterThan);
assert!(
sel_gt_90 > 0.0 && sel_gt_90 < 0.2,
"Expected ~0.1, got {}",
sel_gt_90
);
}
#[test]
fn test_histogram_json_round_trip() {
let values: Vec<Value> = (0..100).map(Value::Integer).collect();
let histogram = Histogram::from_sorted_values(&values, 10).unwrap();
let json = histogram.to_json();
let parsed = Histogram::from_json(&json).expect("Failed to parse histogram JSON");
assert_eq!(parsed.total_rows, histogram.total_rows);
assert_eq!(parsed.rows_per_bucket, histogram.rows_per_bucket);
assert_eq!(parsed.boundaries.len(), histogram.boundaries.len());
}
#[test]
fn r7_l01_legacy_histogram_boundaries_are_rejected() {
let current = Histogram {
boundaries: vec![Value::text("001"), Value::text("1e3")],
rows_per_bucket: 1,
bucket_counts: vec![2],
upper_repeats: vec![1],
total_rows: 2,
};
assert_eq!(
Histogram::from_json(¤t.to_json()).unwrap().boundaries,
current.boundaries
);
let legacy = r#"{"boundaries":["001","1e3"],"rows_per_bucket":1,"total_rows":2}"#;
assert!(Histogram::from_json(legacy).is_none());
}
#[test]
fn test_range_with_histogram() {
let values: Vec<Value> = (0..100).map(Value::Integer).collect();
let histogram = Histogram::from_sorted_values(&values, 10).unwrap();
let mut col_stats = ColumnStats::new("test".to_string());
col_stats.set_histogram(&histogram);
col_stats.min_value = Some(Value::Integer(0));
col_stats.max_value = Some(Value::Integer(99));
col_stats.distinct_count = 100;
let sel = SelectivityEstimator::range_with_histogram(
&col_stats,
&Value::Integer(50),
HistogramOp::LessThan,
);
assert!(sel > 0.4 && sel < 0.6, "Expected ~0.5, got {}", sel);
}
#[test]
fn test_histogram_empty_values() {
let values: Vec<Value> = vec![];
let histogram = Histogram::from_sorted_values(&values, 10);
assert!(histogram.is_none());
}
#[test]
fn test_histogram_with_nulls() {
use radixdb_core::DataType;
let mut values: Vec<Value> = (0..50).map(Value::Integer).collect();
values.extend((0..10).map(|_| Value::Null(DataType::Integer)));
values.extend((50..100).map(Value::Integer));
let histogram = Histogram::from_sorted_values(&values, 10).unwrap();
assert_eq!(histogram.total_rows, 100); }
#[test]
fn test_histogram_between_range_selectivity() {
let values: Vec<Value> = (0..100).map(Value::Integer).collect();
let histogram = Histogram::from_sorted_values(&values, 10).unwrap();
let sel_25_75 =
histogram.estimate_range_selectivity(&Value::Integer(25), &Value::Integer(75));
assert!(
sel_25_75 > 0.4 && sel_25_75 < 0.65,
"Expected ~0.5 for BETWEEN 25 AND 75, got {}",
sel_25_75
);
let sel_0_10 =
histogram.estimate_range_selectivity(&Value::Integer(0), &Value::Integer(10));
assert!(
sel_0_10 > 0.05 && sel_0_10 < 0.2,
"Expected ~0.1 for BETWEEN 0 AND 10, got {}",
sel_0_10
);
let sel_90_100 =
histogram.estimate_range_selectivity(&Value::Integer(90), &Value::Integer(100));
assert!(
sel_90_100 > 0.05 && sel_90_100 < 0.2,
"Expected ~0.1 for BETWEEN 90 AND 100, got {}",
sel_90_100
);
let sel_full =
histogram.estimate_range_selectivity(&Value::Integer(0), &Value::Integer(100));
assert!(
sel_full > 0.9,
"Expected ~1.0 for full range, got {}",
sel_full
);
}
#[test]
fn test_histogram_between_single_bucket() {
let values: Vec<Value> = (0..100).map(Value::Integer).collect();
let histogram = Histogram::from_sorted_values(&values, 10).unwrap();
let sel_5_8 = histogram.estimate_range_selectivity(&Value::Integer(5), &Value::Integer(8));
assert!(
sel_5_8 > 0.0 && sel_5_8 < 0.15,
"Expected small selectivity for narrow range, got {}",
sel_5_8
);
}
#[test]
fn test_histogram_between_float_values() {
let values: Vec<Value> = (0..100).map(|i| Value::Float(i as f64)).collect();
let histogram = Histogram::from_sorted_values(&values, 10).unwrap();
let sel = histogram.estimate_range_selectivity(&Value::Float(25.0), &Value::Float(75.0));
assert!(
sel > 0.4 && sel < 0.65,
"Expected ~0.5 for BETWEEN 25.0 AND 75.0, got {}",
sel
);
}
#[test]
fn r3_l04_batch_c_histogram_roundtrip_preserves_scalar_types() {
let domains = vec![
vec![Value::Integer(i64::MIN), Value::Integer(i64::MAX)],
vec![Value::Float(-0.0), Value::Float(1.0)],
vec![
Value::timestamp(
chrono::DateTime::from_timestamp_millis(1_700_000_000_123).unwrap(),
),
Value::timestamp(
chrono::DateTime::from_timestamp_millis(1_700_000_000_124).unwrap(),
),
],
vec![Value::uuid([0x5a; 16]), Value::uuid([0x5b; 16])],
vec![Value::decimal(12345, 8, 2), Value::decimal(12346, 8, 2)],
vec![Value::text("42"), Value::text("43")],
];
for boundaries in domains {
let histogram = Histogram {
boundaries: boundaries.clone(),
rows_per_bucket: 7,
bucket_counts: vec![7],
upper_repeats: vec![1],
total_rows: 7,
};
let decoded = Histogram::from_json(&histogram.to_json())
.expect("typed statistics histogram must decode");
assert_eq!(decoded.boundaries, boundaries);
}
let mixed = Histogram {
boundaries: vec![Value::Integer(1), Value::text("2")],
rows_per_bucket: 1,
bucket_counts: vec![1],
upper_repeats: vec![0],
total_rows: 1,
};
assert!(Histogram::from_json(&mixed.to_json()).is_none());
}
#[test]
fn histogram_extremes_and_malformed_shapes_fail_closed() {
let histogram =
Histogram::from_sorted_values(&[Value::Integer(i64::MIN), Value::Integer(i64::MAX)], 1)
.unwrap();
let estimate = histogram.estimate_selectivity(&Value::Integer(0), HistogramOp::LessThan);
assert!(estimate.is_finite() && (0.0..=1.0).contains(&estimate));
assert_eq!(
SelectivityEstimator::join_cardinality(u64::MAX, u64::MAX, 1, 1),
u64::MAX
);
for malformed in [
r#"{"boundaries":["I:1","I:2"],"rows_per_bucket":0,"bucket_counts":[1],"upper_repeats":[0],"total_rows":1}"#,
r#"{"boundaries":["I:2","I:1"],"rows_per_bucket":1,"bucket_counts":[1],"upper_repeats":[0],"total_rows":1}"#,
r#"{"boundaries":["I:1","I:2"],"rows_per_bucket":1,"bucket_counts":[2],"upper_repeats":[3],"total_rows":1}"#,
] {
assert!(Histogram::from_json(malformed).is_none());
}
}
#[test]
fn r8_l01_batch_g_histogram_retains_duplicate_frequency_mass() {
let mut values = vec![Value::Integer(0); 90];
values.extend((1..=10).map(Value::Integer));
let histogram = Histogram::from_sorted_values(&values, 10).unwrap();
assert_eq!(histogram.bucket_counts.iter().sum::<u64>(), 100);
assert_eq!(histogram.upper_repeats[0], 90);
assert_eq!(
histogram.estimate_selectivity(&Value::Integer(0), HistogramOp::LessThan),
0.0
);
assert!(
(histogram.estimate_selectivity(&Value::Integer(0), HistogramOp::Equal) - 0.9).abs()
< 0.0001
);
assert!(
(histogram.estimate_selectivity(&Value::Integer(0), HistogramOp::LessThanOrEqual)
- 0.9)
.abs()
< 0.0001
);
let decoded = Histogram::from_json(&histogram.to_json()).unwrap();
assert_eq!(decoded.bucket_counts, histogram.bucket_counts);
assert_eq!(decoded.upper_repeats, histogram.upper_repeats);
}
}