Skip to content

Commit 7afcbc1

Browse files
authored
fix(query): render named-timezone timestamps (#283)
* fix(query): render named-timezone timestamps Results are fetched as Arrow IPC and formatted client-side, so a timestamp arrives as a raw integer plus a timezone recorded in the schema. Formatting one whose zone is a named IANA zone ("UTC", "America/New_York") needs a timezone database; arrow was built with default features off, so none was compiled in and its formatter returned an error for those values. The cell then reported them as null. Only named zones were affected: a fixed offset ("+00:00") and a zone-less timestamp both format without a timezone database, so a neighbouring column in the same row looked correct and the loss was easy to miss. Nothing was wrong with the data — the same query served inline, formatted by the service, returned the values correctly. Enable arrow's chrono-tz feature, and stop turning a formatting failure into a null: the cell has already been checked for null above, so reaching that arm means a present value could not be rendered, and reporting it as null asserts the opposite. The output shape is kept (callers rely on one value per column) and the failure is reported on stderr instead, once per run so a large result cannot flood it. Two tests cover it: named zones across several time units, and a sweep over the temporal and decimal types a query can return asserting none of them render a present value as null. Both fail without the feature. Regressed in 50e3439, which moved result fetching from JSON to Arrow. * fix(query): handle per-value format errors, warn per type Two review points. The warn-once flag was global, so a result with two unformattable column types reported only the first and the second went back to nulling silently — the exact hazard this removes. Key the set on the data type, which is what the failure is a property of. `try_new` only reports a type Arrow cannot format at all. A single out-of-range value inside a formattable type fails later, in the value formatter, and the plain `Display` impl under the default `safe: true` writes the text "ERROR: <msg>" into the cell as if it were data (and panics when the inner write fails). Render the value through `try_to_string` so one bad value degrades the same way a bad type does. Test covers it: an out-of-range Timestamp(Second) must not panic and must not put an error message in the data. Without the change it fails with "ERROR: Cast error: Failed to convert ... to datetime".
1 parent 8d13e16 commit 7afcbc1

3 files changed

Lines changed: 256 additions & 4 deletions

File tree

‎Cargo.lock‎

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,15 @@ reqwest = { version = "0.13", features = ["blocking", "json"] }
3333
rayon = "1.10"
3434
serde = { version = "1", features = ["derive"] }
3535
serde_json = "1"
36-
arrow = { version = "55", default-features = false, features = ["ipc"] }
36+
arrow = { version = "55", default-features = false, features = [
37+
"ipc",
38+
# Timestamps carrying a named IANA zone ("UTC", "America/New_York")
39+
# cannot be formatted without a timezone database — arrow's formatter
40+
# returns an error for them, and only fixed offsets ("+00:00") parse
41+
# without it. Results are fetched as Arrow and formatted here, so
42+
# without this feature every named-zone timestamp fails to render.
43+
"chrono-tz",
44+
] }
3745
serde_yaml = "0.9"
3846
base64 = "0.22"
3947
crossterm = "0.28"

‎src/commands/query.rs‎

Lines changed: 212 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,35 @@ fn value_to_string(v: &Value) -> String {
111111
}
112112
}
113113

