Skip to content

Commit 08ac1e1

Browse files
authored
Merge pull request #961 from MITLibraries/use-491
Only calculate aggregations when requested
2 parents e915ecb + 96b20df commit 08ac1e1

6 files changed

Lines changed: 216 additions & 13 deletions

File tree

app/graphql/types/query_type.rb

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ def search(searchterm:, citation:, contributors:, funding_information:, geodista
109109
query = construct_query(searchterm, citation, contributors, funding_information, geodistance, geobox, identifiers,
110110
locations, subjects, title, source, boolean_type, filters, per_page, query_mode)
111111

112-
results = Opensearch.new.search(from, query, Timdex::OSClient, highlight: highlight_requested?, index: index, fulltext: fulltext, query_mode: query_mode)
112+
results = Opensearch.new.search(from, query, Timdex::OSClient, highlight: highlight_requested?, index: index,
113+
fulltext: fulltext, query_mode: query_mode, requested_aggregations: requested_aggregations)
113114

114115
response = {}
115116
response[:hits] = results['hits']['total']['value']
@@ -122,6 +123,22 @@ def highlight_requested?
122123
context[:tracers].first.log_data[:used_fields].include?('Record.highlight')
123124
end
124125

126+
# Convert aggregation fields to format expected by aggregations model.
127+
# We believe format was set to `content_format` because when TIMDEX was a rest REST API, the
128+
# format parameter would likely have triggered the format feature (i.e. format html, format
129+
# json, format xml, etc). We are retaining this out of caution.
130+
def requested_aggregation_field(field_name)
131+
return :content_format if field_name == 'format'
132+
133+
field_name.underscore.to_sym
134+
end
135+
136+
def requested_aggregations
137+
used_fields = context[:tracers].first.log_data[:used_fields]
138+
used_fields.select { |field| field.start_with?('Aggregations.') }
139+
.map { |field| requested_aggregation_field(field.sub('Aggregations.', '')) }
140+
end
141+
125142
# Long-term, we will probably want to define these fields discretely in RecordType. However, this might end up
126143
# adding confusion while we are maintaining deprecated fields in that class. We should refactor this either soon
127144
# after removing GraphQL V1 or as part of that work.
@@ -174,17 +191,19 @@ def source_deprecation_handler(query, new_source, old_source)
174191
end
175192

176193
def collapse_buckets(es_aggs)
194+
return nil if es_aggs.nil? || es_aggs.empty?
195+
177196
{
178-
access_to_files: es_aggs['access_to_files']['only_file_access']['access_types']['buckets'],
179-
contributors: es_aggs['contributors']['contributor_names']['buckets'],
180-
source: es_aggs['source']['buckets'],
181-
subjects: es_aggs['subjects']['subject_names']['buckets'],
182-
places: es_aggs['places']['only_spatial']['place_names']['buckets'],
183-
languages: es_aggs['languages']['buckets'],
184-
literary_form: es_aggs['literary_form']['buckets'],
185-
format: es_aggs['content_format']['buckets'],
186-
content_type: es_aggs['content_type']['buckets']
187-
}
197+
access_to_files: es_aggs.dig('access_to_files', 'only_file_access', 'access_types', 'buckets'),
198+
contributors: es_aggs.dig('contributors', 'contributor_names', 'buckets'),
199+
source: es_aggs.dig('source', 'buckets'),
200+
subjects: es_aggs.dig('subjects', 'subject_names', 'buckets'),
201+
places: es_aggs.dig('places', 'only_spatial', 'place_names', 'buckets'),
202+
languages: es_aggs.dig('languages', 'buckets'),
203+
literary_form: es_aggs.dig('literary_form', 'buckets'),
204+
format: es_aggs.dig('content_format', 'buckets'),
205+
content_type: es_aggs.dig('content_type', 'buckets')
206+
}.compact
188207
end
189208
end
190209
end

app/models/aggregations.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ def self.all
1616
}
1717
end
1818

