You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by yo...@apache.org on 2024/03/04 10:49:55 UTC

(superset) branch master updated: feat: datediff in datetime_parser (#27368)

This is an automated email from the ASF dual-hosted git repository.

yongjiezhao pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new e89ec4d593 feat: datediff in datetime_parser (#27368)
e89ec4d593 is described below

commit e89ec4d593092b4d90009f13013523911e53bfa8
Author: Yongjie Zhao <yo...@gmail.com>
AuthorDate: Mon Mar 4 11:49:48 2024 +0100

    feat: datediff in datetime_parser (#27368)
    
    Co-authored-by: Yongjie Zhao <yo...@burda-forward.de>
---
 superset/utils/date_parser.py               | 75 +++++++++++++++++++++--------
 tests/unit_tests/utils/date_parser_tests.py | 32 ++++++++++++
 2 files changed, 88 insertions(+), 19 deletions(-)

diff --git a/superset/utils/date_parser.py b/superset/utils/date_parser.py
index 0253edee1c..615fab4559 100644
--- a/superset/utils/date_parser.py
+++ b/superset/utils/date_parser.py
@@ -14,13 +14,14 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+from __future__ import annotations
+
 import calendar
 import logging
 import re
 from datetime import datetime, timedelta
 from functools import lru_cache
 from time import struct_time
-from typing import Optional
 
 import pandas as pd
 import parsedatetime
@@ -99,8 +100,8 @@ def dttm_from_timetuple(date_: struct_time) -> datetime:
 
 
 def get_past_or_future(
-    human_readable: Optional[str],
-    source_time: Optional[datetime] = None,
+    human_readable: str | None,
+    source_time: datetime | None = None,
 ) -> datetime:
     cal = parsedatetime.Calendar()
     source_dttm = dttm_from_timetuple(
@@ -110,8 +111,8 @@ def get_past_or_future(
 
 
 def parse_human_timedelta(
-    human_readable: Optional[str],
-    source_time: Optional[datetime] = None,
+    human_readable: str | None,
+    source_time: datetime | None = None,
 ) -> timedelta:
     """
     Returns ``datetime.timedelta`` from natural language time deltas
@@ -126,7 +127,7 @@ def parse_human_timedelta(
 
 
 def parse_past_timedelta(
-    delta_str: str, source_time: Optional[datetime] = None
+    delta_str: str, source_time: datetime | None = None
 ) -> timedelta:
     """
     Takes a delta like '1 year' and finds the timedelta for that period in
@@ -143,14 +144,14 @@ def parse_past_timedelta(
 
 
 def get_since_until(  # pylint: disable=too-many-arguments,too-many-locals,too-many-branches,too-many-statements
-    time_range: Optional[str] = None,
-    since: Optional[str] = None,
-    until: Optional[str] = None,
-    time_shift: Optional[str] = None,
-    relative_start: Optional[str] = None,
-    relative_end: Optional[str] = None,
-    instant_time_comparison_range: Optional[str] = None,
-) -> tuple[Optional[datetime], Optional[datetime]]:
+    time_range: str | None = None,
+    since: str | None = None,
+    until: str | None = None,
+    time_shift: str | None = None,
+    relative_start: str | None = None,
+    relative_end: str | None = None,
+    instant_time_comparison_range: str | None = None,
+) -> tuple[datetime | None, datetime | None]:
     """Return `since` and `until` date time tuple from string representations of
     time_range, since, until and time_shift.
 
@@ -228,7 +229,7 @@ def get_since_until(  # pylint: disable=too-many-arguments,too-many-locals,too-m
         ]
 
         since_and_until_partition = [_.strip() for _ in time_range.split(separator, 1)]
-        since_and_until: list[Optional[str]] = []
+        since_and_until: list[str | None] = []
         for part in since_and_until_partition:
             if not part:
                 # if since or until is "", set as None
@@ -351,12 +352,31 @@ class EvalDateAddFunc:  # pylint: disable=too-few-public-methods
     def eval(self) -> datetime:
         dttm_expression, delta, unit = self.value
         dttm = dttm_expression.eval()
+        delta = delta.eval() if hasattr(delta, "eval") else delta
         if unit.lower() == "quarter":
             delta = delta * 3
             unit = "month"
         return dttm + parse_human_timedelta(f"{delta} {unit}s", dttm)
 
 
+class EvalDateDiffFunc:  # pylint: disable=too-few-public-methods
+    def __init__(self, tokens: ParseResults) -> None:
+        self.value = tokens[1]
+
+    def eval(self) -> int:
+        if len(self.value) == 2:
+            _dttm_from, _dttm_to = self.value
+            return (_dttm_to.eval() - _dttm_from.eval()).days
+
+        if len(self.value) == 3:
+            _dttm_from, _dttm_to, _unit = self.value
+            if _unit == "year":
+                return _dttm_to.eval().year - _dttm_from.eval().year
+            if _unit == "day":
+                return (_dttm_to.eval() - _dttm_from.eval()).days
+        raise ValueError(_("Unable to calculate such a date delta"))
+
+
 class EvalDateTruncFunc:  # pylint: disable=too-few-public-methods
     def __init__(self, tokens: ParseResults) -> None:
         self.value = tokens[1]
@@ -441,6 +461,7 @@ def datetime_parser() -> ParseResults:  # pylint: disable=too-many-locals
     (  # pylint: disable=invalid-name
         DATETIME,
         DATEADD,
+        DATEDIFF,
         DATETRUNC,
         LASTDAY,
         HOLIDAY,
@@ -454,11 +475,10 @@ def datetime_parser() -> ParseResults:  # pylint: disable=too-many-locals
         SECOND,
     ) = map(
         CaselessKeyword,
-        "datetime dateadd datetrunc lastday holiday "
+        "datetime dateadd datediff datetrunc lastday holiday "
         "year quarter month week day hour minute second".split(),
     )
     lparen, rparen, comma = map(Suppress, "(),")
-    int_operand = pyparsing_common.signed_integer().setName("int_operand")
     text_operand = quotedString.setName("text_operand").setParseAction(EvalText)
 
     # allow expression to be used recursively
@@ -471,6 +491,12 @@ def datetime_parser() -> ParseResults:  # pylint: disable=too-many-locals
         datetime_func | dateadd_func | datetrunc_func | lastday_func | holiday_func
     )
 
+    # literal integer and expression that return a literal integer
+    datediff_func = Forward().setName("datediff")
+    int_operand = (
+        pyparsing_common.signed_integer().setName("int_operand") | datediff_func
+    )
+
     datetime_func <<= (DATETIME + lparen + text_operand + rparen).setParseAction(
         EvalDateTimeFunc
     )
@@ -517,11 +543,22 @@ def datetime_parser() -> ParseResults:  # pylint: disable=too-many-locals
         )
         + rparen
     ).setParseAction(EvalHolidayFunc)
+    datediff_func <<= (
+        DATEDIFF
+        + lparen
+        + Group(
+            date_expr
+            + comma
+            + date_expr
+            + ppOptional(comma + (YEAR | DAY) + ppOptional(comma))
+        )
+        + rparen
+    ).setParseAction(EvalDateDiffFunc)
 
-    return date_expr
+    return date_expr | datediff_func
 
 
-def datetime_eval(datetime_expression: Optional[str] = None) -> Optional[datetime]:
+def datetime_eval(datetime_expression: str | None = None) -> datetime | None:
     if datetime_expression:
         try:
             return datetime_parser().parseString(datetime_expression)[0].eval()
diff --git a/tests/unit_tests/utils/date_parser_tests.py b/tests/unit_tests/utils/date_parser_tests.py
index 41f4c95022..b031e54a66 100644
--- a/tests/unit_tests/utils/date_parser_tests.py
+++ b/tests/unit_tests/utils/date_parser_tests.py
@@ -65,6 +65,8 @@ def mock_parse_human_datetime(s: str) -> Optional[datetime]:
         return datetime(2000, 1, 1)
     elif s == "2018-01-01T00:00:00":
         return datetime(2018, 1, 1)
+    elif s == "2018-01-10T00:00:00":
+        return datetime(2018, 1, 10)
     elif s == "2018-12-31T23:59:59":
         return datetime(2018, 12, 31, 23, 59, 59)
     elif s == "2022-01-01T00:00:00":
@@ -366,6 +368,36 @@ def test_datetime_eval() -> None:
     expected = datetime(2018, 2, 28, 0, 0, 0)
     assert result == expected
 
+    result = datetime_eval(
+        "datediff(datetime('2018-01-01T00:00:00'), datetime('2018-01-10T00:00:00'))"  # pylint: disable=line-too-long,useless-suppression
+    )
+    assert result == 9
+
+    result = datetime_eval(
+        "datediff(datetime('2018-01-10T00:00:00'), datetime('2018-01-01T00:00:00'))"  # pylint: disable=line-too-long,useless-suppression
+    )
+    assert result == -9
+
+    result = datetime_eval(
+        "datediff(datetime('2018-01-01T00:00:00'), datetime('2018-01-10T00:00:00'), day)"  # pylint: disable=line-too-long,useless-suppression
+    )
+    assert result == 9
+
+    result = datetime_eval(
+        "datediff(datetime('2018-01-01T00:00:00'), datetime('2018-01-10T00:00:00'), year)"  # pylint: disable=line-too-long,useless-suppression
+    )
+    assert result == 0
+
+    result = datetime_eval(
+        "dateadd("
+        "datetime('2018-01-01T00:00:00'), "
+        "datediff(datetime('2018-01-10T00:00:00'), datetime('2018-01-01T00:00:00')), "  # pylint: disable=line-too-long,useless-suppression
+        "day,"
+        "),"
+    )
+    expected = datetime(2017, 12, 23, 0, 0, 0)
+    assert result == expected
+
 
 @patch("superset.utils.date_parser.datetime")
 def test_parse_human_timedelta(mock_datetime: Mock) -> None: