mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 08:13:34 +00:00
fix(duckdb): normalize temporal display
This commit is contained in:
@@ -103,6 +103,7 @@ export function applyColumnFormatter(value: CellValue, formatter: ColumnFormatte
|
||||
|
||||
function formatDateTime(value: CellValue, unit: DateTimeFormatterUnit): string {
|
||||
if (value === null) return displayCellValue(value);
|
||||
if (typeof value === "string" && !isTimestampString(value, unit)) return displayCellValue(value);
|
||||
const numeric = typeof value === "number" ? value : Number(String(value).trim());
|
||||
if (!Number.isFinite(numeric)) return displayCellValue(value);
|
||||
const timestamp =
|
||||
@@ -111,6 +112,15 @@ function formatDateTime(value: CellValue, unit: DateTimeFormatterUnit): string {
|
||||
return Number.isNaN(date.getTime()) ? displayCellValue(value) : date.toLocaleString();
|
||||
}
|
||||
|
||||
function isTimestampString(value: string, unit: DateTimeFormatterUnit): boolean {
|
||||
const text = value.trim();
|
||||
if (!/^-?\d+$/.test(text)) return false;
|
||||
const digits = text.startsWith("-") ? text.length - 1 : text.length;
|
||||
if (unit === "seconds") return digits === 10;
|
||||
if (unit === "milliseconds") return digits === 13;
|
||||
return digits === 10 || digits === 13;
|
||||
}
|
||||
|
||||
function formatJsonPath(value: CellValue, path: string): string {
|
||||
if (value === null) return displayCellValue(value);
|
||||
if (typeof value !== "string") return displayCellValue(value);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use chrono::{DateTime, Duration as ChronoDuration, NaiveDate, NaiveDateTime, NaiveTime, Utc};
|
||||
use duckdb::types::{TimeUnit, ValueRef};
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
@@ -27,6 +29,65 @@ pub fn duckdb_execute(con: &duckdb::Connection, sql: &str) -> Result<db::QueryRe
|
||||
duckdb_execute_with_max_rows(con, sql, None)
|
||||
}
|
||||
|
||||
fn duckdb_temporal_value_to_json(row: &duckdb::Row<'_>, idx: usize) -> Option<serde_json::Value> {
|
||||
match row.get_ref(idx).ok()? {
|
||||
ValueRef::Null => Some(serde_json::Value::Null),
|
||||
ValueRef::Date32(days) => duckdb_date32_to_string(days).map(serde_json::Value::String),
|
||||
ValueRef::Time64(unit, value) => duckdb_time64_to_string(unit, value).map(serde_json::Value::String),
|
||||
ValueRef::Timestamp(unit, value) => duckdb_timestamp_to_string(unit, value).map(serde_json::Value::String),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn duckdb_date32_to_string(days: i32) -> Option<String> {
|
||||
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1)?;
|
||||
epoch.checked_add_signed(ChronoDuration::days(i64::from(days))).map(|date| date.to_string())
|
||||
}
|
||||
|
||||
fn duckdb_time64_to_string(unit: TimeUnit, value: i64) -> Option<String> {
|
||||
let nanos = duckdb_time_unit_to_nanos(unit, value)?;
|
||||
let seconds = nanos.div_euclid(1_000_000_000);
|
||||
let nanos_remainder = nanos.rem_euclid(1_000_000_000) as u32;
|
||||
if !(0..86_400).contains(&seconds) {
|
||||
return None;
|
||||
}
|
||||
let time = NaiveTime::from_num_seconds_from_midnight_opt(seconds as u32, nanos_remainder)?;
|
||||
Some(format_temporal_without_empty_fraction(time.to_string()))
|
||||
}
|
||||
|
||||
fn duckdb_timestamp_to_string(unit: TimeUnit, value: i64) -> Option<String> {
|
||||
let nanos = duckdb_time_unit_to_nanos(unit, value)?;
|
||||
let seconds = nanos.div_euclid(1_000_000_000);
|
||||
let nanos_remainder = nanos.rem_euclid(1_000_000_000) as u32;
|
||||
let dt: DateTime<Utc> = DateTime::from_timestamp(seconds, nanos_remainder)?;
|
||||
Some(format_naive_datetime(dt.naive_utc()))
|
||||
}
|
||||
|
||||
fn duckdb_time_unit_to_nanos(unit: TimeUnit, value: i64) -> Option<i64> {
|
||||
match unit {
|
||||
TimeUnit::Second => value.checked_mul(1_000_000_000),
|
||||
TimeUnit::Millisecond => value.checked_mul(1_000_000),
|
||||
TimeUnit::Microsecond => value.checked_mul(1_000),
|
||||
TimeUnit::Nanosecond => Some(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_naive_datetime(value: NaiveDateTime) -> String {
|
||||
if value.and_utc().timestamp_subsec_nanos() == 0 {
|
||||
value.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
} else {
|
||||
format_temporal_without_empty_fraction(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn format_temporal_without_empty_fraction(value: String) -> String {
|
||||
if !value.contains('.') {
|
||||
return value;
|
||||
}
|
||||
let trimmed = value.trim_end_matches('0').trim_end_matches('.');
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
pub fn duckdb_execute_with_max_rows(
|
||||
con: &duckdb::Connection,
|
||||
sql: &str,
|
||||
@@ -48,6 +109,9 @@ pub fn duckdb_execute_with_max_rows(
|
||||
while let Some(row) = rows.next().map_err(|e| e.to_string())? {
|
||||
let vals: Vec<serde_json::Value> = (0..col_count)
|
||||
.map(|i| {
|
||||
if let Some(value) = duckdb_temporal_value_to_json(row, i) {
|
||||
return value;
|
||||
}
|
||||
row.get::<_, String>(i)
|
||||
.map(serde_json::Value::String)
|
||||
.or_else(|_| row.get::<_, i64>(i).map(|v| serde_json::Value::Number(v.into())))
|
||||
@@ -1050,6 +1114,27 @@ mod tests {
|
||||
assert!(!is_connection_error("os error 13"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duckdb_execute_formats_temporal_values_by_column_type() {
|
||||
let con = duckdb::Connection::open_in_memory().expect("connect in-memory DuckDB");
|
||||
let result = duckdb_execute(
|
||||
&con,
|
||||
"SELECT DATE '2026-05-14' AS d, TIME '16:58:15' AS t, TIMESTAMP '2026-05-14 16:58:15.0' AS ts, NULL::TIMESTAMP AS nts",
|
||||
)
|
||||
.expect("execute temporal query");
|
||||
|
||||
assert_eq!(result.columns, vec!["d", "t", "ts", "nts"]);
|
||||
assert_eq!(
|
||||
result.rows,
|
||||
vec![vec![
|
||||
serde_json::Value::String("2026-05-14".to_string()),
|
||||
serde_json::Value::String("16:58:15".to_string()),
|
||||
serde_json::Value::String("2026-05-14 16:58:15".to_string()),
|
||||
serde_json::Value::Null,
|
||||
]]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_driver_query_params_include_database_and_schema_context() {
|
||||
let config = ConnectionConfig {
|
||||
|
||||
@@ -23,6 +23,11 @@ test("formats unix timestamps in seconds, milliseconds, and auto mode", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("does not treat compact date strings as unix timestamps", () => {
|
||||
assert.equal(applyColumnFormatter("20260514", { kind: "datetime", unit: "auto" }), "20260514");
|
||||
assert.equal(applyColumnFormatter("2026-05-14", { kind: "datetime", unit: "auto" }), "2026-05-14");
|
||||
});
|
||||
|
||||
test("extracts simple JSON paths from object and array strings", () => {
|
||||
const payload = JSON.stringify({ user: { name: "Ada" }, items: [{ id: 7 }] });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user