Skip to content

Commit 67ed322

Browse files
committed
fix(types): coerce mismatched timestamp string formats for compare
eq_coerced/cmp_coerced compared timestamp-valued strings byte-for-byte, so a stored ISO-8601 value ("...T13:00:00.000000Z") never matched or ordered correctly against a SQL-style filter literal ("... 13:00:00") even when they denoted the same instant. Both paths now parse operands as datetimes and compare by epoch micros when both sides parse, falling back to lexicographic comparison for ordinary strings.
1 parent b6a2052 commit 67ed322

2 files changed

Lines changed: 96 additions & 2 deletions

File tree

nodedb-query/src/msgpack_scan/filter.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,43 @@ mod tests {
528528
assert!(filter("score", "gt", nodedb_types::Value::Integer(80)).matches_binary(&doc));
529529
}
530530

531+
#[test]
532+
fn timestamp_upper_bound_matches() {
533+
let doc = encode(&json!({"t": "2026-07-02T13:00:00.000000Z"}));
534+
assert!(
535+
filter(
536+
"t",
537+
"lte",
538+
nodedb_types::Value::String("2026-07-02 14:00:00".into())
539+
)
540+
.matches_binary(&doc)
541+
);
542+
assert!(
543+
filter(
544+
"t",
545+
"lt",
546+
nodedb_types::Value::String("2026-07-02 14:00:00".into())
547+
)
548+
.matches_binary(&doc)
549+
);
550+
assert!(
551+
filter(
552+
"t",
553+
"gte",
554+
nodedb_types::Value::String("2026-07-02 12:00:00".into())
555+
)
556+
.matches_binary(&doc)
557+
);
558+
assert!(
559+
filter(
560+
"t",
561+
"eq",
562+
nodedb_types::Value::String("2026-07-02 13:00:00".into())
563+
)
564+
.matches_binary(&doc)
565+
);
566+
}
567+
531568
// ── Indexed variant tests ──────────────────────────────────────────
532569

533570
#[test]

nodedb-types/src/value/coerce.rs

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ impl Value {
1919
(Value::Integer(a), Value::Float(b)) => *a as f64 == *b,
2020
(Value::Float(a), Value::Integer(b)) => *a == *b as f64,
2121
(Value::Float(a), Value::Float(b)) => a == b,
22-
(Value::String(a), Value::String(b)) => a == b,
22+
(Value::String(a), Value::String(b)) => {
23+
a == b
24+
|| matches!(
25+
(crate::NdbDateTime::parse(a), crate::NdbDateTime::parse(b)),
26+
(Some(x), Some(y)) if x.micros == y.micros
27+
)
28+
}
2329
// Coercion: number vs string
2430
(Value::Integer(a), Value::String(s)) => {
2531
s.parse::<i64>().is_ok_and(|n| *a == n)
@@ -33,7 +39,10 @@ impl Value {
3339
(Value::String(s), Value::Float(b)) => s.parse::<f64>().is_ok_and(|n| n == *b),
3440
// Structural equality on ND cells: same coords and same attrs.
3541
(Value::ArrayCell(a), Value::ArrayCell(b)) => a == b,
36-
_ => false,
42+
(a, b) => match (datetime_micros(a), datetime_micros(b)) {
43+
(Some(x), Some(y)) => x == y,
44+
_ => false,
45+
},
3746
}
3847
}
3948

@@ -78,6 +87,9 @@ impl Value {
7887
if let (Some(a), Some(b)) = (self_f64, other_f64) {
7988
return a.partial_cmp(&b).unwrap_or(Ordering::Equal);
8089
}
90+
if let (Some(a), Some(b)) = (datetime_micros(self), datetime_micros(other)) {
91+
return a.cmp(&b);
92+
}
8193
let a_str = match self {
8294
Value::String(s) => s.as_str(),
8395
_ => return Ordering::Equal,
@@ -90,6 +102,17 @@ impl Value {
90102
}
91103
}
92104

105+
/// Epoch micros for a datetime-typed value, or a string that parses as an
106+
/// ISO-8601 / SQL timestamp. Returns `None` for anything not datetime-like so
107+
/// ordinary strings keep lexicographic ordering.
108+
fn datetime_micros(v: &Value) -> Option<i64> {
109+
match v {
110+
Value::NaiveDateTime(dt) | Value::DateTime(dt) => Some(dt.micros),
111+
Value::String(s) => crate::NdbDateTime::parse(s).map(|dt| dt.micros),
112+
_ => None,
113+
}
114+
}
115+
93116
#[cfg(test)]
94117
mod tests {
95118
use super::*;
@@ -162,6 +185,40 @@ mod tests {
162185
);
163186
}
164187

188+
#[test]
189+
fn cmp_coerced_timestamp_mismatched_string_formats() {
190+
use std::cmp::Ordering;
191+
let stored = Value::String("2026-07-02T13:00:00.000000Z".into());
192+
assert_eq!(
193+
stored.cmp_coerced(&Value::String("2026-07-02 14:00:00".into())),
194+
Ordering::Less
195+
);
196+
assert_eq!(
197+
stored.cmp_coerced(&Value::String("2026-07-02 12:00:00".into())),
198+
Ordering::Greater
199+
);
200+
}
201+
202+
#[test]
203+
fn eq_coerced_timestamp_mismatched_string_formats() {
204+
let stored = Value::String("2026-07-02T13:00:00.000000Z".into());
205+
assert!(stored.eq_coerced(&Value::String("2026-07-02 13:00:00".into())));
206+
}
207+
208+
#[test]
209+
fn cmp_coerced_ordinary_strings_stay_lexicographic() {
210+
use std::cmp::Ordering;
211+
// Non-timestamp strings must NOT be datetime-coerced.
212+
assert_eq!(
213+
Value::String("apple".into()).cmp_coerced(&Value::String("banana".into())),
214+
Ordering::Less
215+
);
216+
assert_eq!(
217+
Value::String("zed".into()).cmp_coerced(&Value::String("abc".into())),
218+
Ordering::Greater
219+
);
220+
}
221+
165222
#[test]
166223
fn eq_coerced_symmetry() {
167224
let cases = [

0 commit comments

Comments
 (0)