Skip to content

Commit a4b52ec

Browse files
committed
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 deed093 commit a4b52ec

1 file changed

Lines changed: 65 additions & 15 deletions

File tree

‎src/commands/query.rs‎

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

114-
/// Warn once per process that a column could not be rendered.
114+
/// Warn that a column could not be rendered, once per data type.
115115
///
116-
/// Deduplicated because the failure is a property of the column's type, not of
117-
/// any one row: a million-row result would otherwise print a million lines.
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.
118120
fn warn_unformattable_once(data_type: &arrow::datatypes::DataType, err: &arrow::error::ArrowError) {
119121
use crossterm::style::Stylize;
120-
use std::sync::atomic::{AtomicBool, Ordering};
121-
static WARNED: AtomicBool = AtomicBool::new(false);
122-
if WARNED.swap(true, Ordering::Relaxed) {
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()) {
123134
return;
124135
}
125136
eprintln!(
@@ -240,15 +251,23 @@ fn arrow_cell(col: &dyn arrow::array::Array, row: usize) -> Value {
240251
_ => {
241252
use arrow::util::display::{ArrayFormatter, FormatOptions};
242253
let opts = FormatOptions::default();
243-
match ArrayFormatter::try_new(col, &opts) {
244-
Ok(f) => Value::String(f.value(row).to_string()),
245-
// `col.is_null(row)` was checked above, so the cell *has* a
246-
// value and we simply could not render it. Emitting `null` here
247-
// claims the opposite, and is indistinguishable from a real
248-
// null to anything reading the output — which is how a whole
249-
// class of unformattable column went unnoticed. Keep the shape
250-
// (callers rely on a value per column) but say so on stderr,
251-
// once per run so a large result cannot drown the terminal.
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),
252271
Err(e) => {
253272
warn_unformattable_once(col.data_type(), &e);
254273
Value::Null
@@ -882,6 +901,37 @@ mod tests {
882901
}
883902
}
884903

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+
885935
#[test]
886936
fn hint_for_missing_database_context() {
887937
let tip = cross_source_hint(

0 commit comments

Comments
 (0)