From 1065984a353e6b4070d39ccbd51bb0842b225bb5 Mon Sep 17 00:00:00 2001 From: Daniel Sotirhos Date: Wed, 1 Apr 2026 13:55:06 -0700 Subject: [PATCH 1/7] Remove obsolete FIXME (#3413) --- src/azul/service/query_service.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/azul/service/query_service.py b/src/azul/service/query_service.py index 83c6f63af4..d5cbd1d4cb 100644 --- a/src/azul/service/query_service.py +++ b/src/azul/service/query_service.py @@ -335,8 +335,6 @@ def _prepare_aggregation(self, *, facet: str, facet_path: FieldPath) -> Agg: nested_agg = agg # Make an inner agg that will contain the terms in question path = dotted(facet_path, 'keyword') - # FIXME: Approximation errors for terms aggregation are unchecked - # https://github.com/DataBiosphere/azul/issues/3413 nested_agg.bucket(name='myTerms', agg_type='terms', field=path, From 68d8af0be6184f6a88b54cf28099ee762679ac3b Mon Sep 17 00:00:00 2001 From: Daniel Sotirhos Date: Tue, 21 Apr 2026 09:03:32 -0700 Subject: [PATCH 2/7] Change test data to use a valid HCA facet name --- test/service/test_response.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/service/test_response.py b/test/service/test_response.py index 0486d313c9..5fac3cf88d 100644 --- a/test/service/test_response.py +++ b/test/service/test_response.py @@ -446,7 +446,7 @@ def test_response_stage_files_summaries(self): ] } }, - 'disease': { + 'sampleDisease': { 'doc_count': 21, 'untagged': { 'doc_count': 12 @@ -490,7 +490,7 @@ def test_response_stage_files_facets(self): 'total': 21, 'type': 'terms' }, - 'disease': { + 'sampleDisease': { 'terms': [ { 'term': 'silver', @@ -709,7 +709,7 @@ def test_response_stage_projects(self): 'total': 2 }, 'termFacets': { - 'disease': { + 'sampleDisease': { 'terms': [ { 'count': 9, From a3f15bb907437a1c77986e162b4bf2b715271654 Mon Sep 17 00:00:00 2001 From: Daniel Sotirhos Date: Mon, 30 Mar 2026 15:00:43 -0700 Subject: [PATCH 3/7] Refactor method --- src/azul/service/query_service.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/azul/service/query_service.py b/src/azul/service/query_service.py index d5cbd1d4cb..3bcf78151b 100644 --- a/src/azul/service/query_service.py +++ b/src/azul/service/query_service.py @@ -331,15 +331,19 @@ def _prepare_aggregation(self, *, facet: str, facet_path: FieldPath) -> Agg: agg_type='nested', path=dotted(facet_path)) facet_path = dotted(facet_path, field_type.agg_property) + path = dotted(facet_path, 'keyword') + nested_agg.bucket(name='myTerms', + agg_type='terms', + field=path, + size=config.terms_aggregation_size) + nested_agg.bucket('untagged', 'missing', field=path) else: - nested_agg = agg - # Make an inner agg that will contain the terms in question - path = dotted(facet_path, 'keyword') - nested_agg.bucket(name='myTerms', - agg_type='terms', - field=path, - size=config.terms_aggregation_size) - nested_agg.bucket('untagged', 'missing', field=path) + path = dotted(facet_path, 'keyword') + agg.bucket(name='myTerms', + agg_type='terms', + field=path, + size=config.terms_aggregation_size) + agg.bucket('untagged', 'missing', field=path) return agg def _annotate_aggs_for_translation(self, request: Search): From 8b17022221ca0e8ef7262213c44331f5e5375d88 Mon Sep 17 00:00:00 2001 From: Daniel Sotirhos Date: Tue, 21 Apr 2026 08:51:38 -0700 Subject: [PATCH 4/7] Refactor functionality from QueryController into DocumentService --- src/azul/indexer/document_service.py | 22 ++++++++++++++++++++++ src/azul/service/query_controller.py | 18 +----------------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/azul/indexer/document_service.py b/src/azul/indexer/document_service.py index 3105f97556..2ad895b626 100644 --- a/src/azul/indexer/document_service.py +++ b/src/azul/indexer/document_service.py @@ -21,6 +21,7 @@ FieldTypes, FieldTypes1, Nested, + pass_thru_bool, ) from azul.indexer.document import ( Aggregate, @@ -122,6 +123,27 @@ def field_types(self, catalog: CatalogName) -> FieldTypes: # does not undergo translation ) + @cache + def field_types_by_name(self, catalog: CatalogName) -> Mapping[str, FieldType]: + """ + Returns the field type for each supported sort and filter field, using + the name of the field as provided by clients. Unlike field_types(), this + is a flat mapping and includes the synthetic field 'accessible' that has + no entry in the plugin's field_mapping. + + :return: dict with field names as keys and each field's type as value + """ + plugin = self.metadata_plugin(catalog) + result = {} + for field, path in plugin.field_mapping.items(): + field_type = self.field_type(catalog, path) + if isinstance(field_type, FieldType): + result[field] = field_type + accessible_field = plugin.special_fields.accessible.name + assert accessible_field not in result, result + result[accessible_field] = pass_thru_bool + return result + def catalogued_field_types(self) -> CataloguedFieldTypes: return { catalog: self.field_types(catalog) diff --git a/src/azul/service/query_controller.py b/src/azul/service/query_controller.py index 93cd928b27..4667e1612d 100644 --- a/src/azul/service/query_controller.py +++ b/src/azul/service/query_controller.py @@ -28,7 +28,6 @@ from azul.field_type import ( FieldType, Mode, - pass_thru_bool, ) from azul.lib import ( cache, @@ -224,19 +223,4 @@ def _filter_schema_validator(self, @cache def _field_types(self, catalog: CatalogName) -> Mapping[str, FieldType]: - """ - Returns the field type for each supported sort and filter field, using - the name of the field as provided by clients. - """ - result = {} - plugin = self._metadata_plugin - for field, path in plugin.field_mapping.items(): - field_type = self._service.field_type(catalog, path) - if isinstance(field_type, FieldType): - result[field] = field_type - # This field is a synthetic element of the response and will never be - # null. Including it here helps to streamline request validation. - accessible_field = plugin.special_fields.accessible.name - assert accessible_field not in result, result - result[accessible_field] = pass_thru_bool - return result + return self._service.field_types_by_name(catalog) From aabb30fe271c8cc09abd9109b298caf84098fc45 Mon Sep 17 00:00:00 2001 From: Daniel Sotirhos Date: Wed, 1 Apr 2026 13:50:41 -0700 Subject: [PATCH 5/7] [1/2] Add support for HCA tissue atlas (#7128) Add tests --- test/indexer/test_indexer.py | 35 +++++++++++++++++ test/service/test_response.py | 73 +++++++++++++++++++++++++++++++---- 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/test/indexer/test_indexer.py b/test/indexer/test_indexer.py index 2669962f9b..34a6f14297 100644 --- a/test/indexer/test_indexer.py +++ b/test/indexer/test_indexer.py @@ -1746,6 +1746,41 @@ def test_organoid_priority(self): self.assertEqual(inner_cell_suspensions_in_contributions + inner_cell_suspensions_in_aggregates, inner_cell_suspensions) + def test_nested_field_aggregation(self): + bundles = [ + # Bundles with the following tissue_atlas (atlas/version) values: + # [None/None (x2), Lung/None, Retina/v1.0, Blood/v1.0] + self.bundle_fqid(uuid='2c7d06b8-658e-4c51-9de4-a768322f84c5', + version='2021-09-21T17:27:23.898000Z'), + # [Blood/v1.0] + self.bundle_fqid(uuid='587d74b4-1075-4bbf-b96a-4d1ede0481b2', + version='2018-10-10T02:23:43.182000Z'), + # [] (none) + self.bundle_fqid(uuid='97f0cc83-f0ac-417a-8a29-221c77debde8', + version='2019-10-14T19:54:15.397406Z') + ] + for bundle in bundles: + self._index_canned_bundle(bundle) + hits = self._get_all_hits() + expected = { + '50151324-f3ed-4358-98af-ec352a940a61': [ + {'atlas': '~null', 'version': '~null'}, + {'atlas': 'Lung', 'version': '~null'}, + {'atlas': 'Retina', 'version': 'v1.0'}, + {'atlas': 'Blood', 'version': 'v1.0'} + ], + '6615efae-fca8-4dd2-a223-9cfcf30fe94d': [ + {'atlas': 'Blood', 'version': 'v1.0'} + ], + '4e6f083b-5b9a-4393-9890-2a83da8188f1': [ + ] + } + for hit in self._filter_hits(hits, DocumentType.aggregate, 'projects'): + contents = hit['_source']['contents'] + project = cast(JSON, one(contents['projects'])) + project_id = project['document_id'] + self.assertEqual(expected[project_id], project['tissue_atlas']) + def test_accessions_fields(self): bundle_fqid = self.bundle_fqid(uuid='fa5be5eb-2d64-49f5-8ed8-bd627ac9bc7a', version='2019-02-14T19:24:38.034764Z') diff --git a/test/service/test_response.py b/test/service/test_response.py index 5fac3cf88d..4384615379 100644 --- a/test/service/test_response.py +++ b/test/service/test_response.py @@ -2630,6 +2630,64 @@ def from_response(cls, hit: JSON) -> Self: }) +class TestNestedFieldAggregation(IndexResponseTestCase): + maxDiff = None + + @classmethod + def bundles(cls) -> list[BundleFQID]: + return [ + # 1 file, 1 sample + # tissue_atlas=[None/None (x2), Lung/None, Retina/v1.0, Blood/v1.0] + cls.bundle_fqid(uuid='2c7d06b8-658e-4c51-9de4-a768322f84c5', + version='2021-09-21T17:27:23.898000Z'), + # 20 files, 1 sample + # tissue_atlas=[Blood/v1.0] + cls.bundle_fqid(uuid='587d74b4-1075-4bbf-b96a-4d1ede0481b2', + version='2018-10-10T02:23:43.182000Z'), + # 2 files, 1 sample + # tissue_atlas=[] (none) + cls.bundle_fqid(uuid='97f0cc83-f0ac-417a-8a29-221c77debde8', + version='2019-10-14T19:54:15.397406Z'), + ] + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_indices() + + @classmethod + def tearDownClass(cls): + cls._teardown_indices() + super().tearDownClass() + + def test_nested_field_facet(self): + tissue_atlas_terms = [ + {'atlas': 'Blood', 'version': 'v1.0'}, + {'atlas': None, 'version': None}, + {'atlas': 'Lung', 'version': None}, + {'atlas': 'Retina', 'version': 'v1.0'}, + None, + ] + tissue_atlas_term_counts = { + 'projects': [2, 1, 1, 1, 1], + 'bundles': [2, 1, 1, 1, 1], + 'samples': [2, 1, 1, 1, 1], + 'files': [21, 1, 1, 1, 2] + } + for entity_type, counts in tissue_atlas_term_counts.items(): + with self.subTest(entity_type=entity_type): + url = self.base_url.set(path='/index/' + entity_type, + args=(self._params(size=1))) + response = requests.get(str(url)) + response.raise_for_status() + response_json = response.json() + facets = response_json['termFacets'] + expected = [] + for count, term in zip(counts, tissue_atlas_terms): + expected.append({'count': count, 'term': term}) + self.assertElasticEqual(expected, facets['tissueAtlas']['terms']) + + class TestSortAndFilterByCellCount(IndexResponseTestCase): maxDiff = None @@ -3561,7 +3619,6 @@ def test_projects_response(self): ] self.assertEqual(expected_publications, project['publications']) expected_tissue_atlas = [ - {'atlas': None, 'version': None}, {'atlas': None, 'version': None}, {'atlas': 'Lung', 'version': None}, {'atlas': 'Retina', 'version': 'v1.0'}, @@ -3573,12 +3630,14 @@ def test_projects_response(self): self.assertTrue(project['isTissueAtlasProject']) tissue_atlas = response_json['termFacets']['tissueAtlas'] - self.assertEqual(5, tissue_atlas['total']) - terms = { - entry['term']: entry['count'] - for entry in tissue_atlas['terms'] - } - self.assertEqual({None: 2, 'Lung': 1, 'Retina': 1, 'Blood': 1}, terms) + self.assertEqual(4, tissue_atlas['total']) + expected_tissue_atlas_terms = [ + {'count': 1, 'term': {'atlas': 'Blood', 'version': 'v1.0'}}, + {'count': 1, 'term': {'atlas': 'Lung', 'version': None}}, + {'count': 1, 'term': {'atlas': 'Retina', 'version': 'v1.0'}}, + {'count': 1, 'term': {'atlas': None, 'version': None}}, + ] + self.assertEqual(expected_tissue_atlas_terms, tissue_atlas['terms']) def test_data_use_and_duos_id(self): test_data = [ From 8b5b6454710ae9699640200c0343608107c7aa82 Mon Sep 17 00:00:00 2001 From: Daniel Sotirhos Date: Tue, 17 Mar 2026 10:29:44 -0700 Subject: [PATCH 6/7] [r 2/2] Add support for HCA tissue atlas (#7128) Implement support for aggregation of a nested field --- src/azul/field_type.py | 15 ++++- src/azul/indexer/document_service.py | 1 - .../plugins/metadata/hca/indexer/transform.py | 9 ++- .../plugins/metadata/hca/service/response.py | 23 +++++-- src/azul/service/query_service.py | 63 ++++++++++++++----- test/service/test_app_logging.py | 2 +- 6 files changed, 86 insertions(+), 27 deletions(-) diff --git a/src/azul/field_type.py b/src/azul/field_type.py index ec2766e1ad..40df4cf6ce 100644 --- a/src/azul/field_type.py +++ b/src/azul/field_type.py @@ -26,7 +26,6 @@ ) from more_itertools import ( - first, one, ) @@ -431,13 +430,23 @@ def from_index(self, value: str) -> str | None: class Nested(PassThrough[JSON]): properties: Mapping[str, FieldType] - agg_property: str def __init__(self, **properties): super().__init__(JSON, es_type='nested') - self.agg_property = first(properties.keys()) self.properties = properties + def to_index(self, value: JSON) -> JSON: + return { + field: field_type.to_index(value[field]) + for field, field_type in self.properties.items() + } + + def from_index(self, value: JSON) -> JSON: + return { + field: field_type.from_index(value[field]) + for field, field_type in self.properties.items() + } + def api_filter_values_schema(self, operator: str, mode: Mode) -> JSON: assert operator == 'is' schema = super().api_filter_values_schema(operator, mode) diff --git a/src/azul/indexer/document_service.py b/src/azul/indexer/document_service.py index 2ad895b626..91b6f26251 100644 --- a/src/azul/indexer/document_service.py +++ b/src/azul/indexer/document_service.py @@ -97,7 +97,6 @@ def field_type(self, catalog: CatalogName, path: FieldPath) -> FieldType: if isinstance(field_types, Nested): element = next(elements, None) if element is not None: - assert element == field_types.agg_property, (element, field_types) field_types = field_types.properties[element] assert isinstance(field_types, FieldType), (path, field_types) element = next(elements, None) diff --git a/src/azul/plugins/metadata/hca/indexer/transform.py b/src/azul/plugins/metadata/hca/indexer/transform.py index 465f72d4cf..d1c2e68ec4 100644 --- a/src/azul/plugins/metadata/hca/indexer/transform.py +++ b/src/azul/plugins/metadata/hca/indexer/transform.py @@ -735,7 +735,14 @@ def _project(self, project: api.Project) -> MutableJSON: 'accessions': list(map(self._accession, project.accessions)), 'is_tissue_atlas_project': any(bionetwork.atlas_project for bionetwork in project.bionetworks), - 'tissue_atlas': list(map(self._tissue_atlas, project.bionetworks)), + # We deduplicate the `tissue_atlas` field values since duplicate + # values in a nested field would cause incorrect term facet totals. + 'tissue_atlas': [ + dict(d) for d in dict.fromkeys( + tuple(self._tissue_atlas(b).items()) + for b in project.bionetworks + ) + ], 'bionetwork_name': json_sorted(bionetwork.name for bionetwork in project.bionetworks), 'estimated_cell_count': project.estimated_cell_count, diff --git a/src/azul/plugins/metadata/hca/service/response.py b/src/azul/plugins/metadata/hca/service/response.py index 768faebff0..b1dbaf4628 100644 --- a/src/azul/plugins/metadata/hca/service/response.py +++ b/src/azul/plugins/metadata/hca/service/response.py @@ -18,6 +18,9 @@ one, ) +from azul.field_type import ( + Nested, +) from azul.lib import ( cached_property, ) @@ -558,9 +561,11 @@ def file_type_summary(aggregate_file: JSON) -> FileTypeSummaryForHit: ] return summarized_hit - def make_terms(self, agg) -> Terms: - def choose_entry(_term): - if 'key_as_string' in _term: + def make_terms(self, field_type, agg) -> Terms: + def choose_entry(_term, nested_keys): + if nested_keys is not None: + return dict(zip(nested_keys, _term['key'])) + elif 'key_as_string' in _term: return _term['key_as_string'] elif (term_key := _term['key']) is None: return None @@ -573,8 +578,13 @@ def choose_entry(_term): terms: list[Term] = [] for bucket in agg['myTerms']['buckets']: - term = Term(term=choose_entry(bucket), - count=bucket['doc_count']) + doc_count = bucket['doc_count'] + if isinstance(field_type, Nested): + nested_keys = [path[-1] for path in agg['myTerms']['meta']['paths']] + else: + nested_keys = None + term = Term(term=choose_entry(bucket, nested_keys), + count=doc_count) try: sub_agg = bucket['myProjectIds'] except KeyError: @@ -605,8 +615,9 @@ def choose_entry(_term): type='terms') def make_facets(self, aggs: JSON) -> dict[str, Terms]: + field_types = self.service.field_types_by_name(self.catalog) facets = {} for facet, agg in aggs.items(): if facet != '_project_agg': # Filter out project specific aggs - facets[facet] = self.make_terms(agg) + facets[facet] = self.make_terms(field_types[facet], agg) return facets diff --git a/src/azul/service/query_service.py b/src/azul/service/query_service.py index 3bcf78151b..2261a860e9 100644 --- a/src/azul/service/query_service.py +++ b/src/azul/service/query_service.py @@ -35,6 +35,7 @@ ) from opensearchpy.helpers.aggs import ( Agg, + MultiTerms, Terms, ) from opensearchpy.helpers.query import ( @@ -327,16 +328,28 @@ def _prepare_aggregation(self, *, facet: str, facet_path: FieldPath) -> Agg: field_type = self.service.field_type(self.catalog, facet_path) if isinstance(field_type, Nested): - nested_agg = agg.bucket(name='nested', - agg_type='nested', - path=dotted(facet_path)) - facet_path = dotted(facet_path, field_type.agg_property) - path = dotted(facet_path, 'keyword') - nested_agg.bucket(name='myTerms', - agg_type='terms', - field=path, - size=config.terms_aggregation_size) - nested_agg.bucket('untagged', 'missing', field=path) + path = dotted(facet_path) + # A nested aggregation to aggregate on fields inside a nested field + agg.bucket(name='nested', + agg_type='nested', + path=path) + # A multi-terms aggregation to form composite keys made from the + # fields inside a nested field + agg.aggs.nested.bucket(name='myTerms', + agg_type='multi_terms', + terms=[ + {'field': path + f'.{field}.keyword'} + for field in field_type.properties + ], + size=config.terms_aggregation_size) + # A filter aggregation to work around that we can't use a missing + # aggregation with a nested field. + # See https://github.com/elastic/elasticsearch/issues/9571 + agg.bucket(name='untagged', + agg_type='filter', + filter=Q('bool', must_not=[ + Q('nested', path=path, query=Q('exists', field=path)) + ])) else: path = dotted(facet_path, 'keyword') agg.bucket(name='myTerms', @@ -354,13 +367,20 @@ def _annotate_aggs_for_translation(self, request: Search): """ def annotate(agg: Agg): - if isinstance(agg, Terms): - path = agg.field.split('.') - if path[-1] == 'keyword': - path.pop() + if isinstance(agg, (Terms, MultiTerms)): if not hasattr(agg, 'meta'): agg.meta = {} - agg.meta['path'] = path + if hasattr(agg, 'terms'): + # A MultiTerms agg contains multiple fields, and we need the + # path of each one. We store the paths in the same order the + # fields occur in the `terms` list. + agg.meta['paths'] = [] + for term in agg.terms: + path = term['field'].removesuffix('.keyword').split('.') + agg.meta['paths'].append(path) + else: + path = agg.field.removesuffix('.keyword').split('.') + agg.meta['path'] = path if hasattr(agg, 'aggs'): subs = agg.aggs for sub_name in subs: @@ -393,6 +413,8 @@ def translate(k, v: MutableJSON): translate(k, v) else: try: + # We annotated Terms aggregates with `path`, a dotted path + # to a field in an index document path = v['meta']['path'] except KeyError: pass @@ -401,6 +423,17 @@ def translate(k, v: MutableJSON): for bucket in buckets: bucket['key'] = field_type.from_index(bucket['key']) translate(k, bucket) + try: + # We annotated MultiTerms aggregates with `paths`, a list of + # dotted paths for the fields inside a nested field + paths = v['meta']['paths'] + except KeyError: + pass + else: + for i, path in enumerate(paths): + field_type = self.service.field_type(self.catalog, tuple(path)) + for bucket in buckets: + bucket['key'][i] = field_type.from_index(bucket['key'][i]) for k, v in aggs.items(): translate(k, v) diff --git a/test/service/test_app_logging.py b/test/service/test_app_logging.py index 15c2aab84d..bf67512ee6 100644 --- a/test/service/test_app_logging.py +++ b/test/service/test_app_logging.py @@ -153,7 +153,7 @@ def filter_body(organ: str) -> JSON: elif debug == 1: expected_log = f'… with a response body starting in {body[:prefix_len]}' elif debug > 1: - expected_log = f'… with a response body of length 9137 being {body}' + expected_log = f'… with a response body of length 9163 being {body}' else: assert False self.assertEqual(expected_log, body_log_message) From 5b5526f926b8f5c1fad00e458cc9357dcaf4402c Mon Sep 17 00:00:00 2001 From: Daniel Sotirhos Date: Thu, 30 Apr 2026 14:19:59 -0700 Subject: [PATCH 7/7] fixup! [r 2/2] Add support for HCA tissue atlas (#7128) --- src/azul/field_type.py | 6 ++++-- src/azul/plugins/metadata/hca/service/response.py | 8 ++++---- src/azul/service/query_service.py | 2 ++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/azul/field_type.py b/src/azul/field_type.py index 40df4cf6ce..1e2ffeff79 100644 --- a/src/azul/field_type.py +++ b/src/azul/field_type.py @@ -428,11 +428,13 @@ def from_index(self, value: str) -> str | None: null_datetime: NullableDateTime = NullableDateTime(str, str) -class Nested(PassThrough[JSON]): +class Nested(FieldType[JSON, JSON]): + allow_sorting_by_empty_lists = False + es_type = 'nested' properties: Mapping[str, FieldType] def __init__(self, **properties): - super().__init__(JSON, es_type='nested') + super().__init__(JSON, JSON) self.properties = properties def to_index(self, value: JSON) -> JSON: diff --git a/src/azul/plugins/metadata/hca/service/response.py b/src/azul/plugins/metadata/hca/service/response.py index b1dbaf4628..f6a38cd962 100644 --- a/src/azul/plugins/metadata/hca/service/response.py +++ b/src/azul/plugins/metadata/hca/service/response.py @@ -576,13 +576,13 @@ def choose_entry(_term, nested_keys): else: return str(term_key) + if isinstance(field_type, Nested): + nested_keys = [path[-1] for path in agg['myTerms']['meta']['paths']] + else: + nested_keys = None terms: list[Term] = [] for bucket in agg['myTerms']['buckets']: doc_count = bucket['doc_count'] - if isinstance(field_type, Nested): - nested_keys = [path[-1] for path in agg['myTerms']['meta']['paths']] - else: - nested_keys = None term = Term(term=choose_entry(bucket, nested_keys), count=doc_count) try: diff --git a/src/azul/service/query_service.py b/src/azul/service/query_service.py index 2261a860e9..599ee150c3 100644 --- a/src/azul/service/query_service.py +++ b/src/azul/service/query_service.py @@ -434,6 +434,8 @@ def translate(k, v: MutableJSON): field_type = self.service.field_type(self.catalog, tuple(path)) for bucket in buckets: bucket['key'][i] = field_type.from_index(bucket['key'][i]) + for bucket in buckets: + translate(k, bucket) for k, v in aggs.items(): translate(k, v)