Skip to content

Commit 7cbf521

Browse files
authored
Merge pull request piccolo-orm#52 from piccolo-orm/timestamptz_column
Timestamptz column
2 parents 3b19aa0 + b417dc7 commit 7cbf521

File tree

9 files changed

+285
-9
lines changed

9 files changed

+285
-9
lines changed

docs/src/piccolo/schema/column_types.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ Timestamp
146146

147147
.. autoclass:: Timestamp
148148

149+
===========
150+
Timestamptz
151+
===========
152+
153+
.. autoclass:: Timestamptz
154+
149155
-------------------------------------------------------------------------------
150156

151157
****

piccolo/columns/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
Serial,
1717
Text,
1818
Timestamp,
19+
Timestamptz,
1920
UUID,
2021
Varchar,
2122
)

piccolo/columns/column_types.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
TimestampCustom,
2121
TimestampNow,
2222
)
23+
from piccolo.columns.defaults.timestamptz import (
24+
TimestamptzArg,
25+
TimestamptzCustom,
26+
TimestamptzNow,
27+
)
2328
from piccolo.columns.defaults.uuid import UUID4, UUIDArg
2429
from piccolo.columns.operators.string import ConcatPostgres, ConcatSQLite
2530
from piccolo.columns.reference import LazyTableReference
@@ -545,6 +550,11 @@ def __init__(
545550
self._validate_default(default, TimestampArg.__args__) # type: ignore
546551

547552
if isinstance(default, datetime):
553+
if default.tzinfo is not None:
554+
raise ValueError(
555+
"Timestamp only stores timezone naive datetime objects - "
556+
"use Timestamptz instead."
557+
)
548558
default = TimestampCustom.from_datetime(default)
549559

550560
if default == datetime.now:
@@ -555,6 +565,58 @@ def __init__(
555565
super().__init__(**kwargs)
556566

557567

568+
class Timestamptz(Column):
569+
"""
570+
Used for storing timezone aware datetimes. Uses the ``datetime`` type for
571+
values. The values are converted to UTC in the database, and are also
572+
returned as UTC.
573+
574+
**Example**
575+
576+
.. code-block:: python
577+
578+
import datetime
579+
580+
class Concert(Table):
581+
starts = Timestamptz()
582+
583+
# Create
584+
>>> Concert(
585+
>>> starts=datetime.datetime(
586+
>>> year=2050, month=1, day=1, tzinfo=datetime.timezone.tz
587+
>>> )
588+
>>> ).save().run_sync()
589+
590+
# Query
591+
>>> Concert.select(Concert.starts).run_sync()
592+
{
593+
'starts': datetime.datetime(
594+
2050, 1, 1, 0, 0, tzinfo=datetime.timezone.utc
595+
)
596+
}
597+
598+
"""
599+
600+
value_type = datetime
601+
602+
def __init__(
603+
self, default: TimestamptzArg = TimestamptzNow(), **kwargs
604+
) -> None:
605+
self._validate_default(
606+
default, TimestamptzArg.__args__ # type: ignore
607+
)
608+
609+
if isinstance(default, datetime):
610+
default = TimestamptzCustom.from_datetime(default)
611+
612+
if default == datetime.now:
613+
default = TimestamptzNow()
614+
615+
self.default = default
616+
kwargs.update({"default": default})
617+
super().__init__(**kwargs)
618+
619+
558620
class Date(Column):
559621
"""
560622
Used for storing dates. Uses the ``date`` type for values.

piccolo/columns/defaults/timestamp.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,12 @@ def python(self):
5353
class TimestampCustom(Default):
5454
def __init__(
5555
self,
56-
year: int,
57-
month: int,
58-
day: int,
59-
hour: int,
60-
second: int,
61-
microsecond: int,
56+
year: int = 2000,
57+
month: int = 1,
58+
day: int = 1,
59+
hour: int = 0,
60+
second: int = 0,
61+
microsecond: int = 0,
6262
):
6363
self.year = year
6464
self.month = month
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from __future__ import annotations
2+
import datetime
3+
import typing as t
4+
5+
from .timestamp import TimestampOffset, TimestampNow, TimestampCustom
6+
7+
8+
class TimestamptzOffset(TimestampOffset):
9+
def python(self):
10+
return datetime.datetime.now(
11+
tz=datetime.timezone.utc
12+
) + datetime.timedelta(
13+
days=self.days,
14+
hours=self.hours,
15+
minutes=self.minutes,
16+
seconds=self.seconds,
17+
)
18+
19+
20+
class TimestamptzNow(TimestampNow):
21+
def python(self):
22+
return datetime.datetime.now(tz=datetime.timezone.utc)
23+
24+
25+
class TimestamptzCustom(TimestampCustom):
26+
@property
27+
def datetime(self):
28+
return datetime.datetime(
29+
year=self.year,
30+
month=self.month,
31+
day=self.day,
32+
hour=self.hour,
33+
second=self.second,
34+
microsecond=self.microsecond,
35+
tzinfo=datetime.timezone.utc,
36+
)
37+
38+
@classmethod
39+
def from_datetime(cls, instance: datetime.datetime): # type: ignore
40+
if instance.tzinfo is not None:
41+
instance = instance.astimezone(datetime.timezone.utc)
42+
return cls(
43+
year=instance.year,
44+
month=instance.month,
45+
day=instance.month,
46+
hour=instance.hour,
47+
second=instance.second,
48+
microsecond=instance.microsecond,
49+
)
50+
51+
52+
TimestamptzArg = t.Union[
53+
TimestamptzCustom,
54+
TimestamptzNow,
55+
TimestamptzOffset,
56+
None,
57+
datetime.datetime,
58+
]
59+
60+
61+
__all__ = [
62+
"TimestamptzArg",
63+
"TimestamptzCustom",
64+
"TimestamptzNow",
65+
"TimestamptzOffset",
66+
]

piccolo/engine/sqlite.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,24 @@
2222
# consistent with the Postgres engine.
2323

2424

25+
# In
26+
27+
2528
def convert_numeric_in(value):
2629
"""
2730
Convert any Decimal values into floats.
2831
"""
2932
return float(value)
3033

3134

32-
def convert_uuid_in(value):
35+
def convert_uuid_in(value) -> str:
3336
"""
3437
Converts the UUID value being passed into sqlite.
3538
"""
3639
return str(value)
3740

3841

39-
def convert_time_in(value):
42+
def convert_time_in(value) -> str:
4043
"""
4144
Converts the time value being passed into sqlite.
4245
"""
@@ -50,13 +53,27 @@ def convert_date_in(value):
5053
return value.isoformat()
5154

5255

56+
def convert_datetime_in(value: datetime.datetime) -> str:
57+
"""
58+
Converts the datetime into a string. If it's timezone aware, we want to
59+
convert it to UTC first. This is to replicate Postgres, which stores
60+
timezone aware datetimes in UTC.
61+
"""
62+
if value.tzinfo is not None:
63+
value = value.astimezone(datetime.timezone.utc)
64+
return str(value)
65+
66+
5367
def convert_timedelta_in(value):
5468
"""
5569
Converts the timedelta value being passed into sqlite.
5670
"""
5771
return value.total_seconds()
5872

5973

74+
# Out
75+
76+
6077
def convert_numeric_out(value: bytes) -> Decimal:
6178
"""
6279
Convert float values into Decimals.
@@ -104,18 +121,28 @@ def convert_boolean_out(value: bytes) -> bool:
104121
return _value == "1"
105122

106123

124+
def convert_timestamptz_out(value: bytes) -> datetime.datetime:
125+
"""
126+
If the value is from a timstamptz column, convert it to a datetime value,
127+
with a timezone of UTC.
128+
"""
129+
return datetime.datetime.fromisoformat(value.decode("utf8"))
130+
131+
107132
sqlite3.register_converter("Numeric", convert_numeric_out)
108133
sqlite3.register_converter("Integer", convert_int_out)
109134
sqlite3.register_converter("UUID", convert_uuid_out)
110135
sqlite3.register_converter("Date", convert_date_out)
111136
sqlite3.register_converter("Time", convert_time_out)
112137
sqlite3.register_converter("Seconds", convert_seconds_out)
113138
sqlite3.register_converter("Boolean", convert_boolean_out)
139+
sqlite3.register_converter("Timestamptz", convert_timestamptz_out)
114140

115141
sqlite3.register_adapter(Decimal, convert_numeric_in)
116142
sqlite3.register_adapter(uuid.UUID, convert_uuid_in)
117143
sqlite3.register_adapter(datetime.time, convert_time_in)
118144
sqlite3.register_adapter(datetime.date, convert_date_in)
145+
sqlite3.register_adapter(datetime.datetime, convert_datetime_in)
119146
sqlite3.register_adapter(datetime.timedelta, convert_timedelta_in)
120147

121148
###############################################################################

tests/columns/test_timestamp.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
import datetime
22
from unittest import TestCase
33

4-
from piccolo.table import Table
54
from piccolo.columns.column_types import Timestamp
65
from piccolo.columns.defaults.timestamp import TimestampNow
6+
from piccolo.table import Table
77

88

99
class MyTable(Table):
1010
created_on = Timestamp()
1111

1212

1313
class MyTableDefault(Table):
14+
"""
15+
A table containing all of the possible `default` arguments for
16+
`Timestamp`.
17+
"""
18+
1419
created_on = Timestamp(default=TimestampNow())
1520

1621

@@ -22,13 +27,23 @@ def tearDown(self):
2227
MyTable.alter().drop_table().run_sync()
2328

2429
def test_timestamp(self):
30+
"""
31+
Make sure a datetime can be stored and retrieved.
32+
"""
2533
created_on = datetime.datetime.now()
2634
row = MyTable(created_on=created_on)
2735
row.save().run_sync()
2836

2937
result = MyTable.objects().first().run_sync()
3038
self.assertEqual(result.created_on, created_on)
3139

40+
def test_timezone_aware(self):
41+
"""
42+
Raise an error if a timezone aware datetime is given as a default.
43+
"""
44+
with self.assertRaises(ValueError):
45+
Timestamp(default=datetime.datetime.now(tz=datetime.timezone.utc))
46+
3247

3348
class TestTimestampDefault(TestCase):
3449
def setUp(self):
@@ -38,6 +53,9 @@ def tearDown(self):
3853
MyTableDefault.alter().drop_table().run_sync()
3954

4055
def test_timestamp(self):
56+
"""
57+
Make sure the default values get created correctly.
58+
"""
4159
created_on = datetime.datetime.now()
4260
row = MyTableDefault()
4361
row.save().run_sync()
@@ -46,3 +64,4 @@ def test_timestamp(self):
4664
self.assertTrue(
4765
result.created_on - created_on < datetime.timedelta(seconds=1)
4866
)
67+
self.assertTrue(result.created_on.tzinfo is None)

0 commit comments

Comments
 (0)