From 993951430b9a797f90de0c8aae47b97820e669ea Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 5 Oct 2026 12:10:55 +0200 Subject: [PATCH] feat(search): add nested aggregations Sub-aggregations on both engines: bleve folds child buckets below their parent through the same collector, OpenSearch nests them natively, so a request stays one search per space. The proto gains sub_aggregations on the option and on the bucket; merge, finalize, validation and the bucket limit walk every level, results are positional at every level, a metric has no buckets to nest in. AGG-10 to AGG-15, 20, 35, 40 and 44 in the parity suite, AGG-14 as a known OpenSearch divergence. --- .../opencloud/services/search/v0/search.pb.go | 348 ++++++++++-------- .../services/search/v0/search.swagger.json | 14 + .../opencloud/services/search/v0/search.proto | 6 + services/graph/pkg/service/v0/searchquery.go | 19 +- .../pkg/service/v0/searchquery_mapping.go | 13 +- .../graph/pkg/service/v0/searchquery_test.go | 10 +- .../search/pkg/aggregation/aggregation.go | 7 +- .../pkg/aggregation/aggregation_test.go | 16 + services/search/pkg/aggregation/merge.go | 19 +- services/search/pkg/aggregation/merge_test.go | 96 ++++- services/search/pkg/aggregation/validate.go | 6 + .../search/pkg/aggregation/validate_test.go | 10 + services/search/pkg/bleve/aggregations.go | 44 ++- .../pkg/opensearch/internal/aggs/aggs.go | 25 +- .../pkg/opensearch/internal/aggs/aggs_test.go | 59 +++ services/search/pkg/parity/README.md | 10 + .../search/pkg/parity/aggregations_test.go | 108 +++++- 17 files changed, 603 insertions(+), 207 deletions(-) diff --git a/protogen/gen/opencloud/services/search/v0/search.pb.go b/protogen/gen/opencloud/services/search/v0/search.pb.go index d968b2e364..7f5a84efdb 100644 --- a/protogen/gen/opencloud/services/search/v0/search.pb.go +++ b/protogen/gen/opencloud/services/search/v0/search.pb.go @@ -478,6 +478,9 @@ type AggregationOption struct { // rather than a bucket aggregation; the corresponding AggregationResult // carries `metric` instead of `buckets`. MetricDefinition *MetricDefinition `protobuf:"bytes,4,opt,name=metric_definition,json=metricDefinition,proto3" json:"metric_definition,omitempty"` + // Optional. Nested aggregations computed within each bucket of this + // aggregation. + SubAggregations []*AggregationOption `protobuf:"bytes,5,rep,name=sub_aggregations,json=subAggregations,proto3" json:"sub_aggregations,omitempty"` } func (x *AggregationOption) Reset() { @@ -540,6 +543,13 @@ func (x *AggregationOption) GetMetricDefinition() *MetricDefinition { return nil } +func (x *AggregationOption) GetSubAggregations() []*AggregationOption { + if x != nil { + return x.SubAggregations + } + return nil +} + type MetricDefinition struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -886,6 +896,9 @@ type Bucket struct { Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` Count int64 `protobuf:"varint,2,opt,name=count,proto3" json:"count,omitempty"` + // Nested aggregation results, one per sub_aggregation of the parent + // AggregationOption, at the same position. + SubAggregations []*AggregationResult `protobuf:"bytes,3,rep,name=sub_aggregations,json=subAggregations,proto3" json:"sub_aggregations,omitempty"` } func (x *Bucket) Reset() { @@ -934,6 +947,13 @@ func (x *Bucket) GetCount() int64 { return 0 } +func (x *Bucket) GetSubAggregations() []*AggregationResult { + if x != nil { + return x.SubAggregations + } + return nil +} + type IndexSpaceRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1179,7 +1199,7 @@ var file_opencloud_services_search_v0_search_proto_rawDesc = []byte{ 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, - 0x89, 0x02, 0x0a, 0x11, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4f, + 0xeb, 0x02, 0x0a, 0x11, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x18, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, @@ -1195,149 +1215,161 @@ var file_opencloud_services_search_v0_search_proto_rawDesc = []byte{ 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x10, 0x6d, 0x65, 0x74, 0x72, 0x69, - 0x63, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x50, 0x0a, 0x10, 0x4d, - 0x65, 0x74, 0x72, 0x69, 0x63, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, - 0x3c, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x28, 0x2e, - 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x65, 0x74, - 0x72, 0x69, 0x63, 0x4b, 0x69, 0x6e, 0x64, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x22, 0xe4, 0x01, - 0x0a, 0x10, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x43, 0x0a, 0x07, 0x73, 0x6f, 0x72, 0x74, 0x5f, 0x62, 0x79, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0e, 0x32, 0x2a, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, + 0x63, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x60, 0x0a, 0x10, 0x73, + 0x75, 0x62, 0x5f, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, + 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, + 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x0f, 0x73, 0x75, + 0x62, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x50, 0x0a, + 0x10, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x3c, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, + 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, + 0x65, 0x74, 0x72, 0x69, 0x63, 0x4b, 0x69, 0x6e, 0x64, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x22, + 0xe4, 0x01, 0x0a, 0x10, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, + 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x43, 0x0a, 0x07, 0x73, 0x6f, 0x72, 0x74, 0x5f, 0x62, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2a, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, + 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x53, 0x6f, 0x72, 0x74, 0x42, + 0x79, 0x52, 0x06, 0x73, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x12, 0x23, 0x0a, 0x0d, 0x69, 0x73, 0x5f, + 0x64, 0x65, 0x73, 0x63, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x0c, 0x69, 0x73, 0x44, 0x65, 0x73, 0x63, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x23, + 0x0a, 0x0d, 0x6d, 0x69, 0x6e, 0x69, 0x6d, 0x75, 0x6d, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x6d, 0x69, 0x6e, 0x69, 0x6d, 0x75, 0x6d, 0x43, 0x6f, + 0x75, 0x6e, 0x74, 0x12, 0x41, 0x0a, 0x06, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x04, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, - 0x76, 0x30, 0x2e, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x53, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x52, - 0x06, 0x73, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x12, 0x23, 0x0a, 0x0d, 0x69, 0x73, 0x5f, 0x64, 0x65, - 0x73, 0x63, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, - 0x69, 0x73, 0x44, 0x65, 0x73, 0x63, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x23, 0x0a, 0x0d, - 0x6d, 0x69, 0x6e, 0x69, 0x6d, 0x75, 0x6d, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x05, 0x52, 0x0c, 0x6d, 0x69, 0x6e, 0x69, 0x6d, 0x75, 0x6d, 0x43, 0x6f, 0x75, 0x6e, - 0x74, 0x12, 0x41, 0x0a, 0x06, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, - 0x2e, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x06, 0x72, 0x61, - 0x6e, 0x67, 0x65, 0x73, 0x22, 0x31, 0x0a, 0x0b, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x61, - 0x6e, 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x74, 0x6f, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x02, 0x74, 0x6f, 0x22, 0xa7, 0x01, 0x0a, 0x11, 0x41, 0x67, 0x67, 0x72, - 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x14, 0x0a, - 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, - 0x65, 0x6c, 0x64, 0x12, 0x3e, 0x0a, 0x07, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x18, 0x02, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, - 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x2e, 0x76, 0x30, 0x2e, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x07, 0x62, 0x75, 0x63, 0x6b, - 0x65, 0x74, 0x73, 0x12, 0x3c, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, - 0x76, 0x30, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, - 0x63, 0x22, 0xb7, 0x01, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x3c, 0x0a, 0x04, - 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, + 0x76, 0x30, 0x2e, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x06, + 0x72, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x22, 0x31, 0x0a, 0x0b, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, + 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x74, 0x6f, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x74, 0x6f, 0x22, 0xa7, 0x01, 0x0a, 0x11, 0x41, 0x67, + 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, + 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x3e, 0x0a, 0x07, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x73, + 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, + 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x07, 0x62, 0x75, + 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x3c, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, + 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x52, 0x06, 0x6d, 0x65, 0x74, + 0x72, 0x69, 0x63, 0x22, 0xb7, 0x01, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x3c, + 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x28, 0x2e, 0x6f, + 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x65, 0x74, 0x72, + 0x69, 0x63, 0x4b, 0x69, 0x6e, 0x64, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x19, 0x0a, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x01, 0x48, 0x00, 0x52, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x88, 0x01, 0x01, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x75, 0x6d, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x01, 0x52, 0x03, 0x73, 0x75, 0x6d, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, + 0x10, 0x0a, 0x03, 0x6d, 0x69, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x01, 0x52, 0x03, 0x6d, 0x69, + 0x6e, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x78, 0x18, 0x06, 0x20, 0x01, 0x28, 0x01, 0x52, 0x03, + 0x6d, 0x61, 0x78, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x8c, 0x01, + 0x0a, 0x06, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x12, 0x5a, 0x0a, 0x10, 0x73, 0x75, 0x62, 0x5f, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, - 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, - 0x4b, 0x69, 0x6e, 0x64, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x19, 0x0a, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x01, 0x48, 0x00, 0x52, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x88, 0x01, 0x01, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x75, 0x6d, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x01, 0x52, 0x03, 0x73, 0x75, 0x6d, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x10, 0x0a, - 0x03, 0x6d, 0x69, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x01, 0x52, 0x03, 0x6d, 0x69, 0x6e, 0x12, - 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x78, 0x18, 0x06, 0x20, 0x01, 0x28, 0x01, 0x52, 0x03, 0x6d, 0x61, - 0x78, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x30, 0x0a, 0x06, 0x42, - 0x75, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x94, 0x01, - 0x0a, 0x11, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x17, - 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x66, 0x6f, 0x72, 0x63, 0x65, - 0x5f, 0x72, 0x65, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, - 0x66, 0x6f, 0x72, 0x63, 0x65, 0x52, 0x65, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x26, 0x0a, 0x0b, - 0x63, 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72, - 0x65, 0x6e, 0x63, 0x79, 0x22, 0xd1, 0x01, 0x0a, 0x12, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, - 0x61, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x40, 0x0a, 0x0e, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, - 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, - 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, - 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0d, 0x73, 0x70, 0x61, 0x63, 0x65, - 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e, 0x64, 0x65, - 0x78, 0x65, 0x64, 0x5f, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x0d, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x64, 0x53, 0x70, 0x61, 0x63, 0x65, 0x73, 0x12, - 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x53, 0x70, 0x61, 0x63, - 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x2a, 0x7d, 0x0a, 0x0a, 0x4d, 0x65, 0x74, 0x72, - 0x69, 0x63, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x1b, 0x0a, 0x17, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, - 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, - 0x44, 0x10, 0x00, 0x12, 0x13, 0x0a, 0x0f, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, - 0x4e, 0x44, 0x5f, 0x53, 0x55, 0x4d, 0x10, 0x01, 0x12, 0x13, 0x0a, 0x0f, 0x4d, 0x45, 0x54, 0x52, - 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x4d, 0x49, 0x4e, 0x10, 0x02, 0x12, 0x13, 0x0a, - 0x0f, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x4d, 0x41, 0x58, - 0x10, 0x03, 0x12, 0x13, 0x0a, 0x0f, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, - 0x44, 0x5f, 0x41, 0x56, 0x47, 0x10, 0x04, 0x2a, 0x8c, 0x01, 0x0a, 0x0c, 0x42, 0x75, 0x63, 0x6b, - 0x65, 0x74, 0x53, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x12, 0x1e, 0x0a, 0x1a, 0x42, 0x55, 0x43, 0x4b, - 0x45, 0x54, 0x5f, 0x53, 0x4f, 0x52, 0x54, 0x5f, 0x42, 0x59, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, - 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x42, 0x55, 0x43, 0x4b, - 0x45, 0x54, 0x5f, 0x53, 0x4f, 0x52, 0x54, 0x5f, 0x42, 0x59, 0x5f, 0x43, 0x4f, 0x55, 0x4e, 0x54, - 0x10, 0x01, 0x12, 0x20, 0x0a, 0x1c, 0x42, 0x55, 0x43, 0x4b, 0x45, 0x54, 0x5f, 0x53, 0x4f, 0x52, - 0x54, 0x5f, 0x42, 0x59, 0x5f, 0x4b, 0x45, 0x59, 0x5f, 0x41, 0x53, 0x5f, 0x53, 0x54, 0x52, 0x49, - 0x4e, 0x47, 0x10, 0x02, 0x12, 0x20, 0x0a, 0x1c, 0x42, 0x55, 0x43, 0x4b, 0x45, 0x54, 0x5f, 0x53, - 0x4f, 0x52, 0x54, 0x5f, 0x42, 0x59, 0x5f, 0x4b, 0x45, 0x59, 0x5f, 0x41, 0x53, 0x5f, 0x4e, 0x55, - 0x4d, 0x42, 0x45, 0x52, 0x10, 0x03, 0x32, 0xb3, 0x02, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72, 0x63, - 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x85, 0x01, 0x0a, 0x06, 0x53, 0x65, - 0x61, 0x72, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, - 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x2c, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, - 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0x20, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1a, 0x3a, 0x01, 0x2a, 0x22, 0x15, 0x2f, 0x61, 0x70, 0x69, - 0x2f, 0x76, 0x30, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, - 0x68, 0x12, 0x98, 0x01, 0x0a, 0x0a, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, - 0x12, 0x2f, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x0f, 0x73, 0x75, 0x62, + 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x94, 0x01, 0x0a, + 0x11, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x17, 0x0a, + 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x5f, + 0x72, 0x65, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x66, + 0x6f, 0x72, 0x63, 0x65, 0x52, 0x65, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x26, 0x0a, 0x0b, 0x63, + 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, + 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72, 0x65, + 0x6e, 0x63, 0x79, 0x22, 0xd1, 0x01, 0x0a, 0x12, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, + 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x40, 0x0a, 0x0e, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x64, + 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0d, 0x73, 0x70, 0x61, 0x63, 0x65, 0x44, + 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e, 0x64, 0x65, 0x78, + 0x65, 0x64, 0x5f, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x0d, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x64, 0x53, 0x70, 0x61, 0x63, 0x65, 0x73, 0x12, 0x21, + 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x53, 0x70, 0x61, 0x63, 0x65, + 0x73, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x2a, 0x7d, 0x0a, 0x0a, 0x4d, 0x65, 0x74, 0x72, 0x69, + 0x63, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x1b, 0x0a, 0x17, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, + 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, + 0x10, 0x00, 0x12, 0x13, 0x0a, 0x0f, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, + 0x44, 0x5f, 0x53, 0x55, 0x4d, 0x10, 0x01, 0x12, 0x13, 0x0a, 0x0f, 0x4d, 0x45, 0x54, 0x52, 0x49, + 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x4d, 0x49, 0x4e, 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, + 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x4d, 0x41, 0x58, 0x10, + 0x03, 0x12, 0x13, 0x0a, 0x0f, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, + 0x5f, 0x41, 0x56, 0x47, 0x10, 0x04, 0x2a, 0x8c, 0x01, 0x0a, 0x0c, 0x42, 0x75, 0x63, 0x6b, 0x65, + 0x74, 0x53, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x12, 0x1e, 0x0a, 0x1a, 0x42, 0x55, 0x43, 0x4b, 0x45, + 0x54, 0x5f, 0x53, 0x4f, 0x52, 0x54, 0x5f, 0x42, 0x59, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, + 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x42, 0x55, 0x43, 0x4b, 0x45, + 0x54, 0x5f, 0x53, 0x4f, 0x52, 0x54, 0x5f, 0x42, 0x59, 0x5f, 0x43, 0x4f, 0x55, 0x4e, 0x54, 0x10, + 0x01, 0x12, 0x20, 0x0a, 0x1c, 0x42, 0x55, 0x43, 0x4b, 0x45, 0x54, 0x5f, 0x53, 0x4f, 0x52, 0x54, + 0x5f, 0x42, 0x59, 0x5f, 0x4b, 0x45, 0x59, 0x5f, 0x41, 0x53, 0x5f, 0x53, 0x54, 0x52, 0x49, 0x4e, + 0x47, 0x10, 0x02, 0x12, 0x20, 0x0a, 0x1c, 0x42, 0x55, 0x43, 0x4b, 0x45, 0x54, 0x5f, 0x53, 0x4f, + 0x52, 0x54, 0x5f, 0x42, 0x59, 0x5f, 0x4b, 0x45, 0x59, 0x5f, 0x41, 0x53, 0x5f, 0x4e, 0x55, 0x4d, + 0x42, 0x45, 0x52, 0x10, 0x03, 0x32, 0xb3, 0x02, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x85, 0x01, 0x0a, 0x06, 0x53, 0x65, 0x61, + 0x72, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, + 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x2c, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, - 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x30, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, - 0x2e, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x25, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1f, 0x3a, 0x01, 0x2a, 0x22, 0x1a, - 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x30, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x69, - 0x6e, 0x64, 0x65, 0x78, 0x2d, 0x73, 0x70, 0x61, 0x63, 0x65, 0x30, 0x01, 0x32, 0xa7, 0x01, 0x0a, - 0x0d, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x95, - 0x01, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x30, 0x2e, 0x6f, 0x70, 0x65, 0x6e, - 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, - 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, - 0x6e, 0x64, 0x65, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x31, 0x2e, 0x6f, 0x70, - 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, - 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, - 0x68, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, - 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x20, 0x3a, 0x01, 0x2a, 0x22, 0x1b, 0x2f, 0x61, 0x70, 0x69, 0x2f, - 0x76, 0x30, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x2f, - 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x42, 0xf2, 0x02, 0x5a, 0x4a, 0x67, 0x69, 0x74, 0x68, 0x75, - 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, - 0x65, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, - 0x6f, 0x75, 0x64, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2f, 0x73, 0x65, 0x61, 0x72, - 0x63, 0x68, 0x2f, 0x76, 0x30, 0x92, 0x41, 0xa2, 0x02, 0x12, 0xb7, 0x01, 0x0a, 0x10, 0x4f, 0x70, - 0x65, 0x6e, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x20, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x22, 0x51, - 0x0a, 0x0e, 0x4f, 0x70, 0x65, 0x6e, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x20, 0x47, 0x6d, 0x62, 0x48, - 0x12, 0x29, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x20, + 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1a, 0x3a, 0x01, 0x2a, 0x22, 0x15, 0x2f, 0x61, 0x70, 0x69, 0x2f, + 0x76, 0x30, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x12, 0x98, 0x01, 0x0a, 0x0a, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x12, + 0x2f, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x49, + 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x30, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, + 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x25, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1f, 0x3a, 0x01, 0x2a, 0x22, 0x1a, 0x2f, + 0x61, 0x70, 0x69, 0x2f, 0x76, 0x30, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x69, 0x6e, + 0x64, 0x65, 0x78, 0x2d, 0x73, 0x70, 0x61, 0x63, 0x65, 0x30, 0x01, 0x32, 0xa7, 0x01, 0x0a, 0x0d, + 0x49, 0x6e, 0x64, 0x65, 0x78, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x95, 0x01, + 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x30, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, + 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, + 0x64, 0x65, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x31, 0x2e, 0x6f, 0x70, 0x65, + 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x49, 0x6e, 0x64, 0x65, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0x82, + 0xd3, 0xe4, 0x93, 0x02, 0x20, 0x3a, 0x01, 0x2a, 0x22, 0x1b, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, + 0x30, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x2f, 0x73, + 0x65, 0x61, 0x72, 0x63, 0x68, 0x42, 0xf2, 0x02, 0x5a, 0x4a, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, - 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x1a, 0x14, 0x73, 0x75, 0x70, - 0x70, 0x6f, 0x72, 0x74, 0x40, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x65, - 0x75, 0x2a, 0x49, 0x0a, 0x0a, 0x41, 0x70, 0x61, 0x63, 0x68, 0x65, 0x2d, 0x32, 0x2e, 0x30, 0x12, - 0x3b, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, + 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, + 0x75, 0x64, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x2f, 0x76, 0x30, 0x92, 0x41, 0xa2, 0x02, 0x12, 0xb7, 0x01, 0x0a, 0x10, 0x4f, 0x70, 0x65, + 0x6e, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x20, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x22, 0x51, 0x0a, + 0x0e, 0x4f, 0x70, 0x65, 0x6e, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x20, 0x47, 0x6d, 0x62, 0x48, 0x12, + 0x29, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, - 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x62, 0x6c, 0x6f, 0x62, 0x2f, - 0x6d, 0x61, 0x69, 0x6e, 0x2f, 0x4c, 0x49, 0x43, 0x45, 0x4e, 0x53, 0x45, 0x32, 0x05, 0x31, 0x2e, - 0x30, 0x2e, 0x30, 0x2a, 0x02, 0x01, 0x02, 0x32, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, - 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x72, 0x3e, 0x0a, 0x10, 0x44, - 0x65, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x72, 0x20, 0x4d, 0x61, 0x6e, 0x75, 0x61, 0x6c, 0x12, - 0x2a, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x64, 0x6f, 0x63, 0x73, 0x2e, 0x6f, 0x70, - 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x65, 0x75, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x73, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, + 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x1a, 0x14, 0x73, 0x75, 0x70, 0x70, + 0x6f, 0x72, 0x74, 0x40, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x65, 0x75, + 0x2a, 0x49, 0x0a, 0x0a, 0x41, 0x70, 0x61, 0x63, 0x68, 0x65, 0x2d, 0x32, 0x2e, 0x30, 0x12, 0x3b, + 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, + 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, + 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x62, 0x6c, 0x6f, 0x62, 0x2f, 0x6d, + 0x61, 0x69, 0x6e, 0x2f, 0x4c, 0x49, 0x43, 0x45, 0x4e, 0x53, 0x45, 0x32, 0x05, 0x31, 0x2e, 0x30, + 0x2e, 0x30, 0x2a, 0x02, 0x01, 0x02, 0x32, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x72, 0x3e, 0x0a, 0x10, 0x44, 0x65, + 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x72, 0x20, 0x4d, 0x61, 0x6e, 0x75, 0x61, 0x6c, 0x12, 0x2a, + 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x64, 0x6f, 0x63, 0x73, 0x2e, 0x6f, 0x70, 0x65, + 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x65, 0x75, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x73, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, } var ( @@ -1385,24 +1417,26 @@ var file_opencloud_services_search_v0_search_proto_depIdxs = []int32{ 10, // 7: opencloud.services.search.v0.SearchIndexResponse.aggregations:type_name -> opencloud.services.search.v0.AggregationResult 8, // 8: opencloud.services.search.v0.AggregationOption.bucket_definition:type_name -> opencloud.services.search.v0.BucketDefinition 7, // 9: opencloud.services.search.v0.AggregationOption.metric_definition:type_name -> opencloud.services.search.v0.MetricDefinition - 0, // 10: opencloud.services.search.v0.MetricDefinition.kind:type_name -> opencloud.services.search.v0.MetricKind - 1, // 11: opencloud.services.search.v0.BucketDefinition.sort_by:type_name -> opencloud.services.search.v0.BucketSortBy - 9, // 12: opencloud.services.search.v0.BucketDefinition.ranges:type_name -> opencloud.services.search.v0.BucketRange - 12, // 13: opencloud.services.search.v0.AggregationResult.buckets:type_name -> opencloud.services.search.v0.Bucket - 11, // 14: opencloud.services.search.v0.AggregationResult.metric:type_name -> opencloud.services.search.v0.Metric - 0, // 15: opencloud.services.search.v0.Metric.kind:type_name -> opencloud.services.search.v0.MetricKind - 17, // 16: opencloud.services.search.v0.IndexSpaceResponse.space_duration:type_name -> google.protobuf.Duration - 2, // 17: opencloud.services.search.v0.SearchProvider.Search:input_type -> opencloud.services.search.v0.SearchRequest - 13, // 18: opencloud.services.search.v0.SearchProvider.IndexSpace:input_type -> opencloud.services.search.v0.IndexSpaceRequest - 4, // 19: opencloud.services.search.v0.IndexProvider.Search:input_type -> opencloud.services.search.v0.SearchIndexRequest - 3, // 20: opencloud.services.search.v0.SearchProvider.Search:output_type -> opencloud.services.search.v0.SearchResponse - 14, // 21: opencloud.services.search.v0.SearchProvider.IndexSpace:output_type -> opencloud.services.search.v0.IndexSpaceResponse - 5, // 22: opencloud.services.search.v0.IndexProvider.Search:output_type -> opencloud.services.search.v0.SearchIndexResponse - 20, // [20:23] is the sub-list for method output_type - 17, // [17:20] is the sub-list for method input_type - 17, // [17:17] is the sub-list for extension type_name - 17, // [17:17] is the sub-list for extension extendee - 0, // [0:17] is the sub-list for field type_name + 6, // 10: opencloud.services.search.v0.AggregationOption.sub_aggregations:type_name -> opencloud.services.search.v0.AggregationOption + 0, // 11: opencloud.services.search.v0.MetricDefinition.kind:type_name -> opencloud.services.search.v0.MetricKind + 1, // 12: opencloud.services.search.v0.BucketDefinition.sort_by:type_name -> opencloud.services.search.v0.BucketSortBy + 9, // 13: opencloud.services.search.v0.BucketDefinition.ranges:type_name -> opencloud.services.search.v0.BucketRange + 12, // 14: opencloud.services.search.v0.AggregationResult.buckets:type_name -> opencloud.services.search.v0.Bucket + 11, // 15: opencloud.services.search.v0.AggregationResult.metric:type_name -> opencloud.services.search.v0.Metric + 0, // 16: opencloud.services.search.v0.Metric.kind:type_name -> opencloud.services.search.v0.MetricKind + 10, // 17: opencloud.services.search.v0.Bucket.sub_aggregations:type_name -> opencloud.services.search.v0.AggregationResult + 17, // 18: opencloud.services.search.v0.IndexSpaceResponse.space_duration:type_name -> google.protobuf.Duration + 2, // 19: opencloud.services.search.v0.SearchProvider.Search:input_type -> opencloud.services.search.v0.SearchRequest + 13, // 20: opencloud.services.search.v0.SearchProvider.IndexSpace:input_type -> opencloud.services.search.v0.IndexSpaceRequest + 4, // 21: opencloud.services.search.v0.IndexProvider.Search:input_type -> opencloud.services.search.v0.SearchIndexRequest + 3, // 22: opencloud.services.search.v0.SearchProvider.Search:output_type -> opencloud.services.search.v0.SearchResponse + 14, // 23: opencloud.services.search.v0.SearchProvider.IndexSpace:output_type -> opencloud.services.search.v0.IndexSpaceResponse + 5, // 24: opencloud.services.search.v0.IndexProvider.Search:output_type -> opencloud.services.search.v0.SearchIndexResponse + 22, // [22:25] is the sub-list for method output_type + 19, // [19:22] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name } func init() { file_opencloud_services_search_v0_search_proto_init() } diff --git a/protogen/gen/opencloud/services/search/v0/search.swagger.json b/protogen/gen/opencloud/services/search/v0/search.swagger.json index 50f52d2b7b..d932e3c7da 100644 --- a/protogen/gen/opencloud/services/search/v0/search.swagger.json +++ b/protogen/gen/opencloud/services/search/v0/search.swagger.json @@ -186,6 +186,13 @@ "metricDefinition": { "$ref": "#/definitions/v0MetricDefinition", "description": "Optional. When set, this aggregation is a scalar metric over `field`\nrather than a bucket aggregation; the corresponding AggregationResult\ncarries `metric` instead of `buckets`." + }, + "subAggregations": { + "type": "array", + "items": { + "$ref": "#/definitions/v0AggregationOption" + }, + "description": "Optional. Nested aggregations computed within each bucket of this\naggregation." } } }, @@ -277,6 +284,13 @@ "count": { "type": "string", "format": "int64" + }, + "subAggregations": { + "type": "array", + "items": { + "$ref": "#/definitions/v0AggregationResult" + }, + "description": "Nested aggregation results, one per sub_aggregation of the parent\nAggregationOption, at the same position." } } }, diff --git a/protogen/proto/opencloud/services/search/v0/search.proto b/protogen/proto/opencloud/services/search/v0/search.proto index 5f81142fdf..2bc2ff12cf 100644 --- a/protogen/proto/opencloud/services/search/v0/search.proto +++ b/protogen/proto/opencloud/services/search/v0/search.proto @@ -138,6 +138,9 @@ message AggregationOption { // rather than a bucket aggregation; the corresponding AggregationResult // carries `metric` instead of `buckets`. MetricDefinition metric_definition = 4 [(google.api.field_behavior) = OPTIONAL]; + // Optional. Nested aggregations computed within each bucket of this + // aggregation. + repeated AggregationOption sub_aggregations = 5 [(google.api.field_behavior) = OPTIONAL]; } message MetricDefinition { @@ -205,6 +208,9 @@ message Metric { message Bucket { string key = 1; int64 count = 2; + // Nested aggregation results, one per sub_aggregation of the parent + // AggregationOption, at the same position. + repeated AggregationResult sub_aggregations = 3; } message IndexSpaceRequest { diff --git a/services/graph/pkg/service/v0/searchquery.go b/services/graph/pkg/service/v0/searchquery.go index be86393775..a899b52393 100644 --- a/services/graph/pkg/service/v0/searchquery.go +++ b/services/graph/pkg/service/v0/searchquery.go @@ -92,13 +92,17 @@ func unsupportedProperty(sr libregraph.SearchRequest) string { if len(sr.AggregationFilters) > 0 { return "aggregationFilters" } - for _, a := range sr.Aggregations { - switch { - case a.LibreGraphGeohashDefinition != nil: - return "@libre.graph.geohashDefinition" - case len(a.LibreGraphSubAggregations) > 0: - return "@libre.graph.subAggregations" + var geohash func(aggs []libregraph.AggregationOption) bool + geohash = func(aggs []libregraph.AggregationOption) bool { + for _, a := range aggs { + if a.LibreGraphGeohashDefinition != nil || geohash(a.LibreGraphSubAggregations) { + return true + } } + return false + } + if geohash(sr.Aggregations) { + return "@libre.graph.geohashDefinition" } return "" } @@ -243,6 +247,9 @@ func validateAggregations(aggs []libregraph.AggregationOption) error { return fmt.Errorf("minimumCount of the aggregation on %q must not be negative", a.Field) } } + if err := validateAggregations(a.LibreGraphSubAggregations); err != nil { + return err + } } return nil } diff --git a/services/graph/pkg/service/v0/searchquery_mapping.go b/services/graph/pkg/service/v0/searchquery_mapping.go index 1a944ec830..d5344fbf42 100644 --- a/services/graph/pkg/service/v0/searchquery_mapping.go +++ b/services/graph/pkg/service/v0/searchquery_mapping.go @@ -55,6 +55,7 @@ func indexField(field string) string { func resolveFields(opts []*searchsvc.AggregationOption) { for _, opt := range opts { opt.Field = indexField(opt.Field) + resolveFields(opt.SubAggregations) } } @@ -68,6 +69,9 @@ func libregraphAggregationsToSearch(in []libregraph.AggregationOption) []*search if a.BucketDefinition != nil { agg.BucketDefinition = libregraphBucketDefinitionToSearch(*a.BucketDefinition) } + if len(a.LibreGraphSubAggregations) > 0 { + agg.SubAggregations = libregraphAggregationsToSearch(a.LibreGraphSubAggregations) + } if md := a.LibreGraphMetricDefinition; md != nil { agg.MetricDefinition = &searchsvc.MetricDefinition{Kind: metricKinds[md.Kind]} } @@ -99,8 +103,8 @@ func libregraphRangesToSearch(in []libregraph.BucketAggregationRange) []*searchs } // searchAggregationsToLibregraph maps the results to their definitions by -// position: the search service answers one result per aggregation, in request -// order. +// position, at every level: the search service answers one result per +// aggregation, in request order. func searchAggregationsToLibregraph(in []*searchsvc.AggregationResult, defs []libregraph.AggregationOption) []libregraph.SearchAggregation { if len(in) == 0 { return nil @@ -123,8 +127,9 @@ func searchAggregationsToLibregraph(in []*searchsvc.AggregationResult, defs []li agg.Buckets = make([]libregraph.SearchBucket, 0, len(in[i].GetBuckets())) for _, b := range in[i].GetBuckets() { agg.Buckets = append(agg.Buckets, libregraph.SearchBucket{ - Key: libregraph.PtrString(b.GetKey()), - Count: libregraph.PtrInt64(b.GetCount()), + Key: libregraph.PtrString(b.GetKey()), + Count: libregraph.PtrInt64(b.GetCount()), + LibreGraphSubAggregations: searchAggregationsToLibregraph(b.GetSubAggregations(), def.LibreGraphSubAggregations), }) } out = append(out, agg) diff --git a/services/graph/pkg/service/v0/searchquery_test.go b/services/graph/pkg/service/v0/searchquery_test.go index 0ca52122cc..ae94c90fb5 100644 --- a/services/graph/pkg/service/v0/searchquery_test.go +++ b/services/graph/pkg/service/v0/searchquery_test.go @@ -92,8 +92,9 @@ type searchAggregationJSON struct { Value *float64 `json:"value"` } `json:"@libre.graph.metric"` Buckets []struct { - Key string `json:"key"` - Count int64 `json:"count"` + Key string `json:"key"` + Count int64 `json:"count"` + SubAggregations []searchAggregationJSON `json:"@libre.graph.subAggregations"` } `json:"buckets"` } @@ -309,6 +310,8 @@ var _ = ginkgo.Describe("SearchQuery", func() { ginkgo.Entry("an unknown sortBy", `"aggregations": [{"field": "audio.artist", "bucketDefinition": {"sortBy": "relevance"}}]`), ginkgo.Entry("a size below one", `"aggregations": [{"field": "audio.artist", "size": 0}]`), ginkgo.Entry("a negative minimumCount", `"aggregations": [{"field": "audio.artist", "bucketDefinition": {"sortBy": "count", "minimumCount": -1}}]`), + ginkgo.Entry("an unknown metric kind two levels down", + `"aggregations": [{"field": "audio.artist", "@libre.graph.subAggregations": [{"field": "audio.album", "@libre.graph.subAggregations": [{"field": "audio.year", "@libre.graph.metricDefinition": {"kind": "median"}}]}]}]`), ginkgo.Entry("a field the index does not know", `"aggregations": [{"field": "audio.nonexistent"}]`), ginkgo.Entry("a range bound that is no number", `"aggregations": [{"field": "audio.year", "bucketDefinition": {"sortBy": "count", "ranges": [{"from": "1970", "to": "198o"}]}}]`), ) @@ -407,7 +410,8 @@ var _ = ginkgo.Describe("SearchQuery", func() { ginkgo.Entry("aggregationFilters", `"aggregationFilters": ["audio.artist:\"ǂǂ5361786f6e\""]`, "aggregationFilters"), ginkgo.Entry("a geohash aggregation", `"aggregations": [{"field": "location", "@libre.graph.geohashDefinition": {"precision": 5}}]`, "geohashDefinition"), - ginkgo.Entry("sub-aggregations", `"aggregations": [{"field": "audio.artist", "@libre.graph.subAggregations": [{"field": "audio.album"}]}]`, "subAggregations"), + ginkgo.Entry("a nested geohash aggregation", + `"aggregations": [{"field": "audio.artist", "@libre.graph.subAggregations": [{"field": "location", "@libre.graph.geohashDefinition": {"precision": 5}}]}]`, "geohashDefinition"), ) ginkgo.It("rejects an $expand it does not know with 400", func() { diff --git a/services/search/pkg/aggregation/aggregation.go b/services/search/pkg/aggregation/aggregation.go index e1dd3d4936..61cc9c0602 100644 --- a/services/search/pkg/aggregation/aggregation.go +++ b/services/search/pkg/aggregation/aggregation.go @@ -32,8 +32,8 @@ func KindOf(opt *searchsvc.AggregationOption) Kind { } // MaxBuckets is how many buckets the aggregations of one request may have in -// one space: the search.max_buckets default of OpenSearch, held on every -// engine. +// one space, all levels counted: the search.max_buckets default of OpenSearch, +// held on every engine. const MaxBuckets = math.MaxUint16 // ErrTooManyBuckets is what an engine answers beyond MaxBuckets; the service @@ -52,6 +52,9 @@ func countBuckets(results []*searchsvc.AggregationResult) int { n := 0 for _, r := range results { n += len(r.GetBuckets()) + for _, b := range r.GetBuckets() { + n += countBuckets(b.GetSubAggregations()) + } } return n } diff --git a/services/search/pkg/aggregation/aggregation_test.go b/services/search/pkg/aggregation/aggregation_test.go index d5746314ce..7a2b94e861 100644 --- a/services/search/pkg/aggregation/aggregation_test.go +++ b/services/search/pkg/aggregation/aggregation_test.go @@ -11,6 +11,17 @@ import ( ) var _ = Describe("CheckBuckets", func() { + // artists of an album each: twice as many buckets as artists + artistsWithAlbum := func(artists int) []*searchsvc.AggregationResult { + res := &searchsvc.AggregationResult{Field: "audio.artist"} + for i := range artists { + res.Buckets = append(res.Buckets, &searchsvc.Bucket{Key: strconv.Itoa(i), Count: 1, SubAggregations: []*searchsvc.AggregationResult{ + {Field: "audio.album", Buckets: []*searchsvc.Bucket{{Key: "Singles", Count: 1}}}, + }}) + } + return []*searchsvc.AggregationResult{res} + } + artists := func(count int) []*searchsvc.AggregationResult { res := &searchsvc.AggregationResult{Field: "audio.artist"} for i := range count { @@ -25,4 +36,9 @@ var _ = Describe("CheckBuckets", func() { Expect(aggregation.CheckBuckets(artists(65535))).To(Succeed()) Expect(aggregation.CheckBuckets(artists(65536))).To(MatchError(aggregation.ErrTooManyBuckets)) }) + + It("counts the buckets of all levels", func() { + Expect(aggregation.CheckBuckets(artistsWithAlbum(32767))).To(Succeed(), "65534 buckets") + Expect(aggregation.CheckBuckets(artistsWithAlbum(32768))).To(MatchError(aggregation.ErrTooManyBuckets), "65536 buckets") + }) }) diff --git a/services/search/pkg/aggregation/merge.go b/services/search/pkg/aggregation/merge.go index ecbd584f93..9ba975a333 100644 --- a/services/search/pkg/aggregation/merge.go +++ b/services/search/pkg/aggregation/merge.go @@ -29,14 +29,15 @@ func Empty(opts []*searchsvc.AggregationOption) []*searchsvc.AggregationResult { } // RangeBuckets lists every range of a range aggregation in request order: the -// answered bucket where there is one, a count of zero otherwise. +// answered bucket where there is one, a count of zero and empty +// sub-aggregations otherwise. func RangeBuckets(opt *searchsvc.AggregationOption, answered map[string]*searchsvc.Bucket) []*searchsvc.Bucket { ranges := opt.GetBucketDefinition().GetRanges() out := make([]*searchsvc.Bucket, 0, len(ranges)) for _, r := range ranges { bucket, ok := answered[RangeKey(r)] if !ok { - bucket = &searchsvc.Bucket{Key: RangeKey(r)} + bucket = &searchsvc.Bucket{Key: RangeKey(r), SubAggregations: Empty(opt.GetSubAggregations())} } out = append(out, bucket) } @@ -44,8 +45,8 @@ func RangeBuckets(opt *searchsvc.AggregationOption, answered map[string]*searchs } // Merge folds the results of one space into acc and returns it. Results -// belong to the option at their position, so several aggregations on one -// field stay apart. +// belong to the option at their position, at every level, so several +// aggregations on one field stay apart. func Merge(opts []*searchsvc.AggregationOption, acc, results []*searchsvc.AggregationResult) []*searchsvc.AggregationResult { if acc == nil { acc = Empty(opts) @@ -70,13 +71,16 @@ func Merge(opts []*searchsvc.AggregationOption, acc, results []*searchsvc.Aggreg acc[i].Buckets = append(acc[i].Buckets, merged) } merged.Count += b.GetCount() + if subs := opt.GetSubAggregations(); len(subs) > 0 { + merged.SubAggregations = Merge(subs, merged.GetSubAggregations(), b.GetSubAggregations()) + } } } return acc } -// Finalize shapes merged results per their options: buckets get their minimum -// count, order and size, metrics their value. +// Finalize shapes merged results per their options, at every level: buckets +// get their minimum count, order and size, metrics their value. func Finalize(opts []*searchsvc.AggregationOption, results []*searchsvc.AggregationResult) { for i, opt := range opts { r := results[i] @@ -87,6 +91,9 @@ func Finalize(opts []*searchsvc.AggregationOption, results []*searchsvc.Aggregat continue } r.Buckets = shapeBuckets(opt, r.GetBuckets()) + for _, b := range r.GetBuckets() { + Finalize(opt.GetSubAggregations(), b.GetSubAggregations()) + } } } diff --git a/services/search/pkg/aggregation/merge_test.go b/services/search/pkg/aggregation/merge_test.go index 35c4b2fe99..7cea3019be 100644 --- a/services/search/pkg/aggregation/merge_test.go +++ b/services/search/pkg/aggregation/merge_test.go @@ -31,8 +31,8 @@ func buckets(field string, bs ...*searchsvc.Bucket) *searchsvc.AggregationResult return &searchsvc.AggregationResult{Field: field, Buckets: bs} } -func bucket(key string, count int64) *searchsvc.Bucket { - return &searchsvc.Bucket{Key: key, Count: count} +func bucket(key string, count int64, subs ...*searchsvc.AggregationResult) *searchsvc.Bucket { + return &searchsvc.Bucket{Key: key, Count: count, SubAggregations: subs} } func rangesOf(sortBy searchsvc.BucketSortBy, rs ...*searchsvc.BucketRange) *searchsvc.BucketDefinition { @@ -40,23 +40,29 @@ func rangesOf(sortBy searchsvc.BucketSortBy, rs ...*searchsvc.BucketRange) *sear } // render flattens results like the parity suite: one line per bucket or -// metric. +// metric, nested ones below their bucket. func render(results []*searchsvc.AggregationResult) []string { - out := []string{} - for _, r := range results { - if m := r.GetMetric(); m != nil { - if m.Value == nil { - out = append(out, fmt.Sprintf("%s %s none", r.GetField(), m.GetKind())) - } else { - out = append(out, fmt.Sprintf("%s %s=%v", r.GetField(), m.GetKind(), m.GetValue())) + var walk func(prefix string, results []*searchsvc.AggregationResult) []string + walk = func(prefix string, results []*searchsvc.AggregationResult) []string { + out := []string{} + for _, r := range results { + if m := r.GetMetric(); m != nil { + if m.Value == nil { + out = append(out, prefix+fmt.Sprintf("%s %s none", r.GetField(), m.GetKind())) + } else { + out = append(out, prefix+fmt.Sprintf("%s %s=%v", r.GetField(), m.GetKind(), m.GetValue())) + } + continue + } + for _, b := range r.GetBuckets() { + line := prefix + fmt.Sprintf("%s %s=%d", r.GetField(), b.GetKey(), b.GetCount()) + out = append(out, line) + out = append(out, walk(line+" / ", b.GetSubAggregations())...) } - continue - } - for _, b := range r.GetBuckets() { - out = append(out, fmt.Sprintf("%s %s=%d", r.GetField(), b.GetKey(), b.GetCount())) } + return out } - return out + return walk("", results) } // merged runs the service layer's fold over the answers of several spaces. @@ -81,7 +87,8 @@ var _ = Describe("Merge and Finalize", func() { opts := []*searchsvc.AggregationOption{ {Field: "audio.artist"}, metricOpt("audio.year", sum), - {Field: "audio.year", BucketDefinition: rangesOf(searchsvc.BucketSortBy_BUCKET_SORT_BY_KEY_AS_NUMBER, &searchsvc.BucketRange{To: "1980"}, &searchsvc.BucketRange{From: "1980"})}, + {Field: "audio.year", BucketDefinition: rangesOf(searchsvc.BucketSortBy_BUCKET_SORT_BY_KEY_AS_NUMBER, &searchsvc.BucketRange{To: "1980"}, &searchsvc.BucketRange{From: "1980"}), + SubAggregations: []*searchsvc.AggregationOption{{Field: "audio.artist"}, metricOpt("audio.year", max)}}, } got := merged(opts) Expect(got).To(HaveLen(3)) @@ -89,7 +96,8 @@ var _ = Describe("Merge and Finalize", func() { Expect(got[0].GetBuckets()).To(BeEmpty()) Expect(render(got)).To(Equal([]string{ "audio.year METRIC_KIND_SUM none", - "audio.year ..1980=0", "audio.year 1980..=0", + "audio.year ..1980=0", "audio.year ..1980=0 / audio.year METRIC_KIND_MAX none", + "audio.year 1980..=0", "audio.year 1980..=0 / audio.year METRIC_KIND_MAX none", })) }) @@ -146,6 +154,44 @@ var _ = Describe("Merge and Finalize", func() { })) }) + It("keeps sibling metrics on one field apart below a bucket that spans spaces", func() { + opts := []*searchsvc.AggregationOption{{Field: "audio.artist", SubAggregations: []*searchsvc.AggregationOption{ + metricOpt("audio.year", sum), + metricOpt("audio.year", avg), + }}} + space := func(years ...float64) []*searchsvc.AggregationResult { + return []*searchsvc.AggregationResult{buckets("audio.artist", + bucket("Saxon", int64(len(years)), metric("audio.year", sum, years...), metric("audio.year", avg, years...)), + )} + } + got := merged(opts, space(1971, 1975), space(1980)) + Expect(render(got)).To(Equal([]string{ + "audio.artist Saxon=3", + "audio.artist Saxon=3 / audio.year METRIC_KIND_SUM=5926", + "audio.artist Saxon=3 / audio.year METRIC_KIND_AVG=1975.3333333333333", + })) + }) + + It("answers nested results in request order", func() { + opts := []*searchsvc.AggregationOption{{Field: "audio.artist", SubAggregations: []*searchsvc.AggregationOption{ + {Field: "audio.genre"}, {Field: "audio.album"}, metricOpt("audio.year", max), {Field: "audio.composers"}, + }}} + space := []*searchsvc.AggregationResult{buckets("audio.artist", bucket("Saxon", 1, + buckets("audio.genre", bucket("Metal", 1)), + buckets("audio.album", bucket("Wheels of Steel", 1)), + metric("audio.year", max, 1980), + buckets("audio.composers", bucket("Byford", 1)), + ))} + for range 20 { + got := merged(opts, space, space) + fields := []string{} + for _, sub := range got[0].GetBuckets()[0].GetSubAggregations() { + fields = append(fields, sub.GetField()) + } + Expect(fields).To(Equal([]string{"audio.genre", "audio.album", "audio.year", "audio.composers"})) + } + }) + DescribeTable("reduces a metric over the values of all spaces", func(kind searchsvc.MetricKind, want string) { opts := []*searchsvc.AggregationOption{metricOpt("audio.year", kind)} @@ -237,5 +283,21 @@ var _ = Describe("Merge and Finalize", func() { ) Expect(render(got)).To(Equal([]string{"audio.artist Saxon=2"})) }) + + It("shapes the buckets of a sub-aggregation by its own definition", func() { + opts := []*searchsvc.AggregationOption{{Field: "audio.artist", SubAggregations: []*searchsvc.AggregationOption{{ + Field: "audio.album", Size: 2, + BucketDefinition: &searchsvc.BucketDefinition{SortBy: searchsvc.BucketSortBy_BUCKET_SORT_BY_KEY_AS_STRING, MinimumCount: 2}, + }}}} + space := []*searchsvc.AggregationResult{buckets("audio.artist", bucket("Saxon", 4, buckets("audio.album", + bucket("Wheels of Steel", 1), bucket("Strong Arm of the Law", 1), bucket("Denim and Leather", 1), bucket("Crusader", 1), + )))} + Expect(render(merged(opts, space, space))).To(Equal([]string{ + "audio.artist Saxon=8", + "audio.artist Saxon=8 / audio.album Crusader=2", + "audio.artist Saxon=8 / audio.album Denim and Leather=2", + })) + Expect(render(merged(opts, space))).To(Equal([]string{"audio.artist Saxon=4"}), "one space alone stays below the minimum count") + }) }) }) diff --git a/services/search/pkg/aggregation/validate.go b/services/search/pkg/aggregation/validate.go index d79e90505a..14d72e1035 100644 --- a/services/search/pkg/aggregation/validate.go +++ b/services/search/pkg/aggregation/validate.go @@ -21,6 +21,9 @@ func ValidateOptions(opts []*searchsvc.AggregationOption, fieldType func(string) if t != mapping.TypeNumeric { return fmt.Errorf("metric aggregation needs a numeric field, %q is none", field) } + if len(opt.GetSubAggregations()) > 0 { + return fmt.Errorf("metric aggregation on %q has no buckets to nest sub-aggregations in", field) + } case KindRange: parsed, err := ParseRanges(field, opt.GetBucketDefinition().GetRanges()) if err != nil { @@ -34,6 +37,9 @@ func ValidateOptions(opts []*searchsvc.AggregationOption, fieldType func(string) return err } } + if err := ValidateOptions(opt.GetSubAggregations(), fieldType); err != nil { + return err + } } return nil } diff --git a/services/search/pkg/aggregation/validate_test.go b/services/search/pkg/aggregation/validate_test.go index 0fd28c59c3..762180e0d9 100644 --- a/services/search/pkg/aggregation/validate_test.go +++ b/services/search/pkg/aggregation/validate_test.go @@ -39,6 +39,11 @@ var _ = Describe("ValidateOptions", func() { Entry("numeric ranges on a number", &searchsvc.AggregationOption{Field: "audio.year", BucketDefinition: numeric}), Entry("date ranges on a date", &searchsvc.AggregationOption{Field: "photo.takenDateTime", BucketDefinition: dates}), Entry("a metric on a number", &searchsvc.AggregationOption{Field: "audio.year", MetricDefinition: sum}), + Entry("a tree of them", &searchsvc.AggregationOption{Field: "audio.artist", SubAggregations: []*searchsvc.AggregationOption{ + {Field: "audio.year", BucketDefinition: numeric, SubAggregations: []*searchsvc.AggregationOption{ + {Field: "audio.album"}, {Field: "audio.year", MetricDefinition: sum}, + }}, + }}), ) DescribeTable("rejects an aggregation the index cannot answer", @@ -53,8 +58,13 @@ var _ = Describe("ValidateOptions", func() { Entry("terms on a geopoint", &searchsvc.AggregationOption{Field: "location"}), Entry("a metric on a keyword", &searchsvc.AggregationOption{Field: "audio.artist", MetricDefinition: sum}), Entry("a metric on a date", &searchsvc.AggregationOption{Field: "photo.takenDateTime", MetricDefinition: sum}), + Entry("a metric with sub-aggregations", &searchsvc.AggregationOption{Field: "audio.year", MetricDefinition: sum, + SubAggregations: []*searchsvc.AggregationOption{{Field: "audio.artist"}}}), Entry("numeric ranges on a keyword", &searchsvc.AggregationOption{Field: "audio.artist", BucketDefinition: numeric}), Entry("numeric ranges on a date", &searchsvc.AggregationOption{Field: "photo.takenDateTime", BucketDefinition: numeric}), Entry("date ranges on a number", &searchsvc.AggregationOption{Field: "audio.year", BucketDefinition: dates}), + Entry("an invalid aggregation two levels down", &searchsvc.AggregationOption{Field: "audio.artist", SubAggregations: []*searchsvc.AggregationOption{ + {Field: "audio.album", SubAggregations: []*searchsvc.AggregationOption{{Field: "photo.takenDateTime"}}}, + }}), ) }) diff --git a/services/search/pkg/bleve/aggregations.go b/services/search/pkg/bleve/aggregations.go index 503c6e5ffb..f4948cb856 100644 --- a/services/search/pkg/bleve/aggregations.go +++ b/services/search/pkg/bleve/aggregations.go @@ -17,8 +17,8 @@ import ( ) // Bleve facets count the indexed terms of one field (numbers among them as -// prefix-coded terms), so every aggregation is folded from doc values by -// aggCollector, hooked into the collector walk through bleve's +// prefix-coded terms) and cannot nest, so every aggregation is folded from doc +// values by aggCollector, hooked into the collector walk through bleve's // document-match-handler context key. Loading hits for them instead costs a // stored-document decode per match. @@ -39,6 +39,7 @@ type aggLevel struct { kind aggregation.Kind fieldType string ranges aggregation.Ranges + children []*aggLevel } // numeric values are prefix-coded in the index, their terms come from the @@ -56,6 +57,13 @@ func newAggLevel(opt *searchService.AggregationOption) (*aggLevel, error) { } l.ranges = ranges } + for _, sub := range opt.GetSubAggregations() { + child, err := newAggLevel(sub) + if err != nil { + return nil, err + } + l.children = append(l.children, child) + } return l, nil } @@ -69,6 +77,7 @@ type fieldValues struct { type bucketAcc struct { counts map[string]int64 + subs map[string][]*bucketAcc metric *searchService.Metric } @@ -76,7 +85,11 @@ func newBucketAcc(l *aggLevel) *bucketAcc { if l.kind == aggregation.KindMetric { return &bucketAcc{metric: &searchService.Metric{Kind: l.opt.GetMetricDefinition().GetKind()}} } - return &bucketAcc{counts: map[string]int64{}} + a := &bucketAcc{counts: map[string]int64{}} + if len(l.children) > 0 { + a.subs = map[string][]*bucketAcc{} + } + return a } // aggCollector serves one search; bleve's collector is single-threaded. @@ -119,6 +132,9 @@ func (c *aggCollector) register(l *aggLevel) { } else { fv.asNumbers = true } + for _, child := range l.children { + c.register(child) + } } // The handler runs for every match, before the top-n cut. @@ -216,8 +232,22 @@ func (c *aggCollector) fold(a *bucketAcc, l *aggLevel) { } } -func (c *aggCollector) foldBucket(a *bucketAcc, _ *aggLevel, key string) { +func (c *aggCollector) foldBucket(a *bucketAcc, l *aggLevel, key string) { a.counts[key]++ + if len(l.children) == 0 { + return + } + subs, ok := a.subs[key] + if !ok { + subs = make([]*bucketAcc, len(l.children)) + for i, child := range l.children { + subs[i] = newBucketAcc(child) + } + a.subs[key] = subs + } + for i, child := range l.children { + c.fold(subs[i], child) + } } // results returns one result per aggregation, in request order. @@ -238,7 +268,11 @@ func (a *bucketAcc) result(l *aggLevel) *searchService.AggregationResult { counted := make(map[string]*searchService.Bucket, len(a.counts)) for key, count := range a.counts { - counted[key] = &searchService.Bucket{Key: key, Count: count} + b := &searchService.Bucket{Key: key, Count: count} + for i, child := range l.children { + b.SubAggregations = append(b.SubAggregations, a.subs[key][i].result(child)) + } + counted[key] = b } if l.kind == aggregation.KindRange { r.Buckets = aggregation.RangeBuckets(l.opt, counted) diff --git a/services/search/pkg/opensearch/internal/aggs/aggs.go b/services/search/pkg/opensearch/internal/aggs/aggs.go index fc0217fad8..5d2df9c6f0 100644 --- a/services/search/pkg/opensearch/internal/aggs/aggs.go +++ b/services/search/pkg/opensearch/internal/aggs/aggs.go @@ -12,10 +12,10 @@ import ( "github.com/opencloud-eu/opencloud/services/search/pkg/aggregation" ) -// Build renders the aggregations as native OpenSearch aggregations, each -// named by its position. A terms aggregation asks for every bucket up to the -// limit: the service layer cuts to the requested size after the cross-space -// merge, a cut here would make that merge inexact. +// Build renders the aggregation tree as native OpenSearch aggregations, each +// named by its position among its siblings. A terms aggregation asks for +// every bucket up to the limit: the service layer cuts to the requested size +// after the cross-space merge, a cut here would make that merge inexact. func Build(opts []*searchsvc.AggregationOption) (map[string]any, error) { if len(opts) == 0 { return nil, nil @@ -51,6 +51,13 @@ func build(opt *searchsvc.AggregationOption) (map[string]any, error) { default: entry = map[string]any{"terms": map[string]any{"field": opt.GetField(), "size": aggregation.MaxBuckets}} } + children, err := Build(opt.GetSubAggregations()) + if err != nil { + return nil, err + } + if children != nil { + entry["aggs"] = children + } return entry, nil } @@ -159,10 +166,18 @@ func parseBucket(opt *searchsvc.AggregationOption, raw json.RawMessage) (*search KeyAsString *string `json:"key_as_string"` DocCount int64 `json:"doc_count"` } + children := aggNode{} if err := json.Unmarshal(raw, &head); err != nil { return nil, fmt.Errorf("decode bucket of %s: %w", opt.GetField(), err) } - return &searchsvc.Bucket{Key: bucketKey(head.Key, head.KeyAsString), Count: head.DocCount}, nil + if err := json.Unmarshal(raw, &children); err != nil { + return nil, fmt.Errorf("decode bucket of %s: %w", opt.GetField(), err) + } + subs, err := parse(opt.GetSubAggregations(), children) + if err != nil { + return nil, err + } + return &searchsvc.Bucket{Key: bucketKey(head.Key, head.KeyAsString), Count: head.DocCount, SubAggregations: subs}, nil } // bucketKey spells a response key like the bleve backend does: a number diff --git a/services/search/pkg/opensearch/internal/aggs/aggs_test.go b/services/search/pkg/opensearch/internal/aggs/aggs_test.go index e333418e2d..6e60b22086 100644 --- a/services/search/pkg/opensearch/internal/aggs/aggs_test.go +++ b/services/search/pkg/opensearch/internal/aggs/aggs_test.go @@ -110,6 +110,11 @@ var _ = Describe("Aggregations", func() { Expect(err).To(HaveOccurred()) }) + It("rejects a malformed range below another aggregation", func() { + _, err := aggs.Build([]*searchsvc.AggregationOption{{Field: "audio.artist", SubAggregations: []*searchsvc.AggregationOption{malformed}}}) + Expect(err).To(HaveOccurred()) + }) + It("lists every requested range in request order, empty ones included", func() { out := parse(`{"a_0": {"buckets": [ {"key": "1970..1980", "doc_count": 4}, @@ -177,4 +182,58 @@ var _ = Describe("Aggregations", func() { &searchsvc.AggregationOption{Field: "audio.year", MetricDefinition: &searchsvc.MetricDefinition{Kind: searchsvc.MetricKind_METRIC_KIND_SUM}}, `{"a_0": {"count": "many"}}`), ) + + It("nests sub-aggregations in their parent, named by position", func() { + artist := build(&searchsvc.AggregationOption{ + Field: "audio.artist", + SubAggregations: []*searchsvc.AggregationOption{ + {Field: "audio.year", BucketDefinition: &searchsvc.BucketDefinition{Ranges: []*searchsvc.BucketRange{{To: "1980"}}}, + SubAggregations: []*searchsvc.AggregationOption{{Field: "audio.album"}}}, + {Field: "audio.duration", MetricDefinition: &searchsvc.MetricDefinition{Kind: searchsvc.MetricKind_METRIC_KIND_SUM}}, + }, + })["a_0"].(map[string]any) + children := artist["aggs"].(map[string]any) + Expect(children).To(HaveKey("a_1"), "the metric") + Expect(children["a_1"].(map[string]any)).To(HaveKey("stats")) + + years := children["a_0"].(map[string]any) + Expect(years).To(HaveKey("range")) + albums := years["aggs"].(map[string]any)["a_0"].(map[string]any)["terms"].(map[string]any) + Expect(albums["field"]).To(Equal("audio.album")) + }) + + It("reads nested results below their bucket, terms below a range below terms too", func() { + out := parse(`{"a_0": {"buckets": [ + {"key": "Saxon", "doc_count": 2, + "a_0": {"buckets": [ + {"key": "..1980", "doc_count": 2, "a_0": {"buckets": [{"key": "Wheels of Steel", "doc_count": 2}]}}, + {"key": "1980..", "doc_count": 0, "a_0": {"buckets": []}} + ]}, + "a_1": {"count": 2, "min": 1971.0, "max": 1975.0, "sum": 3946.0}}, + {"key": "Accept", "doc_count": 1, + "a_0": {"buckets": [ + {"key": "..1980", "doc_count": 0, "a_0": {"buckets": []}}, + {"key": "1980..", "doc_count": 1, "a_0": {"buckets": [{"key": "Breaker", "doc_count": 1}]}} + ]}, + "a_1": {"count": 1, "min": 1981.0, "max": 1981.0, "sum": 1981.0}} + ]}}`, &searchsvc.AggregationOption{ + Field: "audio.artist", + SubAggregations: []*searchsvc.AggregationOption{ + {Field: "audio.year", BucketDefinition: &searchsvc.BucketDefinition{Ranges: []*searchsvc.BucketRange{{To: "1980"}, {From: "1980"}}}, + SubAggregations: []*searchsvc.AggregationOption{{Field: "audio.album"}}}, + {Field: "audio.year", MetricDefinition: &searchsvc.MetricDefinition{Kind: searchsvc.MetricKind_METRIC_KIND_SUM}}, + }, + }) + Expect(keys(out[0])).To(Equal([]string{"Saxon=2", "Accept=1"})) + + saxon := out[0].GetBuckets()[0].GetSubAggregations() + Expect(saxon).To(HaveLen(2)) + Expect(keys(saxon[0])).To(Equal([]string{"..1980=2", "1980..=0"})) + Expect(keys(saxon[0].GetBuckets()[0].GetSubAggregations()[0])).To(Equal([]string{"Wheels of Steel=2"}), "only the albums of this artist") + Expect(saxon[0].GetBuckets()[1].GetSubAggregations()[0].GetBuckets()).To(BeEmpty()) + Expect(saxon[1].GetMetric().GetSum()).To(Equal(3946.0)) + + accept := out[0].GetBuckets()[1].GetSubAggregations() + Expect(keys(accept[0].GetBuckets()[1].GetSubAggregations()[0])).To(Equal([]string{"Breaker=1"})) + }) }) diff --git a/services/search/pkg/parity/README.md b/services/search/pkg/parity/README.md index 8488374ee6..0272eb4ff2 100644 --- a/services/search/pkg/parity/README.md +++ b/services/search/pkg/parity/README.md @@ -745,11 +745,20 @@ Fixtures: | AGG-07 | `mediatype:image` reads `photo.takenDateTime buckets per date range, an empty range counts zero` | photo.take...00:00Z=0, photo.take...00:00Z=1, photo.take...00:00Z=2, photo.take...00:00Z=2 | photo.take...00:00Z=0, photo.take...00:00Z=1, photo.take...00:00Z=2, photo.take...00:00Z=2 | photo.take...00:00Z=0, photo.take...00:00Z=1, photo.take...00:00Z=2, photo.take...00:00Z=2 | ✅ | | AGG-08 | `mediatype:image` reads `open-ended date ranges` | photo.take...00:00Z=3, photo.take...:00Z..=1 | photo.take...00:00Z=3, photo.take...:00Z..=1 | photo.take...00:00Z=3, photo.take...:00Z..=1 | ✅ | | AGG-09 | `mediatype:image` reads `malformed date range bound` | bad request | bad request | bad request | ✅ | +| AGG-10 | `mediatype:audio` reads `album buckets nested in artist buckets` | audio.arti... Steel=2, audio.arti...Bomber=2, audio.arti...Spades=1, audio.artist Motörhead=3, audio.artist Saxon=2 | audio.arti... Steel=2, audio.arti...Bomber=2, audio.arti...Spades=1, audio.artist Motörhead=3, audio.artist Saxon=2 | audio.arti... Steel=2, audio.arti...Bomber=2, audio.arti...Spades=1, audio.artist Motörhead=3, audio.artist Saxon=2 | ✅ | +| AGG-11 | `mediatype:audio` reads `sum and avg of audio.year per artist` | audio.arti... count=2, audio.arti... count=3, audio.arti...sum=3946, audio.arti...sum=5982, audio.artist Motörhead=3, audio.artist Saxon=2 | audio.arti... count=2, audio.arti... count=3, audio.arti...sum=3946, audio.arti...sum=5982, audio.artist Motörhead=3, audio.artist Saxon=2 | audio.arti... count=2, audio.arti... count=3, audio.arti...sum=3946, audio.arti...sum=5982, audio.artist Motörhead=3, audio.artist Saxon=2 | ✅ | +| AGG-12 | `mediatype:audio` reads `artist buckets nested in audio.year decades` | audio.year 1970..1980=2, audio.year 1980..1990=1, audio.year 1990..2000=1, audio.year 2000..2010=3, audio.year... Saxon=2, audio.year...örhead=1, audio.year...örhead=1, audio.year...örhead=1 | audio.year 1970..1980=2, audio.year 1980..1990=1, audio.year 1990..2000=1, audio.year 2000..2010=3, audio.year... Saxon=2, audio.year...örhead=1, audio.year...örhead=1, audio.year...örhead=1 | audio.year 1970..1980=2, audio.year 1980..1990=1, audio.year 1990..2000=1, audio.year 2000..2010=3, audio.year... Saxon=2, audio.year...örhead=1, audio.year...örhead=1, audio.year...örhead=1 | ✅ | +| AGG-13 | `mediatype:audio` reads `max audio.year per album per artist, three levels` | audio.arti... Steel=2, audio.arti...Bomber=2, audio.arti...Spades=1, audio.arti...max=1975, audio.arti...max=1999, audio.arti...max=2001, audio.artist Motörhead=3, audio.artist Saxon=2 | audio.arti... Steel=2, audio.arti...Bomber=2, audio.arti...Spades=1, audio.arti...max=1975, audio.arti...max=1999, audio.arti...max=2001, audio.artist Motörhead=3, audio.artist Saxon=2 | audio.arti... Steel=2, audio.arti...Bomber=2, audio.arti...Spades=1, audio.arti...max=1975, audio.arti...max=1999, audio.arti...max=2001, audio.artist Motörhead=3, audio.artist Saxon=2 | ✅ | +| AGG-14 | `mediatype:image` reads `MimeType buckets nested in open-ended date ranges` | photo.take...00:00Z=3, photo.take...:00Z..=1, photo.take...e/jpeg=1, photo.take...e/jpeg=3 | photo.take...00:00Z=3, photo.take...:00Z..=1, photo.take...e/jpeg=1, photo.take...e/jpeg=3 | error | ❌ known | +| AGG-15 | `mediatype:audio` reads `nested aggregations cover every match on a page of one` | 1 of 7 matches, audio.arti... Steel=2, audio.arti...Bomber=2, audio.arti...Spades=1, audio.artist Motörhead=3, audio.artist Saxon=2, audio.year sum=13942 | 1 of 7 matches, audio.arti... Steel=2, audio.arti...Bomber=2, audio.arti...Spades=1, audio.artist Motörhead=3, audio.artist Saxon=2, audio.year sum=13942 | 1 of 7 matches, audio.arti... Steel=2, audio.arti...Bomber=2, audio.arti...Spades=1, audio.artist Motörhead=3, audio.artist Saxon=2, audio.year sum=13942 | ✅ | | AGG-18 | `mediatype:audio` reads `two range aggregations and a metric on audio.year stay apart` | audio.year ..1980=2, audio.year ..2000=4, audio.year 1980..=5, audio.year 2000..=3, audio.year max=2009 | audio.year ..1980=2, audio.year ..2000=4, audio.year 1980..=5, audio.year 2000..=3, audio.year max=2009 | audio.year ..1980=2, audio.year ..2000=4, audio.year 1980..=5, audio.year 2000..=3, audio.year max=2009 | ✅ | +| AGG-20 | `mediatype:audio` reads `album buckets in audio.year ranges in artist buckets` | audio.arti... Steel=2, audio.arti.....1980=0, audio.arti.....1980=2, audio.arti...1980..=0, audio.arti...1980..=3, audio.arti...Bomber=2, audio.arti...Spades=1, audio.artist Motörhead=3, … +1 more | audio.arti... Steel=2, audio.arti.....1980=0, audio.arti.....1980=2, audio.arti...1980..=0, audio.arti...1980..=3, audio.arti...Bomber=2, audio.arti...Spades=1, audio.artist Motörhead=3, … +1 more | audio.arti... Steel=2, audio.arti.....1980=0, audio.arti.....1980=2, audio.arti...1980..=0, audio.arti...1980..=3, audio.arti...Bomber=2, audio.arti...Spades=1, audio.artist Motörhead=3, … +1 more | ✅ | | AGG-21 | `mediatype:image` reads `a metric without a single value has none` | audio.year avg none, audio.year min none, audio.year sum none | audio.year avg none, audio.year min none, audio.year sum none | audio.year avg none, audio.year min none, audio.year sum none | ✅ | | AGG-34 | `mediatype:audio` reads `term buckets on the numeric audio.year` | audio.year 1971=1, audio.year 1975=1, audio.year 1982=1, audio.year 1999=1, audio.year 2001=1, audio.year 2005=1, audio.year 2009=1 | audio.year 1971=1, audio.year 1975=1, audio.year 1982=1, audio.year 1999=1, audio.year 2001=1, audio.year 2005=1, audio.year 2009=1 | audio.year 1971=1, audio.year 1975=1, audio.year 1982=1, audio.year 1999=1, audio.year 2001=1, audio.year 2005=1, audio.year 2009=1 | ✅ | +| AGG-35 | `mediatype:audio` reads `audio.year buckets nested in artist buckets` | audio.arti...r 1971=1, audio.arti...r 1975=1, audio.arti...r 1982=1, audio.arti...r 1999=1, audio.arti...r 2001=1, audio.artist Motörhead=3, audio.artist Saxon=2 | audio.arti...r 1971=1, audio.arti...r 1975=1, audio.arti...r 1982=1, audio.arti...r 1999=1, audio.arti...r 2001=1, audio.artist Motörhead=3, audio.artist Saxon=2 | audio.arti...r 1971=1, audio.arti...r 1975=1, audio.arti...r 1982=1, audio.arti...r 1999=1, audio.arti...r 2001=1, audio.artist Motörhead=3, audio.artist Saxon=2 | ✅ | | AGG-37 | `mediatype:audio` reads `term buckets on the bool audio.hasDrm, spelled true and false` | audio.hasDrm false=1, audio.hasDrm true=1 | audio.hasDrm false=1, audio.hasDrm true=1 | audio.hasDrm false=1, audio.hasDrm true=1 | ✅ | | AGG-39 | `mediatype:audio` reads `no bucket for the empty Title of every match` | no match | no match | no match | ✅ | +| AGG-40 | `mediatype:audio` reads `no bucket for the empty Title, with sub-aggregations neither` | no match | no match | no match | ✅ | | AGG-41 | `mediatype:audio` reads `matches of the same score in the order of their ids, page after page` | 3 of 7 matches, a.mp3, b.mp3, c.mp3 | 3 of 7 matches, a.mp3, b.mp3, c.mp3 | 3 of 7 matches, a.mp3, b.mp3, c.mp3 | ✅ | ### cardinality @@ -773,3 +782,4 @@ Fixtures: | AGG-17 | `mediatype:audio` reads `one bucket per artist, cardinality above the page size` | 10050 items | audio.arti...-00000=1, audio.arti...-00001=1, audio.arti...-00002=1, audio.arti...-00003=1, audio.arti...-00004=1, audio.arti...-00005=1, audio.arti...-00006=1, audio.arti...-00007=1, … +10042 more | audio.arti...-00000=1, audio.arti...-00001=1, audio.arti...-00002=1, audio.arti...-00003=1, audio.arti...-00004=1, audio.arti...-00005=1, audio.arti...-00006=1, audio.arti...-00007=1, … +10042 more | ✅ | | AGG-42 | `mediatype:audio` reads `the total counts every match` | 1 of 10050 matches | 1 of 10050 matches | 1 of 10050 matches | ✅ | | AGG-43 | `mediatype:audio` reads `a page reaching beyond the first 10000 matches is refused` | bad request | bad request | bad request | ✅ | +| AGG-44 | `mediatype:audio` reads `more than 65535 buckets in one request are refused` | bad request | bad request | bad request | ✅ | diff --git a/services/search/pkg/parity/aggregations_test.go b/services/search/pkg/parity/aggregations_test.go index 46ad50148e..1bb94fdab0 100644 --- a/services/search/pkg/parity/aggregations_test.go +++ b/services/search/pkg/parity/aggregations_test.go @@ -159,6 +159,70 @@ func aggregationCases() []aggCase { &searchService.BucketRange{From: "2018-08-11T00:00:00Z", To: "not-a-date"}, )}}, wantBadRequest: true}, + {id: 10, query: "mediatype:audio", reads: "album buckets nested in artist buckets", + aggs: []*searchService.AggregationOption{{Field: "audio.artist", SubAggregations: []*searchService.AggregationOption{{Field: "audio.album"}}}}, + want: []string{ + "audio.artist Saxon=2", "audio.artist Saxon=2 / audio.album Wheels of Steel=2", + "audio.artist Motörhead=3", "audio.artist Motörhead=3 / audio.album Bomber=2", "audio.artist Motörhead=3 / audio.album Ace of Spades=1", + }}, + {id: 11, query: "mediatype:audio", reads: "sum and avg of audio.year per artist", + aggs: []*searchService.AggregationOption{{Field: "audio.artist", SubAggregations: []*searchService.AggregationOption{ + metric("audio.year", searchService.MetricKind_METRIC_KIND_SUM), + metric("audio.year", searchService.MetricKind_METRIC_KIND_AVG), + }}}, + want: []string{ + "audio.artist Saxon=2", "audio.artist Saxon=2 / audio.year sum=3946", "audio.artist Saxon=2 / audio.year avg sum=3946 count=2", + "audio.artist Motörhead=3", "audio.artist Motörhead=3 / audio.year sum=5982", "audio.artist Motörhead=3 / audio.year avg sum=5982 count=3", + }}, + {id: 12, query: "mediatype:audio", reads: "artist buckets nested in audio.year decades", + aggs: []*searchService.AggregationOption{{Field: "audio.year", BucketDefinition: ranges( + &searchService.BucketRange{From: "1970", To: "1980"}, + &searchService.BucketRange{From: "1980", To: "1990"}, + &searchService.BucketRange{From: "1990", To: "2000"}, + &searchService.BucketRange{From: "2000", To: "2010"}, + ), SubAggregations: []*searchService.AggregationOption{{Field: "audio.artist"}}}}, + want: []string{ + "audio.year 1970..1980=2", "audio.year 1970..1980=2 / audio.artist Saxon=2", + "audio.year 1980..1990=1", "audio.year 1980..1990=1 / audio.artist Motörhead=1", + "audio.year 1990..2000=1", "audio.year 1990..2000=1 / audio.artist Motörhead=1", + "audio.year 2000..2010=3", "audio.year 2000..2010=3 / audio.artist Motörhead=1", + }}, + {id: 13, query: "mediatype:audio", reads: "max audio.year per album per artist, three levels", + aggs: []*searchService.AggregationOption{{Field: "audio.artist", SubAggregations: []*searchService.AggregationOption{ + {Field: "audio.album", SubAggregations: []*searchService.AggregationOption{ + metric("audio.year", searchService.MetricKind_METRIC_KIND_MAX), + }}, + }}}, + want: []string{ + "audio.artist Saxon=2", "audio.artist Saxon=2 / audio.album Wheels of Steel=2", "audio.artist Saxon=2 / audio.album Wheels of Steel=2 / audio.year max=1975", + "audio.artist Motörhead=3", "audio.artist Motörhead=3 / audio.album Bomber=2", "audio.artist Motörhead=3 / audio.album Bomber=2 / audio.year max=1999", + "audio.artist Motörhead=3 / audio.album Ace of Spades=1", "audio.artist Motörhead=3 / audio.album Ace of Spades=1 / audio.year max=2001", + }}, + {id: 14, query: "mediatype:image", reads: "MimeType buckets nested in open-ended date ranges", + aggs: []*searchService.AggregationOption{{Field: "photo.takenDateTime", BucketDefinition: ranges( + &searchService.BucketRange{To: "2019-01-01T00:00:00Z"}, + &searchService.BucketRange{From: "2019-01-01T00:00:00Z"}, + ), SubAggregations: []*searchService.AggregationOption{{Field: "MimeType"}}}}, + want: []string{ + "photo.takenDateTime ..2019-01-01T00:00:00Z=3", "photo.takenDateTime ..2019-01-01T00:00:00Z=3 / MimeType image/jpeg=3", + "photo.takenDateTime 2019-01-01T00:00:00Z..=1", "photo.takenDateTime 2019-01-01T00:00:00Z..=1 / MimeType image/jpeg=1", + }, + // OpenSearch maps MimeType as wildcard, which serves no doc values to + // aggregate on; the follow-up is an aggregatable keyword sibling + engineOverrides: map[string]override{"opensearch": {want: []string{"error"}}}}, + {id: 15, query: "mediatype:audio", reads: "nested aggregations cover every match on a page of one", + pageSize: 1, + aggs: []*searchService.AggregationOption{ + {Field: "audio.artist", SubAggregations: []*searchService.AggregationOption{{Field: "audio.album"}}}, + metric("audio.year", searchService.MetricKind_METRIC_KIND_SUM), + }, + want: []string{ + "1 of 7 matches", + "audio.artist Saxon=2", "audio.artist Saxon=2 / audio.album Wheels of Steel=2", + "audio.artist Motörhead=3", "audio.artist Motörhead=3 / audio.album Bomber=2", "audio.artist Motörhead=3 / audio.album Ace of Spades=1", + "audio.year sum=13942", + }}, + // 16 and 19 pinned malformed bounds twice over, 17 is the cardinality case below {id: 18, query: "mediatype:audio", reads: "two range aggregations and a metric on audio.year stay apart", aggs: []*searchService.AggregationOption{ {Field: "audio.year", BucketDefinition: ranges(&searchService.BucketRange{To: "1980"}, &searchService.BucketRange{From: "1980"})}, @@ -170,6 +234,21 @@ func aggregationCases() []aggCase { "audio.year ..2000=4", "audio.year 2000..=3", "audio.year max=2009", }}, + {id: 20, query: "mediatype:audio", reads: "album buckets in audio.year ranges in artist buckets", + aggs: []*searchService.AggregationOption{{Field: "audio.artist", SubAggregations: []*searchService.AggregationOption{{ + Field: "audio.year", + BucketDefinition: ranges(&searchService.BucketRange{To: "1980"}, &searchService.BucketRange{From: "1980"}), + SubAggregations: []*searchService.AggregationOption{{Field: "audio.album"}}, + }}}}, + want: []string{ + "audio.artist Saxon=2", + "audio.artist Saxon=2 / audio.year ..1980=2", "audio.artist Saxon=2 / audio.year ..1980=2 / audio.album Wheels of Steel=2", + "audio.artist Saxon=2 / audio.year 1980..=0", + "audio.artist Motörhead=3", + "audio.artist Motörhead=3 / audio.year ..1980=0", + "audio.artist Motörhead=3 / audio.year 1980..=3", + "audio.artist Motörhead=3 / audio.year 1980..=3 / audio.album Bomber=2", "audio.artist Motörhead=3 / audio.year 1980..=3 / audio.album Ace of Spades=1", + }}, {id: 21, query: "mediatype:image", reads: "a metric without a single value has none", aggs: []*searchService.AggregationOption{ metric("audio.year", searchService.MetricKind_METRIC_KIND_SUM), @@ -183,11 +262,20 @@ func aggregationCases() []aggCase { "audio.year 1971=1", "audio.year 1975=1", "audio.year 1982=1", "audio.year 1999=1", "audio.year 2001=1", "audio.year 2005=1", "audio.year 2009=1", }}, + {id: 35, query: "mediatype:audio", reads: "audio.year buckets nested in artist buckets", + aggs: []*searchService.AggregationOption{{Field: "audio.artist", SubAggregations: []*searchService.AggregationOption{{Field: "audio.year"}}}}, + want: []string{ + "audio.artist Saxon=2", "audio.artist Saxon=2 / audio.year 1971=1", "audio.artist Saxon=2 / audio.year 1975=1", + "audio.artist Motörhead=3", "audio.artist Motörhead=3 / audio.year 1982=1", + "audio.artist Motörhead=3 / audio.year 1999=1", "audio.artist Motörhead=3 / audio.year 2001=1", + }}, {id: 37, query: "mediatype:audio", reads: "term buckets on the bool audio.hasDrm, spelled true and false", aggs: []*searchService.AggregationOption{{Field: "audio.hasDrm"}}, want: []string{"audio.hasDrm true=1", "audio.hasDrm false=1"}}, {id: 39, query: "mediatype:audio", reads: "no bucket for the empty Title of every match", aggs: []*searchService.AggregationOption{{Field: "Title"}}}, + {id: 40, query: "mediatype:audio", reads: "no bucket for the empty Title, with sub-aggregations neither", + aggs: []*searchService.AggregationOption{{Field: "Title", SubAggregations: []*searchService.AggregationOption{{Field: "audio.artist"}}}}}, {id: 41, query: "mediatype:audio", reads: "matches of the same score in the order of their ids, page after page", pageSize: 3, listMatches: true, want: []string{"3 of 7 matches", "a.mp3", "b.mp3", "c.mp3"}}, @@ -195,7 +283,7 @@ func aggregationCases() []aggCase { } // renderAggregations flattens an answer into comparable strings, one per -// bucket or metric. +// bucket or metric. A nested result renders under its bucket, joined by " / ". func renderAggregations(resp *searchService.SearchIndexResponse, err error) []string { if err != nil { if _, ok := err.(errtypes.BadRequest); ok { @@ -227,7 +315,11 @@ func renderAggregation(prefix string, a *searchService.AggregationResult) []stri out := []string{} for _, b := range a.Buckets { - out = append(out, prefix+fmt.Sprintf("%s %s=%d", a.Field, b.Key, b.Count)) + line := prefix + fmt.Sprintf("%s %s=%d", a.Field, b.Key, b.Count) + out = append(out, line) + for _, sub := range b.SubAggregations { + out = append(out, renderAggregation(line+" / ", sub)...) + } } return out @@ -243,6 +335,15 @@ func cardinalityFixtures() []search.Resource { return docs } +// nested puts a terms aggregation on each field into the one before it. +func nested(fields ...string) *searchService.AggregationOption { + opt := &searchService.AggregationOption{Field: fields[0]} + if len(fields) > 1 { + opt.SubAggregations = []*searchService.AggregationOption{nested(fields[1:]...)} + } + return opt +} + func cardinalityCases() []aggCase { count := 10050 return []aggCase{ @@ -255,6 +356,9 @@ func cardinalityCases() []aggCase { {id: 43, query: "mediatype:audio", reads: "a page reaching beyond the first 10000 matches is refused", pageSize: 10001, wantBadRequest: true}, + {id: 44, query: "mediatype:audio", reads: "more than 65535 buckets in one request are refused", + aggs: []*searchService.AggregationOption{nested("audio.artist", "Name", "ID", "RootID", "ParentID", "audio.album", "audio.year")}, + wantBadRequest: true}, } }