Skip to content

Commit 655a5a2

Browse files
authored
Add TimeZone support (microsoft#140)
* Added timezone support and skipped unit test that uses cursor * Removed unneeded import
1 parent b02c821 commit 655a5a2

File tree

9 files changed

+100
-11
lines changed

9 files changed

+100
-11
lines changed

mssql/base.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import re
99
import time
1010
import struct
11+
import datetime
1112

1213
from django.core.exceptions import ImproperlyConfigured
1314

@@ -35,7 +36,7 @@
3536
from .client import DatabaseClient # noqa
3637
from .creation import DatabaseCreation # noqa
3738
from .features import DatabaseFeatures # noqa
38-
from .introspection import DatabaseIntrospection # noqa
39+
from .introspection import DatabaseIntrospection, SQL_TIMESTAMP_WITH_TIMEZONE # noqa
3940
from .operations import DatabaseOperations # noqa
4041
from .schema import DatabaseSchemaEditor # noqa
4142

@@ -80,6 +81,13 @@ def encode_value(v):
8081
return v
8182

8283

84+
def handle_datetimeoffset(dto_value):
85+
# Decode bytes returned from SQL Server
86+
# source: https://github.com/mkleehammer/pyodbc/wiki/Using-an-Output-Converter-function
87+
tup = struct.unpack("<6hI2h", dto_value) # e.g., (2017, 3, 16, 10, 35, 18, 500000000)
88+
return datetime.datetime(tup[0], tup[1], tup[2], tup[3], tup[4], tup[5], tup[6] // 1000)
89+
90+
8391
class DatabaseWrapper(BaseDatabaseWrapper):
8492
vendor = 'microsoft'
8593
display_name = 'SQL Server'
@@ -95,7 +103,7 @@ class DatabaseWrapper(BaseDatabaseWrapper):
95103
'BooleanField': 'bit',
96104
'CharField': 'nvarchar(%(max_length)s)',
97105
'DateField': 'date',
98-
'DateTimeField': 'datetime2',
106+
'DateTimeField': 'datetimeoffset',
99107
'DecimalField': 'numeric(%(max_digits)s, %(decimal_places)s)',
100108
'DurationField': 'bigint',
101109
'FileField': 'nvarchar(%(max_length)s)',
@@ -364,6 +372,9 @@ def get_new_connection(self, conn_params):
364372
if not need_to_retry:
365373
raise
366374

375+
# Handling values from DATETIMEOFFSET columns
376+
# source: https://github.com/mkleehammer/pyodbc/wiki/Using-an-Output-Converter-function
377+
conn.add_output_converter(SQL_TIMESTAMP_WITH_TIMEZONE, handle_datetimeoffset)
367378
conn.timeout = query_timeout
368379
if setencoding:
369380
for entry in setencoding:

mssql/features.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
4646
supports_subqueries_in_group_by = False
4747
supports_tablespaces = True
4848
supports_temporal_subtraction = True
49-
supports_timezones = False
49+
supports_timezones = True
5050
supports_transactions = True
5151
uses_savepoints = True
5252
has_bulk_insert = True

mssql/introspection.py

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

1313
SQL_AUTOFIELD = -777555
1414
SQL_BIGAUTOFIELD = -777444
15+
SQL_TIMESTAMP_WITH_TIMEZONE = -155
1516

1617

1718
def get_schema_name():
@@ -41,7 +42,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
4142
Database.SQL_TINYINT: 'SmallIntegerField',
4243
Database.SQL_TYPE_DATE: 'DateField',
4344
Database.SQL_TYPE_TIME: 'TimeField',
44-
Database.SQL_TYPE_TIMESTAMP: 'DateTimeField',
45+
SQL_TIMESTAMP_WITH_TIMEZONE: 'DateTimeField',
4546
Database.SQL_VARBINARY: 'BinaryField',
4647
Database.SQL_VARCHAR: 'TextField',
4748
Database.SQL_WCHAR: 'CharField',

mssql/operations.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import datetime
55
import uuid
66
import warnings
7+
import sys
78

89
from django.conf import settings
910
from django.db.backends.base.operations import BaseDatabaseOperations
@@ -26,7 +27,7 @@ def max_in_list_size(self):
2627
return 2048
2728

2829
def _convert_field_to_tz(self, field_name, tzname):
29-
if settings.USE_TZ and not tzname == 'UTC':
30+
if tzname and settings.USE_TZ and self.connection.timezone_name != tzname:
3031
offset = self._get_utcoffset(tzname)
3132
field_name = 'DATEADD(second, %d, %s)' % (offset, field_name)
3233
return field_name
@@ -107,7 +108,7 @@ def combine_expression(self, connector, sub_expressions):
107108

108109
def convert_datetimefield_value(self, value, expression, connection):
109110
if value is not None:
110-
if settings.USE_TZ:
111+
if settings.USE_TZ and not timezone.is_aware(value):
111112
value = timezone.make_aware(value, self.connection.timezone)
112113
return value
113114

@@ -129,6 +130,8 @@ def date_extract_sql(self, lookup_type, field_name):
129130
return "DATEPART(weekday, %s)" % field_name
130131
elif lookup_type == 'week':
131132
return "DATEPART(iso_week, %s)" % field_name
133+
elif lookup_type == 'iso_week_day':
134+
return "DATEPART(weekday, DATEADD(day, -1, %s))" % field_name
132135
elif lookup_type == 'iso_year':
133136
return "YEAR(DATEADD(day, 26 - DATEPART(isoww, %s), %s))" % (field_name, field_name)
134137
else:
@@ -144,7 +147,8 @@ def date_interval_sql(self, timedelta):
144147
sql = 'DATEADD(microsecond, %d%%s, CAST(%s AS datetime2))' % (timedelta.microseconds, sql)
145148
return sql
146149

147-
def date_trunc_sql(self, lookup_type, field_name, tzname=''):
150+
def date_trunc_sql(self, lookup_type, field_name, tzname=None):
151+
field_name = self._convert_field_to_tz(field_name, tzname)
148152
CONVERT_YEAR = 'CONVERT(varchar, DATEPART(year, %s))' % field_name
149153
CONVERT_QUARTER = 'CONVERT(varchar, 1+((DATEPART(quarter, %s)-1)*3))' % field_name
150154
CONVERT_MONTH = 'CONVERT(varchar, DATEPART(month, %s))' % field_name
@@ -480,9 +484,22 @@ def adapt_datetimefield_value(self, value):
480484
"""
481485
if value is None:
482486
return None
483-
if settings.USE_TZ and timezone.is_aware(value):
484-
# pyodbc donesn't support datetimeoffset
485-
value = value.astimezone(self.connection.timezone).replace(tzinfo=None)
487+
488+
# Expression values are adapted by the database.
489+
if hasattr(value, 'resolve_expression'):
490+
return value
491+
492+
if timezone.is_aware(value):
493+
if settings.USE_TZ:
494+
# When support for time zones is enabled, Django stores datetime information
495+
# in UTC in the database and uses time-zone-aware objects internally
496+
# source: https://docs.djangoproject.com/en/dev/topics/i18n/timezones/#overview
497+
value = value.astimezone(timezone.utc)
498+
else:
499+
# When USE_TZ is False, settings.TIME_ZONE is the time zone in
500+
# which Django will store all datetimes
501+
# source: https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TIME_ZONE
502+
value = timezone.make_naive(value, self.connection.timezone)
486503
return value
487504

488505
def time_trunc_sql(self, lookup_type, field_name, tzname=''):

test.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ coverage run tests/runtests.py --settings=testapp.settings --noinput \
105105
select_related_onetoone \
106106
select_related_regress \
107107
serializers \
108+
timezones \
108109
transaction_hooks \
109110
transactions \
110111
update \
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 4.0.4 on 2022-05-18 20:55
2+
3+
from django.db import migrations, models
4+
import django.utils.timezone
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('testapp', '0019_customer_name_customer_address'),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name='TimeZone',
16+
fields=[
17+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18+
('date', models.DateTimeField(default=django.utils.timezone.now)),
19+
],
20+
),
21+
]

testapp/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,6 @@ class Customer_address(models.Model):
219219
Customer_address = models.CharField(max_length=100)
220220
class Meta:
221221
ordering = ['Customer_address']
222+
223+
class TimeZone(models.Model):
224+
date = models.DateTimeField(default=timezone.now)

testapp/settings.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,19 @@
272272
'backends.tests.BackendTestCase.test_queries_logger',
273273
'migrations.test_operations.OperationTests.test_alter_field_pk_mti_fk',
274274
'migrations.test_operations.OperationTests.test_run_sql_add_missing_semicolon_on_collect_sql',
275-
'migrations.test_operations.OperationTests.test_alter_field_pk_mti_and_fk_to_base'
275+
'migrations.test_operations.OperationTests.test_alter_field_pk_mti_and_fk_to_base',
276+
277+
# Timezone
278+
'timezones.tests.NewDatabaseTests.test_cursor_explicit_time_zone',
279+
# Skipped next tests because pyodbc drops timezone https://github.com/mkleehammer/pyodbc/issues/810
280+
#LegacyDatabaseTests
281+
'timezones.tests.LegacyDatabaseTests.test_cursor_execute_accepts_naive_datetime',
282+
'timezones.tests.LegacyDatabaseTests.test_cursor_execute_returns_naive_datetime',
283+
# NewDatabaseTests
284+
'timezones.tests.NewDatabaseTests.test_cursor_execute_accepts_naive_datetime',
285+
'timezones.tests.NewDatabaseTests.test_cursor_execute_returns_naive_datetime',
286+
'timezones.tests.NewDatabaseTests.test_cursor_execute_accepts_aware_datetime',
287+
'timezones.tests.NewDatabaseTests.test_cursor_execute_returns_aware_datetime',
276288
]
277289

278290
REGEX_TESTS = [

testapp/tests/test_timezones.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the BSD license.
3+
4+
import datetime
5+
6+
from django.test import TestCase
7+
8+
from ..models import TimeZone
9+
10+
class TestDateTimeField(TestCase):
11+
12+
def test_iso_week_day(self):
13+
days = {
14+
1: TimeZone.objects.create(date=datetime.datetime(2022, 5, 16)),
15+
2: TimeZone.objects.create(date=datetime.datetime(2022, 5, 17)),
16+
3: TimeZone.objects.create(date=datetime.datetime(2022, 5, 18)),
17+
4: TimeZone.objects.create(date=datetime.datetime(2022, 5, 19)),
18+
5: TimeZone.objects.create(date=datetime.datetime(2022, 5, 20)),
19+
6: TimeZone.objects.create(date=datetime.datetime(2022, 5, 21)),
20+
7: TimeZone.objects.create(date=datetime.datetime(2022, 5, 22)),
21+
}
22+
for k, v in days.items():
23+
self.assertSequenceEqual(TimeZone.objects.filter(date__iso_week_day=k), [v])

0 commit comments

Comments
 (0)