Skip to content

Commit 158e2ca

Browse files
committed
Support Interval Type
1 parent c01d58f commit 158e2ca

File tree

6 files changed

+321
-256
lines changed

6 files changed

+321
-256
lines changed

Pipfile.lock

Lines changed: 197 additions & 173 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

databend_sqlalchemy/connector.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ def escape_item(self, item):
4848
return self.escape_number(item)
4949
elif isinstance(item, decimal.Decimal):
5050
return self.escape_number(item)
51+
elif isinstance(item, timedelta):
52+
return self.escape_string(f'{item.total_seconds()} seconds') + '::interval'
5153
elif isinstance(item, (datetime, date, time, timedelta)):
5254
return self.escape_string(item.strftime("%Y-%m-%d %H:%M:%S"))
5355
else:

databend_sqlalchemy/databend_dialect.py

Lines changed: 7 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@
5050
)
5151
from sqlalchemy.engine import ExecutionContext, default
5252
from sqlalchemy.exc import DBAPIError, NoSuchTableError
53+
5354
from .dml import Merge
55+
from .types import INTERVAL
5456

5557
RESERVED_WORDS = {
5658
'Error', 'EOI', 'Whitespace', 'Comment', 'CommentBlock', 'Ident', 'ColumnPosition', 'LiteralString',
@@ -232,44 +234,8 @@ def process(value):
232234
return process
233235

234236

235-
class DatabendInterval(sqltypes.Interval):
236-
"""Stores interval as a datetime relative to epoch, see base implementation."""
237-
238-
_reg = re.compile(r"(\d+)-(\d+)-(\d+) (\d+):(\d+):(\d+)")
239-
240-
def result_processor(self, dialect, coltype):
241-
def process(value):
242-
if value is None:
243-
return None
244-
if isinstance(value, str):
245-
m = self._reg.match(value)
246-
if not m:
247-
raise ValueError(
248-
"could not parse %r as a datetime value" % (value,)
249-
)
250-
groups = m.groups()
251-
dt = datetime.datetime(*[
252-
int(groups[0] or self.epoch.year),
253-
int(groups[1] or self.epoch.month),
254-
int(groups[2] or self.epoch.day),
255-
int(groups[3] or 0),
256-
int(groups[4] or 0),
257-
int(groups[5] or 0),
258-
])
259-
else:
260-
dt = value
261-
return dt - self.epoch
262-
263-
return process
264-
265-
def literal_processor(self, dialect):
266-
def process(value):
267-
if value is not None:
268-
d = self.epoch + value
269-
interval_str = d.isoformat(" ", timespec="microseconds")
270-
return f"'{interval_str}'"
271-
272-
return process
237+
class DatabendInterval(INTERVAL):
238+
render_bind_cast = True
273239

274240

275241
# Type converters
@@ -526,6 +492,9 @@ def visit_JSON(self, type_, **kw):
526492
def visit_TIME(self, type_, **kw):
527493
return "DATETIME"
528494

495+
def visit_INTERVAL(self, type, **kw):
496+
return "INTERVAL"
497+
529498

530499
class DatabendDDLCompiler(compiler.DDLCompiler):
531500

databend_sqlalchemy/provision.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
from sqlalchemy.testing.provision import create_db
33
from sqlalchemy.testing.provision import drop_db
4-
from sqlalchemy.testing.provision import configure_follower
4+
from sqlalchemy.testing.provision import configure_follower, update_db_opts
55

66

77
@create_db.for_db("databend")
@@ -36,3 +36,8 @@ def _databend_drop_db(cfg, eng, ident):
3636
def _databend_configure_follower(config, ident):
3737
config.test_schema = "%s_test_schema" % ident
3838
config.test_schema_2 = "%s_test_schema_2" % ident
39+
40+
# Uncomment to debug SQL Statements in tests
41+
# @update_db_opts.for_db("databend")
42+
# def _mssql_update_db_opts(db_url, db_opts):
43+
# db_opts["echo"] = True

databend_sqlalchemy/types.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
2+
from __future__ import annotations
3+
4+
import datetime as dt
5+
import re
6+
from typing import Optional, Type, Any
7+
8+
from sqlalchemy.engine.interfaces import Dialect
9+
from sqlalchemy.sql import sqltypes
10+
from sqlalchemy.sql import type_api
11+
12+
INTERVAL_RE = re.compile(
13+
r"^"
14+
r"(?:(?P<days>-?\d+) (days? ?))?"
15+
r"(?:(?P<sign>[-+])?"
16+
r"(?P<hours>\d+):"
17+
r"(?P<minutes>\d\d):"
18+
r"(?P<seconds>\d\d)"
19+
r"(?:\.(?P<microseconds>\d{1,6}))?"
20+
r")?$"
21+
)
22+
23+
24+
# ToDo - This is perhaps how numeric should be defined
25+
# class NUMERIC(sqltypes.Numeric):
26+
# def result_processor(self, dialect, type_):
27+
#
28+
# orig = super().result_processor(dialect, type_)
29+
#
30+
# def process(value):
31+
# if value is not None:
32+
# if self.decimal_return_scale:
33+
# value = decimal.Decimal(f'{value:.{self.decimal_return_scale}f}')
34+
# else:
35+
# value = decimal.Decimal(value)
36+
# if orig:
37+
# return orig(value)
38+
# return value
39+
#
40+
# return process
41+
42+
43+
class INTERVAL(type_api.NativeForEmulated, sqltypes._AbstractInterval):
44+
"""Databend INTERVAL type."""
45+
46+
__visit_name__ = "INTERVAL"
47+
native = True
48+
49+
def __init__(
50+
self, precision: Optional[int] = None, fields: Optional[str] = None
51+
) -> None:
52+
"""Construct an INTERVAL.
53+
54+
:param precision: optional integer precision value
55+
:param fields: string fields specifier. allows storage of fields
56+
to be limited, such as ``"YEAR"``, ``"MONTH"``, ``"DAY TO HOUR"``,
57+
etc.
58+
59+
"""
60+
self.precision = precision
61+
self.fields = fields
62+
63+
@classmethod
64+
def adapt_emulated_to_native(
65+
cls, interval: sqltypes.Interval, **kw: Any # type: ignore[override]
66+
) -> INTERVAL:
67+
return INTERVAL(precision=interval.second_precision)
68+
69+
@property
70+
def _type_affinity(self) -> Type[sqltypes.Interval]:
71+
return sqltypes.Interval
72+
73+
def as_generic(self, allow_nulltype: bool = False) -> sqltypes.Interval:
74+
return sqltypes.Interval(native=True, second_precision=self.precision)
75+
76+
@property
77+
def python_type(self) -> Type[dt.timedelta]:
78+
return dt.timedelta
79+
80+
def literal_processor(
81+
self, dialect: Dialect
82+
) -> Optional[type_api._LiteralProcessorType[dt.timedelta]]:
83+
def process(value: dt.timedelta) -> str:
84+
return f"to_interval('{value.total_seconds()} seconds')"
85+
86+
return process
87+
88+
# ToDo - If BendSQL returns a timedelta for interval, then we should be able to remove this method
89+
def result_processor(
90+
self, dialect: Dialect, coltype: Any
91+
) -> type_api._ResultProcessorType[dt.timedelta]:
92+
93+
def process(value: Any) -> Optional[dt.timedelta]:
94+
"""Parse a duration string and return a datetime.timedelta."""
95+
if value is None:
96+
return None
97+
98+
match = INTERVAL_RE.match(value)
99+
if match:
100+
kw = match.groupdict()
101+
sign = -1 if kw.pop("sign", "+") == "-" else 1
102+
if kw.get("microseconds"):
103+
kw["microseconds"] = kw["microseconds"].ljust(6, "0")
104+
kw = {k: float(v.replace(",", ".")) for k, v in kw.items() if v is not None}
105+
days = dt.timedelta(kw.pop("days", 0.0) or 0.0)
106+
107+
return days + sign * dt.timedelta(**kw)
108+
109+
return process

tests/test_sqlalchemy.py

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,10 @@
1616
from sqlalchemy.testing.suite import JoinTest as _JoinTest
1717
from sqlalchemy.testing.suite import BizarroCharacterFKResolutionTest as _BizarroCharacterFKResolutionTest
1818
from sqlalchemy.testing.suite import ServerSideCursorsTest as _ServerSideCursorsTest
19-
from sqlalchemy.testing.suite import IntervalTest as _IntervalTest
20-
from sqlalchemy.testing.suite import PrecisionIntervalTest as _PrecisionIntervalTest
2119
from sqlalchemy.testing.suite import EnumTest as _EnumTest
2220
from sqlalchemy import types as sql_types
2321
from sqlalchemy import testing, select
2422
from sqlalchemy.testing import config, eq_
25-
from databend_sqlalchemy.databend_dialect import DatabendInterval
2623

2724

2825
class ComponentReflectionTest(_ComponentReflectionTest):
@@ -283,47 +280,6 @@ def test_roundtrip_fetchmany(self):
283280
pass
284281

285282

286-
class IntervalTest(_IntervalTest):
287-
__backend__ = True
288-
datatype = DatabendInterval
289-
290-
@testing.skip("databend") # Skipped because cannot figure out the literal() part
291-
def test_arithmetic_operation_literal_interval(self, connection):
292-
pass
293-
294-
@testing.skip("databend") # Skipped because cannot figure out the literal() part
295-
def test_arithmetic_operation_table_interval_and_literal_interval(
296-
self, connection, arithmetic_table_fixture
297-
):
298-
pass
299-
300-
@testing.skip("databend") # Skipped because cannot figure out the literal() part
301-
def test_arithmetic_operation_table_date_and_literal_interval(
302-
self, connection, arithmetic_table_fixture
303-
):
304-
pass
305-
306-
307-
class PrecisionIntervalTest(_PrecisionIntervalTest):
308-
__backend__ = True
309-
datatype = DatabendInterval
310-
311-
@testing.skip("databend") # Skipped because cannot figure out the literal() part
312-
def test_arithmetic_operation_literal_interval(self, connection):
313-
pass
314-
315-
@testing.skip("databend") # Skipped because cannot figure out the literal() part
316-
def test_arithmetic_operation_table_interval_and_literal_interval(
317-
self, connection, arithmetic_table_fixture
318-
):
319-
pass
320-
321-
@testing.skip("databend") # Skipped because cannot figure out the literal() part
322-
def test_arithmetic_operation_table_date_and_literal_interval(
323-
self, connection, arithmetic_table_fixture
324-
):
325-
pass
326-
327283
class EnumTest(_EnumTest):
328284
__backend__ = True
329285

0 commit comments

Comments
 (0)