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
This commit is contained in:
Mike Bayer
2022-01-05 12:20:46 -05:00
parent 9298ce03e1
commit 8a62aa58fa
6 changed files with 127 additions and 1 deletions
+17
View File
@@ -0,0 +1,17 @@
.. change::
:tags: bug, sql, postgresql
:tickets: 7537
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).
.. change::
:tags: bug, postgresql, asyncpg
:tickets: 7537
Improved support for asyncpg handling of TIME WITH TIMEZONE, which
was not fully implemented.
+17 -1
View File
@@ -622,6 +622,13 @@ class DateTime(_LookupExpressionAdapter, TypeEngine):
def get_dbapi_type(self, dbapi):
return dbapi.DATETIME
def _resolve_for_literal(self, value):
with_timezone = value.tzinfo is not None
if with_timezone and not self.timezone:
return DATETIME_TIMEZONE
else:
return self
@property
def python_type(self):
return dt.datetime
@@ -692,6 +699,13 @@ class Time(_LookupExpressionAdapter, TypeEngine):
def python_type(self):
return dt.time
def _resolve_for_literal(self, value):
with_timezone = value.tzinfo is not None
if with_timezone and not self.timezone:
return TIME_TIMEZONE
else:
return self
@util.memoized_property
def _expression_adaptations(self):
# Based on https://www.postgresql.org/docs/current/\
@@ -2994,6 +3008,8 @@ STRINGTYPE = String()
INTEGERTYPE = Integer()
MATCHTYPE = MatchType()
TABLEVALUE = TableValueType()
DATETIME_TIMEZONE = DateTime(timezone=True)
TIME_TIMEZONE = Time(timezone=True)
_type_map = {
int: Integer(),
@@ -3031,7 +3047,7 @@ def _resolve_value_to_type(value):
)
return NULLTYPE
else:
return _result_type
return _result_type._resolve_for_literal(value)
# back-assign to type_api
+11
View File
@@ -592,6 +592,17 @@ class TypeEngine(Traversible):
)
return new_type
def _resolve_for_literal(self, value):
"""adjust this type given a literal Python value that will be
stored in a bound parameter.
Used exclusively by _resolve_value_to_type().
.. versionadded:: 1.4.30 or 2.0
"""
return self
@util.memoized_property
def _type_affinity(self):
"""Return a rudimental 'affinity' value expressing the general class
+33
View File
@@ -753,6 +753,29 @@ class SuiteRequirements(Requirements):
return exclusions.open()
@property
def datetime_timezone(self):
"""target dialect supports representation of Python
datetime.datetime() with tzinfo with DateTime(timezone=True)."""
return exclusions.closed()
@property
def time_timezone(self):
"""target dialect supports representation of Python
datetime.time() with tzinfo with Time(timezone=True)."""
return exclusions.closed()
@property
def datetime_implicit_bound(self):
"""target dialect when given a datetime object will bind it such
that the database server knows the object is a datetime, and not
a plain string.
"""
return exclusions.open()
@property
def datetime_microseconds(self):
"""target dialect supports representation of Python
@@ -767,6 +790,16 @@ class SuiteRequirements(Requirements):
if TIMESTAMP is used."""
return exclusions.closed()
@property
def timestamp_microseconds_implicit_bound(self):
"""target dialect when given a datetime object which also includes
a microseconds portion when using the TIMESTAMP data type
will bind it such that the database server knows
the object is a datetime with microseconds, and not a plain string.
"""
return self.timestamp_microseconds
@property
def datetime_historic(self):
"""target dialect supports representation of Python
@@ -302,6 +302,11 @@ class _DateFixture(_LiteralRoundTripFixture, fixtures.TestBase):
Column("decorated_date_data", Decorated),
)
@testing.requires.datetime_implicit_bound
def test_select_direct(self, connection):
result = connection.scalar(select(literal(self.data)))
eq_(result, self.data)
def test_round_trip(self, connection):
date_table = self.tables.date_table
@@ -376,6 +381,15 @@ class DateTimeTest(_DateFixture, fixtures.TablesTest):
data = datetime.datetime(2012, 10, 15, 12, 57, 18)
class DateTimeTZTest(_DateFixture, fixtures.TablesTest):
__requires__ = ("datetime_timezone",)
__backend__ = True
datatype = DateTime(timezone=True)
data = datetime.datetime(
2012, 10, 15, 12, 57, 18, tzinfo=datetime.timezone.utc
)
class DateTimeMicrosecondsTest(_DateFixture, fixtures.TablesTest):
__requires__ = ("datetime_microseconds",)
__backend__ = True
@@ -389,6 +403,11 @@ class TimestampMicrosecondsTest(_DateFixture, fixtures.TablesTest):
datatype = TIMESTAMP
data = datetime.datetime(2012, 10, 15, 12, 57, 18, 396)
@testing.requires.timestamp_microseconds_implicit_bound
def test_select_direct(self, connection):
result = connection.scalar(select(literal(self.data)))
eq_(result, self.data)
class TimeTest(_DateFixture, fixtures.TablesTest):
__requires__ = ("time",)
@@ -397,6 +416,13 @@ class TimeTest(_DateFixture, fixtures.TablesTest):
data = datetime.time(12, 57, 18)
class TimeTZTest(_DateFixture, fixtures.TablesTest):
__requires__ = ("time_timezone",)
__backend__ = True
datatype = Time(timezone=True)
data = datetime.time(12, 57, 18, tzinfo=datetime.timezone.utc)
class TimeMicrosecondsTest(_DateFixture, fixtures.TablesTest):
__requires__ = ("time_microseconds",)
__backend__ = True
@@ -1515,6 +1541,7 @@ __all__ = (
"JSONLegacyStringCastIndexTest",
"DateTest",
"DateTimeTest",
"DateTimeTZTest",
"TextTest",
"NumericTest",
"IntegerTest",
@@ -1524,6 +1551,7 @@ __all__ = (
"TimeMicrosecondsTest",
"TimestampMicrosecondsTest",
"TimeTest",
"TimeTZTest",
"TrueDivTest",
"DateTimeMicrosecondsTest",
"DateHistoricTest",
+21
View File
@@ -1046,6 +1046,23 @@ class DefaultRequirements(SuiteRequirements):
return exclusions.open()
@property
def datetime_implicit_bound(self):
"""target dialect when given a datetime object will bind it such
that the database server knows the object is a datetime, and not
a plain string.
"""
return exclusions.fails_on(["mysql", "mariadb"])
@property
def datetime_timezone(self):
return exclusions.only_on("postgresql")
@property
def time_timezone(self):
return exclusions.only_on("postgresql") + exclusions.skip_if("+pg8000")
@property
def datetime_microseconds(self):
"""target dialect supports representation of Python
@@ -1061,6 +1078,10 @@ class DefaultRequirements(SuiteRequirements):
return only_on(["oracle"])
@property
def timestamp_microseconds_implicit_bound(self):
return self.timestamp_microseconds + exclusions.fails_on(["oracle"])
@property
def datetime_historic(self):
"""target dialect supports representation of Python