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

janhoy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-orbit.git


The following commit(s) were added to refs/heads/main by this push:
     new ec305c7c Convert date bounds correctly: space separator, and whole-day 
rounding (#74)
ec305c7c is described below

commit ec305c7ca8959c56ebfebfc6485f434521bcafe3
Author: Serhiy Bzhezytskyy <[email protected]>
AuthorDate: Thu Sep 17 22:54:54 2026 +0300

    Convert date bounds correctly: space separator, and whole-day rounding (#74)
---
 solrorbit/conversion/query.py              | 89 +++++++++++++++++++-----------
 tests/unit/solr/test_workload_converter.py | 52 +++++++++++++++++
 2 files changed, 109 insertions(+), 32 deletions(-)

diff --git a/solrorbit/conversion/query.py b/solrorbit/conversion/query.py
index 432b22b0..41f26786 100644
--- a/solrorbit/conversion/query.py
+++ b/solrorbit/conversion/query.py
@@ -26,7 +26,7 @@ Native Solr workloads should not go through this translation 
layer.
 """
 
 import logging
-from datetime import datetime
+from datetime import datetime, timedelta
 
 from .field import normalize_field_name
 
@@ -183,21 +183,25 @@ def _translate_query_node(node: dict, fq_list: list = 
None) -> str:
             field = normalize_field_name(field)
             # gt/lt are exclusive; Solr spells that with a curly bracket.
             if "gte" in bounds:
-                lo, lo_bracket = bounds["gte"], "["
+                lo, lo_bracket, lo_key = bounds["gte"], "[", "gte"
             elif "gt" in bounds:
-                lo, lo_bracket = bounds["gt"], "{"
+                lo, lo_bracket, lo_key = bounds["gt"], "{", "gt"
             else:
-                lo, lo_bracket = "*", "["
+                lo, lo_bracket, lo_key = "*", "[", None
             if "lte" in bounds:
-                hi, hi_bracket = bounds["lte"], "]"
+                hi, hi_bracket, hi_key = bounds["lte"], "]", "lte"
             elif "lt" in bounds:
-                hi, hi_bracket = bounds["lt"], "}"
+                hi, hi_bracket, hi_key = bounds["lt"], "}", "lt"
             else:
-                hi, hi_bracket = "*", "]"
+                hi, hi_bracket, hi_key = "*", "]", None
             # Convert dates if format is specified (common for date fields)
             os_format = bounds.get("format")
-            lo = _convert_date_to_solr_format(lo, os_format)
-            hi = _convert_date_to_solr_format(hi, os_format)
+            lo, lo_is_date_only = _convert_date_to_solr_format(lo, os_format)
+            hi, hi_is_date_only = _convert_date_to_solr_format(hi, os_format)
+            if hi_is_date_only and hi_key == "lte":
+                hi, hi_bracket = _round_date_only_bound(hi), "}"
+            if lo_is_date_only and lo_key == "gt":
+                lo, lo_bracket = _round_date_only_bound(lo), "["
             return f"{field}:{lo_bracket}{lo} TO {hi}{hi_bracket}"
 
     if "exists" in node:
@@ -529,7 +533,18 @@ def _calendar_interval_to_solr_gap(interval: str) -> str:
     return mapping.get(str(interval).lower(), "+1MONTH")
 
 
-def _convert_date_to_solr_format(date_str, os_format=None) -> str:
+OS_TO_PYTHON_FORMAT = {
+    "dd/MM/yyyy": ("%d/%m/%Y", False),
+    "MM/dd/yyyy": ("%m/%d/%Y", False),
+    "yyyy-MM-dd": ("%Y-%m-%d", False),
+    "yyyy/MM/dd": ("%Y/%m/%d", False),
+    "dd-MM-yyyy": ("%d-%m-%Y", False),
+    "MM-dd-yyyy": ("%m-%d-%Y", False),
+    "yyyy-MM-dd HH:mm:ss": ("%Y-%m-%d %H:%M:%S", True),
+}
+
+
+def _convert_date_to_solr_format(date_str, os_format=None) -> tuple:
     """
     Convert an OpenSearch date string to Solr ISO 8601 format.
 
@@ -538,50 +553,60 @@ def _convert_date_to_solr_format(date_str, 
os_format=None) -> str:
         os_format: Optional OpenSearch date format pattern (e.g., "dd/MM/yyyy")
 
     Returns:
-        ISO 8601 date string for Solr (e.g., "2015-01-01T00:00:00Z")
+        (value, is_date_only) — the ISO 8601 date string for Solr
+        (e.g., "2015-01-01T00:00:00Z"), and whether the source named a whole
+        day rather than an instant.
 
     If the date is already in ISO format or conversion fails, returns the
     original string unchanged.
     """
     if not isinstance(date_str, str) or date_str in ("*", "now"):
-        return date_str
-
-    # Map OpenSearch date format patterns to Python strptime format
-    OS_TO_PYTHON_FORMAT = {
-        "dd/MM/yyyy": "%d/%m/%Y",
-        "MM/dd/yyyy": "%m/%d/%Y",
-        "yyyy-MM-dd": "%Y-%m-%d",
-        "yyyy/MM/dd": "%Y/%m/%d",
-        "dd-MM-yyyy": "%d-%m-%Y",
-        "MM-dd-yyyy": "%m-%d-%Y",
-        # Add more as needed
-    }
+        return date_str, False
 
     # If format is provided, use it to parse the date
     if os_format:
-        python_fmt = OS_TO_PYTHON_FORMAT.get(os_format)
-        if python_fmt:
+        pattern = OS_TO_PYTHON_FORMAT.get(os_format)
+        if pattern:
+            python_fmt, has_time = pattern
             try:
                 dt = datetime.strptime(date_str, python_fmt)
-                return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
+                return dt.strftime("%Y-%m-%dT%H:%M:%SZ"), not has_time
             except ValueError:
                 logger.warning(f"Failed to parse date '{date_str}' with format 
'{os_format}'")
-                return date_str
+                return date_str, False
         else:
             logger.warning(f"Unknown OpenSearch date format: '{os_format}'")
 
     # Try common patterns if no format specified
-    for python_fmt in OS_TO_PYTHON_FORMAT.values():
+    for python_fmt, has_time in OS_TO_PYTHON_FORMAT.values():
         try:
             dt = datetime.strptime(date_str, python_fmt)
-            return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
+            return dt.strftime("%Y-%m-%dT%H:%M:%SZ"), not has_time
         except ValueError:
             continue
 
     # If it's already in ISO-like format, return as-is
     # (handles cases like "2015-01-01T00:00:00Z" or partial ISO)
-    if "T" in date_str or len(date_str) == 10:  # YYYY-MM-DD
-        return date_str
+    if "T" in date_str:
+        return date_str, False
 
     logger.warning(f"Could not parse date '{date_str}', using as-is")
-    return date_str
+    return date_str, False
+
+
+def _round_date_only_bound(value: str) -> str:
+    """
+    Advance a whole-day bound to the start of the following day.
+
+    A date without a time names a day, and OpenSearch rounds it to the edge of
+    that day: `lte` and `gt` go to its LAST millisecond, `gte` and `lt` to its
+    first. Solr rounds nothing, so only the two that move to the end of the day
+    need translating, and naming the next day's first instant says that without
+    depending on how fine Solr's date precision happens to be.
+    """
+    try:
+        dt = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
+    except ValueError:
+        logger.warning(f"Could not round whole-day bound '{value}', using 
as-is")
+        return value
+    return (dt + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
diff --git a/tests/unit/solr/test_workload_converter.py 
b/tests/unit/solr/test_workload_converter.py
index 423dc9c7..fd1566bb 100644
--- a/tests/unit/solr/test_workload_converter.py
+++ b/tests/unit/solr/test_workload_converter.py
@@ -228,6 +228,58 @@ class TestTranslateToSolrJsonDsl(unittest.TestCase):
         self.assertEqual("fare_amount:[* TO 100}", self._range({"lt": 100}))
         self.assertEqual("fare_amount:[* TO 100]", self._range({"lte": 100}))
 
+    def _date_range(self, bounds):
+        return translate_to_solr_json_dsl(
+            {"query": {"range": {"dropoff_datetime": bounds}}})["query"]
+
+    def test_a_datetime_with_a_space_separator_is_converted(self):
+        self.assertEqual(
+            "dropoff_datetime:[2015-01-01T00:00:00Z TO 2016-01-01T00:00:00Z}",
+            self._date_range({"gte": "2015-01-01 00:00:00", "lt": "2016-01-01 
00:00:00"}))
+
+    def test_a_whole_day_lte_covers_that_day(self):
+        self.assertEqual(
+            "dropoff_datetime:[2015-01-01T00:00:00Z TO 2015-01-22T00:00:00Z}",
+            self._date_range({"gte": "01/01/2015", "lte": "21/01/2015",
+                              "format": "dd/MM/yyyy"}))
+
+    def test_a_whole_day_gt_excludes_that_day(self):
+        self.assertEqual(
+            "dropoff_datetime:[2015-01-02T00:00:00Z TO *]",
+            self._date_range({"gt": "01/01/2015", "format": "dd/MM/yyyy"}))
+
+    def test_the_bounds_that_round_down_do_not_move(self):
+        self.assertEqual(
+            "dropoff_datetime:[2015-01-01T00:00:00Z TO *]",
+            self._date_range({"gte": "01/01/2015", "format": "dd/MM/yyyy"}))
+        self.assertEqual(
+            "dropoff_datetime:[* TO 2015-01-01T00:00:00Z}",
+            self._date_range({"lt": "01/01/2015", "format": "dd/MM/yyyy"}))
+
+    def test_only_the_bound_that_is_used_is_rounded(self):
+        self.assertEqual(
+            "dropoff_datetime:[2015-01-01T00:00:00Z TO 2015-01-22T00:00:00Z}",
+            self._date_range({"gte": "01/01/2015", "gt": "05/01/2015",
+                              "lte": "21/01/2015", "lt": "10/01/2015",
+                              "format": "dd/MM/yyyy"}))
+
+    def test_a_bound_that_is_not_a_date_keeps_its_bracket(self):
+        for value in ("0000000010", "not-a-date", "2015-02-30"):
+            self.assertEqual(
+                f"serial_no:[0000000001 TO {value}]",
+                translate_to_solr_json_dsl(
+                    {"query": {"range": {"serial_no": {"gte": "0000000001",
+                                                      "lte": 
value}}}})["query"])
+
+    def test_an_instant_is_not_rounded(self):
+        self.assertEqual(
+            "dropoff_datetime:[2015-01-01T00:00:00Z TO 2016-01-01T00:00:00Z]",
+            self._date_range({"gte": "2015-01-01T00:00:00Z",
+                              "lte": "2016-01-01T00:00:00Z"}))
+        self.assertEqual(
+            "dropoff_datetime:[* TO 2016-01-01T00:00:00Z]",
+            self._date_range({"lte": "2016-01-01 00:00:00"}))
+
     def test_bool_with_filter_goes_to_fq(self):
         body = {
             "query": {

Reply via email to