Skip to content

Commit 8a62aa5

Browse files
committed
implement second-level type resolution for literals
Added additional rule to the system that determines ``TypeEngine`` implementations from Python literals to apply a second level of adjustment to the type, so that a Python datetime with or without tzinfo can set the ``timezone=True`` parameter on the returned :class:`.DateTime` object, as well as :class:`.Time`. This helps with some round-trip scenarios on type-sensitive PostgreSQL dialects such as asyncpg, psycopg3 (2.0 only). For 1.4 specifically, the backport improves support for asyncpg handling of TIME WITH TIMEZONE, which was not fully implemented. 2.0's reworked PostgreSQL architecture had this handled already. Fixes: #7537 Change-Id: Icdb07db85af5f7f39f1c1ef855fe27609770094b
1 parent 9298ce0 commit 8a62aa5

6 files changed

Lines changed: 127 additions & 1 deletion

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.. change::
2+
:tags: bug, sql, postgresql
3+
:tickets: 7537
4+
5+
Added additional rule to the system that determines ``TypeEngine``
6+
implementations from Python literals to apply a second level of adjustment
7+
to the type, so that a Python datetime with or without tzinfo can set the
8+
``timezone=True`` parameter on the returned :class:`.DateTime` object, as
9+
well as :class:`.Time`. This helps with some round-trip scenarios on
10+
type-sensitive PostgreSQL dialects such as asyncpg, psycopg3 (2.0 only).
11+
12+
.. change::
13+
:tags: bug, postgresql, asyncpg
14+
:tickets: 7537
15+
16+
Improved support for asyncpg handling of TIME WITH TIMEZONE, which
17+
was not fully implemented.

lib/sqlalchemy/sql/sqltypes.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,13 @@ def __init__(self, timezone=False):
622622
def get_dbapi_type(self, dbapi):
623623
return dbapi.DATETIME
624624

625+
def _resolve_for_literal(self, value):
626+
with_timezone = value.tzinfo is not None
627+
if with_timezone and not self.timezone:
628+
return DATETIME_TIMEZONE
629+
else:
630+
return self
631+
625632
@property
626633
def python_type(self):
627634
return dt.datetime
@@ -692,6 +699,13 @@ def get_dbapi_type(self, dbapi):
692699
def python_type(self):
693700
return dt.time
694701

702+
def _resolve_for_literal(self, value):
703+
with_timezone = value.tzinfo is not None
704+
if with_timezone and not self.timezone:
705+
return TIME_TIMEZONE
706+
else:
707+
return self
708+
695709
@util.memoized_property
696710
def _expression_adaptations(self):
697711
# Based on https://www.postgresql.org/docs/current/\
@@ -2994,6 +3008,8 @@ class MatchType(Boolean):
29943008
INTEGERTYPE = Integer()
29953009
MATCHTYPE = MatchType()
29963010
TABLEVALUE = TableValueType()
3011+
DATETIME_TIMEZONE = DateTime(timezone=True)
3012+
TIME_TIMEZONE = Time(timezone=True)
29973013

29983014
_type_map = {
29993015
int: Integer(),
@@ -3031,7 +3047,7 @@ def _resolve_value_to_type(value):
30313047
)
30323048
return NULLTYPE
30333049
else:
3034-
return _result_type
3050+
return _result_type._resolve_for_literal(value)
30353051

30363052

30373053
# back-assign to type_api

lib/sqlalchemy/sql/type_api.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,17 @@ def with_variant(
592592
)
593593
return new_type
594594

595+
def _resolve_for_literal(self, value):
596+
"""adjust this type given a literal Python value that will be
597+
stored in a bound parameter.
598+
599+
Used exclusively by _resolve_value_to_type().
600+
601+
.. versionadded:: 1.4.30 or 2.0
602+
603+
"""
604+
return self
605+
595606
@util.memoized_property
596607
def _type_affinity(self):
597608
"""Return a rudimental 'affinity' value expressing the general class

lib/sqlalchemy/testing/requirements.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,29 @@ def datetime(self):
753753

754754
return exclusions.open()
755755

756+
@property
757+
def datetime_timezone(self):
758+
"""target dialect supports representation of Python
759+
datetime.datetime() with tzinfo with DateTime(timezone=True)."""
760+
761+
return exclusions.closed()
762+
763+
@property
764+
def time_timezone(self):
765+
"""target dialect supports representation of Python
766+
datetime.time() with tzinfo with Time(timezone=True)."""
767+
768+
return exclusions.closed()
769+
770+
@property
771+
def datetime_implicit_bound(self):
772+
"""target dialect when given a datetime object will bind it such
773+
that the database server knows the object is a datetime, and not
774+
a plain string.
775+
776+
"""
777+
return exclusions.open()
778+
756779
@property
757780
def datetime_microseconds(self):
758781
"""target dialect supports representation of Python
@@ -767,6 +790,16 @@ def timestamp_microseconds(self):
767790
if TIMESTAMP is used."""
768791
return exclusions.closed()
769792

793+
@property
794+
def timestamp_microseconds_implicit_bound(self):
795+
"""target dialect when given a datetime object which also includes
796+
a microseconds portion when using the TIMESTAMP data type
797+
will bind it such that the database server knows
798+
the object is a datetime with microseconds, and not a plain string.
799+
800+
"""
801+
return self.timestamp_microseconds
802+
770803
@property
771804
def datetime_historic(self):
772805
"""target dialect supports representation of Python

lib/sqlalchemy/testing/suite/test_types.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,11 @@ class Decorated(TypeDecorator):
302302
Column("decorated_date_data", Decorated),
303303
)
304304

305+
@testing.requires.datetime_implicit_bound
306+
def test_select_direct(self, connection):
307+
result = connection.scalar(select(literal(self.data)))
308+
eq_(result, self.data)
309+
305310
def test_round_trip(self, connection):
306311
date_table = self.tables.date_table
307312

@@ -376,6 +381,15 @@ class DateTimeTest(_DateFixture, fixtures.TablesTest):
376381
data = datetime.datetime(2012, 10, 15, 12, 57, 18)
377382

378383

384+
class DateTimeTZTest(_DateFixture, fixtures.TablesTest):
385+
__requires__ = ("datetime_timezone",)
386+
__backend__ = True
387+
datatype = DateTime(timezone=True)
388+
data = datetime.datetime(
389+
2012, 10, 15, 12, 57, 18, tzinfo=datetime.timezone.utc
390+
)
391+
392+
379393
class DateTimeMicrosecondsTest(_DateFixture, fixtures.TablesTest):
380394
__requires__ = ("datetime_microseconds",)
381395
__backend__ = True
@@ -389,6 +403,11 @@ class TimestampMicrosecondsTest(_DateFixture, fixtures.TablesTest):
389403
datatype = TIMESTAMP
390404
data = datetime.datetime(2012, 10, 15, 12, 57, 18, 396)
391405

406+
@testing.requires.timestamp_microseconds_implicit_bound
407+
def test_select_direct(self, connection):
408+
result = connection.scalar(select(literal(self.data)))
409+
eq_(result, self.data)
410+
392411

393412
class TimeTest(_DateFixture, fixtures.TablesTest):
394413
__requires__ = ("time",)
@@ -397,6 +416,13 @@ class TimeTest(_DateFixture, fixtures.TablesTest):
397416
data = datetime.time(12, 57, 18)
398417

399418

419+
class TimeTZTest(_DateFixture, fixtures.TablesTest):
420+
__requires__ = ("time_timezone",)
421+
__backend__ = True
422+
datatype = Time(timezone=True)
423+
data = datetime.time(12, 57, 18, tzinfo=datetime.timezone.utc)
424+
425+
400426
class TimeMicrosecondsTest(_DateFixture, fixtures.TablesTest):
401427
__requires__ = ("time_microseconds",)
402428
__backend__ = True
@@ -1515,6 +1541,7 @@ def test_string_cast_crit_against_string_basic(self):
15151541
"JSONLegacyStringCastIndexTest",
15161542
"DateTest",
15171543
"DateTimeTest",
1544+
"DateTimeTZTest",
15181545
"TextTest",
15191546
"NumericTest",
15201547
"IntegerTest",
@@ -1524,6 +1551,7 @@ def test_string_cast_crit_against_string_basic(self):
15241551
"TimeMicrosecondsTest",
15251552
"TimestampMicrosecondsTest",
15261553
"TimeTest",
1554+
"TimeTZTest",
15271555
"TrueDivTest",
15281556
"DateTimeMicrosecondsTest",
15291557
"DateHistoricTest",

test/requirements.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,23 @@ def datetime(self):
10461046

10471047
return exclusions.open()
10481048

1049+
@property
1050+
def datetime_implicit_bound(self):
1051+
"""target dialect when given a datetime object will bind it such
1052+
that the database server knows the object is a datetime, and not
1053+
a plain string.
1054+
1055+
"""
1056+
return exclusions.fails_on(["mysql", "mariadb"])
1057+
1058+
@property
1059+
def datetime_timezone(self):
1060+
return exclusions.only_on("postgresql")
1061+
1062+
@property
1063+
def time_timezone(self):
1064+
return exclusions.only_on("postgresql") + exclusions.skip_if("+pg8000")
1065+
10491066
@property
10501067
def datetime_microseconds(self):
10511068
"""target dialect supports representation of Python
@@ -1061,6 +1078,10 @@ def timestamp_microseconds(self):
10611078

10621079
return only_on(["oracle"])
10631080

1081+
@property
1082+
def timestamp_microseconds_implicit_bound(self):
1083+
return self.timestamp_microseconds + exclusions.fails_on(["oracle"])
1084+
10641085
@property
10651086
def datetime_historic(self):
10661087
"""target dialect supports representation of Python

0 commit comments

Comments
 (0)