Repository navigation
fix(NoTicket): preserve datetime parameter timezone semantics #544
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,10 +61,11 @@ | |
|
|
||
|
|
||
| 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: | ||
|
Check failure on line 68 in src/firebolt/common/statement_formatter.py
|
||
| """For Python value to be used in a SQL query.""" | ||
| if isinstance(value, bool): | ||
| return "true" if value else "false" | ||
|
|
@@ -73,11 +74,16 @@ | |
| 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" | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missed this in #527: this should only apply for FB2. Fixed here. |
||
| ) | ||
| 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" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. An aware datetime compared with a |
||
| return f"{sql_type} '{value.isoformat(sep=' ')}'" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A datetime parameter used in a TEXT context now fails or stores a different value. Before this change it was an untyped string literal that Firebolt coerced to whatever type the context needed. Now it is a typed
I checked both on Firebolt Core. Neither case is mentioned in the compatibility note in the docs. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A datetime parameter compared with a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Aware datetimes whose UTC offset has a seconds part now fail on the server. |
||
| elif isinstance(value, date): | ||
| return f"'{value.isoformat()}'" | ||
| elif isinstance(value, bytes): | ||
|
|
@@ -274,7 +280,7 @@ | |
|
|
||
| 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: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}"') |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}"') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This note only covers assigning an aware parameter to a
TIMESTAMPcolumn. Reads are affected too. Older SDK versions stored aware values as UTC wall-clock time inTIMESTAMPcolumns. In a non-UTC session, the same aware datetime used in a filter no longer matches those rows. I reproduced it on Core withtimezone=Europe/Berlin: with a storedts = 2024-01-01 12:00:00,WHERE ts = ?withdatetime(2024,1,1,12,tzinfo=timezone.utc)returned 1 row with the old SDK and returns 0 now. Please document this explicitly, since applications that set a session timezone will silently get different results from existing data. It is also worth considering whether this needs a major version bump.