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 55b263e7 Randomize range bounds in Solr's string queries (#76)
55b263e7 is described below

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

    Randomize range bounds in Solr's string queries (#76)
    
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 solrorbit/workload/loader.py  |  59 ++++++++++++++++++++++++
 tests/workload/loader_test.py | 104 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 163 insertions(+)

diff --git a/solrorbit/workload/loader.py b/solrorbit/workload/loader.py
index b2326090..5e6a8e5e 100644
--- a/solrorbit/workload/loader.py
+++ b/solrorbit/workload/loader.py
@@ -1008,6 +1008,11 @@ class 
QueryRandomizerWorkloadProcessor(WorkloadProcessor):
     DEFAULT_N = 5000
     DEFAULT_ALPHA = 1
     DEFAULT_QUERY_RANDOMIZATION_INFO = QueryRandomizationInfo("range", 
[["gte", "gt"], ["lte", "lt"]], ["format"])
+    SOLR_RANGE_TERM_PATTERN = re.compile(r"(?P<field>[A-Za-z_][A-Za-z0-9_.]*):"
+                                        
r"(?P<lower_bracket>[\[{])(?P<lower>[^\s\[\]{}]+)"
+                                        r"\s+TO\s+"
+                                        
r"(?P<upper>[^\s\[\]{}]+)(?P<upper_bracket>[\]}])")
+
     def __init__(self, cfg):
         self.randomization_enabled = cfg.opts("workload", 
"randomization.enabled", mandatory=False, default_value=False)
         self.rf = float(cfg.opts("workload", "randomization.repeat_frequency", 
mandatory=False, default_value=self.DEFAULT_RF))
@@ -1076,6 +1081,56 @@ class 
QueryRandomizerWorkloadProcessor(WorkloadProcessor):
             # leaf node
             return []
 
+    def solr_string_paths(self, body):
+        if isinstance(body.get("query"), str):
+            yield ("query",)
+        filters = body.get("filter")
+        if isinstance(filters, str):
+            yield ("filter",)
+        elif isinstance(filters, list):
+            for i, filter_clause in enumerate(filters):
+                if isinstance(filter_clause, str):
+                    yield ("filter", i)
+
+    def solr_range_term_states_both_bounds(self, match):
+        return match.group("lower") != "*" and match.group("upper") != "*"
+
+    def extract_solr_range_terms(self, body):
+        fields_and_paths = []
+        for path in self.solr_string_paths(body):
+            for match in 
self.SOLR_RANGE_TERM_PATTERN.finditer(self.get_dict_from_previous_path(body, 
path)):
+                if self.solr_range_term_states_both_bounds(match):
+                    fields_and_paths.append((match.group("field"), path))
+        return fields_and_paths
+
+    def set_solr_range_terms(self, params, fields_and_paths, new_values, 
query_randomization_info):
+        bound_names = [parameter_name_options[0] for parameter_name_options in 
query_randomization_info.parameter_name_options_list]
+        if len(bound_names) != 2:
+            return params
+        lower_name, upper_name = bound_names
+        new_values_by_path = {}
+        for field_and_path, new_value in zip(fields_and_paths, new_values):
+            new_values_by_path.setdefault(field_and_path[1], 
[]).append(new_value)
+
+        for path, path_new_values in new_values_by_path.items():
+            remaining = iter(path_new_values)
+
+            def replace(match, remaining=remaining):
+                if not self.solr_range_term_states_both_bounds(match):
+                    return match.group(0)
+                new_value = next(remaining, None)
+                if new_value is None:
+                    return match.group(0)
+                return "{}:{}{} TO {}{}".format(match.group("field"),
+                                                match.group("lower_bracket"),
+                                                new_value[lower_name],
+                                                new_value[upper_name],
+                                                match.group("upper_bracket"))
+
+            parent = self.get_dict_from_previous_path(params["body"], 
path[:-1])
+            parent[path[-1]] = self.SOLR_RANGE_TERM_PATTERN.sub(replace, 
parent[path[-1]])
+        return params
+
     def extract_fields_and_paths(self, params, query_randomization_info):
         # Search for fields used in range queries, and the paths to those 
fields
         # Return pairs of (field, path_to_field)
@@ -1087,11 +1142,15 @@ class 
QueryRandomizerWorkloadProcessor(WorkloadProcessor):
             raise exceptions.SystemSetupError(
                 f"Cannot extract range query fields from these params: 
{params}\n, missing params[\"body\"][\"query\"]\n"
                 f"Make sure the operation in operations/default.json is 
well-formed")
+        if isinstance(root, str):
+            return self.extract_solr_range_terms(params["body"])
         fields_and_paths = self.extract_fields_helper(root, [], 
query_randomization_info)
         return fields_and_paths
 
     def set_range(self, params, fields_and_paths, new_values, 
query_randomization_info):
         assert len(fields_and_paths) == len(new_values)
+        if isinstance(params["body"].get("query"), str):
+            return self.set_solr_range_terms(params, fields_and_paths, 
new_values, query_randomization_info)
         for field_and_path, new_value in zip(fields_and_paths, new_values):
             field = field_and_path[0]
             path = field_and_path[1]
diff --git a/tests/workload/loader_test.py b/tests/workload/loader_test.py
index eccebb79..283a2ed8 100644
--- a/tests/workload/loader_test.py
+++ b/tests/workload/loader_test.py
@@ -2159,6 +2159,110 @@ class WorkloadRandomizationTests(TestCase):
         geo_point_expected = [("location", ["geo_bounding_box"])]
         self.assertEqual(geo_point_result, geo_point_expected)
 
+    def test_range_finding_function_for_string_queries(self):
+        cfg = config.Config()
+        processor = loader.QueryRandomizerWorkloadProcessor(cfg)
+        default_info = 
loader.QueryRandomizerWorkloadProcessor.DEFAULT_QUERY_RANDOMIZATION_INFO
+
+        query_range = {
+            "name": "range",
+            "operation-type": "search",
+            "body": {
+                "query": "total_amount:[5 TO 15}"
+            }
+        }
+        self.assertEqual(processor.extract_fields_and_paths(query_range, 
default_info),
+                         [("total_amount", ("query",))])
+
+        filter_range = {
+            "name": "distance_amount_facet",
+            "operation-type": "search",
+            "body": {
+                "query": "*:*",
+                "filter": ["trip_distance:[0 TO 50}"],
+                "limit": 0
+            }
+        }
+        self.assertEqual(processor.extract_fields_and_paths(filter_range, 
default_info),
+                         [("trip_distance", ("filter", 0))])
+
+        several_terms = {
+            "name": "several",
+            "operation-type": "search",
+            "body": {
+                "query": "*:*",
+                "filter": ["trip_distance:[0 TO 50} AND total_amount:{5 TO 
100]", "passenger_count:2"]
+            }
+        }
+        self.assertEqual(processor.extract_fields_and_paths(several_terms, 
default_info),
+                         [("trip_distance", ("filter", 0)), ("total_amount", 
("filter", 0))])
+
+        no_range = {"name": "match-all", "operation-type": "search", "body": 
{"query": "*:*"}}
+        self.assertEqual(processor.extract_fields_and_paths(no_range, 
default_info), [])
+
+    def test_set_range_keeps_the_brackets_of_a_string_query(self):
+        cfg = config.Config()
+        processor = loader.QueryRandomizerWorkloadProcessor(cfg)
+        default_info = 
loader.QueryRandomizerWorkloadProcessor.DEFAULT_QUERY_RANDOMIZATION_INFO
+        params = {
+            "body": {
+                "query": "*:*",
+                "filter": ["trip_distance:[0 TO 50} AND total_amount:{5 TO 
100]"]
+            }
+        }
+        fields_and_paths = processor.extract_fields_and_paths(params, 
default_info)
+        result = processor.set_range(params, fields_and_paths,
+                                    [{"gte": 3, "lte": 7}, {"gte": 10.5, 
"lte": 20.25}], default_info)
+        self.assertEqual(result["body"]["filter"][0], "trip_distance:[3 TO 7} 
AND total_amount:{10.5 TO 20.25]")
+        self.assertEqual(result["body"]["query"], "*:*")
+
+    def test_get_randomized_values_for_string_queries(self):
+        cfg = config.Config()
+        cfg.add(config.Scope.application, "workload", 
"randomization.repeat_frequency", 0.0)
+        processor = loader.QueryRandomizerWorkloadProcessor(cfg)
+        default_info = 
loader.QueryRandomizerWorkloadProcessor.DEFAULT_QUERY_RANDOMIZATION_INFO
+        new_value = {"gte": "2015-01-05T00:00:00Z", "lte": 
"2015-01-09T00:00:00Z", "format": "yyyy-MM-dd"}
+        params = {
+            "index": "nyc_taxis",
+            "body": {
+                "query": "dropoff_datetime:[2015-01-01T00:00:00Z TO 
2015-01-22T00:00:00Z}",
+                "limit": 0
+            }
+        }
+        result = processor.get_randomized_values(None, params, default_info,
+                                                 
op_name="date_histogram_facet",
+                                                 get_standard_value=lambda 
op_name, field, index: new_value,
+                                                 
get_standard_value_source=lambda op_name, field: lambda: new_value)
+        self.assertEqual(result["body"]["query"], 
"dropoff_datetime:[2015-01-05T00:00:00Z TO 2015-01-09T00:00:00Z}")
+        self.assertEqual(result["body"]["limit"], 0)
+
+    def test_a_string_range_that_leaves_a_bound_open_is_not_randomized(self):
+        cfg = config.Config()
+        processor = loader.QueryRandomizerWorkloadProcessor(cfg)
+        default_info = 
loader.QueryRandomizerWorkloadProcessor.DEFAULT_QUERY_RANDOMIZATION_INFO
+        for query in ("total_amount:[* TO 15}", "total_amount:[5 TO *]", 
"total_amount:[* TO *]"):
+            params = {"body": {"query": query}}
+            self.assertEqual(processor.extract_fields_and_paths(params, 
default_info), [])
+            self.assertEqual(processor.set_range(params, [], [], 
default_info)["body"]["query"], query)
+
+    def 
test_a_term_that_is_not_randomized_does_not_take_another_terms_value(self):
+        cfg = config.Config()
+        processor = loader.QueryRandomizerWorkloadProcessor(cfg)
+        default_info = 
loader.QueryRandomizerWorkloadProcessor.DEFAULT_QUERY_RANDOMIZATION_INFO
+        params = {"body": {"query": "total_amount:[* TO 15} AND 
trip_distance:[1 TO 9}"}}
+        fields_and_paths = processor.extract_fields_and_paths(params, 
default_info)
+        self.assertEqual(fields_and_paths, [("trip_distance", ("query",))])
+        result = processor.set_range(params, fields_and_paths, [{"gte": 3, 
"lte": 7}], default_info)
+        self.assertEqual(result["body"]["query"], "total_amount:[* TO 15} AND 
trip_distance:[3 TO 7}")
+
+    def test_a_value_source_that_omits_a_bound_is_not_silently_ignored(self):
+        cfg = config.Config()
+        processor = loader.QueryRandomizerWorkloadProcessor(cfg)
+        default_info = 
loader.QueryRandomizerWorkloadProcessor.DEFAULT_QUERY_RANDOMIZATION_INFO
+        params = {"body": {"query": "total_amount:[5 TO 15}"}}
+        fields_and_paths = processor.extract_fields_and_paths(params, 
default_info)
+        with self.assertRaises(KeyError):
+            processor.set_range(params, fields_and_paths, [{"gt": 3, "lt": 
7}], default_info)
 
     def test_get_randomized_values(self):
         helper = self.StandardValueHelper()

Reply via email to