diff --git a/docs/src/piccolo/functions/datetime.rst b/docs/src/piccolo/functions/datetime.rst new file mode 100644 index 000000000..ee33e8c4a --- /dev/null +++ b/docs/src/piccolo/functions/datetime.rst @@ -0,0 +1,67 @@ +Datetime functions +================== + +.. currentmodule:: piccolo.query.functions.datetime + +Postgres / Cockroach +-------------------- + +Extract +~~~~~~~ + +.. autoclass:: Extract + + +SQLite +------ + +Strftime +~~~~~~~~ + +.. autoclass:: Strftime + + +Database agnostic +----------------- + +These convenience functions work consistently across database engines. + +They all work very similarly, for example: + +.. code-block:: python + + >>> from piccolo.query.functions import Year + >>> await Concert.select( + ... Year(Concert.starts, alias="start_year") + ... ) + [{"start_year": 2024}] + +Year +~~~~ + +.. autofunction:: Year + +Month +~~~~~ + +.. autofunction:: Month + +Day +~~~ + +.. autofunction:: Day + +Hour +~~~~ + +.. autofunction:: Hour + +Minute +~~~~~~ + +.. autofunction:: Minute + +Second +~~~~~~ + +.. autofunction:: Second diff --git a/docs/src/piccolo/functions/index.rst b/docs/src/piccolo/functions/index.rst index 93b3fe4f7..da3dfe43d 100644 --- a/docs/src/piccolo/functions/index.rst +++ b/docs/src/piccolo/functions/index.rst @@ -9,5 +9,6 @@ Functions can be used to modify how queries are run, and what is returned. ./basic_usage ./string ./math + ./datetime ./type_conversion ./aggregate diff --git a/piccolo/query/functions/__init__.py b/piccolo/query/functions/__init__.py index 9b83eca7b..8f8944d32 100644 --- a/piccolo/query/functions/__init__.py +++ b/piccolo/query/functions/__init__.py @@ -1,4 +1,5 @@ from .aggregate import Avg, Count, Max, Min, Sum +from .datetime import Day, Extract, Hour, Month, Second, Strftime, Year from .math import Abs, Ceil, Floor, Round from .string import Length, Lower, Ltrim, Reverse, Rtrim, Upper from .type_conversion import Cast @@ -9,15 +10,23 @@ "Cast", "Ceil", "Count", + "Day", + "Extract", + "Extract", "Floor", + "Hour", "Length", "Lower", "Ltrim", "Max", "Min", + "Month", "Reverse", "Round", "Rtrim", + "Second", + "Strftime", "Sum", "Upper", + "Year", ) diff --git a/piccolo/query/functions/datetime.py b/piccolo/query/functions/datetime.py new file mode 100644 index 000000000..130f846ce --- /dev/null +++ b/piccolo/query/functions/datetime.py @@ -0,0 +1,260 @@ +import typing as t + +from piccolo.columns.base import Column +from piccolo.columns.column_types import ( + Date, + Integer, + Time, + Timestamp, + Timestamptz, +) +from piccolo.querystring import QueryString + +from .type_conversion import Cast + +############################################################################### +# Postgres / Cockroach + +ExtractComponent = t.Literal[ + "century", + "day", + "decade", + "dow", + "doy", + "epoch", + "hour", + "isodow", + "isoyear", + "julian", + "microseconds", + "millennium", + "milliseconds", + "minute", + "month", + "quarter", + "second", + "timezone", + "timezone_hour", + "timezone_minute", + "week", + "year", +] + + +class Extract(QueryString): + def __init__( + self, + identifier: t.Union[Date, Time, Timestamp, Timestamptz, QueryString], + datetime_component: ExtractComponent, + alias: t.Optional[str] = None, + ): + """ + .. note:: This is for Postgres / Cockroach only. + + Extract a date or time component from a ``Date`` / ``Time`` / + ``Timestamp`` / ``Timestamptz`` column. For example, getting the month + from a timestamp: + + .. code-block:: python + + >>> from piccolo.query.functions import Extract + >>> await Concert.select( + ... Extract(Concert.starts, "month", alias="start_month") + ... ) + [{"start_month": 12}] + + :param identifier: + Identifies the column. + :param datetime_component: + The date or time component to extract from the column. + + """ + if datetime_component.lower() not in t.get_args(ExtractComponent): + raise ValueError("The date time component isn't recognised.") + + super().__init__( + f"EXTRACT({datetime_component} FROM {{}})", + identifier, + alias=alias, + ) + + +############################################################################### +# SQLite + + +class Strftime(QueryString): + def __init__( + self, + identifier: t.Union[Date, Time, Timestamp, Timestamptz, QueryString], + datetime_format: str, + alias: t.Optional[str] = None, + ): + """ + .. note:: This is for SQLite only. + + Format a datetime value. For example: + + .. code-block:: python + + >>> from piccolo.query.functions import Strftime + >>> await Concert.select( + ... Strftime(Concert.starts, "%Y", alias="start_year") + ... ) + [{"start_month": "2024"}] + + :param identifier: + Identifies the column. + :param datetime_format: + A string describing the output format (see SQLite's + `documentation `_ + for more info). + + """ + super().__init__( + f"strftime('{datetime_format}', {{}})", + identifier, + alias=alias, + ) + + +############################################################################### +# Database agnostic + + +def _get_engine_type(identifier: t.Union[Column, QueryString]) -> str: + if isinstance(identifier, Column): + return identifier._meta.engine_type + elif isinstance(identifier, QueryString) and ( + columns := identifier.columns + ): + return columns[0]._meta.engine_type + else: + raise ValueError("Unable to determine the engine type") + + +def _extract_component( + identifier: t.Union[Date, Time, Timestamp, Timestamptz, QueryString], + sqlite_format: str, + postgres_format: ExtractComponent, + alias: t.Optional[str], +): + engine_type = _get_engine_type(identifier=identifier) + + return Cast( + ( + Strftime( + identifier=identifier, + datetime_format=sqlite_format, + ) + if engine_type == "sqlite" + else Extract( + identifier=identifier, + datetime_component=postgres_format, + ) + ), + Integer(), + alias=alias, + ) + + +def Year( + identifier: t.Union[Date, Timestamp, Timestamptz, QueryString], + alias: t.Optional[str] = None, +): + """ + Extract the year as an integer. + """ + return _extract_component( + identifier=identifier, + sqlite_format="%Y", + postgres_format="year", + alias=alias, + ) + + +def Month( + identifier: t.Union[Date, Timestamp, Timestamptz, QueryString], + alias: t.Optional[str] = None, +): + """ + Extract the month as an integer. + """ + return _extract_component( + identifier=identifier, + sqlite_format="%m", + postgres_format="month", + alias=alias, + ) + + +def Day( + identifier: t.Union[Date, Timestamp, Timestamptz, QueryString], + alias: t.Optional[str] = None, +): + """ + Extract the day as an integer. + """ + return _extract_component( + identifier=identifier, + sqlite_format="%d", + postgres_format="day", + alias=alias, + ) + + +def Hour( + identifier: t.Union[Time, Timestamp, Timestamptz, QueryString], + alias: t.Optional[str] = None, +): + """ + Extract the hour as an integer. + """ + return _extract_component( + identifier=identifier, + sqlite_format="%H", + postgres_format="hour", + alias=alias, + ) + + +def Minute( + identifier: t.Union[Time, Timestamp, Timestamptz, QueryString], + alias: t.Optional[str] = None, +): + """ + Extract the minute as an integer. + """ + return _extract_component( + identifier=identifier, + sqlite_format="%M", + postgres_format="minute", + alias=alias, + ) + + +def Second( + identifier: t.Union[Time, Timestamp, Timestamptz, QueryString], + alias: t.Optional[str] = None, +): + """ + Extract the second as an integer. + """ + return _extract_component( + identifier=identifier, + sqlite_format="%S", + postgres_format="second", + alias=alias, + ) + + +__all__ = ( + "Extract", + "Strftime", + "Year", + "Month", + "Day", + "Hour", + "Minute", + "Second", +) diff --git a/requirements/doc-requirements.txt b/requirements/doc-requirements.txt index 3c23f8905..9e407e150 100644 --- a/requirements/doc-requirements.txt +++ b/requirements/doc-requirements.txt @@ -1,3 +1,3 @@ -Sphinx==5.1.1 -piccolo-theme>=0.12.0 +Sphinx==7.3.7 +piccolo-theme==0.22.0 sphinx-autobuild==2021.3.14 diff --git a/tests/query/functions/test_datetime.py b/tests/query/functions/test_datetime.py new file mode 100644 index 000000000..3e2d33d0b --- /dev/null +++ b/tests/query/functions/test_datetime.py @@ -0,0 +1,114 @@ +import datetime + +from piccolo.columns import Timestamp +from piccolo.query.functions.datetime import ( + Day, + Extract, + Hour, + Minute, + Month, + Second, + Strftime, + Year, +) +from piccolo.table import Table +from tests.base import engines_only, sqlite_only + +from .base import FunctionTest + + +class Concert(Table): + starts = Timestamp() + + +class DatetimeTest(FunctionTest): + tables = [Concert] + + def setUp(self) -> None: + super().setUp() + self.concert = Concert( + { + Concert.starts: datetime.datetime( + year=2024, month=6, day=14, hour=23, minute=46, second=10 + ) + } + ) + self.concert.save().run_sync() + + +@engines_only("postgres", "cockroach") +class TestExtract(DatetimeTest): + def test_extract(self): + self.assertEqual( + Concert.select( + Extract(Concert.starts, "year", alias="starts_year") + ).run_sync(), + [{"starts_year": self.concert.starts.year}], + ) + + def test_invalid_format(self): + with self.assertRaises(ValueError): + Extract( + Concert.starts, + "abc123", # type: ignore + alias="starts_year", + ) + + +@sqlite_only +class TestStrftime(DatetimeTest): + def test_strftime(self): + self.assertEqual( + Concert.select( + Strftime(Concert.starts, "%Y", alias="starts_year") + ).run_sync(), + [{"starts_year": str(self.concert.starts.year)}], + ) + + +class TestDatabaseAgnostic(DatetimeTest): + def test_year(self): + self.assertEqual( + Concert.select( + Year(Concert.starts, alias="starts_year") + ).run_sync(), + [{"starts_year": self.concert.starts.year}], + ) + + def test_month(self): + self.assertEqual( + Concert.select( + Month(Concert.starts, alias="starts_month") + ).run_sync(), + [{"starts_month": self.concert.starts.month}], + ) + + def test_day(self): + self.assertEqual( + Concert.select(Day(Concert.starts, alias="starts_day")).run_sync(), + [{"starts_day": self.concert.starts.day}], + ) + + def test_hour(self): + self.assertEqual( + Concert.select( + Hour(Concert.starts, alias="starts_hour") + ).run_sync(), + [{"starts_hour": self.concert.starts.hour}], + ) + + def test_minute(self): + self.assertEqual( + Concert.select( + Minute(Concert.starts, alias="starts_minute") + ).run_sync(), + [{"starts_minute": self.concert.starts.minute}], + ) + + def test_second(self): + self.assertEqual( + Concert.select( + Second(Concert.starts, alias="starts_second") + ).run_sync(), + [{"starts_second": self.concert.starts.second}], + )