From b95936010dedaf1f30a342afe92a72433b551789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Jaquemet?= Date: Fri, 26 Dec 2025 06:30:47 -0700 Subject: [PATCH 1/5] handle datetimeoffset timezone part This fixes the handling of the timezone part for a DATETIMEOFFSET type. --- mssql/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mssql/base.py b/mssql/base.py index c69046ac..9c4d5299 100644 --- a/mssql/base.py +++ b/mssql/base.py @@ -88,7 +88,8 @@ def handle_datetimeoffset(dto_value): # Decode bytes returned from SQL Server # source: https://github.com/mkleehammer/pyodbc/wiki/Using-an-Output-Converter-function tup = struct.unpack("<6hI2h", dto_value) # e.g., (2017, 3, 16, 10, 35, 18, 500000000) - return datetime.datetime(tup[0], tup[1], tup[2], tup[3], tup[4], tup[5], tup[6] // 1000) + return datetime.datetime(tup[0], tup[1], tup[2], tup[3], tup[4], tup[5], tup[6] // 1000, + datetime.timezone(datetime.timedelta(hours=tup[7], minutes=tup[8]))) class DatabaseWrapper(BaseDatabaseWrapper): From 5493ebb2ec8fbbce7edf71b134d356cc49e1e98c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Jaquemet?= Date: Fri, 26 Dec 2025 06:37:43 -0700 Subject: [PATCH 2/5] Fix Now() to be timezone-aware When USE_TZ=True, it's important that Now() reflect the current time, with timezone offset. If the database system OS is not using UTC , the SYSDATETIME() is a local-timezone value, which incorrectly CASTED as UTC (because offset = 00:00). This fix and the previous one allows to leverage the timezone part of SYSDATETIMEOFFSET() to ensure the timezone offset makes Now() the correct value. --- mssql/functions.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mssql/functions.py b/mssql/functions.py index 861c6ae4..0cc75c87 100644 --- a/mssql/functions.py +++ b/mssql/functions.py @@ -153,9 +153,11 @@ def sqlserver_exists(self, compiler, connection, template=None, **extra_context) return sql, params def sqlserver_now(self, compiler, connection, **extra_context): - return self.as_sql( - compiler, connection, template="SYSDATETIME()", **extra_context - ) + if settings.USE_TZ: + return self.as_sql(compiler, connection, template="SYSDATETIMEOFFSET()", **extra_context) + else: + # continue using SYSDATETIME to get the DB local time when django is not TZ aware + return self.as_sql(compiler, connection, template="SYSDATETIME()", **extra_context) def sqlserver_lookup(self, compiler, connection): # MSSQL doesn't allow EXISTS() to be compared to another expression From 23dc8c5bca1028cd9d34f2deb34bd2946b666d1a Mon Sep 17 00:00:00 2001 From: Gaurav Sharma Date: Thu, 21 May 2026 18:40:31 +0530 Subject: [PATCH 3/5] FIX: Add missing settings import, improve comment, add timezone offset tests - Add missing 'from django.conf import settings' to functions.py (fixes NameError when Now() is compiled) - Update handle_datetimeoffset comment to document full 9-element tuple - Add tests for positive/negative timezone offsets in handle_datetimeoffset - Add NowSQLTemplateTests to verify SYSDATETIMEOFFSET vs SYSDATETIME based on USE_TZ setting - Remove unnecessary else branch in sqlserver_now Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- mssql/base.py | 10 +++++++--- mssql/functions.py | 5 ++--- testapp/tests/test_base.py | 34 +++++++++++++++++++++++++++------- testapp/tests/test_queries.py | 22 ++++++++++++++++++++++ 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/mssql/base.py b/mssql/base.py index 4e53eca4..645cc0ff 100644 --- a/mssql/base.py +++ b/mssql/base.py @@ -99,9 +99,13 @@ def encode_value(v): def handle_datetimeoffset(dto_value): # Decode bytes returned from SQL Server # source: https://github.com/mkleehammer/pyodbc/wiki/Using-an-Output-Converter-function - tup = struct.unpack("<6hI2h", dto_value) # e.g., (2017, 3, 16, 10, 35, 18, 500000000) - return datetime.datetime(tup[0], tup[1], tup[2], tup[3], tup[4], tup[5], tup[6] // 1000, - datetime.timezone(datetime.timedelta(hours=tup[7], minutes=tup[8]))) + # Format: 6 shorts (year, month, day, hour, minute, second), + # 1 unsigned int (nanoseconds), 2 shorts (tz_offset_hour, tz_offset_minute) + tup = struct.unpack("<6hI2h", dto_value) + return datetime.datetime( + tup[0], tup[1], tup[2], tup[3], tup[4], tup[5], tup[6] // 1000, + datetime.timezone(datetime.timedelta(hours=tup[7], minutes=tup[8])), + ) class DatabaseWrapper(BaseDatabaseWrapper): diff --git a/mssql/functions.py b/mssql/functions.py index 488fa58d..79ff2055 100644 --- a/mssql/functions.py +++ b/mssql/functions.py @@ -5,6 +5,7 @@ import itertools from django import VERSION +from django.conf import settings from django.core import validators from django.db import NotSupportedError, connections, transaction from django.db.models import BooleanField, CheckConstraint, Q, Value @@ -171,9 +172,7 @@ def sqlserver_exists(self, compiler, connection, template=None, **extra_context) def sqlserver_now(self, compiler, connection, **extra_context): if settings.USE_TZ: return self.as_sql(compiler, connection, template="SYSDATETIMEOFFSET()", **extra_context) - else: - # continue using SYSDATETIME to get the DB local time when django is not TZ aware - return self.as_sql(compiler, connection, template="SYSDATETIME()", **extra_context) + return self.as_sql(compiler, connection, template="SYSDATETIME()", **extra_context) def sqlserver_lookup(self, compiler, connection): # MSSQL doesn't allow EXISTS() to be compared to another expression diff --git a/testapp/tests/test_base.py b/testapp/tests/test_base.py index 196eb1cf..89ee2900 100644 --- a/testapp/tests/test_base.py +++ b/testapp/tests/test_base.py @@ -138,10 +138,8 @@ def test_empty_token(self): class TestHandleDatetimeoffset(SimpleTestCase): """Tests for the handle_datetimeoffset function.""" - def test_datetime_conversion(self): - """Test conversion of binary datetime offset to Python datetime.""" - # Pack a known datetime: 2023-06-15 14:30:45.123456 - # Format: year, month, day, hour, minute, second, nanoseconds (as microseconds * 1000), tz_hour, tz_min + def test_datetime_conversion_utc(self): + """Test conversion of binary datetime offset with UTC (zero offset).""" dto_bytes = struct.pack("<6hI2h", 2023, 6, 15, 14, 30, 45, 123456000, 0, 0) result = handle_datetimeoffset(dto_bytes) @@ -153,10 +151,31 @@ def test_datetime_conversion(self): self.assertEqual(result.minute, 30) self.assertEqual(result.second, 45) self.assertEqual(result.microsecond, 123456) + self.assertIsNotNone(result.tzinfo) + self.assertEqual(result.utcoffset(), datetime.timedelta(0)) - def test_datetime_edge_case(self): - """Test with edge case values.""" - # Midnight on Jan 1, 2000 + def test_datetime_positive_offset(self): + """Test conversion with a positive timezone offset (+05:30 IST).""" + dto_bytes = struct.pack("<6hI2h", 2024, 1, 10, 9, 0, 0, 0, 5, 30) + result = handle_datetimeoffset(dto_bytes) + + self.assertEqual(result.year, 2024) + self.assertEqual(result.hour, 9) + self.assertIsNotNone(result.tzinfo) + self.assertEqual(result.utcoffset(), datetime.timedelta(hours=5, minutes=30)) + + def test_datetime_negative_offset(self): + """Test conversion with a negative timezone offset (-05:00 EST).""" + dto_bytes = struct.pack("<6hI2h", 2024, 12, 25, 18, 0, 0, 0, -5, 0) + result = handle_datetimeoffset(dto_bytes) + + self.assertEqual(result.year, 2024) + self.assertEqual(result.hour, 18) + self.assertIsNotNone(result.tzinfo) + self.assertEqual(result.utcoffset(), datetime.timedelta(hours=-5)) + + def test_datetime_edge_case_midnight_utc(self): + """Test with edge case: midnight on Jan 1, 2000 at UTC.""" dto_bytes = struct.pack("<6hI2h", 2000, 1, 1, 0, 0, 0, 0, 0, 0) result = handle_datetimeoffset(dto_bytes) @@ -167,6 +186,7 @@ def test_datetime_edge_case(self): self.assertEqual(result.minute, 0) self.assertEqual(result.second, 0) self.assertEqual(result.microsecond, 0) + self.assertEqual(result.utcoffset(), datetime.timedelta(0)) class TestDatabaseWrapperIsDriverNotFoundError(SimpleTestCase): diff --git a/testapp/tests/test_queries.py b/testapp/tests/test_queries.py index 2719a90d..3c33bf8d 100644 --- a/testapp/tests/test_queries.py +++ b/testapp/tests/test_queries.py @@ -5,6 +5,7 @@ from django.db import connections, connection, models from django.db.models.functions import Now from django.test import TransactionTestCase, TestCase, skipUnlessDBFeature +from django.test.utils import override_settings from django.utils import timezone from ..models import Author, BinaryData @@ -178,3 +179,24 @@ def test_single_insert_with_returning_fields_when_bulk_rows_unsupported(self): self.assertNotIn("SCOPE_IDENTITY", ctx[0]["sql"]) finally: connection.features_class.can_return_rows_from_bulk_insert = old_return_rows_flag + + +class NowSQLTemplateTests(TestCase): + """Regression tests for #371 / PR #484: Now() should emit + SYSDATETIMEOFFSET() when USE_TZ=True, SYSDATETIME() otherwise.""" + + @override_settings(USE_TZ=True) + def test_now_uses_sysdatetimeoffset_when_use_tz(self): + qs = Author.objects.annotate(ts=Now()).filter(first_name="x") + compiler = qs.query.get_compiler(using="default") + sql_compiled, _ = compiler.as_sql() + self.assertIn("SYSDATETIMEOFFSET()", sql_compiled) + self.assertNotIn("SYSDATETIME()", sql_compiled) + + @override_settings(USE_TZ=False) + def test_now_uses_sysdatetime_when_no_tz(self): + qs = Author.objects.annotate(ts=Now()).filter(first_name="x") + compiler = qs.query.get_compiler(using="default") + sql_compiled, _ = compiler.as_sql() + self.assertIn("SYSDATETIME()", sql_compiled) + self.assertNotIn("SYSDATETIMEOFFSET()", sql_compiled) From 575ad8a8acc773e1f2aab19966782fa563c8c7e2 Mon Sep 17 00:00:00 2001 From: Gaurav Sharma Date: Thu, 21 May 2026 23:49:22 +0530 Subject: [PATCH 4/5] Add tests for negative half-hour and +05:45 timezone offsets Cover edge cases raised during review: -09:30 (Marquesas) and +05:45 (Nepal) to verify signed offset components are handled correctly by handle_datetimeoffset. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testapp/tests/test_base.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/testapp/tests/test_base.py b/testapp/tests/test_base.py index 89ee2900..4a27d9c5 100644 --- a/testapp/tests/test_base.py +++ b/testapp/tests/test_base.py @@ -174,6 +174,25 @@ def test_datetime_negative_offset(self): self.assertIsNotNone(result.tzinfo) self.assertEqual(result.utcoffset(), datetime.timedelta(hours=-5)) + def test_datetime_negative_half_hour_offset(self): + """Test conversion with a negative half-hour offset (-09:30 Marquesas).""" + dto_bytes = struct.pack("<6hI2h", 2024, 7, 1, 12, 0, 0, 0, -9, -30) + result = handle_datetimeoffset(dto_bytes) + + self.assertEqual(result.hour, 12) + self.assertIsNotNone(result.tzinfo) + expected = datetime.timedelta(hours=-9, minutes=-30) + self.assertEqual(result.utcoffset(), expected) + + def test_datetime_positive_three_quarter_offset(self): + """Test conversion with +05:45 (Nepal) offset.""" + dto_bytes = struct.pack("<6hI2h", 2024, 3, 15, 10, 30, 0, 0, 5, 45) + result = handle_datetimeoffset(dto_bytes) + + self.assertEqual(result.hour, 10) + self.assertIsNotNone(result.tzinfo) + self.assertEqual(result.utcoffset(), datetime.timedelta(hours=5, minutes=45)) + def test_datetime_edge_case_midnight_utc(self): """Test with edge case: midnight on Jan 1, 2000 at UTC.""" dto_bytes = struct.pack("<6hI2h", 2000, 1, 1, 0, 0, 0, 0, 0, 0) From fbb690c2c46583959fa524303e2ba73055c27378 Mon Sep 17 00:00:00 2001 From: Gaurav Sharma Date: Fri, 22 May 2026 07:45:40 +0530 Subject: [PATCH 5/5] FIX: Use correct field name in NowSQLTemplateTests (name, not first_name) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testapp/tests/test_queries.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testapp/tests/test_queries.py b/testapp/tests/test_queries.py index 60260443..7ea92119 100644 --- a/testapp/tests/test_queries.py +++ b/testapp/tests/test_queries.py @@ -202,7 +202,7 @@ class NowSQLTemplateTests(TestCase): @override_settings(USE_TZ=True) def test_now_uses_sysdatetimeoffset_when_use_tz(self): - qs = Author.objects.annotate(ts=Now()).filter(first_name="x") + qs = Author.objects.annotate(ts=Now()).filter(name="x") compiler = qs.query.get_compiler(using="default") sql_compiled, _ = compiler.as_sql() self.assertIn("SYSDATETIMEOFFSET()", sql_compiled) @@ -210,7 +210,7 @@ def test_now_uses_sysdatetimeoffset_when_use_tz(self): @override_settings(USE_TZ=False) def test_now_uses_sysdatetime_when_no_tz(self): - qs = Author.objects.annotate(ts=Now()).filter(first_name="x") + qs = Author.objects.annotate(ts=Now()).filter(name="x") compiler = qs.query.get_compiler(using="default") sql_compiled, _ = compiler.as_sql() self.assertIn("SYSDATETIME()", sql_compiled)