diff --git a/apps/desktop/src/lib/columnFormatter.ts b/apps/desktop/src/lib/columnFormatter.ts index 2df6b02db0..f8b42a7a26 100644 --- a/apps/desktop/src/lib/columnFormatter.ts +++ b/apps/desktop/src/lib/columnFormatter.ts @@ -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); diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index 94c15015db..58bfba1683 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -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, idx: usize) -> Option { + 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 { + 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 { + 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 { + 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 = DateTime::from_timestamp(seconds, nanos_remainder)?; + Some(format_naive_datetime(dt.naive_utc())) +} + +fn duckdb_time_unit_to_nanos(unit: TimeUnit, value: i64) -> Option { + 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 = (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 { diff --git a/packages/app-tests/columnFormatter.test.ts b/packages/app-tests/columnFormatter.test.ts index fd8a0d1519..df7f4e7e24 100644 --- a/packages/app-tests/columnFormatter.test.ts +++ b/packages/app-tests/columnFormatter.test.ts @@ -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 }] });