Skip to content

Commit e7544b5

Browse files
author
Alex Hill
committed
Add PostgreSQL-only AtTimeZone database function
1 parent cd830e0 commit e7544b5

File tree

3 files changed

+175
-2
lines changed

3 files changed

+175
-2
lines changed

naivedatetimefield/__init__.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import django
66
import pytz
77
from django.core import exceptions, checks
8-
from django.db.models import DateTimeField
8+
from django.db.models import DateTimeField, Func, Value
99
from django.utils import timezone
1010
from django.utils.dateparse import parse_date, parse_datetime
1111
from django.utils.translation import gettext_lazy as _
@@ -175,6 +175,38 @@ def as_sql(self, compiler, connection):
175175
return super(NaiveAsSQLMixin, self).as_sql(compiler, connection)
176176

177177

178+
class AtTimeZone(Func):
179+
"""
180+
This implements PostgreSQL's AT TIME ZONE construct, which returns a naive
181+
datetime if used with a timezone-aware datetime, and vice versa.
182+
183+
See https://www.postgresql.org/docs/9.6/functions-datetime.html#FUNCTIONS-DATETIME-ZONECONVERT # noqa
184+
"""
185+
def __init__(self, value, tz):
186+
super(AtTimeZone, self).__init__(
187+
value,
188+
tz,
189+
template='%(expressions)s',
190+
arg_joiner=' AT TIME ZONE ',
191+
)
192+
193+
def _resolve_output_field(self):
194+
if getattr(self, '_output_field', None) is None:
195+
value_field, _ = super(AtTimeZone, self).get_source_fields()
196+
if isinstance(value_field, NaiveDateTimeField):
197+
self._output_field = DateTimeField()
198+
elif isinstance(value_field, DateTimeField):
199+
self._output_field = NaiveDateTimeField()
200+
elif value_field is None:
201+
value_expr = self.get_source_expressions()[0]
202+
if isinstance(value_expr, Value):
203+
if timezone.is_naive(value_expr.value):
204+
self._output_field = DateTimeField()
205+
else:
206+
self._output_field = NaiveDateTimeField()
207+
return getattr(self, '_output_field', None)
208+
209+
178210
_monkeypatching = False
179211

180212

tests/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
class NaiveDateTimeTestModel(models.Model):
77
aware = models.DateTimeField()
88
naive = NaiveDateTimeField()
9+
timezone = models.CharField(max_length=100, blank=True, default='utc')
10+
11+
class Meta:
12+
ordering = ['pk']
913

1014

1115
class NaiveDateTimeAutoNowAddModel(models.Model):

tests/tests.py

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
import pytz
66
from django import db
77
from django.contrib.auth.models import User
8-
from django.db.models import functions
8+
from django.db import connection
9+
from django.db.models import functions, Value
910
from django.test import TestCase, override_settings
1011
from django.utils import timezone
1112

1213
import naivedatetimefield
14+
from naivedatetimefield import AtTimeZone
1315
from .models import (
1416
NaiveDateTimeTestModel,
1517
NaiveDateTimeAutoNowAddModel,
@@ -344,3 +346,138 @@ def test_select_by_naive(self):
344346
).count()
345347

