From cad87570ad65b96bf66c5cbf7e16fa2f3014df43 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Mon, 11 May 2026 19:16:31 +0100 Subject: [PATCH 1/4] add `AtTimeZone` function --- docs/src/piccolo/functions/datetime.rst | 6 ++- piccolo/query/functions/datetime.py | 63 +++++++++++++++++++++++++ tests/query/functions/test_datetime.py | 27 ++++++++++- 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/docs/src/piccolo/functions/datetime.rst b/docs/src/piccolo/functions/datetime.rst index ee33e8c4a..0c7323b2f 100644 --- a/docs/src/piccolo/functions/datetime.rst +++ b/docs/src/piccolo/functions/datetime.rst @@ -6,12 +6,16 @@ Datetime functions Postgres / Cockroach -------------------- +AtTimeZone +~~~~~~~~~~ + +.. autoclass:: AtTimeZone + Extract ~~~~~~~ .. autoclass:: Extract - SQLite ------ diff --git a/piccolo/query/functions/datetime.py b/piccolo/query/functions/datetime.py index 146fc5c42..e007aeba9 100644 --- a/piccolo/query/functions/datetime.py +++ b/piccolo/query/functions/datetime.py @@ -1,4 +1,7 @@ +from __future__ import annotations + from typing import Literal, Optional, Union, get_args +from zoneinfo import ZoneInfo from piccolo.columns.base import Column from piccolo.columns.column_types import ( @@ -248,6 +251,65 @@ def Second( ) +class AtTimeZone(QueryString): + def __init__( + self, + identifier: Union[Timestamptz, QueryString], + timezone: ZoneInfo | str, + alias: Optional[str] = None, + ): + """ + .. note:: This is for Postgres / Cockroach only. + + Convert the :class:`Timestamptz ` + column to the given timezone. + + For example:: + + class Signing(Table): + starts = Timestamptz() + + >>> await Signing.select( + ... Timezone(Signing.starts, 'EST', alias='starts_est'), + ... Signing.starts, + ... ) + [{ + 'starts_est': datetime.datetime( + 2026, 12, 20, 5, 0 + ), + 'starts': datetime.datetime( + 2026, 12, 20, 10, 0, tzinfo=datetime.timezone.utc + ) + }] + + """ # noqa: E501 + # Preserve the original alias from the column. + + from piccolo.columns import Column + + if isinstance(identifier, Column): + alias = ( + alias + or identifier._alias + or identifier._meta.get_default_alias() + ) + elif isinstance(identifier, QueryString): + alias = alias or identifier._alias + + ####################################################################### + + if isinstance(timezone, str): + # Validate it's a correct timezone + timezone = ZoneInfo(timezone) + + super().__init__( + "{} AT TIME ZONE {}", + identifier, + timezone.key, + alias=alias, + ) + + __all__ = ( "Extract", "Strftime", @@ -257,4 +319,5 @@ def Second( "Hour", "Minute", "Second", + "AtTimeZone", ) diff --git a/tests/query/functions/test_datetime.py b/tests/query/functions/test_datetime.py index 382f688ab..c1714f97f 100644 --- a/tests/query/functions/test_datetime.py +++ b/tests/query/functions/test_datetime.py @@ -1,7 +1,8 @@ import datetime -from piccolo.columns import Timestamp +from piccolo.columns import Timestamp, Timestamptz from piccolo.query.functions.datetime import ( + AtTimeZone, Day, Extract, Hour, @@ -12,7 +13,7 @@ Year, ) from piccolo.table import Table -from piccolo.testing.test_case import TableTest +from piccolo.testing.test_case import AsyncTableTest, TableTest from tests.base import engines_only, sqlite_only @@ -111,3 +112,25 @@ def test_second(self): ).run_sync(), [{"starts_second": self.concert.starts.second}], ) + + +class Signing(Table): + starts = Timestamptz() + + +@engines_only("cockroach", "postgres") +class TestAtTimeZone(AsyncTableTest): + + tables = [Signing] + + async def test_at_time_zone(self): + await Signing( + starts=datetime.datetime( + 2026, 5, 11, 7, 0, 0, tzinfo=datetime.timezone.utc + ) + ).save() + + self.assertEqual( + await Signing.select(AtTimeZone(Signing.starts, "EST")), + [{"starts": datetime.datetime(2026, 5, 11, 2, 0, 0)}], + ) From c91e24ebbc5d928427f20ddaac43aee59b36a8fe Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Mon, 11 May 2026 23:01:21 +0100 Subject: [PATCH 2/4] add `Time` columns to playground --- piccolo/apps/playground/commands/run.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/piccolo/apps/playground/commands/run.py b/piccolo/apps/playground/commands/run.py index 1effd6922..5b94b2f01 100644 --- a/piccolo/apps/playground/commands/run.py +++ b/piccolo/apps/playground/commands/run.py @@ -25,6 +25,7 @@ Numeric, Serial, Text, + Time, Timestamp, Timestamptz, Varchar, @@ -143,6 +144,8 @@ class RecordingStudio(Table): id: Serial name = Varchar(length=100) facilities = JSON(null=True) + opens_at = Time() + closes_at = Time() @classmethod def get_readable(cls) -> Readable: @@ -279,6 +282,8 @@ def populate(): {"name": "Bob Williams"}, ], }, + RecordingStudio.opens_at: datetime.time(9, 0, 0), + RecordingStudio.closes_at: datetime.time(17, 0, 0), } ) recording_studio_1.save().run_sync() @@ -294,6 +299,8 @@ def populate(): {"name": "Frank Smith"}, ], }, + RecordingStudio.opens_at: datetime.time(10, 0, 0), + RecordingStudio.closes_at: datetime.time(20, 0, 0), }, ) recording_studio_2.save().run_sync() From 763489134fb5181b4cfc6c3f37378f8d0a7f23d8 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Mon, 11 May 2026 23:01:40 +0100 Subject: [PATCH 3/4] also accept `timestamp` and `time` columns --- piccolo/query/functions/datetime.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/piccolo/query/functions/datetime.py b/piccolo/query/functions/datetime.py index e007aeba9..edc935591 100644 --- a/piccolo/query/functions/datetime.py +++ b/piccolo/query/functions/datetime.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime from typing import Literal, Optional, Union, get_args from zoneinfo import ZoneInfo @@ -254,15 +255,16 @@ def Second( class AtTimeZone(QueryString): def __init__( self, - identifier: Union[Timestamptz, QueryString], - timezone: ZoneInfo | str, + identifier: Union[Time, Timestamp, Timestamptz, QueryString], + timezone: ZoneInfo | str | datetime.timedelta, alias: Optional[str] = None, ): """ .. note:: This is for Postgres / Cockroach only. - Convert the :class:`Timestamptz ` - column to the given timezone. + Convert the column to the given timezone. See the + `Postgres docs `_ + for more information. For example:: @@ -270,7 +272,7 @@ class Signing(Table): starts = Timestamptz() >>> await Signing.select( - ... Timezone(Signing.starts, 'EST', alias='starts_est'), + ... AtTimeZone(Signing.starts, 'EST', alias='starts_est'), ... Signing.starts, ... ) [{ @@ -282,6 +284,10 @@ class Signing(Table): ) }] + :param timezone: + Valid arguments are ``'EST'``, ``ZoneInfo('EST')`` and + ``timedelta(hours=5)``. + """ # noqa: E501 # Preserve the original alias from the column. @@ -302,10 +308,21 @@ class Signing(Table): # Validate it's a correct timezone timezone = ZoneInfo(timezone) + if isinstance(timezone, datetime.timedelta): + total_seconds = timezone.total_seconds() + prefix = "+" if total_seconds >= 0 else "-" + timezone = ( + f"{prefix}{datetime.timedelta(seconds=abs(total_seconds))}" + ) + + if isinstance(timezone, ZoneInfo): + # Validate it's a correct timezone + timezone = timezone.key + super().__init__( "{} AT TIME ZONE {}", identifier, - timezone.key, + timezone, alias=alias, ) From ffc04a67608cc18ba2fa0f0fcea0560dd7936079 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Mon, 11 May 2026 23:02:03 +0100 Subject: [PATCH 4/4] test more arg types --- tests/query/functions/test_datetime.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/query/functions/test_datetime.py b/tests/query/functions/test_datetime.py index c1714f97f..5780fb690 100644 --- a/tests/query/functions/test_datetime.py +++ b/tests/query/functions/test_datetime.py @@ -1,4 +1,5 @@ import datetime +import zoneinfo from piccolo.columns import Timestamp, Timestamptz from piccolo.query.functions.datetime import ( @@ -130,7 +131,15 @@ async def test_at_time_zone(self): ) ).save() - self.assertEqual( - await Signing.select(AtTimeZone(Signing.starts, "EST")), - [{"starts": datetime.datetime(2026, 5, 11, 2, 0, 0)}], - ) + for timezone in ( + "EST", + zoneinfo.ZoneInfo("EST"), + datetime.timedelta(hours=5), + ): + self.assertEqual( + await Signing.select(AtTimeZone(Signing.starts, timezone)), + [{"starts": datetime.datetime(2026, 5, 11, 2, 0, 0)}], + ) + + with self.assertRaises(zoneinfo.ZoneInfoNotFoundError): + AtTimeZone(Signing.starts, "ABC")