114+
/// Warn that a column could not be rendered, once per data type.
115+
///
116+
/// Keyed on the type rather than a single global flag: the failure is a
117+
/// property of the type, so a million-row result must not print a million
118+
/// lines — but a result with two unformattable types has to report both, or
119+
/// the second one goes back to nulling silently.
120+
fn warn_unformattable_once(data_type: &arrow::datatypes::DataType, err: &arrow::error::ArrowError) {
121+
use crossterm::style::Stylize;
122+
use std::collections::HashSet;
123+
use std::sync::{Mutex, OnceLock};
124+
125+
static WARNED: OnceLock<Mutex<HashSet<arrow::datatypes::DataType>>> = OnceLock::new();
126+
let seen = WARNED.get_or_init(|| Mutex::new(HashSet::new()));
127+
// A poisoned lock only means another thread panicked mid-warn; recover the
128+
// set rather than take the process down over a diagnostic.
129+
let mut seen = match seen.lock() {
130+
Ok(g) => g,
131+
Err(poisoned) => poisoned.into_inner(),
132+
};
133+
if !seen.insert(data_type.clone()) {
134+
return;
135+
}
136+
eprintln!(
137+
"{}",
138+
format!("warning: could not format a {data_type} value, so it is shown as null: {err}")
139+
.yellow()
140+
);
141+
}
142+
114143
/// Convert one cell of an Arrow array to a `serde_json::Value`.
115144
fn arrow_cell(col: &dyn arrow::array::Array, row: usize) -> Value {
116145
use arrow::array::*;
@@ -222,9 +251,28 @@ fn arrow_cell(col: &dyn arrow::array::Array, row: usize) -> Value {
222251
_ => {
223252
use arrow::util::display::{ArrayFormatter, FormatOptions};
224253
let opts = FormatOptions::default();
225-
ArrayFormatter::try_new(col, &opts)
226-
.map(|f| Value::String(f.value(row).to_string()))
227-
.unwrap_or(Value::Null)
254+
// `col.is_null(row)` was checked above, so the cell *has* a value.
255+
// Reporting it as `null` claims the opposite and is
256+
// indistinguishable from a real null to anything reading the
257+
// output — which is how a whole class of unformattable column went
258+
// unnoticed. Keep the shape (callers rely on a value per column)
259+
// but say so on stderr, deduplicated per type.
260+
//
261+
// Both failure levels are handled. `try_new` reports a type Arrow
262+
// cannot format at all; `try_to_string` reports a single value it
263+
// cannot format. The latter matters because the plain `Display`
264+
// impl, under the default `safe: true`, writes the text
265+
// "ERROR: <msg>" into the cell — an error message wearing the
266+
// costume of data — and panics outright when the inner write fails.
267+
let formatted =
268+
ArrayFormatter::try_new(col, &opts).and_then(|f| f.value(row).try_to_string());
269+
match formatted {
270+
Ok(s) => Value::String(s),
271+
Err(e) => {
272+
warn_unformattable_once(col.data_type(), &e);
273+
Value::Null
274+
}
275+
}
228276
}
229277
}
230278
}
@@ -723,6 +771,167 @@ mod tests {
723771
resp
724772
}
725773

