Skip to content

Commit

Permalink
Adds Year and Month support to Duration (#53)
Browse files Browse the repository at this point in the history
* Added support for Duration PxY (year) and PxM (month)
  • Loading branch information
nickybulthuis authored Jun 28, 2024
1 parent 4ccecc9 commit c0b5bb2
Show file tree
Hide file tree
Showing 11 changed files with 132 additions and 34 deletions.
11 changes: 9 additions & 2 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ All notable changes to this project will be documented in this file.
The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.0.0/>`_\ ,
and this project adheres to `Semantic Versioning <https://semver.org/spec/v2.0.0.html>`_.

[Unreleased]
---------------------

Added
^^^^^

* Parser: Added support for durations specified in years and months.

[0.10.0] - 2024-01-21
---------------------
Expand Down Expand Up @@ -35,9 +42,9 @@ Fixed

* SQLAlchemy functions defined by ``odata_query``

- no longer clash with other functions defined in ``sqlalchemy.func`` with
- no longer clash with other functions defined in ``sqlalchemy.func`` with
the same name.
- inherit cache to prevent SQLAlchemy performance warnings.
- inherit cache to prevent SQLAlchemy performance warnings.


[0.8.1] - 2023-02-17
Expand Down
18 changes: 14 additions & 4 deletions odata_query/ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from dateutil.parser import isoparse

DURATION_PATTERN = re.compile(r"([+-])?P(\d+D)?(?:T(\d+H)?(\d+M)?(\d+(?:\.\d+)?S)?)?")
DURATION_PATTERN = re.compile(r"([+-])?P(\d+Y)?(\d+M)?(\d+D)?(?:T(\d+H)?(\d+M)?(\d+(?:\.\d+)?S)?)?")


@dataclass(frozen=True)
Expand Down Expand Up @@ -125,7 +125,15 @@ class Duration(_Literal):

@property
def py_val(self) -> dt.timedelta:
sign, days, hours, minutes, seconds = self.unpack()
sign, years, months, days, hours, minutes, seconds = self.unpack()

# Initialize days to 0 if None
days = float(days or 0)

# Approximate conversion, adjust as necessary for more precision
days += float(years or 0) * 365.25 # Average including leap years
days += float(months or 0) * 30.44 # Average month length

delta = dt.timedelta(
days=float(days or 0),
hours=float(hours or 0),
Expand All @@ -150,14 +158,16 @@ def unpack(
if not match:
raise ValueError(f"Could not unpack Duration with value {self.val}")

sign, days, hours, minutes, seconds = match.groups()
sign, years, months, days, hours, minutes, seconds = match.groups()

_years = years[:-1] if years else None
_months = months[:-1] if months else None
_days = days[:-1] if days else None
_hours = hours[:-1] if hours else None
_minutes = minutes[:-1] if minutes else None
_seconds = seconds[:-1] if seconds else None

return sign, _days, _hours, _minutes, _seconds
return sign, _years, _months, _days, _hours, _minutes, _seconds


@dataclass(frozen=True)
Expand Down
2 changes: 1 addition & 1 deletion odata_query/grammar.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def error(self, token: Token):
# Primitive literals
####################################################################################

@_(r"duration'[+-]?P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?'")
@_(r"duration'[+-]?P(?:\d+Y)?(?:\d+M)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?'")
def DURATION(self, t):
":meta private:"
val = t.value.upper()
Expand Down
6 changes: 5 additions & 1 deletion odata_query/sql/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,14 @@ def visit_DateTime(self, node: ast.DateTime) -> str:

def visit_Duration(self, node: ast.Duration) -> str:
":meta private:"
sign, days, hours, minutes, seconds = node.unpack()
sign, years, months, days, hours, minutes, seconds = node.unpack()

sign = sign or ""
intervals = []
if years:
intervals.append(f"INTERVAL '{years}' YEAR")
if months:
intervals.append(f"INTERVAL '{months}' MONTH")
if days:
intervals.append(f"INTERVAL '{days}' DAY")
if hours:
Expand Down
14 changes: 14 additions & 0 deletions tests/integration/django/test_odata_to_django_q.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,20 @@ def tz(offset: int) -> dt.tzinfo:
+ Value(dt.timedelta(days=1, hours=1, minutes=1, seconds=1))
),
),
(
"published_at eq 2019-01-01T00:00:00 add duration'P1Y'",
Q(
published_at__exact=Value(dt.datetime(2019, 1, 1, 0, 0, 0))
+ Value(dt.timedelta(days=365.25)) # 1 times 365.25 (average year in days)
),
),
(
"published_at eq 2019-01-01T00:00:00 add duration'P2M'",
Q(
published_at__exact=Value(dt.datetime(2019, 1, 1, 0, 0, 0))
+ Value(dt.timedelta(days=60.88)) # 2 times 30.44 (average month in days)
),
),
("contains(title, 'copy')", Q(title__contains=Value("copy"))),
("startswith(title, 'copy')", Q(title__startswith=Value("copy"))),
("endswith(title, 'bla')", Q(title__endswith=Value("bla"))),
Expand Down
8 changes: 8 additions & 0 deletions tests/integration/sql/test_odata_to_athena_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@
"period_start add duration'P365D' ge period_end",
'"period_start" + INTERVAL \'365\' DAY >= "period_end"',
),
(
"period_start add duration'P1Y' ge period_end",
'"period_start" + INTERVAL \'1\' YEAR >= "period_end"',
),
(
"period_start add duration'P2M' ge period_end",
'"period_start" + INTERVAL \'2\' MONTH >= "period_end"',
),
(
"period_start add duration'P365DT12H1M1.1S' ge period_end",
"\"period_start\" + (INTERVAL '365' DAY + INTERVAL '12' HOUR + INTERVAL '1' MINUTE + INTERVAL '1.1' SECOND) >= \"period_end\"",
Expand Down
16 changes: 16 additions & 0 deletions tests/integration/sql/test_odata_to_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,27 @@
"period_start add duration'PT1S' ge period_end",
'"period_start" + INTERVAL \'1\' SECOND >= "period_end"',
),
(
"period_start add duration'P1Y' ge period_end",
'"period_start" + INTERVAL \'1\' YEAR >= "period_end"',
),
(
"period_start add duration'P2M' ge period_end",
'"period_start" + INTERVAL \'2\' MONTH >= "period_end"',
),
("year(period_start) eq 2019", 'EXTRACT (YEAR FROM "period_start") = 2019'),
(
"period_end lt now() sub duration'P365D'",
"\"period_end\" < CURRENT_TIMESTAMP - INTERVAL '365' DAY",
),
(
"period_end lt now() sub duration'P1Y'",
"\"period_end\" < CURRENT_TIMESTAMP - INTERVAL '1' YEAR",
),
(
"period_end lt now() sub duration'P2M'",
"\"period_end\" < CURRENT_TIMESTAMP - INTERVAL '2' MONTH",
),
(
"startswith(trim(meter_id), '999')",
"TRIM(\"meter_id\") LIKE '999%'",
Expand Down
8 changes: 8 additions & 0 deletions tests/integration/sql/test_odata_to_sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@
"period_start add duration'PT1S' ge period_end",
'"period_start" + INTERVAL \'1\' SECOND >= "period_end"',
),
(
"period_start add duration'P1Y' ge period_end",
'"period_start" + INTERVAL \'1\' YEAR >= "period_end"',
),
(
"period_start add duration'P2M' ge period_end",
'"period_start" + INTERVAL \'2\' MONTH >= "period_end"',
),
(
"year(period_start) eq 2019",
"CAST(STRFTIME('%Y', \"period_start\") AS INTEGER) = 2019",
Expand Down
12 changes: 12 additions & 0 deletions tests/integration/sqlalchemy/test_odata_to_sqlalchemy_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,18 @@ def tz(offset: int) -> dt.tzinfo:
== literal(dt.datetime(2019, 1, 1, 0, 0, 0))
+ dt.timedelta(days=1, hours=1, minutes=1, seconds=1),
),
(
"published_at eq 2019-01-01T00:00:00 add duration'P1Y'",
BlogPost.c.published_at
== literal(dt.datetime(2019, 1, 1, 0, 0, 0))
+ dt.timedelta(days=365.25), # 1 times 365.25 (average year in days)
),
(
"published_at eq 2019-01-01T00:00:00 add duration'P2M'",
BlogPost.c.published_at
== literal(dt.datetime(2019, 1, 1, 0, 0, 0))
+ dt.timedelta(days=60.88), # 2 times 30.44 (average month in days)
),
("contains(title, 'copy')", BlogPost.c.title.contains("copy")),
("startswith(title, 'copy')", BlogPost.c.title.startswith("copy")),
("endswith(title, 'bla')", BlogPost.c.title.endswith("bla")),
Expand Down
12 changes: 12 additions & 0 deletions tests/integration/sqlalchemy/test_odata_to_sqlalchemy_orm.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ def tz(offset: int) -> dt.tzinfo:
== literal(dt.datetime(2019, 1, 1, 0, 0, 0))
+ dt.timedelta(days=1, hours=1, minutes=1, seconds=1),
),
(
"published_at eq 2019-01-01T00:00:00 add duration'P1Y'",
BlogPost.published_at
== literal(dt.datetime(2019, 1, 1, 0, 0, 0))
+ dt.timedelta(days=365.25), # 1 times 365.25 (average year in days)
),
(
"published_at eq 2019-01-01T00:00:00 add duration'P2M'",
BlogPost.published_at
== literal(dt.datetime(2019, 1, 1, 0, 0, 0))
+ dt.timedelta(days=60.88), # 2 times 30.44 (average month in days)
),
("contains(title, 'copy')", BlogPost.title.contains("copy")),
("startswith(title, 'copy')", BlogPost.title.startswith("copy")),
("endswith(title, 'bla')", BlogPost.title.endswith("bla")),
Expand Down
59 changes: 33 additions & 26 deletions tests/unit/test_odata_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,32 +75,36 @@ def test_primitive_literal_parsing(value: str, expected_type: type):
@pytest.mark.parametrize(
"value, expected_unpacked",
[
("duration'P12DT23H59M59.9S'", (None, "12", "23", "59", "59.9")),
("duration'-P12DT23H59M59.9S'", ("-", "12", "23", "59", "59.9")),
("duration'P12D'", (None, "12", None, None, None)),
("duration'-P12D'", ("-", "12", None, None, None)),
("duration'PT23H59M59.9S'", (None, None, "23", "59", "59.9")),
("duration'-PT23H59M59.9S'", ("-", None, "23", "59", "59.9")),
("duration'PT23H59M59.9S'", (None, None, "23", "59", "59.9")),
("duration'-PT23H59M59.9S'", ("-", None, "23", "59", "59.9")),
("duration'PT23H59M59S'", (None, None, "23", "59", "59")),
("duration'-PT23H59M59S'", ("-", None, "23", "59", "59")),
("duration'PT23H'", (None, None, "23", None, None)),
("duration'-PT23H'", ("-", None, "23", None, None)),
("duration'PT59M'", (None, None, None, "59", None)),
("duration'-PT59M'", ("-", None, None, "59", None)),
("duration'PT59S'", (None, None, None, None, "59")),
("duration'-PT59S'", ("-", None, None, None, "59")),
("duration'PT59.9S'", (None, None, None, None, "59.9")),
("duration'-PT59.9S'", ("-", None, None, None, "59.9")),
("duration'P12DT23H'", (None, "12", "23", None, None)),
("duration'-P12DT23H'", ("-", "12", "23", None, None)),
("duration'P12DT59M'", (None, "12", None, "59", None)),
("duration'-P12DT59M'", ("-", "12", None, "59", None)),
("duration'P12DT59S'", (None, "12", None, None, "59")),
("duration'-P12DT59S'", ("-", "12", None, None, "59")),
("duration'P12DT59.9S'", (None, "12", None, None, "59.9")),
("duration'-P12DT59.9S'", ("-", "12", None, None, "59.9")),
("duration'P12DT23H59M59.9S'", (None, None, None, "12", "23", "59", "59.9")),
("duration'-P12DT23H59M59.9S'", ("-", None, None, "12", "23", "59", "59.9")),
("duration'P12D'", (None, None, None, "12", None, None, None)),
("duration'-P12D'", ("-", None, None, "12", None, None, None)),
("duration'PT23H59M59.9S'", (None, None, None, None, "23", "59", "59.9")),
("duration'-PT23H59M59.9S'", ("-", None, None, None, "23", "59", "59.9")),
("duration'PT23H59M59.9S'", (None, None, None, None, "23", "59", "59.9")),
("duration'-PT23H59M59.9S'", ("-", None, None, None, "23", "59", "59.9")),
("duration'PT23H59M59S'", (None, None, None, None, "23", "59", "59")),
("duration'-PT23H59M59S'", ("-", None, None, None, "23", "59", "59")),
("duration'PT23H'", (None, None, None, None, "23", None, None)),
("duration'-PT23H'", ("-", None, None, None, "23", None, None)),
("duration'PT59M'", (None, None, None, None, None, "59", None)),
("duration'-PT59M'", ("-", None, None, None, None, "59", None)),
("duration'PT59S'", (None, None, None, None, None, None, "59")),
("duration'-PT59S'", ("-", None, None, None, None, None, "59")),
("duration'PT59.9S'", (None, None, None, None, None, None, "59.9")),
("duration'-PT59.9S'", ("-", None, None, None, None, None, "59.9")),
("duration'P12DT23H'", (None, None, None, "12", "23", None, None)),
("duration'-P12DT23H'", ("-", None, None, "12", "23", None, None)),
("duration'P12DT59M'", (None, None, None, "12", None, "59", None)),
("duration'-P12DT59M'", ("-", None, None, "12", None, "59", None)),
("duration'P12DT59S'", (None, None, None, "12", None, None, "59")),
("duration'-P12DT59S'", ("-", None, None, "12", None, None, "59")),
("duration'P12DT59.9S'", (None, None, None, "12", None, None, "59.9")),
("duration'-P12DT59.9S'", ("-", None, None, "12", None, None, "59.9")),
("duration'-P1Y'", ("-", "1", None, None, None, None, None)),
("duration'P1Y'", (None, "1", None, None, None, None, None)),
("duration'-P12M'", ("-", None, "12", None, None, None, None)),
("duration'P2Y3M'", (None, "2", "3", None, None, None, None)),
],
)
def test_duration_parsing(value: str, expected_unpacked: tuple):
Expand Down Expand Up @@ -166,6 +170,9 @@ def test_geography_literal_parsing(value: str, expected: str):
dt.timedelta(days=12, hours=23, minutes=59, seconds=59.9),
),
("duration'P12D'", dt.timedelta(days=12)),
("duration'P1Y'", dt.timedelta(days=365.25)), # Average including leap years
("duration'P1M'", dt.timedelta(days=30.44)), # Average month length
("duration'P1M2D'", dt.timedelta(days=32.44)),
],
)
def test_python_value_of_literals(odata_val: str, exp_py_val):
Expand Down

0 comments on commit c0b5bb2

Please sign in to comment.