From cb84d303ebbc24ab52825fd6cc446c66eca1df08 Mon Sep 17 00:00:00 2001 From: Serhiy Bzhezytskyy Date: Wed, 2 Sep 2026 10:49:16 +0300 Subject: [PATCH] Preserve range bound inclusivity when translating to Solr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `translate_opensearch_query` folded `gt` into `gte` and `lt` into `lte`, then always emitted square brackets, so every translated range came out inclusive at both ends. Solr supports both forms -- `[a TO b]` and `[a TO b}` -- so nothing about the engine required this. Effect on a shipped workload: nyc_taxis's `range` operation asks for `{"gte": 5, "lt": 15}` and the conversion produces `total_amount:[5 TO 15]`, which also matches `total_amount == 15`. On the 1k corpus that `--test-mode` downloads that is 576 documents against 573; on a 300,649-document sample, 191,735 against 191,441. The same difference appears in OpenSearch itself when its body is changed from `lt` to `lte`, so it is a property of the bound rather than of either engine. `test_range_query` covered only `gte`/`lte`, the one combination the old code got right, and asserted with `assertIn("TO", …)` -- which passes for `[5 TO 100]`, `[5 TO 100}` and `{5 TO 100}` alike. It is an assertEqual now, and the four bound combinations plus the open-ended forms are covered. Both new tests fail without the change. 196 unit tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- solrorbit/conversion/query.py | 17 ++++++++++++++--- tests/unit/solr/test_workload_converter.py | 21 +++++++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/solrorbit/conversion/query.py b/solrorbit/conversion/query.py index 5b4b2766..432b22b0 100644 --- a/solrorbit/conversion/query.py +++ b/solrorbit/conversion/query.py @@ -181,13 +181,24 @@ def _translate_query_node(node: dict, fq_list: list = None) -> str: if "range" in node: for field, bounds in node["range"].items(): field = normalize_field_name(field) - lo = bounds.get("gte", bounds.get("gt", "*")) - hi = bounds.get("lte", bounds.get("lt", "*")) + # gt/lt are exclusive; Solr spells that with a curly bracket. + if "gte" in bounds: + lo, lo_bracket = bounds["gte"], "[" + elif "gt" in bounds: + lo, lo_bracket = bounds["gt"], "{" + else: + lo, lo_bracket = "*", "[" + if "lte" in bounds: + hi, hi_bracket = bounds["lte"], "]" + elif "lt" in bounds: + hi, hi_bracket = bounds["lt"], "}" + else: + hi, hi_bracket = "*", "]" # 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) - return f"{field}:[{lo} TO {hi}]" + return f"{field}:{lo_bracket}{lo} TO {hi}{hi_bracket}" if "exists" in node: field = node["exists"].get("field", "*") diff --git a/tests/unit/solr/test_workload_converter.py b/tests/unit/solr/test_workload_converter.py index 1927a4cf..423dc9c7 100644 --- a/tests/unit/solr/test_workload_converter.py +++ b/tests/unit/solr/test_workload_converter.py @@ -209,11 +209,24 @@ def test_term_query(self): result = translate_to_solr_json_dsl(body) self.assertIn("vendor_id", result["query"]) + def _range(self, bounds): + return translate_to_solr_json_dsl( + {"query": {"range": {"fare_amount": bounds}}})["query"] + def test_range_query(self): - body = {"query": {"range": {"fare_amount": {"gte": 5, "lte": 100}}}} - result = translate_to_solr_json_dsl(body) - self.assertIn("fare_amount", result["query"]) - self.assertIn("TO", result["query"]) + self.assertEqual("fare_amount:[5 TO 100]", self._range({"gte": 5, "lte": 100})) + + def test_exclusive_bounds_use_curly_brackets(self): + self.assertEqual("fare_amount:[5 TO 100}", self._range({"gte": 5, "lt": 100})) + self.assertEqual("fare_amount:{5 TO 100]", self._range({"gt": 5, "lte": 100})) + self.assertEqual("fare_amount:{5 TO 100}", self._range({"gt": 5, "lt": 100})) + + def test_a_missing_bound_is_open_and_inclusive(self): + # An absent bound is `*`, which has nothing to exclude. + self.assertEqual("fare_amount:[5 TO *]", self._range({"gte": 5})) + self.assertEqual("fare_amount:{5 TO *]", self._range({"gt": 5})) + self.assertEqual("fare_amount:[* TO 100}", self._range({"lt": 100})) + self.assertEqual("fare_amount:[* TO 100]", self._range({"lte": 100})) def test_bool_with_filter_goes_to_fq(self): body = {