346348
self.assertTrue(find_with_naive_in_utc == 1)
349+
350+
351+
def identity(v):
352+
return v
353+
354+
355+
@skipIf(connection.vendor != "postgresql", "AtTimeZone is only supported in PostgreSQL")
356+
class AtTimeZoneTests(TestCase):
357+
@classmethod
358+
def setUpTestData(cls):
359+
cls.now = datetime.datetime(2019, 1, 15, 10)
360+
cls.perth_tz = pytz.timezone('Australia/Perth')
361+
cls.sydney_tz = pytz.timezone('Australia/Sydney')
362+
cls.adelaide_tz = pytz.timezone('Australia/Adelaide')
363+
364+
cls.perth = NaiveDateTimeTestModel.objects.create(
365+
naive=cls.now,
366+
aware=timezone.make_aware(cls.now, cls.perth_tz),
367+
timezone='Australia/Perth',
368+
)
369+
370+
cls.sydney = NaiveDateTimeTestModel.objects.create(
371+
naive=cls.now,
372+
aware=timezone.make_aware(cls.now, cls.sydney_tz),
373+
timezone='Australia/Sydney',
374+
)
375+
376+
def test_annotate(self):
377+
self.assertQuerysetEqual(
378+
NaiveDateTimeTestModel.objects.annotate(
379+
naive_converted=AtTimeZone('aware', 'timezone'),
380+
aware_converted=AtTimeZone('naive', 'timezone'),
381+
).values_list('naive_converted', 'aware_converted'),
382+
[
383+
(self.now, timezone.make_aware(self.now, self.perth_tz)),
384+
(self.now, timezone.make_aware(self.now, self.sydney_tz)),
385+
],
386+
transform=identity,
387+
)
388+
389+
def test_db_aware_db_timezone(self):
390+
self.assertQuerysetEqual(
391+
NaiveDateTimeTestModel.objects.filter(
392+
naive=AtTimeZone(
393+
'aware',
394+
'timezone',
395+
)
396+
),
397+
[self.perth, self.sydney],
398+
transform=identity,
399+
)
400+
401+
def test_db_aware_value_timezone(self):
402+
self.assertQuerysetEqual(
403+
NaiveDateTimeTestModel.objects.filter(
404+
naive__lt=AtTimeZone(
405+
'aware',
406+
Value('Australia/Adelaide'),
407+
)
408+
),
409+
[self.perth],
410+
transform=identity,
411+
)
412+
413+
def test_db_naive_db_timezone(self):
414+
self.assertQuerysetEqual(
415+
NaiveDateTimeTestModel.objects.filter(
416+
aware=AtTimeZone(
417+
'naive',
418+
'timezone',
419+
)
420+
),
421+
[self.perth, self.sydney],
422+
transform=identity,
423+
)
424+
425+
def test_db_naive_value_timezone(self):
426+
self.assertQuerysetEqual(
427+
NaiveDateTimeTestModel.objects.filter(
428+
aware__lt=AtTimeZone(
429+
'naive',
430+
Value('Australia/Adelaide'),
431+
)
432+
),
433+
[self.sydney],
434+
transform=identity,
435+
)
436+
437+
def test_value_aware_db_timezone(self):
438+
self.assertQuerysetEqual(
439+
NaiveDateTimeTestModel.objects.filter(
440+
naive__lt=AtTimeZone(
441+
timezone.make_aware(self.now, self.adelaide_tz),
442+
'timezone',
443+
)
444+
),
445+
[self.sydney],
446+
transform=identity,
447+
)
448+
449+
def test_value_aware_value_timezone(self):
450+
self.assertQuerysetEqual(
451+
NaiveDateTimeTestModel.objects.filter(
452+
naive=AtTimeZone(
453+
Value(timezone.make_aware(self.now, self.adelaide_tz)),
454+
Value('Australia/Adelaide'),
455+
)
456+
),
457+
[self.perth, self.sydney],
458+
transform=identity,
459+
)
460+
461+
def test_value_naive_db_timezone(self):
462+
self.assertQuerysetEqual(
463+
NaiveDateTimeTestModel.objects.filter(
464+
aware=AtTimeZone(
465+
Value(self.now),
466+
'timezone',
467+
)
468+
),
469+
[self.perth, self.sydney],
470+
transform=identity,
471+
)
472+
473+
def test_value_naive_value_timezone(self):
474+
self.assertQuerysetEqual(
475+
NaiveDateTimeTestModel.objects.filter(
476+
aware__lt=AtTimeZone(
477+
Value(self.now),
478+
Value('Australia/Adelaide'),
479+
)
480+
),
481+
[self.sydney],
482+
transform=identity,
483+
)

0 commit comments

Comments
 (0)