Skip to content

Commit 70bc25c

Browse files
committed
fix incorrect transpilation to tz types, add tests
1 parent 239c349 commit 70bc25c

6 files changed

Lines changed: 65 additions & 1 deletion

File tree

src/mimic_utils/compare_concepts.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,19 +55,34 @@ def _list_tables_duckdb(con, schema):
5555

5656
def _as_numeric(series):
5757
"""Return a float Series if every non-null value coerces, else None."""
58+
if _is_datetime_like_series(series):
59+
return None
5860
coerced = pd.to_numeric(series, errors="coerce")
5961
if coerced.isna().sum() == series.isna().sum():
6062
return coerced.astype(float)
6163
return None
6264

6365

66+
def _is_datetime_like_series(series):
67+
"""Return True when values are already datetimes, even if stored as object dtype."""
68+
if pd.api.types.is_datetime64_any_dtype(series.dtype) or pd.api.types.is_datetime64tz_dtype(series.dtype):
69+
return True
70+
non_null = series.dropna()
71+
if non_null.empty:
72+
return False
73+
sample = non_null.iloc[0]
74+
return isinstance(sample, (pd.Timestamp, np.datetime64)) or hasattr(sample, "tzinfo")
75+
76+
6477
def _as_datetime(series):
6578
"""Return a datetime Series if every non-null value parses, else None."""
6679
import warnings
6780
with warnings.catch_warnings():
6881
warnings.simplefilter("ignore") # silence per-element parse fallback notices
6982
coerced = pd.to_datetime(series, errors="coerce")
7083
if coerced.isna().sum() == series.isna().sum():
84+
if pd.api.types.is_datetime64tz_dtype(coerced.dtype):
85+
coerced = coerced.dt.tz_localize(None)
7186
return coerced
7287
return None
7388

src/mimic_utils/sqlglot_dialects/duckdb.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
class MimicDuckDB(DuckDB):
1212
class Generator(DuckDB.Generator):
1313
def datatype_sql(self, expression: exp.DataType) -> str:
14+
if expression.this == exp.DataType.Type.TIMESTAMPTZ:
15+
return "TIMESTAMP"
1416
if expression.this == exp.DataType.Type.DECIMAL and not expression.expressions:
1517
return "DECIMAL(38, 9)"
1618
return super().datatype_sql(expression)

src/mimic_utils/sqlglot_dialects/postgres.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ def _generate_array_sql(self: Postgres.Generator, expression: exp.Expression) ->
8080

8181
class MimicPostgres(Postgres):
8282
class Generator(Postgres.Generator):
83+
def datatype_sql(self, expression: exp.DataType) -> str:
84+
if expression.this == exp.DataType.Type.TIMESTAMPTZ:
85+
return "TIMESTAMP"
86+
return super().datatype_sql(expression)
87+
8388
TRANSFORMS = {
8489
**Postgres.Generator.TRANSFORMS,
8590
exp.DatetimeDiff: _datetime_diff_sql,

src/mimic_utils/transpile.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import os
3+
import re
34
from pathlib import Path
45
from typing import Union
56

@@ -25,6 +26,12 @@
2526
_CATALOG_TO_REMOVE = "physionet-data"
2627

2728

29+
def _strip_timezone_types(sql: str) -> str:
30+
"""Normalize any timezone-aware timestamp types back to naive TIMESTAMP."""
31+
sql = re.sub(r"\bTIMESTAMP\s+WITH\s+TIME\s+ZONE\b", "TIMESTAMP", sql)
32+
return re.sub(r"\bTIMESTAMPTZ\b", "TIMESTAMP", sql)
33+
34+
2835
def _strip_catalog(parsed: exp.Expression) -> None:
2936
"""Remove the ``physionet-data`` catalog from every table reference in place."""
3037
for table in parsed.find_all(exp.Table):
@@ -50,11 +57,12 @@ def transpile_query(query: str, source_dialect: str = "bigquery", destination_di
5057
# drop comments when targeting it.
5158
keep_comments = destination_dialect != "duckdb"
5259

53-
return parsed.sql(
60+
sql = parsed.sql(
5461
dialect=_DESTINATION_DIALECTS[destination_dialect],
5562
pretty=True,
5663
comments=keep_comments,
5764
)
65+
return _strip_timezone_types(sql)
5866

5967

6068
def transpile_file(

tests/test_compare_concepts.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from datetime import datetime, timedelta, timezone
2+
3+
import pandas as pd
4+
5+
from mimic_utils.compare_concepts import compare_table
6+
7+
8+
def test_compare_table_treats_same_wall_time_as_equal_across_timezones():
9+
pg = pd.DataFrame({"charttime": [datetime(2150, 1, 1, 3, 30, tzinfo=timezone(timedelta(hours=2)))]})
10+
duck = pd.DataFrame({"charttime": [datetime(2150, 1, 1, 3, 30)]})
11+
12+
assert compare_table(pg, duck, 1e-6, 1e-9) == (True, "1 rows OK")
13+
14+
15+
def test_compare_table_flags_different_wall_times_even_if_instants_match():
16+
pg = pd.DataFrame({"charttime": [datetime(2150, 1, 1, 3, 30, tzinfo=timezone(timedelta(hours=2)))]})
17+
duck = pd.DataFrame({"charttime": [datetime(2150, 1, 1, 1, 30)]})
18+
19+
assert compare_table(pg, duck, 1e-6, 1e-9) == (
20+
False,
21+
"1 differing cells in columns: charttime(1)",
22+
)

tests/test_transpile.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ def test_duckdb_comments_stripped():
102102
assert "/*" in transpile_query(bq, "bigquery", "postgres")
103103

104104

105+
@pytest.mark.parametrize(
106+
"bq",
107+
[
108+
"SELECT TIMESTAMP('2150-01-01 00:00:00') AS ts",
109+
"SELECT CAST(DATETIME '2150-01-01 00:00:00' AS TIMESTAMP) AS ts",
110+
],
111+
)
112+
def test_timestamp_outputs_stay_timezone_naive(bq):
113+
for dialect in ("postgres", "duckdb"):
114+
assert "TIMESTAMPTZ" not in transpile_query(bq, "bigquery", dialect)
115+
116+
105117
def test_unsupported_dialect_raises():
106118
with pytest.raises(ValueError):
107119
transpile_query("SELECT 1", "bigquery", "mysql")

0 commit comments

Comments
 (0)