19+
# Return only the aggregations that were requested
20+
# @param requested_names [Array<Symbol>] Array of aggregation names to include (e.g., [:source, :contributors])
21+
# @return [Hash] Filtered aggregations hash with only requested aggregations
22+
def self.for_request(requested_names)
23+
return {} if requested_names.nil? || requested_names.empty?
24+
25+
all_aggs = all
26+
requested_names.each_with_object({}) do |name, result|
27+
result[name] = all_aggs[name] if all_aggs.key?(name)
28+
end
29+
end
30+
1931
def self.access_to_files
2032
{
2133
nested: {

app/models/opensearch.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ class Opensearch
33
SIZE = 20
44
MAX_SIZE = 200
55

6-
def search(from, params, client, highlight: false, index: nil, fulltext: false, query_mode: 'keyword')
6+
def search(from, params, client, highlight: false, index: nil, fulltext: false, query_mode: 'keyword',
7+
requested_aggregations: [])
78
@params = params
89
@highlight = highlight
910
@fulltext = fulltext?(fulltext)
1011
@query_mode = query_mode
12+
@requested_aggregations = requested_aggregations
1113
index = default_index unless index.present?
1214
client.search(index:,
1315
body: build_query(from))
@@ -39,10 +41,13 @@ def build_query(from)
3941
from:,
4042
size: calculate_size,
4143
query:,
42-
aggregations: Aggregations.all,
4344
sort: sort_builder.build
4445
}
4546

47+
# Only include aggregations if any were requested
48+
aggregations = Aggregations.for_request(@requested_aggregations)
49+
query_hash[:aggregations] = aggregations if aggregations.present?
50+
4651
source = source_builder.build
4752
query_hash[:_source] = source if source.present?
4853

test/controllers/graphql_controller_test.rb

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,4 +1062,82 @@ class GraphqlControllerTest < ActionDispatch::IntegrationTest
10621062
end
10631063
end
10641064
end
1065+
1066+
test 'graphql search with camelCase aggregation fields (accessToFiles, contentType)' do
1067+
VCR.use_cassette('opensearch init') do
1068+
VCR.use_cassette('graphql search data analytics') do
1069+
post '/graphql', params: { query: '{
1070+
search(searchterm: "data analytics") {
1071+
aggregations {
1072+
accessToFiles {
1073+
key
1074+
docCount
1075+
}
1076+
contentType {
1077+
key
1078+
docCount
1079+
}
1080+
}
1081+
}
1082+
}' }
1083+
assert_equal(200, response.status)
1084+
json = JSON.parse(response.body)
1085+
aggs = json['data']['search']['aggregations']
1086+
1087+
# Verify aggregations are present when requested
1088+
assert_not_nil aggs
1089+
assert_not_nil aggs['accessToFiles']
1090+
assert_not_nil aggs['contentType']
1091+
1092+
# Verify structure
1093+
assert aggs['accessToFiles'].is_a?(Array)
1094+
assert aggs['contentType'].is_a?(Array)
1095+
end
1096+
end
1097+
end
1098+
1099+
test 'graphql search with format aggregation (should map to content_format)' do
1100+
VCR.use_cassette('opensearch init') do
1101+
VCR.use_cassette('graphql search data analytics') do
1102+
post '/graphql', params: { query: '{
1103+
search(searchterm: "data analytics") {
1104+
aggregations {
1105+
format {
1106+
key
1107+
docCount
1108+
}
1109+
}
1110+
}
1111+
}' }
1112+
assert_equal(200, response.status)
1113+
json = JSON.parse(response.body)
1114+
aggs = json['data']['search']['aggregations']
1115+
1116+
# Verify format aggregation is present and populated
1117+
assert_not_nil aggs
1118+
assert_not_nil aggs['format']
1119+
assert aggs['format'].is_a?(Array)
1120+
end
1121+
end
1122+
end
1123+
1124+
test 'graphql search without aggregations excludes them from OpenSearch query' do
1125+
VCR.use_cassette('opensearch init') do
1126+
VCR.use_cassette('graphql search data analytics') do
1127+
post '/graphql', params: { query: '{
1128+
search(searchterm: "data analytics") {
1129+
hits
1130+
records {
1131+
title
1132+
}
1133+
}
1134+
}' }
1135+
assert_equal(200, response.status)
1136+
json = JSON.parse(response.body)
1137+
1138+
# Verify aggregations field is null when not requested
1139+
assert_nil json['data']['search']['aggregations']
1140+
end
1141+
end
1142+
end
10651143
end

test/models/aggregations_test.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
require 'test_helper'
2+
3+
class AggregationsTest < ActiveSupport::TestCase
4+
test 'for_request returns all aggregations when all are requested' do
5+
requested = %i[access_to_files contributors content_type content_format languages literary_form places source
6+
subjects]
7+
result = Aggregations.for_request(requested)
8+
9+
assert_equal requested.size, result.size
10+
requested.each do |agg_name|
11+
assert result.key?(agg_name)
12+
end
13+
end
14+
15+
test 'for_request returns only requested aggregations' do
16+
requested = %i[source contributors]
17+
result = Aggregations.for_request(requested)
18+
19+
assert_equal 2, result.size
20+
assert result.key?(:source)
21+
assert result.key?(:contributors)
22+
end
23+
24+
test 'for_request returns empty hash when given empty array' do
25+
result = Aggregations.for_request([])
26+
27+
assert_empty result
28+
assert result.is_a?(Hash)
29+
end
30+
31+
test 'for_request returns empty hash when given nil' do
32+
result = Aggregations.for_request(nil)
33+
34+
assert_empty result
35+
assert result.is_a?(Hash)
36+
end
37+
38+
test 'for_request ignores invalid aggregation names' do
39+
requested = %i[source invalid_agg contributors another_invalid]
40+
result = Aggregations.for_request(requested)
41+
42+
assert_equal 2, result.size
43+
assert result.key?(:source)
44+
assert result.key?(:contributors)
45+
assert_not result.key?(:invalid_agg)
46+
assert_not result.key?(:another_invalid)
47+
end
48+
end

test/models/opensearch_test.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,45 @@ class OpensearchTest < ActiveSupport::TestCase
161161
result = os.query
162162
assert_equal(mock_response, result)
163163
end
164+
165+
test 'build_query includes aggregations when requested' do
166+
os = Opensearch.new
167+
os.instance_variable_set(:@params, { q: 'test' })
168+
os.instance_variable_set(:@requested_aggregations, %i[source contributors])
169+
170+
json = JSON.parse(os.build_query(0))
171+
assert json.key?('aggregations')
172+
assert json['aggregations'].key?('source')
173+
assert json['aggregations'].key?('contributors')
174+
assert_not json['aggregations'].key?('languages')
175+
end
176+
177+
test 'build_query excludes aggregations when none requested' do
178+
os = Opensearch.new
179+
os.instance_variable_set(:@params, { q: 'test' })
180+
os.instance_variable_set(:@requested_aggregations, [])
181+
182+
json = JSON.parse(os.build_query(0))
183+
assert_not json.key?('aggregations')
184+
end
185+
186+
test 'build_query excludes aggregations when requested_aggregations is nil' do
187+
os = Opensearch.new
188+
os.instance_variable_set(:@params, { q: 'test' })
189+
os.instance_variable_set(:@requested_aggregations, nil)
190+
191+
json = JSON.parse(os.build_query(0))
192+
assert_not json.key?('aggregations')
193+
end
194+
195+
test 'build_query with aggregations ignores invalid aggregation names' do
196+
os = Opensearch.new
197+
os.instance_variable_set(:@params, { q: 'test' })
198+
os.instance_variable_set(:@requested_aggregations, %i[source invalid_agg])
199+
200+
json = JSON.parse(os.build_query(0))
201+
assert json.key?('aggregations')
202+
assert json['aggregations'].key?('source')
203+
assert_not json['aggregations'].key?('invalid_agg')
204+
end
164205
end

0 commit comments

Comments
 (0)