Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions docsrc/Connecting_and_queries.rst
Original file line number Diff line number Diff line change
Expand Up @@ -911,8 +911,28 @@ as it will lead to a nondeterministic data returned. Follow the best practice fr
Using DATE and DATETIME values
==============================

DATE, DATETIME and TIMESTAMP values used in SQL insertion statements must be provided in
a specific format; otherwise they could be read incorrectly.
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

Copy link
Copy Markdown

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 TIMESTAMP column. Reads are affected too. Older SDK versions stored aware values as UTC wall-clock time in TIMESTAMP columns. In a non-UTC session, the same aware datetime used in a filter no longer matches those rows. I reproduced it on Core with timezone=Europe/Berlin: with a stored ts = 2024-01-01 12:00:00, WHERE ts = ? with datetime(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.

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 values without
timezone information or ``TIMESTAMPTZ`` for timezone-aware values in SQL, for
example ``CAST($1 AS TIMESTAMPTZ)``.

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**

Expand Down Expand Up @@ -1146,4 +1166,3 @@ Transaction limitations and notes

For complete details on transaction capabilities and limitations, see the
`Firebolt explicit transactions documentation <https://docs.firebolt.io/reference-sql/explicit-transactions>`_.

20 changes: 13 additions & 7 deletions src/firebolt/common/statement_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=firebolt-db_firebolt-python-sdk&issues=AaEW_-cMyPcr14nGHaMf&open=AaEW_-cMyPcr14nGHaMf&pullRequest=544
"""For Python value to be used in a SQL query."""
if isinstance(value, bool):
return "true" if value else "false"
Expand All @@ -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"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An aware datetime compared with a TIMESTAMP column now casts the column, not the literal. Firebolt Core plans WHERE ts = ? as Filter (cast(rv544.ts as timestamptz) = TIMESTAMPTZ '...'). With the old untyped literal, the plan was rv544.ts = TIMESTAMP '...'. When ts is the primary index or partition column, wrapping it in a cast per row is likely to stop index/partition pruning, so the query scans the whole table. Naive datetimes compared with DATE columns get the same plan shape (cast(d as timestamp)).

return f"{sql_type} '{value.isoformat(sep=' ')}'"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 TIMESTAMP/TIMESTAMPTZ, so:

  • WHERE text_col = ? with datetime(2024,1,1,12) now errors: operator '=' for input types (text, timestamp) not found. Before, it matched.
  • INSERT INTO t(text_col) VALUES (?) with an aware datetime now stores '2024-01-01 06:15:00+00', rendered in the session timezone with a +00 suffix. Before, it stored '2024-01-01 06:15:00'.

I checked both on Firebolt Core. Neither case is mentioned in the compatibility note in the docs.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A datetime parameter compared with a DATE column now returns different rows, with no error. Before, WHERE d = ? with datetime(2024,1,1,12,0) sent '2024-01-01 12:00:00', which Firebolt casts to DATE and truncates, so it matched rows for 2024-01-01. Now the predicate becomes cast(d as timestamp) = TIMESTAMP '2024-01-01 12:00:00', which matches nothing unless the time is midnight. On Firebolt Core, the same table returned 3 rows with the old literal and 0 with the new one. This affects range filters too, such as d >= ? and d <= ?.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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. isoformat() writes offsets like +00:19:32, and Firebolt rejects them: Unable to cast TEXT '1900-01-01 00:00:00+00:19:32' to timestamptz. Invalid timestamptz constant. Python produces these offsets for zoneinfo LMT offsets on historical dates, for example datetime(1900,1,1,tzinfo=ZoneInfo("Europe/Amsterdam")), and for any timezone(timedelta(seconds=...)). The old code converted to UTC first, so these values worked. One fix is to normalize offsets that are not whole minutes, for example with value.astimezone(timezone.utc) when value.utcoffset().seconds % 60 is non-zero, before formatting.

elif isinstance(value, date):
return f"'{value.isoformat()}'"
elif isinstance(value, bytes):
Expand Down Expand Up @@ -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:
Expand Down
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}"')
61 changes: 61 additions & 0 deletions tests/integration/dbapi/sync/V2/test_datetime_parameters.py
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}"')
52 changes: 47 additions & 5 deletions tests/unit/common/test_typing_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,18 @@ 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),
"TIMESTAMP '2022-01-10 01:01:01.123456'",
),
(datetime(2022, 1, 10, 1, 1, 1, 123456), "'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
([], "[]"),
Expand All @@ -78,7 +81,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=" "
)


Expand Down
Loading