774+
/// A timestamp carrying a *named* IANA zone must render, not come back as
775+
/// null. Rendering it needs a timezone database compiled in; without one
776+
/// Arrow's formatter errors, and this cell used to swallow that error and
777+
/// report the value as null — silently, and only for named zones, so a
778+
/// fixed-offset or zone-less timestamp in the same row looked fine.
779+
#[test]
780+
fn named_timezone_timestamps_render_instead_of_nulling() {
781+
use arrow::array::TimestampMicrosecondArray;
782+
783+
// 2026-01-01T12:00:00Z
784+
let micros = 1_767_268_800_000_000i64;
785+
786+
for zone in ["UTC", "America/New_York", "Europe/London"] {
787+
let col = TimestampMicrosecondArray::from(vec![micros]).with_timezone(zone);
788+
let cell = arrow_cell(&col, 0);
789+
assert!(
790+
cell.is_string(),
791+
"zone {zone} rendered as {cell:?}, expected a formatted string"
792+
);
793+
assert_ne!(cell, Value::Null, "zone {zone} rendered as null");
794+
}
795+
796+
// The two forms that worked even without a timezone database, kept here
797+
// so a regression narrows to the named-zone case rather than all timestamps.
798+
let offset = TimestampMicrosecondArray::from(vec![micros]).with_timezone("+00:00");
799+
assert!(arrow_cell(&offset, 0).is_string());
800+
let naive = TimestampMicrosecondArray::from(vec![micros]);
801+
assert!(arrow_cell(&naive, 0).is_string());
802+
803+
// A genuine null is still a null.
804+
let with_null = TimestampMicrosecondArray::from(vec![None::<i64>]).with_timezone("UTC");
805+
assert_eq!(arrow_cell(&with_null, 0), Value::Null);
806+
}
807+
808+
/// No column type we can be handed may turn a present value into `null`.
809+
///
810+
/// The timezone case above is one instance of a wider hazard: every type
811+
/// without an explicit arm falls through to Arrow's formatter, and a
812+
/// formatter that cannot handle the type used to be reported as a null
813+
/// cell. This walks the temporal and decimal types a query can return and
814+
/// asserts each renders, so a future dependency or feature change that
815+
/// breaks one of them fails here instead of silently blanking a column.
816+
#[test]
817+
fn no_column_type_silently_renders_as_null() {
818+
use arrow::array::{
819+
Date32Array, Date64Array, Decimal128Array, DurationMicrosecondArray, Time32SecondArray,
820+
Time64MicrosecondArray, TimestampMicrosecondArray, TimestampMillisecondArray,
821+
TimestampNanosecondArray, TimestampSecondArray,
822+
};
823+
824+
let micros = 1_767_268_800_000_000i64;
825+
let cases: Vec<(&str, arrow::array::ArrayRef)> = vec![
826+
("Date32", Arc::new(Date32Array::from(vec![20454]))),
827+
(
828+
"Date64",
829+
Arc::new(Date64Array::from(vec![1_767_268_800_000])),
830+
),
831+
("Time32(s)", Arc::new(Time32SecondArray::from(vec![43200]))),
832+
(
833+
"Time64(µs)",
834+
Arc::new(Time64MicrosecondArray::from(vec![43_200_000_000])),
835+
),
836+
(
837+
"Duration(µs)",
838+
Arc::new(DurationMicrosecondArray::from(vec![1_000_000])),
839+
),
840+
(
841+
"Decimal128",
842+
Arc::new(
843+
Decimal128Array::from(vec![123_456i128])
844+
.with_precision_and_scale(10, 3)
845+
.expect("valid decimal"),
846+
),
847+
),
848+
(
849+
"Timestamp(s, naive)",
850+
Arc::new(TimestampSecondArray::from(vec![1_767_268_800])),
851+
),
852+
(
853+
"Timestamp(ms, naive)",
854+
Arc::new(TimestampMillisecondArray::from(vec![1_767_268_800_000])),
855+
),
856+
(
857+
"Timestamp(µs, naive)",
858+
Arc::new(TimestampMicrosecondArray::from(vec![micros])),
859+
),
860+
(
861+
"Timestamp(ns, naive)",
862+
Arc::new(TimestampNanosecondArray::from(vec![micros * 1_000])),
863+
),
864+
(
865+
"Timestamp(s, UTC)",
866+
Arc::new(TimestampSecondArray::from(vec![1_767_268_800]).with_timezone("UTC")),
867+
),
868+
(
869+
"Timestamp(ms, UTC)",
870+
Arc::new(
871+
TimestampMillisecondArray::from(vec![1_767_268_800_000]).with_timezone("UTC"),
872+
),
873+
),
874+
(
875+
"Timestamp(µs, UTC)",
876+
Arc::new(TimestampMicrosecondArray::from(vec![micros]).with_timezone("UTC")),
877+
),
878+
(
879+
"Timestamp(ns, UTC)",
880+
Arc::new(TimestampNanosecondArray::from(vec![micros * 1_000]).with_timezone("UTC")),
881+
),
882+
(
883+
"Timestamp(µs, +05:30)",
884+
Arc::new(TimestampMicrosecondArray::from(vec![micros]).with_timezone("+05:30")),
885+
),
886+
(
887+
"Timestamp(µs, Asia/Kolkata)",
888+
Arc::new(
889+
TimestampMicrosecondArray::from(vec![micros]).with_timezone("Asia/Kolkata"),
890+
),
891+
),
892+
];
893+
894+
for (label, col) in cases {
895+
let cell = arrow_cell(col.as_ref(), 0);
896+
assert_ne!(
897+
cell,
898+
Value::Null,
899+
"{label} has a value at row 0 but rendered as null"
900+
);
901+
}
902+
}
903+
904+
/// A single value Arrow cannot format must not panic, and must not put an
905+
/// error message into the data.
906+
///
907+
/// Two distinct failure levels exist: a type Arrow cannot format at all
908+
/// (caught by `try_new`) and one value inside a formattable type that is
909+
/// out of range. For the latter, the plain `Display` impl under the default
910+
/// `safe: true` writes the literal text "ERROR: <msg>" as the cell value,
911+
/// and panics when the inner write itself fails. Neither is acceptable in a
912+
/// data column, so the value path is rendered fallibly.
913+
#[test]
914+
fn an_unformattable_value_is_null_not_a_panic_or_an_error_string() {
915+
use arrow::array::TimestampSecondArray;
916+
917+
// Far outside the range a second-resolution timestamp can render.
918+
let col = TimestampSecondArray::from(vec![i64::MAX]);
919+
let cell = arrow_cell(&col, 0);
920+
921+
if let Value::String(s) = &cell {
922+
assert!(
923+
!s.contains("ERROR"),
924+
"an error message reached the data as a value: {s}"
925+
);
926+
} else {
927+
assert_eq!(
928+
cell,
929+
Value::Null,
930+
"expected either a rendered value or null"
931+
);
932+
}
933+
}
934+
726935
#[test]
727936
fn hint_for_missing_database_context() {
728937
let tip = cross_source_hint(

0 commit comments

Comments
 (0)