Skip to content

Commit 89cf2b5

Browse files
feat: Fir 18774 new date types (#229)
1 parent 187250a commit 89cf2b5

File tree

7 files changed

+150
-26
lines changed

7 files changed

+150
-26
lines changed

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ install_requires =
2929
cryptography>=3.4.0
3030
httpx[http2]==0.23.0
3131
pydantic[dotenv]>=1.8.2,<1.10
32+
python-dateutil>=2.8.2
3233
readerwriterlock==1.0.9
3334
sqlparse>=0.4.2
3435
trio<0.22.0

src/firebolt/async_db/_types.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import re
34
from collections import namedtuple
45
from datetime import date, datetime, timezone
56
from decimal import Decimal
@@ -18,13 +19,26 @@
1819
try:
1920
from ciso8601 import parse_datetime # type: ignore
2021
except ImportError:
21-
# Unfortunately, there seems to be no support for optional bits in strptime
22-
def parse_datetime(date_string: str) -> datetime: # type: ignore
23-
format = "%Y-%m-%d %H:%M:%S.%f"
24-
# fromisoformat doesn't support milliseconds
25-
if "." in date_string:
26-
return datetime.strptime(date_string, format)
27-
return datetime.fromisoformat(date_string)
22+
unsupported_milliseconds_re = re.compile(r"(?<=\.)\d{1,5}(?!\d)")
23+
24+
def _fix_milliseconds(datetime_string: str) -> str:
25+
# Fill milliseconds with 0 to have exactly 6 digits
26+
# Python parser only supports 3 or 6 digit milliseconds untill 3.11
27+
def align_ms(match: re.Match) -> str:
28+
ms = match.group()
29+
return ms + "0" * (6 - len(ms))
30+
31+
return re.sub(unsupported_milliseconds_re, align_ms, datetime_string)
32+
33+
def _fix_timezone(datetime_string: str) -> str:
34+
# timezone, provided as +/-dd is not supported by datetime.
35+
# We need to append :00 to it
36+
if datetime_string[-3] in "+-":
37+
return datetime_string + ":00"
38+
return datetime_string
39+
40+
def parse_datetime(datetime_string: str) -> datetime:
41+
return datetime.fromisoformat(_fix_timezone(_fix_milliseconds(datetime_string)))
2842

2943

3044
from firebolt.utils.exception import (
@@ -179,9 +193,12 @@ class _InternalType(Enum):
179193
# DATE
180194
Date = "Date"
181195
Date32 = "Date32"
196+
PGDate = "PGDate"
182197

183198
# DATETIME, TIMESTAMP
184199
DateTime = "DateTime"
200+
TimestampNtz = "TimestampNtz"
201+
TimestampTz = "TimestampTz"
185202

186203
# Nullable(Nothing)
187204
Nothing = "Nothing"
@@ -203,7 +220,10 @@ def python_type(self) -> type:
203220
_InternalType.String: str,
204221
_InternalType.Date: date,
205222
_InternalType.Date32: date,
223+
_InternalType.PGDate: date,
206224
_InternalType.DateTime: datetime,
225+
_InternalType.TimestampNtz: datetime,
226+
_InternalType.TimestampTz: datetime,
207227
# For simplicity, this could happen only during 'select null' query
208228
_InternalType.Nothing: str,
209229
}

tests/integration/dbapi/async/test_queries_async.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,15 @@ async def test_connect_engine_name(
9797
all_types_query: str,
9898
all_types_query_description: List[Column],
9999
all_types_query_response: List[ColType],
100+
timezone_name: str,
100101
) -> None:
101102
"""Connecting with engine name is handled properly."""
102103
await test_select(
103104
connection_engine_name,
104105
all_types_query,
105106
all_types_query_description,
106107
all_types_query_response,
108+
timezone_name,
107109
)
108110

109111

@@ -112,13 +114,15 @@ async def test_connect_no_engine(
112114
all_types_query: str,
113115
all_types_query_description: List[Column],
114116
all_types_query_response: List[ColType],
117+
timezone_name: str,
115118
) -> None:
116119
"""Connecting with engine name is handled properly."""
117120
await test_select(
118121
connection_no_engine,
119122
all_types_query,
120123
all_types_query_description,
121124
all_types_query_response,
125+
timezone_name,
122126
)
123127

124128

@@ -127,9 +131,16 @@ async def test_select(
127131
all_types_query: str,
128132
all_types_query_description: List[Column],
129133
all_types_query_response: List[ColType],
134+
timezone_name: str,
130135
) -> None:
131136
"""Select handles all data types properly."""
132137
with connection.cursor() as c:
138+
assert (
139+
await c.execute(f"SET advanced_mode=1") == -1
140+
), "Invalid set statment row count"
141+
assert (
142+
await c.execute(f"SET time_zone={timezone_name}") == -1
143+
), "Invalid set statment row count"
133144
assert await c.execute(all_types_query) == 1, "Invalid row count returned"
134145
assert c.rowcount == 1, "Invalid rowcount value"
135146
data = await c.fetchall()

tests/integration/dbapi/conftest.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import date, datetime
1+
from datetime import date, datetime, timedelta, timezone
22
from decimal import Decimal
33
from logging import getLogger
44
from typing import List
@@ -67,8 +67,11 @@ def all_types_query() -> str:
6767
"'text' as \"string\", "
6868
"CAST('2021-03-28' AS DATE) as \"date\", "
6969
"CAST('1860-03-04' AS DATE_EXT) as \"date32\","
70+
"pgdate '0001-01-01' as pgdate, "
7071
"CAST('2019-07-31 01:01:01' AS DATETIME) as \"datetime\", "
7172
"CAST('2019-07-31 01:01:01.1234' AS TIMESTAMP_EXT(4)) as \"datetime64\", "
73+
"CAST('1111-01-05 17:04:42.123456' as timestampntz) as timestampntz, "
74+
"'1111-01-05 17:04:42.123456'::timestamptz as timestamptz,"
7275
'true as "bool",'
7376
"[1,2,3,4] as \"array\", cast('1231232.123459999990457054844258706536' as "
7477
'decimal(38,30)) as "decimal", '
@@ -92,8 +95,11 @@ def all_types_query_description() -> List[Column]:
9295
Column("string", str, None, None, None, None, None),
9396
Column("date", date, None, None, None, None, None),
9497
Column("date32", date, None, None, None, None, None),
98+
Column("pgdate", date, None, None, None, None, None),
9599
Column("datetime", datetime, None, None, None, None, None),
96100
Column("datetime64", DATETIME64(4), None, None, None, None, None),
101+
Column("timestampntz", datetime, None, None, None, None, None),
102+
Column("timestamptz", datetime, None, None, None, None, None),
97103
Column("bool", int, None, None, None, None, None),
98104
Column("array", ARRAY(int), None, None, None, None, None),
99105
Column("decimal", DECIMAL(38, 30), None, None, None, None, None),
@@ -102,7 +108,7 @@ def all_types_query_description() -> List[Column]:
102108

103109

104110
@fixture
105-
def all_types_query_response() -> List[ColType]:
111+
def all_types_query_response(timezone_offset_seconds: int) -> List[ColType]:
106112
return [
107113
[
108114
1,
@@ -118,8 +124,20 @@ def all_types_query_response() -> List[ColType]:
118124
"text",
119125
date(2021, 3, 28),
120126
date(1860, 3, 4),
127+
date(1, 1, 1),
121128
datetime(2019, 7, 31, 1, 1, 1),
122129
datetime(2019, 7, 31, 1, 1, 1, 123400),
130+
datetime(1111, 1, 5, 17, 4, 42, 123456),
131+
datetime(
132+
1111,
133+
1,
134+
5,
135+
17,
136+
4,
137+
42,
138+
123456,
139+
tzinfo=timezone(timedelta(seconds=timezone_offset_seconds)),
140+
),
123141
1,
124142
[1, 2, 3, 4],
125143
Decimal("1231232.123459999990457054844258706536"),
@@ -128,6 +146,16 @@ def all_types_query_response() -> List[ColType]:
128146
]
129147

130148

149+
@fixture
150+
def timezone_name() -> str:
151+
return "Asia/Calcutta"
152+
153+
154+
@fixture
155+
def timezone_offset_seconds() -> int:
156+
return 5 * 3600 + 53 * 60 + 28 # 05:53:28
157+
158+
131159
@fixture
132160
def create_drop_description() -> List[Column]:
133161
return [

tests/integration/dbapi/sync/test_queries.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,15 @@ def test_connect_engine_name(
5656
all_types_query: str,
5757
all_types_query_description: List[Column],
5858
all_types_query_response: List[ColType],
59+
timezone_name: str,
5960
) -> None:
6061
"""Connecting with engine name is handled properly."""
6162
test_select(
6263
connection_engine_name,
6364
all_types_query,
6465
all_types_query_description,
6566
all_types_query_response,
67+
timezone_name,
6668
)
6769

6870

@@ -71,13 +73,15 @@ def test_connect_no_engine(
7173
all_types_query: str,
7274
all_types_query_description: List[Column],
7375
all_types_query_response: List[ColType],
76+
timezone_name: str,
7477
) -> None:
7578
"""Connecting with engine name is handled properly."""
7679
test_select(
7780
connection_no_engine,
7881
all_types_query,
7982
all_types_query_description,
8083
all_types_query_response,
84+
timezone_name,
8185
)
8286

8387

@@ -86,9 +90,15 @@ def test_select(
8690
all_types_query: str,
8791
all_types_query_description: List[Column],
8892
all_types_query_response: List[ColType],
93+
timezone_name: str,
8994
) -> None:
9095
"""Select handles all data types properly."""
9196
with connection.cursor() as c:
97+
assert c.execute(f"SET advanced_mode=1") == -1, "Invalid set statment row count"
98+
assert (
99+
c.execute(f"SET time_zone={timezone_name}") == -1
100+
), "Invalid set statment row count"
101+
92102
assert c.execute(all_types_query) == 1, "Invalid row count returned"
93103
assert c.rowcount == 1, "Invalid rowcount value"
94104
data = c.fetchall()

tests/unit/async_db/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ def types_map() -> Dict[str, type]:
5050
"String": str,
5151
"Date": date,
5252
"Date32": date,
53+
"PGDate": date,
5354
"DateTime": datetime,
5455
"DateTime64(7)": DATETIME64(7),
56+
"TimestampNtz": datetime,
57+
"TimestampTz": datetime,
5558
"Nullable(Nothing)": str,
5659
"Decimal(123, 4)": DECIMAL(123, 4),
5760
"Decimal(38,0)": DECIMAL(38, 0),

tests/unit/async_db/test_typing_parse.py

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
from datetime import date, datetime
1+
from datetime import date, datetime, timedelta, timezone
22
from decimal import Decimal
3-
from typing import Dict
3+
from typing import Dict, Optional
44

5-
from pytest import raises
5+
from pytest import mark, raises
66

77
from firebolt.async_db import (
88
ARRAY,
@@ -72,22 +72,73 @@ def test_parse_value_str() -> None:
7272
assert parse_value(None, str) is None, "Error parsing str: provided None"
7373

7474

75-
def test_parse_value_datetime() -> None:
76-
"""parse_value parses all date and datetime values correctly."""
77-
# Date
78-
assert parse_value("2021-12-31", date) == date(
79-
2021, 12, 31
80-
), "Error parsing date: str provided"
81-
assert parse_value("1860-12-31", date) == date(
82-
1860, 12, 31
83-
), "Error parsing extended date: str provided"
84-
85-
assert parse_value(None, date) is None, "Error parsing date: None provided"
75+
@mark.parametrize(
76+
"value,expected,case",
77+
[
78+
("2021-12-31", date(2021, 12, 31), "str provided"),
79+
("0001-01-01", date(1, 1, 1), "range low provided"),
80+
("9999-12-31", date(9999, 12, 31), "range high provided"),
81+
(None, None, "None provided"),
82+
("2021-12-31 23:59:59", date(2021, 12, 31), "datetime provided"),
83+
],
84+
)
85+
def test_parse_value_date(value: Optional[str], expected: Optional[date], case: str):
86+
"""parse_value parses all date values correctly."""
87+
assert parse_value(value, date) == expected, f"Error parsing date: {case}"
88+
89+
90+
@mark.parametrize(
91+
"value,expected,case",
92+
[
93+
(
94+
"2021-12-31 23:59:59.1234",
95+
datetime(2021, 12, 31, 23, 59, 59, 123400),
96+
"str provided",
97+
),
98+
(
99+
"0001-01-01 00:00:00.000000",
100+
datetime(1, 1, 1, 0, 0, 0, 0),
101+
"range low provided",
102+
),
103+
(
104+
"9999-12-31 23:59:59.999999",
105+
datetime(9999, 12, 31, 23, 59, 59, 999999),
106+
"range high provided",
107+
),
108+
(
109+
"2021-12-31 23:59:59.1234-03",
110+
datetime(
111+
2021, 12, 31, 23, 59, 59, 123400, tzinfo=timezone(timedelta(hours=-3))
112+
),
113+
"timezone provided",
114+
),
115+
(
116+
"2021-12-31 23:59:59.1234+05:30:12",
117+
datetime(
118+
2021,
119+
12,
120+
31,
121+
23,
122+
59,
123+
59,
124+
123400,
125+
tzinfo=timezone(timedelta(hours=5, minutes=30, seconds=12)),
126+
),
127+
"timezone with seconds provided",
128+
),
129+
(None, None, "None provided"),
130+
("2021-12-31", datetime(2021, 12, 31), "date provided"),
131+
],
132+
)
133+
def test_parse_value_datetime(
134+
value: Optional[str], expected: Optional[date], case: str
135+
):
136+
"""parse_value parses all date values correctly."""
137+
assert parse_value(value, datetime) == expected, f"Error parsing datetime: {case}"
86138

87-
assert parse_value("2021-12-31 23:59:59", date) == date(
88-
2021, 12, 31
89-
), "Error parsing date: datetime string provided"
90139

140+
def test_parse_value_datetime_errors() -> None:
141+
"""parse_value parses all date and datetime values correctly."""
91142
with raises(ValueError):
92143
parse_value("abd", date)
93144

0 commit comments

Comments
 (0)