diff --git a/Cargo.lock b/Cargo.lock index 2ae52b7acc..6d4e704ac7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2100,7 +2100,6 @@ dependencies = [ "fs2", "futures", "httpdate", - "iana-time-zone", "insta", "json5", "jsonwebtoken", diff --git a/apps/desktop/src/components/grid/TemporalCellEditor.vue b/apps/desktop/src/components/grid/TemporalCellEditor.vue index e0a44b4ff8..91d044a04b 100644 --- a/apps/desktop/src/components/grid/TemporalCellEditor.vue +++ b/apps/desktop/src/components/grid/TemporalCellEditor.vue @@ -4,7 +4,7 @@ import type { FocusOutsideEvent, PointerDownOutsideEvent } from "reka-ui"; import { CalendarClock, ChevronDown, ChevronUp, CircleSlash } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { formatTemporalInputValue, parseTemporalInputValue, stepTemporalInputValue, type TemporalCellEditorKind } from "@/lib/dataGrid/dataGridTemporalEditor"; +import { formatTemporalInputValue, parseTemporalInputValue, stepTemporalInputValue, temporalOffsetSuffix, type TemporalCellEditorKind } from "@/lib/dataGrid/dataGridTemporalEditor"; const props = withDefaults( defineProps<{ @@ -134,7 +134,7 @@ function updateTime(part: "hour" | "minute" | "second", rawValue: string | numbe const parts = { ...timeParts.value, [part]: normalizeTimePart(rawValue, part === "hour" ? 23 : 59) }; const nextTime = `${parts.hour}:${parts.minute}:${parts.second}${fractionSuffix.value}`; if (props.kind === "time") { - setModelValue(nextTime, true); + setModelValue(`${nextTime}${temporalOffsetSuffix(localValue.value, props.kind)}`, true); return; } setDateTimeValue(dateParts.value.year, dateParts.value.month, dateParts.value.day, nextTime); @@ -162,7 +162,7 @@ function updateFractionValue(rawValue: string) { const digits = rawValue.replace(/\D/g, "").slice(0, maxLength); const nextTime = `${timeParts.value.hour}:${timeParts.value.minute}:${timeParts.value.second}${digits ? `.${digits}` : ""}`; if (props.kind === "time") { - setModelValue(nextTime, true); + setModelValue(`${nextTime}${temporalOffsetSuffix(localValue.value, props.kind)}`, true); return; } setDateTimeValue(dateParts.value.year, dateParts.value.month, dateParts.value.day, nextTime); @@ -267,7 +267,7 @@ function normalizeTimePart(value: string | number, max: number): string { function setDateTimeValue(year: number, month: number, day: number, time: string) { const dateText = [String(year).padStart(4, "0"), String(month).padStart(2, "0"), String(day).padStart(2, "0")].join("-"); if (props.kind === "date") setModelValue(dateText, true); - else setModelValue(`${dateText} ${time}`, true); + else setModelValue(`${dateText} ${time}${temporalOffsetSuffix(localValue.value, props.kind)}`, true); } function parseFractionDigits(value: string): string { diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridTemporalEditor.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridTemporalEditor.spec.ts index e545585960..7bc3245387 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridTemporalEditor.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridTemporalEditor.spec.ts @@ -40,6 +40,15 @@ describe("dataGridTemporalEditor", () => { expect(stepTemporalInputValue(value, "datetime", "second", 1)).toBe("2026-07-09 12:34:57.123456"); }); + it("preserves timezone offsets when parsing or stepping temporal values", () => { + const datetime = "2026-07-09 12:34:56.123456+12:00"; + const time = "12:34:56.123456-05:30"; + + expect(parseTemporalInputValue(time, "time")).toBe(time); + expect(stepTemporalInputValue(datetime, "datetime", "hour", 1)).toBe("2026-07-09 13:34:56.123456+12:00"); + expect(stepTemporalInputValue(time, "time", "minute", 1)).toBe("12:35:56.123456-05:30"); + }); + it("keeps ordinary datetime values at second precision", () => { expect(temporalCellEditorConfig("datetime")).toEqual({ kind: "datetime", fractionPrecision: 0 }); expect(formatTemporalInputValue("2026-07-09 12:34:56", "datetime")).toBe("2026-07-09T12:34:56"); diff --git a/apps/desktop/src/lib/dataGrid/dataGridTemporalEditor.ts b/apps/desktop/src/lib/dataGrid/dataGridTemporalEditor.ts index e03d92e7e9..2c7199ec2e 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridTemporalEditor.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridTemporalEditor.ts @@ -56,7 +56,10 @@ export function parseTemporalInputValue(value: string, kind: TemporalCellEditorK const text = value.trim(); if (!text) return null; if (kind === "date") return text; - if (kind === "time") return normalizeTimeInput(text) || text; + if (kind === "time") { + const normalized = normalizeTimeInput(text); + return normalized ? `${normalized}${temporalOffsetSuffix(text, kind)}` : text; + } const datetime = text.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})(?::(\d{2})(\.\d{1,9})?)?$/); if (!datetime) return text; @@ -65,9 +68,20 @@ export function parseTemporalInputValue(value: string, kind: TemporalCellEditorK export type TemporalCellEditorPart = "year" | "month" | "day" | "hour" | "minute" | "second"; +export function temporalOffsetSuffix(value: string, kind: TemporalCellEditorKind): string { + const text = value.trim(); + const offset = text.match(/(Z|[+-]\d{2}(?::?\d{2})?)$/i)?.[1]; + if (!offset) return ""; + + const withoutOffset = text.slice(0, -offset.length); + const basePattern = kind === "time" ? /^\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?$/ : /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?$/; + return basePattern.test(withoutOffset) ? offset : ""; +} + export function stepTemporalInputValue(value: string, kind: TemporalCellEditorKind, part: TemporalCellEditorPart, delta: number): string { const dateParts = temporalDateParts(value); const timeParts = temporalTimeParts(value, kind); + const offset = temporalOffsetSuffix(value, kind); if (part === "year") dateParts.year = Math.max(1, Math.min(9999, dateParts.year + delta)); else if (part === "month") dateParts.month = Math.max(1, Math.min(12, dateParts.month + delta)); @@ -82,8 +96,8 @@ export function stepTemporalInputValue(value: string, kind: TemporalCellEditorKi const timeText = [timeParts.hour, timeParts.minute, timeParts.second].map((item) => String(item).padStart(2, "0")).join(":") + timeParts.fraction; if (kind === "date") return dateText; - if (kind === "time") return timeText; - return `${dateText} ${timeText}`; + if (kind === "time") return `${timeText}${offset}`; + return `${dateText} ${timeText}${offset}`; } function normalizeTemporalType(dataType: string | undefined): string { diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml index 1486680073..7b5f2cdcc7 100644 --- a/crates/dbx-core/Cargo.toml +++ b/crates/dbx-core/Cargo.toml @@ -93,9 +93,6 @@ arc-swap = "1" insta = { version = "1", features = ["json", "glob"] } tempfile = "3" -[target.'cfg(not(all(windows, target_vendor = "win7")))'.dependencies] -iana-time-zone = "0.1" - [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Security"] } diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index 29f563ee66..bca0f02456 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -7332,6 +7332,25 @@ mod tests { assert_eq!(format_grid_sql_literal(&json!("it's"), Some(DatabaseType::Postgres), None), "'it''s'"); } + #[test] + fn postgres_temporal_literals_preserve_session_formatted_values_for_grid_writes() { + let timestamp = column("created_at", "timestamp without time zone", false, None); + let timestamptz = column("created_at", "timestamp with time zone", false, None); + + assert_eq!( + format_grid_sql_literal(&json!("2026-09-13 01:00:00"), Some(DatabaseType::Postgres), Some(×tamp),), + "'2026-09-13 01:00:00'" + ); + assert_eq!( + format_grid_sql_literal( + &json!("2026-09-13T13:00:00+12:00"), + Some(DatabaseType::Postgres), + Some(×tamptz), + ), + "'2026-09-13T13:00:00+12:00'" + ); + } + #[test] fn postgres_bytea_literals_decode_prefixed_hex_values() { let bytea = column("payload", "bytea", true, None); diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index e2756c7cfb..4cc7160a9a 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -1,4 +1,4 @@ -use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, NaiveTime}; +use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc}; use deadpool_postgres::{ManagerConfig, Pool, PoolError, RecyclingMethod, Runtime}; use futures::{SinkExt, StreamExt}; use openssl::ssl::{SslConnector, SslFiletype, SslMethod, SslVerifyMode}; @@ -122,9 +122,6 @@ pub struct PostgresTablePartitionLocalObjects { } fn pg_temporal_to_json_value(row: &Row, idx: usize) -> Option { - if let Ok(v) = row.try_get::<_, DateTime>(idx) { - return Some(serde_json::Value::String(format_pg_timestamptz(v))); - } if let Ok(v) = row.try_get::<_, NaiveDateTime>(idx) { return Some(serde_json::Value::String(v.to_string())); } @@ -826,7 +823,7 @@ fn pg_array_to_json_value(row: &Row, idx: usize) -> Option { if let Ok(values) = row.try_get::<_, Vec>>(idx) { return Some(pg_optional_array_to_json(values, |v| serde_json::Value::String(v.to_string()))); } - if let Ok(values) = row.try_get::<_, Vec>>>(idx) { + if let Ok(values) = row.try_get::<_, Vec>>>(idx) { return Some(pg_optional_array_to_json(values, |v| serde_json::Value::String(format_pg_timestamptz(v)))); } if let Ok(values) = row.try_get::<_, Vec>>(idx) { @@ -870,7 +867,7 @@ fn pg_array_to_json_value(row: &Row, idx: usize) -> Option { None } -fn format_pg_timestamptz(value: DateTime) -> String { +fn format_pg_timestamptz(value: DateTime) -> String { value.to_rfc3339() } @@ -957,6 +954,17 @@ fn pg_type_requires_text_protocol(pg_type: &Type, col_type: PgColType) -> bool { return true; } + // PostgreSQL's binary timestamptz decoder converts UTC into chrono::Local, + // which makes the DBX host timezone win over the server session timezone. + // Text output is formatted by PostgreSQL in the session timezone and keeps + // timestamp-without-time-zone values as wall-clock text as well. + if matches!( + col_type, + PgColType::Temporal { fallback: PgTemporalFallback::Probe | PgTemporalFallback::GenericArray } + ) { + return true; + } + match pg_type.kind() { Kind::Enum(_) => false, Kind::Array(element_type) => Type::from_oid(element_type.oid()).is_none(), @@ -967,6 +975,7 @@ fn pg_type_requires_text_protocol(pg_type: &Type, col_type: PgColType) -> bool { pub(crate) fn classify_pg_type(type_name: &str) -> PgColType { let upper = type_name.to_uppercase(); + let temporal_type_name = upper.strip_prefix('_').unwrap_or(&upper); if upper == "BYTEA" { return PgColType::Bytea; @@ -986,11 +995,9 @@ pub(crate) fn classify_pg_type(type_name: &str) -> PgColType { if upper == "DATERANGE" { return PgColType::DateRange; } - if upper.contains("TIMESTAMP") - || upper == "DATE" - || upper == "TIME" - || upper == "TIMETZ" - || upper.contains("INTERVAL") + if temporal_type_name.contains("TIMESTAMP") + || matches!(temporal_type_name, "DATE" | "TIME" | "TIMETZ") + || temporal_type_name.contains("INTERVAL") { let fallback = if upper.starts_with('_') { PgTemporalFallback::GenericArray @@ -1155,6 +1162,12 @@ fn pg_text_fallback_value_with_spatial( .map(|geometry| (serde_json::Value::String(geometry.wkt), geometry.srid, true)) .or_else(|| split_pg_ewkt(value, false).map(|(value, srid)| (value, srid, true))) .unwrap_or_else(|| (serde_json::Value::String(value.to_string()), None, true)), + Some(PgColType::Temporal { fallback: PgTemporalFallback::GenericArray }) => { + pg_temporal_array_text_to_json(value) + .map(|value| (value, None, false)) + .unwrap_or_else(|| (serde_json::Value::String(normalize_pg_temporal_text(value)), None, false)) + } + Some(PgColType::Temporal { .. }) => (serde_json::Value::String(normalize_pg_temporal_text(value)), None, false), Some(_) => (serde_json::Value::String(value.to_string()), None, false), None => decode_pg_text_wkb(value) .map(|geometry| (serde_json::Value::String(geometry.wkt), geometry.srid, true)) @@ -1163,6 +1176,138 @@ fn pg_text_fallback_value_with_spatial( } } +/// Keep PostgreSQL's session-local wall clock while returning the same ISO +/// shape that the grid formatter and editor already understand. PostgreSQL +/// emits a numeric hour-only offset (for example `+08`) in text mode. +fn normalize_pg_temporal_text(value: &str) -> String { + let Some(offset_start) = value.rfind(['+', '-']) else { + return value.to_string(); + }; + let (date_time, offset) = value.split_at(offset_start); + if date_time.as_bytes().get(10) != Some(&b' ') || !matches!(offset.len(), 3 | 6) { + return value.to_string(); + } + let offset_bytes = offset.as_bytes(); + let valid_offset = offset_bytes.first().is_some_and(|sign| matches!(sign, b'+' | b'-')) + && offset_bytes[1..].iter().enumerate().all(|(index, byte)| { + if offset.len() == 6 && index == 2 { + *byte == b':' + } else { + byte.is_ascii_digit() + } + }); + if !valid_offset { + return value.to_string(); + } + + let mut normalized = String::with_capacity(value.len() + (offset.len() == 3) as usize * 3); + normalized.push_str(&date_time[..10]); + normalized.push('T'); + normalized.push_str(&date_time[11..]); + normalized.push_str(offset); + if offset.len() == 3 { + normalized.push_str(":00"); + } + normalized +} + +fn pg_temporal_array_text_to_json(value: &str) -> Option { + fn skip_whitespace(input: &[u8], cursor: &mut usize) { + while input.get(*cursor).is_some_and(u8::is_ascii_whitespace) { + *cursor += 1; + } + } + + fn parse_quoted(input: &[u8], cursor: &mut usize) -> Option { + if input.get(*cursor) != Some(&b'"') { + return None; + } + *cursor += 1; + let mut bytes = Vec::new(); + while let Some(&byte) = input.get(*cursor) { + *cursor += 1; + match byte { + b'"' => return String::from_utf8(bytes).ok(), + b'\\' => { + let escaped = *input.get(*cursor)?; + *cursor += 1; + bytes.push(escaped); + } + _ => bytes.push(byte), + } + } + None + } + + fn parse_array(input: &[u8], cursor: &mut usize) -> Option { + if input.get(*cursor) != Some(&b'{') { + return None; + } + *cursor += 1; + let mut values = Vec::new(); + loop { + skip_whitespace(input, cursor); + match input.get(*cursor)? { + b'}' => { + *cursor += 1; + return Some(serde_json::Value::Array(values)); + } + b'{' => values.push(parse_array(input, cursor)?), + b'"' => values.push(serde_json::Value::String(parse_quoted(input, cursor)?)), + _ => { + let start = *cursor; + while input.get(*cursor).is_some_and(|byte| !matches!(byte, b',' | b'}')) { + *cursor += 1; + } + let token = std::str::from_utf8(input.get(start..*cursor)?).ok()?.trim(); + if token.eq_ignore_ascii_case("NULL") { + values.push(serde_json::Value::Null); + } else { + values.push(serde_json::Value::String(token.to_string())); + } + } + } + skip_whitespace(input, cursor); + if input.get(*cursor) == Some(&b',') { + *cursor += 1; + } else if input.get(*cursor) != Some(&b'}') { + return None; + } + } + } + + fn normalize_array_value(value: serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Array(values) => { + serde_json::Value::Array(values.into_iter().map(normalize_array_value).collect()) + } + serde_json::Value::String(value) => serde_json::Value::String(normalize_pg_temporal_text(&value)), + value => value, + } + } + + let input = value.trim().as_bytes(); + let mut cursor = 0; + // PostgreSQL prefixes arrays with dimension bounds when a lower bound is + // not one, for example `[0:1]={...}`. JSON has no lower-bound metadata, + // so skip the prefix and retain the array values themselves. + let mut has_dimension_bounds = false; + while input.get(cursor) == Some(&b'[') { + has_dimension_bounds = true; + let end = input.get(cursor..)?.iter().position(|byte| *byte == b']')? + cursor; + cursor = end + 1; + } + if has_dimension_bounds { + if input.get(cursor) != Some(&b'=') { + return None; + } + cursor += 1; + } + let parsed = parse_array(input, &mut cursor)?; + skip_whitespace(input, &mut cursor); + (cursor == input.len()).then(|| normalize_array_value(parsed)) +} + fn decode_pg_text_wkb(value: &str) -> Option { let trimmed = value.trim(); let hex = trimmed @@ -2283,16 +2428,10 @@ pub async fn connect_with_max_connections( fallback_timeout: Duration, max_connections: usize, ) -> Result { - #[cfg(all(windows, target_vendor = "win7"))] - { - connect_with_optional_local_timezone_with_max_connections(url, fallback_timeout, None, max_connections).await - } - - #[cfg(not(all(windows, target_vendor = "win7")))] - { - let timezone = iana_time_zone::get_timezone().unwrap_or_else(|_| "UTC".to_string()); - connect_with_local_timezone_with_max_connections(url, fallback_timeout, &timezone, max_connections).await - } + // Leave the PostgreSQL session timezone untouched. PostgreSQL owns the + // timezone used for timestamptz text output; deriving one from DBX's host + // would make the client's OS timezone override the server configuration. + connect_with_optional_local_timezone_with_max_connections(url, fallback_timeout, None, max_connections).await } /// Test-only thin wrappers (the library now connects through @@ -2302,6 +2441,7 @@ async fn connect_with_local_timezone(url: &str, fallback_timeout: Duration, time connect_with_local_timezone_with_max_connections(url, fallback_timeout, timezone, 10).await } +#[cfg(test)] async fn connect_with_local_timezone_with_max_connections( url: &str, fallback_timeout: Duration, @@ -7634,9 +7774,8 @@ async fn execute_query_with_max_rows_inner( let start = Instant::now(); let row_limit = query_result_row_limit(max_rows); - // Discard stale notices from infrastructure statements (e.g. the timezone - // SET issued at connect time) so only messages raised by this statement - // are attached to its result. + // Discard stale notices from infrastructure statements so only messages + // raised by this statement are attached to its result. let _ = drain_postgres_notices(client).await; let result = if postgres_statement_returns_rows(sql) { @@ -9622,11 +9761,37 @@ mod tests { assert!(pg_type_requires_text_protocol(&enum_array, PgColType::GenericArray)); } + #[test] + fn postgres_builtin_temporal_types_use_server_text_protocol() { + for (pg_type, type_name) in [ + (&Type::TIMESTAMP, "timestamp"), + (&Type::TIMESTAMPTZ, "timestamptz"), + (&Type::DATE, "date"), + (&Type::TIME, "time"), + (&Type::TIMETZ, "timetz"), + (&Type::TIMESTAMP_ARRAY, "_timestamp"), + (&Type::TIMESTAMPTZ_ARRAY, "_timestamptz"), + (&Type::DATE_ARRAY, "_date"), + (&Type::TIME_ARRAY, "_time"), + (&Type::TIMETZ_ARRAY, "_timetz"), + ] { + assert!( + pg_type_requires_text_protocol(pg_type, classify_pg_type(type_name)), + "{type_name} should use PostgreSQL's session-formatted text output" + ); + } + assert!(!pg_type_requires_text_protocol(&Type::INT4, PgColType::Other)); + } + #[test] fn postgres_builtin_or_supported_type_keeps_binary_protocol() { assert!(!pg_type_requires_text_protocol(&Type::INT4, PgColType::Other)); assert!(!pg_type_requires_text_protocol(&Type::VARCHAR, PgColType::Other)); assert!(!pg_type_requires_text_protocol(&Type::INT4_ARRAY, PgColType::GenericArray)); + assert!(!pg_type_requires_text_protocol( + &Type::TIMESTAMPTZ, + PgColType::Temporal { fallback: PgTemporalFallback::Vector } + )); assert!(!pg_scalar_type_requires_text_protocol(98_765, PgColType::Vector)); assert!(!pg_scalar_type_requires_text_protocol(98_765, PgColType::Geometry)); } @@ -9984,8 +10149,15 @@ mod tests { assert_eq!(classify_pg_type("time"), PgColType::Temporal { fallback: PgTemporalFallback::Probe }); assert_eq!(classify_pg_type("timetz"), PgColType::Temporal { fallback: PgTemporalFallback::Probe }); assert_eq!(classify_pg_type("interval"), PgColType::Interval); - // 时间数组类型名在原实现中先进时间分支、解码失败后落到通用数组分支 + // Temporal array type names use the generic array fallback after text decoding. assert_eq!(classify_pg_type("_timestamp"), PgColType::Temporal { fallback: PgTemporalFallback::GenericArray }); + assert_eq!( + classify_pg_type("_timestamptz"), + PgColType::Temporal { fallback: PgTemporalFallback::GenericArray } + ); + assert_eq!(classify_pg_type("_date"), PgColType::Temporal { fallback: PgTemporalFallback::GenericArray }); + assert_eq!(classify_pg_type("_time"), PgColType::Temporal { fallback: PgTemporalFallback::GenericArray }); + assert_eq!(classify_pg_type("_timetz"), PgColType::Temporal { fallback: PgTemporalFallback::GenericArray }); assert_eq!(classify_pg_type("_interval"), PgColType::Temporal { fallback: PgTemporalFallback::GenericArray }); // 同时命中时间关键字与 VECTOR( 前缀的类型名,原实现时间解码失败后走 vector 分支 assert_eq!(classify_pg_type("vector(timestamp)"), PgColType::Temporal { fallback: PgTemporalFallback::Vector }); @@ -10007,7 +10179,6 @@ mod tests { assert_eq!(classify_pg_type("_bit"), PgColType::BitStringArray); assert_eq!(classify_pg_type("_varbit"), PgColType::BitStringArray); assert_eq!(classify_pg_type("_int4"), PgColType::GenericArray); - assert_eq!(classify_pg_type("_time"), PgColType::GenericArray); assert_eq!(classify_pg_type("vector"), PgColType::Vector); assert_eq!(classify_pg_type("vector(3)"), PgColType::Vector); assert_eq!(classify_pg_type("geometry"), PgColType::Geometry); @@ -10067,6 +10238,35 @@ mod tests { } } + #[test] + fn postgres_temporal_text_fallback_keeps_session_wall_time_and_normalizes_offset() { + let temporal = PgColType::Temporal { fallback: PgTemporalFallback::Probe }; + assert_eq!( + pg_text_fallback_value("2026-09-13 13:00:00+12", Some(temporal)), + (serde_json::json!("2026-09-13T13:00:00+12:00"), None) + ); + assert_eq!( + pg_text_fallback_value("2026-09-13 01:00:00", Some(temporal)), + (serde_json::json!("2026-09-13 01:00:00"), None) + ); + } + + #[test] + fn postgres_temporal_text_array_fallback_preserves_values_and_nulls() { + let temporal = PgColType::Temporal { fallback: PgTemporalFallback::GenericArray }; + assert_eq!( + pg_text_fallback_value( + r#"{"2026-09-13 13:00:00+12","2026-09-13 01:00:00-05:30",NULL,"NULL"}"#, + Some(temporal), + ), + (serde_json::json!(["2026-09-13T13:00:00+12:00", "2026-09-13T01:00:00-05:30", null, "NULL"]), None,) + ); + assert_eq!( + pg_text_fallback_value(r#"[0:1]={{"2026-09-13 13:00:00+12"},{NULL}}"#, Some(temporal)), + (serde_json::json!([["2026-09-13T13:00:00+12:00"], [null]]), None,) + ); + } + struct DockerPostgres { name: String, port: u16, @@ -10096,6 +10296,48 @@ mod tests { start_docker_postgres_image("postgres:16-alpine", &[]).await } + #[tokio::test] + async fn postgres_temporal_values_follow_server_timezone_and_preserve_grid_write_text() { + let Some(container) = + start_docker_postgres_image_with_postgres_args("postgres:16-alpine", &[], &["-c", "timezone=Etc/GMT-12"]) + .await + else { + return; + }; + let pool = connect(&container.url(), Duration::from_secs(5)).await.expect("connect postgres"); + let client = pool.get().await.expect("checkout postgres"); + + let timezone: String = client.query_one("SHOW timezone", &[]).await.unwrap().get(0); + assert_eq!(timezone, "Etc/GMT-12"); + + let result = execute_select_query( + &client, + "SELECT TIMESTAMP '2026-09-13 01:00:00' AS without_timezone, TIMESTAMPTZ '2026-09-13 01:00:00+00' AS with_timezone", + Instant::now(), + 10, + ) + .await + .expect("query temporal values"); + assert_eq!(result.rows[0][0], serde_json::json!("2026-09-13 01:00:00")); + assert_eq!(result.rows[0][1], serde_json::json!("2026-09-13T13:00:00+12:00")); + + let separator = if container.url().contains('?') { '&' } else { '?' }; + let explicit_url = format!("{}{separator}options=-c%20TimeZone%3DUTC", container.url()); + let explicit_pool = connect(&explicit_url, Duration::from_secs(5)).await.expect("connect explicit timezone"); + let explicit_client = explicit_pool.get().await.expect("checkout explicit timezone"); + let explicit_timezone: String = explicit_client.query_one("SHOW timezone", &[]).await.unwrap().get(0); + assert_eq!(explicit_timezone, "UTC"); + let explicit_result = execute_select_query( + &explicit_client, + "SELECT TIMESTAMPTZ '2026-09-13 01:00:00+00' AS with_timezone", + Instant::now(), + 10, + ) + .await + .expect("query explicitly zoned temporal value"); + assert_eq!(explicit_result.rows[0][0], serde_json::json!("2026-09-13T01:00:00+00:00")); + } + // PostgreSQL 9.3 (pre-pg_sequence/pg_sequence_last_value) only ships an // amd64 image, so Apple Silicon hosts need explicit emulation. async fn start_docker_postgres_9_3() -> Option { @@ -10103,6 +10345,14 @@ mod tests { } async fn start_docker_postgres_image(image: &str, extra_args: &[&str]) -> Option { + start_docker_postgres_image_with_postgres_args(image, extra_args, &[]).await + } + + async fn start_docker_postgres_image_with_postgres_args( + image: &str, + extra_args: &[&str], + postgres_args: &[&str], + ) -> Option { if !docker_ready() { eprintln!("skipping docker-backed postgres test because Docker is unavailable"); return None; @@ -10125,6 +10375,7 @@ mod tests { &format!("{port}:5432"), image, ]) + .args(postgres_args) .status() .expect("start docker postgres"); assert!(status.success(), "docker run postgres container should succeed"); @@ -11091,9 +11342,9 @@ mod tests { } #[test] - fn timestamptz_display_preserves_local_offset() { - let text = format_pg_timestamptz(Local::now()); - assert!(!text.ends_with("+00:00") || Local::now().offset().local_minus_utc() == 0); + fn timestamptz_binary_fallback_uses_utc_instead_of_host_timezone() { + let value = "2026-09-13T01:00:00+00:00".parse::>().unwrap(); + assert_eq!(format_pg_timestamptz(value), "2026-09-13T01:00:00+00:00"); } // --- validate_postgres_ssl_paths ---