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 e8840628 Convert date_histogram fixed_interval instead of defaulting 
to +1MONTH (#77)
e8840628 is described below

commit e88406281390eed07f8dc67fea0fe9f07d754dc8
Author: Serhiy Bzhezytskyy <[email protected]>
AuthorDate: Thu Sep 17 23:11:54 2026 +0300

    Convert date_histogram fixed_interval instead of defaulting to +1MONTH (#77)
---
 solrorbit/conversion/query.py              | 103 ++++++++++++++++++++++-------
 tests/unit/solr/test_workload_converter.py |  69 +++++++++++++++++--
 2 files changed, 143 insertions(+), 29 deletions(-)

diff --git a/solrorbit/conversion/query.py b/solrorbit/conversion/query.py
index 41f26786..cf6b16ba 100644
--- a/solrorbit/conversion/query.py
+++ b/solrorbit/conversion/query.py
@@ -26,6 +26,7 @@ Native Solr workloads should not go through this translation 
layer.
 """
 
 import logging
+import re
 from datetime import datetime, timedelta
 
 from .field import normalize_field_name
@@ -452,12 +453,7 @@ def _convert_single_agg(agg_name: str, agg_def: dict):
         if not field:
             logger.warning("date_histogram agg '%s' has no field — skipping", 
agg_name)
             return None
-        interval = (
-            dh_conf.get("calendar_interval")
-            or dh_conf.get("fixed_interval")
-            or dh_conf.get("interval", "month")
-        )
-        gap = _calendar_interval_to_solr_gap(interval)
+        gap = _date_histogram_gap(dh_conf, agg_name)
         facet_def = {
             "type": "range",
             "field": field,
@@ -512,25 +508,82 @@ def _convert_single_agg(agg_name: str, agg_def: dict):
     return None
 
 
-def _calendar_interval_to_solr_gap(interval: str) -> str:
-    """Convert an OpenSearch calendar_interval or fixed_interval to a Solr 
range gap string."""
-    mapping = {
-        "minute": "+1MINUTE",
-        "1m": "+1MINUTE",
-        "hour": "+1HOUR",
-        "1h": "+1HOUR",
-        "day": "+1DAY",
-        "1d": "+1DAY",
-        "week": "+7DAYS",
-        "1w": "+7DAYS",
-        "month": "+1MONTH",
-        "1m_month": "+1MONTH",  # avoid conflict with 1m (minute)
-        "quarter": "+3MONTHS",
-        "1q": "+3MONTHS",
-        "year": "+1YEAR",
-        "1y": "+1YEAR",
-    }
-    return mapping.get(str(interval).lower(), "+1MONTH")
+CALENDAR_INTERVAL_NAMES = {
+    "minute": "+1MINUTE",
+    "hour": "+1HOUR",
+    "day": "+1DAY",
+    "week": "+7DAYS",
+    "month": "+1MONTH",
+    "quarter": "+3MONTHS",
+    "year": "+1YEAR",
+}
+
+CALENDAR_INTERVAL_ABBREVIATIONS = {
+    "1m": "+1MINUTE",
+    "1h": "+1HOUR",
+    "1d": "+1DAY",
+    "1w": "+7DAYS",
+    "1M": "+1MONTH",
+    "1q": "+3MONTHS",
+    "1y": "+1YEAR",
+}
+
+FIXED_INTERVAL_UNITS = {
+    "ms": "MILLI",
+    "s": "SECOND",
+    "m": "MINUTE",
+    "h": "HOUR",
+    "d": "DAY",
+}
+
+FIXED_INTERVAL_PATTERN = re.compile(r"^(\d+)(ms|s|m|h|d)$")
+
+DEFAULT_GAP = "+1MONTH"
+
+
+def _date_histogram_gap(dh_conf: dict, agg_name: str | None = None) -> str:
+    """
+    Solr range gap for an OpenSearch date_histogram.
+
+    The key the interval arrives under decides how it reads: 
``calendar_interval: 1m``
+    is one minute and ``calendar_interval: 1M`` is one month, while a 
fixed_interval is
+    a multiple of a fixed unit and takes no calendar names at all. The 
deprecated
+    ``interval`` key accepts either form, so both are tried.
+
+    An interval that is neither is logged and falls back to DEFAULT_GAP.
+    """
+    if "calendar_interval" in dh_conf:
+        interval = dh_conf["calendar_interval"]
+        gap = _calendar_interval_to_solr_gap(interval)
+    elif "fixed_interval" in dh_conf:
+        interval = dh_conf["fixed_interval"]
+        gap = _fixed_interval_to_solr_gap(interval)
+    else:
+        interval = dh_conf.get("interval", "month")
+        gap = _calendar_interval_to_solr_gap(interval) or 
_fixed_interval_to_solr_gap(interval)
+    if gap:
+        return gap
+    logger.warning(
+        "date_histogram agg '%s' has interval '%s', which is neither an 
OpenSearch "
+        "calendar_interval nor a fixed_interval — using %s, so the buckets 
will not "
+        "have the width the workload asks for.",
+        agg_name, interval, DEFAULT_GAP,
+    )
+    return DEFAULT_GAP
+
+
+def _calendar_interval_to_solr_gap(interval) -> str | None:
+    """Solr range gap for an OpenSearch calendar_interval, or None if it is 
not one."""
+    return (CALENDAR_INTERVAL_ABBREVIATIONS.get(str(interval))
+            or CALENDAR_INTERVAL_NAMES.get(str(interval).lower()))
+
+
+def _fixed_interval_to_solr_gap(interval) -> str | None:
+    """Solr range gap for an OpenSearch fixed_interval, or None if it is not 
one."""
+    match = FIXED_INTERVAL_PATTERN.match(str(interval))
+    if not match:
+        return None
+    return "+{}{}".format(match.group(1), FIXED_INTERVAL_UNITS[match.group(2)])
 
 
 OS_TO_PYTHON_FORMAT = {
diff --git a/tests/unit/solr/test_workload_converter.py 
b/tests/unit/solr/test_workload_converter.py
index fd1566bb..480117bd 100644
--- a/tests/unit/solr/test_workload_converter.py
+++ b/tests/unit/solr/test_workload_converter.py
@@ -32,6 +32,8 @@ from solrorbit.conversion.query import (
     translate_to_solr_json_dsl,
     _convert_aggregations_to_facets,
     _calendar_interval_to_solr_gap,
+    _date_histogram_gap,
+    _fixed_interval_to_solr_gap,
 )
 
 
@@ -397,13 +399,72 @@ class TestCalendarIntervalToSolrGap(unittest.TestCase):
         self.assertEqual("+1MONTH", _calendar_interval_to_solr_gap("month"))
         self.assertEqual("+1YEAR", _calendar_interval_to_solr_gap("year"))
         self.assertEqual("+1HOUR", _calendar_interval_to_solr_gap("hour"))
+        self.assertEqual("+7DAYS", _calendar_interval_to_solr_gap("week"))
+        self.assertEqual("+3MONTHS", _calendar_interval_to_solr_gap("quarter"))
 
-    def test_unknown_defaults_to_month(self):
-        self.assertEqual("+1MONTH", 
_calendar_interval_to_solr_gap("fortnight"))
-
-    def test_case_insensitive(self):
+    def test_names_are_case_insensitive(self):
         self.assertEqual("+1MONTH", _calendar_interval_to_solr_gap("MONTH"))
 
+    def test_single_unit_abbreviations_are_case_sensitive(self):
+        """OpenSearch reads 1M as a month and 1m as a minute; both occur in 
real workloads."""
+        self.assertEqual("+1MONTH", _calendar_interval_to_solr_gap("1M"))
+        self.assertEqual("+1MINUTE", _calendar_interval_to_solr_gap("1m"))
+
+    def test_not_a_calendar_interval(self):
+        self.assertIsNone(_calendar_interval_to_solr_gap("fortnight"))
+        self.assertIsNone(_calendar_interval_to_solr_gap("60d"))
+
+
+class TestFixedIntervalToSolrGap(unittest.TestCase):
+    def test_every_unit_opensearch_accepts(self):
+        self.assertEqual("+2699999MILLI", 
_fixed_interval_to_solr_gap("2699999ms"))
+        self.assertEqual("+30SECOND", _fixed_interval_to_solr_gap("30s"))
+        self.assertEqual("+90MINUTE", _fixed_interval_to_solr_gap("90m"))
+        self.assertEqual("+3HOUR", _fixed_interval_to_solr_gap("3h"))
+        self.assertEqual("+60DAY", _fixed_interval_to_solr_gap("60d"))
+        self.assertEqual("+2000DAY", _fixed_interval_to_solr_gap("2000d"))
+
+    def test_not_a_fixed_interval(self):
+        self.assertIsNone(_fixed_interval_to_solr_gap("month"))
+        self.assertIsNone(_fixed_interval_to_solr_gap("1M"))
+        self.assertIsNone(_fixed_interval_to_solr_gap("60"))
+
+
+class TestDateHistogramGap(unittest.TestCase):
+    def test_the_key_decides_how_the_interval_reads(self):
+        self.assertEqual("+1MONTH", _date_histogram_gap({"calendar_interval": 
"1M"}))
+        self.assertEqual("+1MINUTE", _date_histogram_gap({"calendar_interval": 
"1m"}))
+        self.assertEqual("+60DAY", _date_histogram_gap({"fixed_interval": 
"60d"}))
+
+    def test_deprecated_interval_key_accepts_either_form(self):
+        self.assertEqual("+1MONTH", _date_histogram_gap({"interval": "1M"}))
+        self.assertEqual("+60DAY", _date_histogram_gap({"interval": "60d"}))
+
+    def test_missing_interval_defaults_to_month(self):
+        self.assertEqual("+1MONTH", _date_histogram_gap({"field": 
"dropoff_datetime"}))
+
+    def test_unconvertible_interval_warns(self):
+        with self.assertLogs("solrorbit.conversion.query", level="WARNING") as 
log:
+            gap = _date_histogram_gap({"calendar_interval": "fortnight"}, 
"dropoffs_over_time")
+        self.assertEqual("+1MONTH", gap)
+        self.assertTrue(any("fortnight" in msg for msg in log.output))
+        self.assertTrue(any("dropoffs_over_time" in msg for msg in log.output))
+
+
+class TestDateHistogramFacetGap(unittest.TestCase):
+    def _gap(self, date_histogram):
+        body = {"aggs": {"dropoffs_over_time": {"date_histogram": 
date_histogram}}}
+        facets = _convert_aggregations_to_facets(body["aggs"])
+        return facets["dropoffs_over_time"]["gap"]
+
+    def test_fixed_interval_keeps_its_width(self):
+        gap = self._gap({"field": "dropoff_datetime", "fixed_interval": "60d"})
+        self.assertEqual("+60DAY", gap)
+
+    def test_calendar_month_abbreviation_is_not_a_minute(self):
+        gap = self._gap({"field": "dropoff_datetime", "calendar_interval": 
"1M"})
+        self.assertEqual("+1MONTH", gap)
+
 
 if __name__ == "__main__":
     unittest.main()

Reply via email to