From cb4e5ea9553bdd4aa6988138dbffde866899fc42 Mon Sep 17 00:00:00 2001 From: collerek Date: Tue, 6 Jul 2021 15:11:26 +0200 Subject: [PATCH] improve date handling --- docs/fields/field-types.md | 8 +- docs/install.md | 16 +++ docs/releases.md | 12 ++ ormar/__init__.py | 2 +- ormar/fields/model_fields.py | 30 ++++- ormar/queryset/actions/filter_action.py | 14 +++ setup.py | 2 +- .../test_dates_with_timezone.py | 118 ++++++++++++++++++ .../test_fields_access.py | 31 ++--- 9 files changed, 206 insertions(+), 27 deletions(-) create mode 100644 tests/test_model_definition/test_dates_with_timezone.py diff --git a/docs/fields/field-types.md b/docs/fields/field-types.md index af886ff..12b703b 100644 --- a/docs/fields/field-types.md +++ b/docs/fields/field-types.md @@ -108,14 +108,18 @@ You can use either `length` and `precision` parameters or `max_digits` and `deci ### Time -`Time()` has no required parameters. +`Time(timezone: bool = False)` has no required parameters. + +You can pass `timezone=True` for timezone aware database column. * Sqlalchemy column: `sqlalchemy.Time` * Type (used for pydantic): `datetime.time` ### DateTime -`DateTime()` has no required parameters. +`DateTime(timezone: bool = False)` has no required parameters. + +You can pass `timezone=True` for timezone aware database column. * Sqlalchemy column: `sqlalchemy.DateTime` * Type (used for pydantic): `datetime.datetime` diff --git a/docs/install.md b/docs/install.md index 26339b2..7ba4afd 100644 --- a/docs/install.md +++ b/docs/install.md @@ -44,6 +44,22 @@ pip install ormar[sqlite] Will install also `aiosqlite`. +### Orjson + +```py +pip install ormar[orjson] +``` + +Will install also `orjson` that is much faster than builtin json parser. + +### Crypto + +```py +pip install ormar[crypto] +``` + +Will install also `cryptography` that is required to work with encrypted columns. + ### Manual installation of dependencies Of course, you can also install these requirements manually with `pip install asyncpg` etc. diff --git a/docs/releases.md b/docs/releases.md index 30185c2..22523bd 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,3 +1,15 @@ +# 0.10.14 + +## ✨ Features + +* Allow passing `timezone:bool = False` parameter to `DateTime` and `Time` fields for timezone aware database columns [#264](https://github.com/collerek/ormar/issues/264) +* Allow passing datetime, date and time for filter on `DateTime`, `Time` and `Date` fields to allow filtering by datetimes instead of converting the value to string [#79](https://github.com/collerek/ormar/issues/79) + +## 🐛 Fixes + +* Fix dependencies from `psycopg2` to `psycopg2-binary` [#255](https://github.com/collerek/ormar/issues/255) + + # 0.10.13 ## ✨ Features diff --git a/ormar/__init__.py b/ormar/__init__.py index 8b86efb..69929d3 100644 --- a/ormar/__init__.py +++ b/ormar/__init__.py @@ -76,7 +76,7 @@ class UndefinedType: # pragma no cover Undefined = UndefinedType() -__version__ = "0.10.13" +__version__ = "0.10.14" __all__ = [ "Integer", "BigInteger", diff --git a/ormar/fields/model_fields.py b/ormar/fields/model_fields.py index 5081da9..07e9ed0 100644 --- a/ormar/fields/model_fields.py +++ b/ormar/fields/model_fields.py @@ -351,6 +351,19 @@ class DateTime(ModelFieldFactory, datetime.datetime): _type = datetime.datetime _sample = "datetime" + def __new__( # type: ignore # noqa CFQ002 + cls, *, timezone: bool = False, **kwargs: Any + ) -> BaseField: # type: ignore + kwargs = { + **kwargs, + **{ + k: v + for k, v in locals().items() + if k not in ["cls", "__class__", "kwargs"] + }, + } + return super().__new__(cls, **kwargs) + @classmethod def get_column_type(cls, **kwargs: Any) -> Any: """ @@ -362,7 +375,7 @@ class DateTime(ModelFieldFactory, datetime.datetime): :return: initialized column with proper options :rtype: sqlalchemy Column """ - return sqlalchemy.DateTime() + return sqlalchemy.DateTime(timezone=kwargs.get("timezone", False)) class Date(ModelFieldFactory, datetime.date): @@ -395,6 +408,19 @@ class Time(ModelFieldFactory, datetime.time): _type = datetime.time _sample = "time" + def __new__( # type: ignore # noqa CFQ002 + cls, *, timezone: bool = False, **kwargs: Any + ) -> BaseField: # type: ignore + kwargs = { + **kwargs, + **{ + k: v + for k, v in locals().items() + if k not in ["cls", "__class__", "kwargs"] + }, + } + return super().__new__(cls, **kwargs) + @classmethod def get_column_type(cls, **kwargs: Any) -> Any: """ @@ -406,7 +432,7 @@ class Time(ModelFieldFactory, datetime.time): :return: initialized column with proper options :rtype: sqlalchemy Column """ - return sqlalchemy.Time() + return sqlalchemy.Time(timezone=kwargs.get("timezone", False)) class JSON(ModelFieldFactory, pydantic.Json): diff --git a/ormar/queryset/actions/filter_action.py b/ormar/queryset/actions/filter_action.py index b013cd3..c8d2f2f 100644 --- a/ormar/queryset/actions/filter_action.py +++ b/ormar/queryset/actions/filter_action.py @@ -1,3 +1,4 @@ +import datetime from typing import Any, Dict, TYPE_CHECKING, Type import sqlalchemy @@ -138,6 +139,19 @@ class FilterAction(QueryAction): if isinstance(self.filter_value, ormar.Model): self.filter_value = self.filter_value.pk + if isinstance( + self.filter_value, (datetime.date, datetime.time, datetime.datetime) + ): + self.filter_value = self.filter_value.isoformat() + + if isinstance(self.filter_value, (list, tuple, set)): + self.filter_value = [ + x.isoformat() + if isinstance(x, (datetime.date, datetime.time, datetime.datetime)) + else x + for x in self.filter_value + ] + op_attr = FILTER_OPERATORS[self.operator] if self.operator == "isnull": op_attr = "is_" if self.filter_value else "isnot" diff --git a/setup.py b/setup.py index ead590c..679fbdc 100644 --- a/setup.py +++ b/setup.py @@ -69,7 +69,7 @@ setup( "typing_extensions>=3.7,<=3.7.4.3", ], extras_require={ - "postgresql": ["asyncpg", "psycopg2"], + "postgresql": ["asyncpg", "psycopg2-binary"], "mysql": ["aiomysql", "pymysql"], "sqlite": ["aiosqlite"], "orjson": ["orjson"], diff --git a/tests/test_model_definition/test_dates_with_timezone.py b/tests/test_model_definition/test_dates_with_timezone.py new file mode 100644 index 0000000..617276f --- /dev/null +++ b/tests/test_model_definition/test_dates_with_timezone.py @@ -0,0 +1,118 @@ +from datetime import timezone, timedelta, datetime, date, time + +import databases +import pytest +import sqlalchemy + +import ormar + +from tests.settings import DATABASE_URL + +database = databases.Database(DATABASE_URL, force_rollback=True) +metadata = sqlalchemy.MetaData() + + +class DateFieldsModel(ormar.Model): + class Meta: + database = database + metadata = metadata + + id: int = ormar.Integer(primary_key=True) + created_date: datetime = ormar.DateTime( + default=datetime.now(tz=timezone(timedelta(hours=3))), timezone=True + ) + updated_date: datetime = ormar.DateTime( + default=datetime.now(tz=timezone(timedelta(hours=3))), + name="modification_date", + timezone=True, + ) + + +class SampleModel(ormar.Model): + class Meta: + database = database + metadata = metadata + + id: int = ormar.Integer(primary_key=True) + updated_at: datetime = ormar.DateTime() + + +class TimeModel(ormar.Model): + class Meta: + database = database + metadata = metadata + + id: int = ormar.Integer(primary_key=True) + elapsed: time = ormar.Time() + + +class DateModel(ormar.Model): + class Meta: + database = database + metadata = metadata + + id: int = ormar.Integer(primary_key=True) + creation_date: date = ormar.Date() + + +@pytest.fixture(autouse=True, scope="module") +def create_test_database(): + engine = sqlalchemy.create_engine(DATABASE_URL) + metadata.drop_all(engine) + metadata.create_all(engine) + yield + metadata.drop_all(engine) + + +@pytest.mark.asyncio +async def test_model_crud_with_timezone(): + async with database: + datemodel = await DateFieldsModel().save() + assert datemodel.created_date is not None + assert datemodel.updated_date is not None + + +@pytest.mark.asyncio +async def test_query_with_datetime_in_filter(): + async with database: + creation_dt = datetime(2021, 5, 18, 0, 0, 0, 0) + sample = await SampleModel.objects.create(updated_at=creation_dt) + + current_dt = datetime(2021, 5, 19, 0, 0, 0, 0) + outdated_samples = await SampleModel.objects.filter( + updated_at__lt=current_dt + ).all() + + assert outdated_samples[0] == sample + + +@pytest.mark.asyncio +async def test_query_with_date_in_filter(): + async with database: + sample = await TimeModel.objects.create(elapsed=time(0, 20, 20)) + await TimeModel.objects.create(elapsed=time(0, 12, 0)) + await TimeModel.objects.create(elapsed=time(0, 19, 55)) + sample4 = await TimeModel.objects.create(elapsed=time(0, 21, 15)) + + threshold = time(0, 20, 0) + samples = await TimeModel.objects.filter(TimeModel.elapsed >= threshold).all() + + assert len(samples) == 2 + assert samples[0] == sample + assert samples[1] == sample4 + + +@pytest.mark.asyncio +async def test_query_with_time_in_filter(): + async with database: + await DateModel.objects.create(creation_date=date(2021, 5, 18)) + sample2 = await DateModel.objects.create(creation_date=date(2021, 5, 19)) + sample3 = await DateModel.objects.create(creation_date=date(2021, 5, 20)) + + outdated_samples = await DateModel.objects.filter( + creation_date__in=[date(2021, 5, 19), date(2021, 5, 20)] + ).all() + + assert len(outdated_samples) == 2 + assert outdated_samples[0] == sample2 + assert outdated_samples[1] == sample3 diff --git a/tests/test_model_definition/test_fields_access.py b/tests/test_model_definition/test_fields_access.py index 4215dc6..6cecf9a 100644 --- a/tests/test_model_definition/test_fields_access.py +++ b/tests/test_model_definition/test_fields_access.py @@ -185,25 +185,14 @@ def test_combining_groups_together(): ) -# @pytest.mark.asyncio -# async def test_filtering_by_field_access(): -# async with database: -# async with database.transaction(force_rollback=True): -# category = await Category(name='Toys').save() -# product1 = await Product(name="G.I Joe", -# rating=4.7, -# category=category).save() -# product2 = await Product(name="My Little Pony", -# rating=3.8, -# category=category).save() -# -# check = Product.object.get(Product.name == "My Little Pony") -# assert check == product2 +@pytest.mark.asyncio +async def test_filtering_by_field_access(): + async with database: + async with database.transaction(force_rollback=True): + category = await Category(name="Toys").save() + product2 = await Product( + name="My Little Pony", rating=3.8, category=category + ).save() -# TODO: Finish implementation -# * overload operators and add missing functions that return FilterAction (V) -# * return OrderAction for desc() and asc() (V) -# * create filter groups for & and | (and ~ - NOT?) (V) -# * accept args in all functions that accept filters? or only filter and exclude? (V) -# all functions: delete, first, get, get_or_none, get_or_create, all, filter, exclude -# * accept OrderActions in order_by (V) + check = await Product.objects.get(Product.name == "My Little Pony") + assert check == product2