use std::str::FromStr;
use vibesql_storage::database::indexes::IndexData;
use vibesql_types::SqlValue;
use super::IndexPredicate;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TemporalKeyType {
Date,
Timestamp,
Time,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BoundSide {
Lower,
Upper,
}
enum CoercedBound {
Unchanged,
Coerced { value: SqlValue, inclusive: bool },
Decline,
}
pub(crate) enum EqualityKeyCoercion {
Unchanged,
Coerced(SqlValue),
Unusable,
}
fn temporal_key_type(sample: &SqlValue) -> Option<TemporalKeyType> {
match sample {
SqlValue::Date(_) => Some(TemporalKeyType::Date),
SqlValue::Timestamp(_) => Some(TemporalKeyType::Timestamp),
SqlValue::Time(_) => Some(TemporalKeyType::Time),
_ => None,
}
}
fn is_string(value: &SqlValue) -> bool {
matches!(value, SqlValue::Varchar(_) | SqlValue::Character(_))
}
fn as_str(value: &SqlValue) -> Option<&str> {
match value {
SqlValue::Varchar(s) | SqlValue::Character(s) => Some(s.as_str()),
_ => None,
}
}
fn temporal_min_value(key_type: TemporalKeyType) -> SqlValue {
let min_date = vibesql_types::Date { year: i32::MIN, month: 1, day: 1 };
let midnight = vibesql_types::Time { hour: 0, minute: 0, second: 0, nanosecond: 0 };
match key_type {
TemporalKeyType::Date => SqlValue::Date(min_date),
TemporalKeyType::Timestamp => {
SqlValue::Timestamp(vibesql_types::Timestamp::new(min_date, midnight))
}
TemporalKeyType::Time => SqlValue::Time(midnight),
}
}
fn coerce_string_bound(
key_type: TemporalKeyType,
bound: &SqlValue,
inclusive: bool,
side: BoundSide,
) -> CoercedBound {
let Some(s) = as_str(bound) else {
return CoercedBound::Unchanged;
};
match key_type {
TemporalKeyType::Date => match vibesql_types::Date::from_str(s) {
Ok(d) => CoercedBound::Coerced { value: SqlValue::Date(d), inclusive },
Err(_) => CoercedBound::Decline,
},
TemporalKeyType::Timestamp => match vibesql_types::Timestamp::from_str(s) {
Ok(t) => coerce_rendered(t.to_string(), SqlValue::Timestamp(t), s, inclusive, side),
Err(_) => CoercedBound::Decline,
},
TemporalKeyType::Time => match vibesql_types::Time::from_str(s) {
Ok(t) => coerce_rendered(t.to_string(), SqlValue::Time(t), s, inclusive, side),
Err(_) => CoercedBound::Decline,
},
}
}
fn coerce_rendered(
rendered: String,
value: SqlValue,
s: &str,
inclusive: bool,
side: BoundSide,
) -> CoercedBound {
if rendered == s {
CoercedBound::Coerced { value, inclusive }
} else if rendered.starts_with(s) {
match side {
BoundSide::Lower => CoercedBound::Coerced { value, inclusive: true },
BoundSide::Upper => CoercedBound::Coerced { value, inclusive: false },
}
} else {
CoercedBound::Decline
}
}
pub(crate) fn coerce_index_predicate_for_temporal_keys(
predicate: Option<IndexPredicate>,
index_data: &IndexData,
) -> Option<IndexPredicate> {
let predicate = predicate?;
let has_string_bound = match &predicate {
IndexPredicate::Range(range) => {
range.start.as_ref().is_some_and(is_string) || range.end.as_ref().is_some_and(is_string)
}
IndexPredicate::In(values) => values.iter().any(is_string),
};
if !has_string_bound {
return Some(predicate);
}
let Some(key_type) = index_data.first_key_value_sample(0).as_ref().and_then(temporal_key_type)
else {
return Some(predicate);
};
match predicate {
IndexPredicate::Range(mut range) => {
if let Some(start) = &range.start {
match coerce_string_bound(key_type, start, range.inclusive_start, BoundSide::Lower)
{
CoercedBound::Unchanged => {}
CoercedBound::Coerced { value, inclusive } => {
range.start = Some(value);
range.inclusive_start = inclusive;
}
CoercedBound::Decline => return None,
}
}
if let Some(end) = &range.end {
match coerce_string_bound(key_type, end, range.inclusive_end, BoundSide::Upper) {
CoercedBound::Unchanged => {}
CoercedBound::Coerced { value, inclusive } => {
range.end = Some(value);
range.inclusive_end = inclusive;
}
CoercedBound::Decline => return None,
}
}
if range.start.is_none() && range.end.is_some() && range.exclude_nulls {
range.start = Some(temporal_min_value(key_type));
range.inclusive_start = true;
}
Some(IndexPredicate::Range(range))
}
IndexPredicate::In(values) => {
let mut coerced = Vec::with_capacity(values.len());
for value in values {
if !is_string(&value) {
coerced.push(value);
continue;
}
match coerce_equality_key_for_sample_type(key_type, &value) {
EqualityKeyCoercion::Unchanged => coerced.push(value),
EqualityKeyCoercion::Coerced(v) => coerced.push(v),
EqualityKeyCoercion::Unusable => match key_type {
TemporalKeyType::Date => return None,
TemporalKeyType::Timestamp | TemporalKeyType::Time => {}
},
}
}
Some(IndexPredicate::In(coerced))
}
}
}
pub(crate) fn coerce_equality_key(sample: &SqlValue, value: &SqlValue) -> EqualityKeyCoercion {
let Some(key_type) = temporal_key_type(sample) else {
return EqualityKeyCoercion::Unchanged;
};
if !is_string(value) {
return EqualityKeyCoercion::Unchanged;
}
coerce_equality_key_for_sample_type(key_type, value)
}
fn coerce_equality_key_for_sample_type(
key_type: TemporalKeyType,
value: &SqlValue,
) -> EqualityKeyCoercion {
let Some(s) = as_str(value) else {
return EqualityKeyCoercion::Unchanged;
};
match key_type {
TemporalKeyType::Date => match vibesql_types::Date::from_str(s) {
Ok(d) => EqualityKeyCoercion::Coerced(SqlValue::Date(d)),
Err(_) => EqualityKeyCoercion::Unusable,
},
TemporalKeyType::Timestamp => match vibesql_types::Timestamp::from_str(s) {
Ok(t) if t.to_string() == s => EqualityKeyCoercion::Coerced(SqlValue::Timestamp(t)),
_ => EqualityKeyCoercion::Unusable,
},
TemporalKeyType::Time => match vibesql_types::Time::from_str(s) {
Ok(t) if t.to_string() == s => EqualityKeyCoercion::Coerced(SqlValue::Time(t)),
_ => EqualityKeyCoercion::Unusable,
},
}
}
#[cfg(test)]
mod tests {
use super::super::RangePredicate;
use super::*;
fn varchar(s: &str) -> SqlValue {
SqlValue::Varchar(arcstr::ArcStr::from(s))
}
fn ts(s: &str) -> SqlValue {
SqlValue::Timestamp(vibesql_types::Timestamp::from_str(s).unwrap())
}
fn date(s: &str) -> SqlValue {
SqlValue::Date(vibesql_types::Date::from_str(s).unwrap())
}
fn index_with_keys(keys: Vec<SqlValue>) -> IndexData {
let mut data = std::collections::BTreeMap::new();
for (i, key) in keys.into_iter().enumerate() {
data.insert(vec![key], vec![i]);
}
IndexData::InMemory { data }
}
fn range(
start: Option<SqlValue>,
end: Option<SqlValue>,
inclusive_start: bool,
inclusive_end: bool,
) -> Option<IndexPredicate> {
Some(IndexPredicate::Range(RangePredicate {
start,
end,
inclusive_start,
inclusive_end,
exclude_nulls: true,
}))
}
#[test]
fn equality_roundtrip_string_coerces_to_timestamp() {
let index = index_with_keys(vec![ts("2017-07-20 15:30:00")]);
let pred = Some(IndexPredicate::Range(RangePredicate {
start: Some(varchar("2017-07-20 15:30:00")),
end: Some(varchar("2017-07-20 15:30:00")),
inclusive_start: true,
inclusive_end: true,
exclude_nulls: false,
}));
let coerced = coerce_index_predicate_for_temporal_keys(pred, &index).unwrap();
match coerced {
IndexPredicate::Range(r) => {
assert_eq!(r.start, Some(ts("2017-07-20 15:30:00")));
assert_eq!(r.end, Some(ts("2017-07-20 15:30:00")));
assert!(r.inclusive_start && r.inclusive_end);
}
other => panic!("expected Range, got {:?}", other),
}
}
#[test]
fn equality_date_only_string_vs_timestamp_keys_becomes_empty_range() {
let index = index_with_keys(vec![ts("2017-07-05 00:00:00")]);
let pred = Some(IndexPredicate::Range(RangePredicate {
start: Some(varchar("2017-07-05")),
end: Some(varchar("2017-07-05")),
inclusive_start: true,
inclusive_end: true,
exclude_nulls: false,
}));
let coerced = coerce_index_predicate_for_temporal_keys(pred, &index).unwrap();
match coerced {
IndexPredicate::Range(r) => {
assert_eq!(r.start, Some(ts("2017-07-05 00:00:00")));
assert_eq!(r.end, Some(ts("2017-07-05 00:00:00")));
assert!(r.inclusive_start);
assert!(!r.inclusive_end);
}
other => panic!("expected Range, got {:?}", other),
}
}
#[test]
fn between_date_only_strings_vs_timestamp_keys() {
let index = index_with_keys(vec![ts("2017-07-05 00:00:00")]);
let pred = range(Some(varchar("2017-07-04")), Some(varchar("2017-07-08")), true, true);
let coerced = coerce_index_predicate_for_temporal_keys(pred, &index).unwrap();
match coerced {
IndexPredicate::Range(r) => {
assert_eq!(r.start, Some(ts("2017-07-04 00:00:00")));
assert!(r.inclusive_start);
assert_eq!(r.end, Some(ts("2017-07-08 00:00:00")));
assert!(!r.inclusive_end);
}
other => panic!("expected Range, got {:?}", other),
}
}
#[test]
fn upper_only_range_gains_minimal_lower_bound() {
let index = index_with_keys(vec![ts("2017-07-05 00:00:00"), SqlValue::Null]);
let pred = range(None, Some(varchar("2017-07-08")), false, false);
let coerced = coerce_index_predicate_for_temporal_keys(pred, &index).unwrap();
match coerced {
IndexPredicate::Range(r) => {
assert!(r.start.is_some(), "expected pinned minimal lower bound");
assert!(matches!(r.start, Some(SqlValue::Timestamp(_))));
assert!(r.inclusive_start);
}
other => panic!("expected Range, got {:?}", other),
}
}
#[test]
fn date_keys_parse_first_any_parseable_format() {
let index = index_with_keys(vec![date("2017-07-20")]);
let pred = range(Some(varchar("2017-07-20")), None, false, false);
let coerced = coerce_index_predicate_for_temporal_keys(pred, &index).unwrap();
match coerced {
IndexPredicate::Range(r) => {
assert_eq!(r.start, Some(date("2017-07-20")));
assert!(!r.inclusive_start);
}
other => panic!("expected Range, got {:?}", other),
}
}
#[test]
fn unparseable_string_declines() {
let index = index_with_keys(vec![ts("2017-07-05 00:00:00")]);
let pred = range(Some(varchar("hello")), None, true, false);
assert!(coerce_index_predicate_for_temporal_keys(pred, &index).is_none());
let date_index = index_with_keys(vec![date("2017-07-05")]);
let pred = range(None, Some(varchar("junk")), false, true);
assert!(coerce_index_predicate_for_temporal_keys(pred, &date_index).is_none());
}
#[test]
fn non_prefix_parseable_forms_decline_for_timestamp_keys() {
let index = index_with_keys(vec![ts("2017-07-05 00:00:00")]);
let pred = range(Some(varchar("2017-07-05T00:00:00")), None, true, false);
assert!(coerce_index_predicate_for_temporal_keys(pred, &index).is_none());
}
#[test]
fn in_list_drops_non_roundtrip_strings_for_timestamp_keys() {
let index = index_with_keys(vec![ts("2017-07-05 00:00:00")]);
let pred = Some(IndexPredicate::In(vec![
varchar("2017-07-05 00:00:00"), varchar("2017-07-06"), varchar("hello"), ]));
let coerced = coerce_index_predicate_for_temporal_keys(pred, &index).unwrap();
match coerced {
IndexPredicate::In(values) => {
assert_eq!(values, vec![ts("2017-07-05 00:00:00")]);
}
other => panic!("expected In, got {:?}", other),
}
}
#[test]
fn in_list_with_unparseable_string_vs_date_keys_declines() {
let index = index_with_keys(vec![date("2017-07-05")]);
let pred = Some(IndexPredicate::In(vec![varchar("2017-07-05"), varchar("junk")]));
assert!(coerce_index_predicate_for_temporal_keys(pred, &index).is_none());
}
#[test]
fn non_temporal_keys_pass_through_unchanged() {
let index = index_with_keys(vec![varchar("apple"), varchar("pear")]);
let pred = range(Some(varchar("banana")), None, true, false);
let coerced = coerce_index_predicate_for_temporal_keys(pred, &index).unwrap();
match coerced {
IndexPredicate::Range(r) => assert_eq!(r.start, Some(varchar("banana"))),
other => panic!("expected Range, got {:?}", other),
}
}
#[test]
fn numeric_bounds_pass_through_unchanged() {
let index = index_with_keys(vec![ts("2017-07-05 00:00:00")]);
let pred = range(Some(SqlValue::Double(5.0)), None, true, false);
let coerced = coerce_index_predicate_for_temporal_keys(pred, &index).unwrap();
match coerced {
IndexPredicate::Range(r) => assert_eq!(r.start, Some(SqlValue::Double(5.0))),
other => panic!("expected Range, got {:?}", other),
}
}
#[test]
fn coerce_equality_key_semantics() {
let ts_sample = ts("2017-07-05 00:00:00");
match coerce_equality_key(&ts_sample, &varchar("2017-07-20 15:30:00")) {
EqualityKeyCoercion::Coerced(v) => assert_eq!(v, ts("2017-07-20 15:30:00")),
_ => panic!("expected Coerced"),
}
assert!(matches!(
coerce_equality_key(&ts_sample, &varchar("2017-07-20")),
EqualityKeyCoercion::Unusable
));
assert!(matches!(
coerce_equality_key(&ts_sample, &SqlValue::Integer(5)),
EqualityKeyCoercion::Unchanged
));
match coerce_equality_key(&date("2017-07-05"), &varchar("2017-07-20")) {
EqualityKeyCoercion::Coerced(v) => assert_eq!(v, date("2017-07-20")),
_ => panic!("expected Coerced"),
}
assert!(matches!(
coerce_equality_key(&date("2017-07-05"), &varchar("junk")),
EqualityKeyCoercion::Unusable
));
}
#[test]
fn trailing_zero_fraction_string_round_trips_after_5332() {
let index = index_with_keys(vec![ts("2024-01-01 13:15:44.5")]);
let pred = range(Some(varchar("2024-01-01 13:15:44.500")), None, false, false);
let coerced = coerce_index_predicate_for_temporal_keys(pred, &index).unwrap();
match coerced {
IndexPredicate::Range(r) => {
assert_eq!(r.start, Some(ts("2024-01-01 13:15:44.500")));
assert!(!r.inclusive_start, "exact round-trip keeps original inclusivity");
}
other => panic!("expected Range, got {:?}", other),
}
}
#[test]
fn short_fraction_string_takes_prefix_rule_after_5332() {
let index = index_with_keys(vec![ts("2024-01-01 13:15:44.5")]);
let pred = range(
Some(varchar("2024-01-01 13:15:44.5")),
Some(varchar("2024-01-01 13:15:44.5")),
true,
true,
);
let coerced = coerce_index_predicate_for_temporal_keys(pred, &index).unwrap();
match coerced {
IndexPredicate::Range(r) => {
assert_eq!(r.start, Some(ts("2024-01-01 13:15:44.5")));
assert!(r.inclusive_start);
assert_eq!(r.end, Some(ts("2024-01-01 13:15:44.5")));
assert!(!r.inclusive_end);
}
other => panic!("expected Range, got {:?}", other),
}
}
fn disk_backed_index_with_keys(
key_schema: Vec<vibesql_types::DataType>,
keys: Vec<SqlValue>,
) -> (IndexData, tempfile::TempDir) {
use std::sync::Arc;
let temp_dir = tempfile::TempDir::new().unwrap();
let storage = Arc::new(vibesql_storage::NativeStorage::new(temp_dir.path()).unwrap());
let page_manager =
Arc::new(vibesql_storage::page::PageManager::new("test_index.idx", storage).unwrap());
let mut sorted_entries: Vec<(Vec<SqlValue>, usize)> =
keys.into_iter().enumerate().map(|(i, key)| (vec![key], i)).collect();
sorted_entries.sort_by(|a, b| a.0.cmp(&b.0));
let btree = vibesql_storage::btree::BTreeIndex::bulk_load(
sorted_entries,
key_schema,
page_manager.clone(),
)
.unwrap();
let index =
IndexData::DiskBacked { btree: Arc::new(parking_lot::Mutex::new(btree)), page_manager };
(index, temp_dir)
}
#[test]
fn disk_backed_timestamp_index_coerces_string_bounds() {
let (index, _dir) = disk_backed_index_with_keys(
vec![vibesql_types::DataType::Timestamp { with_timezone: false }],
vec![ts("2017-07-05 00:00:00"), ts("2017-07-20 15:30:00")],
);
let pred = range(Some(varchar("2017-07-04")), Some(varchar("2017-07-08")), true, true);
let coerced = coerce_index_predicate_for_temporal_keys(pred, &index).unwrap();
match coerced {
IndexPredicate::Range(r) => {
assert_eq!(r.start, Some(ts("2017-07-04 00:00:00")));
assert!(r.inclusive_start);
assert_eq!(r.end, Some(ts("2017-07-08 00:00:00")));
assert!(!r.inclusive_end);
}
other => panic!("expected Range, got {:?}", other),
}
}
#[test]
fn empty_disk_backed_temporal_index_still_coerces() {
let (index, _dir) = disk_backed_index_with_keys(
vec![vibesql_types::DataType::Timestamp { with_timezone: false }],
vec![],
);
let pred = range(Some(varchar("2017-07-20 15:30:00")), None, true, false);
let coerced = coerce_index_predicate_for_temporal_keys(pred, &index).unwrap();
match coerced {
IndexPredicate::Range(r) => {
assert_eq!(r.start, Some(ts("2017-07-20 15:30:00")));
}
other => panic!("expected Range, got {:?}", other),
}
}
#[test]
fn disk_backed_non_temporal_index_passes_through() {
let (index, _dir) = disk_backed_index_with_keys(
vec![vibesql_types::DataType::Integer],
vec![SqlValue::Integer(1), SqlValue::Integer(2)],
);
let pred = range(Some(varchar("2017-07-20")), None, true, false);
let coerced = coerce_index_predicate_for_temporal_keys(pred, &index).unwrap();
match coerced {
IndexPredicate::Range(r) => {
assert_eq!(r.start, Some(varchar("2017-07-20")), "no coercion expected");
}
other => panic!("expected Range, got {:?}", other),
}
}
#[test]
fn disk_backed_equality_lookup_key_coerces_via_sample() {
let (index, _dir) = disk_backed_index_with_keys(
vec![vibesql_types::DataType::Timestamp { with_timezone: false }],
vec![ts("2017-07-20 15:30:00")],
);
let sample = index.first_key_value_sample(0).expect("schema-based sample");
match coerce_equality_key(&sample, &varchar("2017-07-20 15:30:00")) {
EqualityKeyCoercion::Coerced(v) => assert_eq!(v, ts("2017-07-20 15:30:00")),
_ => panic!("expected Coerced"),
}
}
}