From 85f7305828d43fe9df078b176c0ae9d1a7059630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lorenz=20H=C3=BCbschle?= Date: Wed, 7 Oct 2026 16:05:39 +0200 Subject: [PATCH 1/3] fix: preserve datetime parameter timezone semantics --- docsrc/Connecting_and_queries.rst | 17 +++++- src/firebolt/common/statement_formatter.py | 18 +++--- .../V2/test_datetime_parameters_async.py | 61 +++++++++++++++++++ .../dbapi/sync/V2/test_datetime_parameters.py | 61 +++++++++++++++++++ tests/unit/common/test_typing_format.py | 49 +++++++++++++-- 5 files changed, 193 insertions(+), 13 deletions(-) create mode 100644 tests/integration/dbapi/async/V2/test_datetime_parameters_async.py create mode 100644 tests/integration/dbapi/sync/V2/test_datetime_parameters.py diff --git a/docsrc/Connecting_and_queries.rst b/docsrc/Connecting_and_queries.rst index 2250bf3e63b..1339fd65fab 100644 --- a/docsrc/Connecting_and_queries.rst +++ b/docsrc/Connecting_and_queries.rst @@ -911,6 +911,22 @@ as it will lead to a nondeterministic data returned. Follow the best practice fr Using DATE and DATETIME values ============================== +For Firebolt 2.0 query parameters, pass Python ``datetime`` objects directly. +Naive datetimes are bound as ``TIMESTAMP`` values, preserving their date/time +fields. Aware datetimes are bound as ``TIMESTAMPTZ`` values with an explicit UTC +offset, preserving the instant. Both retain microseconds. + +When an aware parameter is assigned to a ``TIMESTAMP`` column, Firebolt converts +it to the session timezone before dropping timezone information. When a naive +parameter is assigned to a ``TIMESTAMPTZ`` column, Firebolt interprets it in the +session timezone. Earlier SDK versions converted aware parameters to UTC and +discarded the offset; applications relying on that behavior should choose their +desired timezone explicitly before passing a naive datetime. + +With ``fb_numeric`` parameters, datetimes are sent as strings with their original +offset and microseconds. Cast the parameter to ``TIMESTAMP`` for naive values or +``TIMESTAMPTZ`` for aware values in SQL, for example ``CAST($1 AS TIMESTAMPTZ)``. + DATE, DATETIME and TIMESTAMP values used in SQL insertion statements must be provided in a specific format; otherwise they could be read incorrectly. @@ -1146,4 +1162,3 @@ Transaction limitations and notes For complete details on transaction capabilities and limitations, see the `Firebolt explicit transactions documentation `_. - diff --git a/src/firebolt/common/statement_formatter.py b/src/firebolt/common/statement_formatter.py index 91364f23867..b72e8f3dc52 100644 --- a/src/firebolt/common/statement_formatter.py +++ b/src/firebolt/common/statement_formatter.py @@ -61,8 +61,9 @@ def _patched_change_splitlevel(self, ttype, value): # type: ignore[no-untyped-d class StatementFormatter: - def __init__(self, escape_chars: Dict[str, str]): + def __init__(self, escape_chars: Dict[str, str], version: int = 2): self.escape_chars = escape_chars + self._version = version def format_value(self, value: ParameterType) -> str: """For Python value to be used in a SQL query.""" @@ -73,11 +74,14 @@ def format_value(self, value: ParameterType) -> str: elif isinstance(value, str): return f"'{''.join(self.escape_chars.get(c, c) for c in value)}'" elif isinstance(value, datetime): - if value.tzinfo is not None: - value = value.astimezone(timezone.utc) - # Keep fractional seconds; whole-second values render as before. - fmt = "%Y-%m-%d %H:%M:%S.%f" if value.microsecond else "%Y-%m-%d %H:%M:%S" - return f"'{value.strftime(fmt)}'" + if self._version == 1: + if value.tzinfo is not None: + value = value.astimezone(timezone.utc) + fmt = "%Y-%m-%d %H:%M:%S.%f" if value.microsecond else "%Y-%m-%d %H:%M:%S" + return f"'{value.strftime(fmt)}'" + # The parameter's type preserves awareness; the server casts to the target. + sql_type = "TIMESTAMPTZ" if value.utcoffset() is not None else "TIMESTAMP" + return f"{sql_type} '{value.isoformat(sep=' ')}'" elif isinstance(value, date): return f"'{value.isoformat()}'" elif isinstance(value, bytes): @@ -274,7 +278,7 @@ def format_bulk_insert( def create_statement_formatter(version: int) -> StatementFormatter: if version == 1: - return StatementFormatter(escape_chars_v1) + return StatementFormatter(escape_chars_v1, version=1) elif version == 2: return StatementFormatter(escape_chars_v2) else: diff --git a/tests/integration/dbapi/async/V2/test_datetime_parameters_async.py b/tests/integration/dbapi/async/V2/test_datetime_parameters_async.py new file mode 100644 index 00000000000..80c0531b279 --- /dev/null +++ b/tests/integration/dbapi/async/V2/test_datetime_parameters_async.py @@ -0,0 +1,61 @@ +from datetime import datetime, timedelta, timezone + +from pytest import MonkeyPatch, mark + +import firebolt.async_db +from firebolt.async_db import Connection +from tests.integration.dbapi.conftest import generate_unique_table_name + + +@mark.parametrize("session_timezone", ["UTC", "Europe/Berlin"]) +@mark.parametrize("aware", [False, True]) +@mark.parametrize("paramstyle", ["qmark", "fb_numeric"]) +async def test_datetime_parameters( + connection: Connection, + monkeypatch: MonkeyPatch, + session_timezone: str, + aware: bool, + paramstyle: str, +) -> None: + monkeypatch.setattr(firebolt.async_db, "paramstyle", paramstyle) + value = datetime(2024, 7, 1, 12, 0, 0, 123456) + session_offset = ( + timezone.utc if session_timezone == "UTC" else timezone(timedelta(hours=2)) + ) + if aware: + value = value.replace(tzinfo=timezone(timedelta(hours=5, minutes=45))) + expected_ts = value.astimezone(session_offset).replace(tzinfo=None) + expected_tz = value.astimezone(session_offset) + else: + expected_ts = value + expected_tz = value.replace(tzinfo=session_offset) + + input_type = "TIMESTAMPTZ" if aware else "TIMESTAMP" + # Server-side JSON parameters are TEXT and require explicit casts. + placeholders = ( + ["?", "?"] + if paramstyle == "qmark" + else [f"CAST(${i} AS {input_type})" for i in (1, 2)] + ) + table = generate_unique_table_name() + connection.init_parameters["timezone"] = session_timezone + async with connection.cursor() as c: + await c.execute(f'CREATE TABLE "{table}" (ts TIMESTAMP, tz TIMESTAMPTZ)') + try: + await c.execute( + f'INSERT INTO "{table}" VALUES ({", ".join(placeholders)})', + [value, value], + ) + await c.execute(f'SELECT ts, tz FROM "{table}"') + assert await c.fetchone() == [expected_ts, expected_tz] + + await c.execute( + f'SELECT COUNT(*) FROM "{table}" WHERE ts = {placeholders[0]} AND tz = {placeholders[1]}', + [value, value], + ) + assert await c.fetchone() == [1] + + await c.execute(f"SELECT {placeholders[0]}", [value]) + assert await c.fetchone() == [value] + finally: + await c.execute(f'DROP TABLE "{table}"') diff --git a/tests/integration/dbapi/sync/V2/test_datetime_parameters.py b/tests/integration/dbapi/sync/V2/test_datetime_parameters.py new file mode 100644 index 00000000000..4c95df6bcf0 --- /dev/null +++ b/tests/integration/dbapi/sync/V2/test_datetime_parameters.py @@ -0,0 +1,61 @@ +from datetime import datetime, timedelta, timezone + +from pytest import MonkeyPatch, mark + +import firebolt.db +from firebolt.db import Connection +from tests.integration.dbapi.conftest import generate_unique_table_name + + +@mark.parametrize("session_timezone", ["UTC", "Europe/Berlin"]) +@mark.parametrize("aware", [False, True]) +@mark.parametrize("paramstyle", ["qmark", "fb_numeric"]) +def test_datetime_parameters( + connection: Connection, + monkeypatch: MonkeyPatch, + session_timezone: str, + aware: bool, + paramstyle: str, +) -> None: + monkeypatch.setattr(firebolt.db, "paramstyle", paramstyle) + value = datetime(2024, 7, 1, 12, 0, 0, 123456) + session_offset = ( + timezone.utc if session_timezone == "UTC" else timezone(timedelta(hours=2)) + ) + if aware: + value = value.replace(tzinfo=timezone(timedelta(hours=5, minutes=45))) + expected_ts = value.astimezone(session_offset).replace(tzinfo=None) + expected_tz = value.astimezone(session_offset) + else: + expected_ts = value + expected_tz = value.replace(tzinfo=session_offset) + + input_type = "TIMESTAMPTZ" if aware else "TIMESTAMP" + # Server-side JSON parameters are TEXT and require explicit casts. + placeholders = ( + ["?", "?"] + if paramstyle == "qmark" + else [f"CAST(${i} AS {input_type})" for i in (1, 2)] + ) + table = generate_unique_table_name() + connection.init_parameters["timezone"] = session_timezone + with connection.cursor() as c: + c.execute(f'CREATE TABLE "{table}" (ts TIMESTAMP, tz TIMESTAMPTZ)') + try: + c.execute( + f'INSERT INTO "{table}" VALUES ({", ".join(placeholders)})', + [value, value], + ) + c.execute(f'SELECT ts, tz FROM "{table}"') + assert c.fetchone() == [expected_ts, expected_tz] + + c.execute( + f'SELECT COUNT(*) FROM "{table}" WHERE ts = {placeholders[0]} AND tz = {placeholders[1]}', + [value, value], + ) + assert c.fetchone() == [1] + + c.execute(f"SELECT {placeholders[0]}", [value]) + assert c.fetchone() == [value] + finally: + c.execute(f'DROP TABLE "{table}"') diff --git a/tests/unit/common/test_typing_format.py b/tests/unit/common/test_typing_format.py index 3f3586f9c97..d551fcd9c44 100644 --- a/tests/unit/common/test_typing_format.py +++ b/tests/unit/common/test_typing_format.py @@ -47,15 +47,15 @@ def formatter_v1() -> StatementFormatter: (False, "false"), # Date, datetime (date(2022, 1, 10), "'2022-01-10'"), - (datetime(2022, 1, 10, 1, 1, 1), "'2022-01-10 01:01:01'"), + (datetime(2022, 1, 10, 1, 1, 1), "TIMESTAMP '2022-01-10 01:01:01'"), ( datetime(2022, 1, 10, 1, 1, 1, tzinfo=timezone(timedelta(hours=1))), - "'2022-01-10 00:01:01'", + "TIMESTAMPTZ '2022-01-10 01:01:01+01:00'", ), - (datetime(2022, 1, 10, 1, 1, 1, 123456), "'2022-01-10 01:01:01.123456'"), + (datetime(2022, 1, 10, 1, 1, 1, 123456), "TIMESTAMP '2022-01-10 01:01:01.123456'"), ( datetime(2022, 1, 10, 1, 1, 1, 50, tzinfo=timezone(timedelta(hours=1))), - "'2022-01-10 00:01:01.000050'", + "TIMESTAMPTZ '2022-01-10 01:01:01.000050+01:00'", ), # List, tuple ([], "[]"), @@ -78,7 +78,46 @@ def test_format_statement_keeps_datetime_fractional_seconds( statement = parse("SELECT * FROM t WHERE ts = ?")[0] assert ( formatter.format_statement(statement, [datetime(2024, 2, 29, 1, 2, 3, 4)]) - == "SELECT * FROM t WHERE ts = '2024-02-29 01:02:03.000004'" + == "SELECT * FROM t WHERE ts = TIMESTAMP '2024-02-29 01:02:03.000004'" + ) + + +@mark.parametrize( + "value,result", + [ + (datetime(1, 1, 1), "TIMESTAMP '0001-01-01 00:00:00'"), + ( + datetime(2024, 7, 1, 12, 0, 0, 4), + "TIMESTAMP '2024-07-01 12:00:00.000004'", + ), + ( + datetime(2024, 7, 1, 12, 0, 0, 123456, tzinfo=timezone.utc), + "TIMESTAMPTZ '2024-07-01 12:00:00.123456+00:00'", + ), + ( + datetime( + 2024, 7, 1, 12, 0, tzinfo=timezone(timedelta(hours=5, minutes=45)) + ), + "TIMESTAMPTZ '2024-07-01 12:00:00+05:45'", + ), + ( + datetime( + 2024, 7, 1, 12, 0, tzinfo=timezone(-timedelta(hours=3, minutes=30)) + ), + "TIMESTAMPTZ '2024-07-01 12:00:00-03:30'", + ), + ], +) +def test_datetime_parameter_types( + formatter: StatementFormatter, value: datetime, result: str +) -> None: + assert formatter.format_value(value) == result + assert formatter.format_value([value]) == f"[{result}]" + assert ( + formatter.format_statement(parse("SELECT ?")[0], [value]) == f"SELECT {result}" + ) + assert formatter.convert_parameter_for_serialization(value) == value.isoformat( + sep=" " ) From d5da7f05c8347e8ef8c65547f25618d8e8397de1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lorenz=20H=C3=BCbschle?= Date: Wed, 7 Oct 2026 17:30:28 +0200 Subject: [PATCH 2/3] docs: clarify datetime parameter timezone behavior --- docsrc/Connecting_and_queries.rst | 34 +++++++++++++++++-------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/docsrc/Connecting_and_queries.rst b/docsrc/Connecting_and_queries.rst index 1339fd65fab..220a1ea0b29 100644 --- a/docsrc/Connecting_and_queries.rst +++ b/docsrc/Connecting_and_queries.rst @@ -911,24 +911,28 @@ as it will lead to a nondeterministic data returned. Follow the best practice fr Using DATE and DATETIME values ============================== -For Firebolt 2.0 query parameters, pass Python ``datetime`` objects directly. -Naive datetimes are bound as ``TIMESTAMP`` values, preserving their date/time -fields. Aware datetimes are bound as ``TIMESTAMPTZ`` values with an explicit UTC -offset, preserving the instant. Both retain microseconds. - -When an aware parameter is assigned to a ``TIMESTAMP`` column, Firebolt converts -it to the session timezone before dropping timezone information. When a naive -parameter is assigned to a ``TIMESTAMPTZ`` column, Firebolt interprets it in the -session timezone. Earlier SDK versions converted aware parameters to UTC and -discarded the offset; applications relying on that behavior should choose their -desired timezone explicitly before passing a naive datetime. +For query parameters, pass Python ``datetime`` objects directly. Datetimes +without timezone information are bound as ``TIMESTAMP`` values, preserving their +date/time fields. Timezone-aware datetimes are bound as ``TIMESTAMPTZ`` values +with an explicit UTC offset, preserving the instant. Both retain microseconds. + +When a timezone-aware parameter is assigned to a ``TIMESTAMP`` column, Firebolt +converts it to the session timezone before dropping timezone information. When +a parameter without timezone information is assigned to a ``TIMESTAMPTZ`` +column, Firebolt interprets it in the session timezone. Earlier SDK versions +converted timezone-aware parameters to UTC and discarded the offset; +applications relying on that behavior should explicitly convert to UTC and +remove timezone information before passing the datetime. With ``fb_numeric`` parameters, datetimes are sent as strings with their original -offset and microseconds. Cast the parameter to ``TIMESTAMP`` for naive values or -``TIMESTAMPTZ`` for aware values in SQL, for example ``CAST($1 AS TIMESTAMPTZ)``. +offset and microseconds. Cast the parameter to ``TIMESTAMP`` for values without +timezone information or ``TIMESTAMPTZ`` for timezone-aware values in SQL, for +example ``CAST($1 AS TIMESTAMPTZ)``. -DATE, DATETIME and TIMESTAMP values used in SQL insertion statements must be provided in -a specific format; otherwise they could be read incorrectly. +Legacy Firebolt 1.0 connections retain their existing datetime formatting. + +When writing date/time values directly into SQL rather than using parameters, +use the following formats: * DATE values should be formatted as **YYYY-MM-DD** From 25da462ecdc643cd0d92782c4a371ad9c9761fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lorenz=20H=C3=BCbschle?= Date: Wed, 7 Oct 2026 17:34:07 +0200 Subject: [PATCH 3/3] style: format datetime changes after rebase --- src/firebolt/common/statement_formatter.py | 4 +++- tests/unit/common/test_typing_format.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/firebolt/common/statement_formatter.py b/src/firebolt/common/statement_formatter.py index b72e8f3dc52..76bfed0c86d 100644 --- a/src/firebolt/common/statement_formatter.py +++ b/src/firebolt/common/statement_formatter.py @@ -77,7 +77,9 @@ def format_value(self, value: ParameterType) -> str: if self._version == 1: if value.tzinfo is not None: value = value.astimezone(timezone.utc) - fmt = "%Y-%m-%d %H:%M:%S.%f" if value.microsecond else "%Y-%m-%d %H:%M:%S" + fmt = ( + "%Y-%m-%d %H:%M:%S.%f" if value.microsecond else "%Y-%m-%d %H:%M:%S" + ) return f"'{value.strftime(fmt)}'" # The parameter's type preserves awareness; the server casts to the target. sql_type = "TIMESTAMPTZ" if value.utcoffset() is not None else "TIMESTAMP" diff --git a/tests/unit/common/test_typing_format.py b/tests/unit/common/test_typing_format.py index d551fcd9c44..2ad31e8d987 100644 --- a/tests/unit/common/test_typing_format.py +++ b/tests/unit/common/test_typing_format.py @@ -52,7 +52,10 @@ def formatter_v1() -> StatementFormatter: datetime(2022, 1, 10, 1, 1, 1, tzinfo=timezone(timedelta(hours=1))), "TIMESTAMPTZ '2022-01-10 01:01:01+01:00'", ), - (datetime(2022, 1, 10, 1, 1, 1, 123456), "TIMESTAMP '2022-01-10 01:01:01.123456'"), + ( + datetime(2022, 1, 10, 1, 1, 1, 123456), + "TIMESTAMP '2022-01-10 01:01:01.123456'", + ), ( datetime(2022, 1, 10, 1, 1, 1, 50, tzinfo=timezone(timedelta(hours=1))), "TIMESTAMPTZ '2022-01-10 01:01:01.000050+01:00'",