• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

felleslosninger / einnsyn-backend / 23042844872

13 Mar 2026 08:37AM UTC coverage: 85.069%. Remained the same
23042844872

push

github

web-flow
EIN-4935: Remove unnecessary RANDOM_PORT from LegacyQueryConverterTest to reduce ApplicationContext count (#622)

* EIN-4935: Remove unnecessary RANDOM_PORT from LegacyQueryConverterTest to reduce ApplicationContext count

2549 of 3425 branches covered (74.42%)

Branch coverage included in aggregate %.

7695 of 8617 relevant lines covered (89.3%)

3.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.31
src/main/java/no/einnsyn/backend/common/search/SearchService.java
1
package no.einnsyn.backend.common.search;
2

3
import co.elastic.clients.elasticsearch.ElasticsearchClient;
4
import co.elastic.clients.elasticsearch._types.ElasticsearchException;
5
import co.elastic.clients.elasticsearch._types.FieldValue;
6
import co.elastic.clients.elasticsearch._types.SearchType;
7
import co.elastic.clients.elasticsearch._types.SortOptions;
8
import co.elastic.clients.elasticsearch._types.SortOrder;
9
import co.elastic.clients.elasticsearch._types.query_dsl.FunctionBoostMode;
10
import co.elastic.clients.elasticsearch._types.query_dsl.FunctionScore;
11
import co.elastic.clients.elasticsearch._types.query_dsl.FunctionScoreQuery;
12
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
13
import co.elastic.clients.elasticsearch.core.SearchRequest;
14
import co.elastic.clients.json.JsonpUtils;
15
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
16
import com.fasterxml.jackson.databind.node.ObjectNode;
17
import jakarta.servlet.http.HttpServletRequest;
18
import java.io.IOException;
19
import java.io.StringReader;
20
import java.nio.charset.StandardCharsets;
21
import java.security.MessageDigest;
22
import java.security.NoSuchAlgorithmException;
23
import java.util.ArrayList;
24
import java.util.HashSet;
25
import java.util.HexFormat;
26
import java.util.List;
27
import java.util.Objects;
28
import lombok.extern.slf4j.Slf4j;
29
import no.einnsyn.backend.common.exceptions.models.BadRequestException;
30
import no.einnsyn.backend.common.exceptions.models.EInnsynException;
31
import no.einnsyn.backend.common.exceptions.models.InternalServerErrorException;
32
import no.einnsyn.backend.common.queryparameters.models.GetParameters;
33
import no.einnsyn.backend.common.responses.models.PaginatedList;
34
import no.einnsyn.backend.common.search.models.SearchParameters;
35
import no.einnsyn.backend.entities.base.models.BaseDTO;
36
import no.einnsyn.backend.entities.journalpost.JournalpostService;
37
import no.einnsyn.backend.entities.moetemappe.MoetemappeService;
38
import no.einnsyn.backend.entities.moetesak.MoetesakService;
39
import no.einnsyn.backend.entities.saksmappe.SaksmappeService;
40
import no.einnsyn.backend.utils.id.IdUtils;
41
import org.springframework.beans.factory.annotation.Value;
42
import org.springframework.stereotype.Service;
43
import org.springframework.transaction.annotation.Transactional;
44
import org.springframework.util.CollectionUtils;
45
import org.springframework.util.StringUtils;
46
import org.springframework.web.util.UriComponentsBuilder;
47

48
@Slf4j
3✔
49
@Service
50
public class SearchService {
51

52
  private final ElasticsearchClient esClient;
53
  private final JournalpostService journalpostService;
54
  private final SaksmappeService saksmappeService;
55
  private final MoetemappeService moetemappeService;
56
  private final MoetesakService moetesakService;
57
  private final SearchQueryService searchQueryService;
58
  private final HttpServletRequest request;
59

60
  @Value("${application.elasticsearch.index}")
61
  private String elasticsearchIndex;
62

63
  @Value("${application.defaultSearchResults:25}")
64
  private int defaultSearchLimit;
65

66
  private static final HexFormat HEX_FORMAT = HexFormat.of();
2✔
67
  private static final String SORT_BY_SCORE = "score";
68
  private static final String RECENT_DOCUMENT_BOOST_STRING =
69
      """
70
      {
71
        "gauss": {
72
          "publisertDato": {
73
            "origin": "now",
74
            "scale": "365d",
75
            "decay": 0.5
76
          }
77
        }
78
      }
79
      """;
80
  private static final FunctionScore RECENT_DOCUMENT_BOOST_FUNCTION =
2✔
81
      FunctionScore.of(f -> f.withJson(new StringReader(RECENT_DOCUMENT_BOOST_STRING)));
10✔
82

83
  /**
84
   * Elasticsearch search_type. Intended for tests to avoid shard-local IDF differences causing
85
   * flaky score ordering.
86
   */
87
  @Value("${application.elasticsearch.searchType:query_then_fetch}")
88
  private String defaultSearchType;
89

90
  public SearchService(
91
      ElasticsearchClient esClient,
92
      JournalpostService journalpostService,
93
      SaksmappeService saksmappeService,
94
      MoetemappeService moetemappeService,
95
      MoetesakService moetesakService,
96
      SearchQueryService searchQueryService,
97
      HttpServletRequest request) {
2✔
98
    this.esClient = esClient;
3✔
99
    this.journalpostService = journalpostService;
3✔
100
    this.saksmappeService = saksmappeService;
3✔
101
    this.moetemappeService = moetemappeService;
3✔
102
    this.moetesakService = moetesakService;
3✔
103
    this.searchQueryService = searchQueryService;
3✔
104
    this.request = request;
3✔
105
  }
1✔
106

107
  /**
108
   * Search for documents matching the given search parameters.
109
   *
110
   * @param searchParams the search parameters
111
   * @return a paginated list of matching documents
112
   * @throws EInnsynException if an error occurs during search
113
   */
114
  @Transactional(readOnly = true)
115
  public PaginatedList<BaseDTO> search(SearchParameters searchParams) throws EInnsynException {
116
    var esSearchRequest = getSearchRequest(searchParams);
4✔
117
    try {
118
      log.debug("search() request: {}", esSearchRequest.toString());
5✔
119
      var esResponse = esClient.search(esSearchRequest, ObjectNode.class);
6✔
120
      log.debug("search() response: {}", esResponse.toString());
5✔
121

122
      var responseList = esResponse.hits().hits();
4✔
123
      if (responseList.size() == 0) {
3✔
124
        return new PaginatedList<BaseDTO>(new ArrayList<BaseDTO>());
7✔
125
      }
126

127
      var startingAfter = searchParams.getStartingAfter();
3✔
128
      var endingBefore = searchParams.getEndingBefore();
3✔
129
      var limit = searchParams.getLimit() != null ? searchParams.getLimit() : defaultSearchLimit;
8!
130
      var hasNext = false;
2✔
131
      var hasPrevious = false;
2✔
132
      var uri = request.getRequestURI();
4✔
133
      var queryString = request.getQueryString();
4✔
134
      var uriBuilder = UriComponentsBuilder.fromUriString(uri).query(queryString);
5✔
135
      var response = new PaginatedList<BaseDTO>();
4✔
136

137
      // If startingAfter, we have a previous page.
138
      if (startingAfter != null) {
2✔
139
        hasPrevious = true;
2✔
140
        if (responseList.size() > limit) {
4✔
141
          hasNext = true;
2✔
142
          responseList = responseList.subList(0, limit);
6✔
143
        }
144
      }
145

146
      // If ending before, we need to reverse the list
147
      else if (endingBefore != null) {
2✔
148
        hasNext = true;
2✔
149
        if (responseList.size() > limit) {
4✔
150
          hasPrevious = true;
2✔
151
          responseList = responseList.subList(0, limit);
5✔
152
        }
153
        responseList = responseList.reversed();
4✔
154
      }
155

156
      // If neither startingAfter nor endingBefore, but more results than limit, we have a next page
157
      else if (responseList.size() > limit) {
4✔
158
        hasNext = true;
2✔
159
        responseList = responseList.subList(0, limit);
5✔
160
      }
161

162
      if (hasNext) {
2✔
163
        var lastHit = responseList.getLast();
4✔
164
        var startingAfterParam = lastHit.sort().stream().map(FieldValue::_toJsonString).toList();
7✔
165
        uriBuilder.replaceQueryParam("endingBefore");
6✔
166
        uriBuilder.replaceQueryParam("startingAfter");
6✔
167
        uriBuilder.replaceQueryParam("startingAfter", startingAfterParam);
5✔
168
        response.setNext(uriBuilder.build().toString());
5✔
169
      }
170

171
      if (hasPrevious) {
2✔
172
        var firstHit = responseList.getFirst();
4✔
173
        var endingBeforeParam = firstHit.sort().stream().map(FieldValue::_toJsonString).toList();
7✔
174
        uriBuilder.replaceQueryParam("startingAfter");
6✔
175
        uriBuilder.replaceQueryParam("endingBefore", endingBeforeParam);
5✔
176
        response.setPrevious(uriBuilder.build().toString());
5✔
177
      }
178

179
      // Prepare paths to expand
180
      var expandPaths =
181
          searchParams.getExpand() != null
3✔
182
              ? new HashSet<String>(searchParams.getExpand())
6✔
183
              : new HashSet<String>();
4✔
184

185
      // Loop through the hits and convert them to SearchResultItems
186
      var searchResultItemList =
1✔
187
          responseList.stream()
4✔
188
              .map(
2✔
189
                  node -> {
190
                    var id = node.id();
3✔
191
                    var entity = IdUtils.resolveEntity(id);
3✔
192

193
                    // Create a query object for the expand paths
194
                    var query = new GetParameters();
4✔
195
                    query.setExpand(new ArrayList<String>(expandPaths));
6✔
196

197
                    // Get the DTO object from the database
198
                    try {
199
                      return switch (entity) {
37!
200
                        case "Journalpost" -> journalpostService.get(id, query);
7✔
201
                        case "Saksmappe" -> saksmappeService.get(id, query);
7✔
202
                        case "Moetemappe" -> moetemappeService.get(id, query);
7✔
203
                        case "Moetesak" -> moetesakService.get(id, query);
7✔
204
                        default -> {
205
                          log.warn("Found document in elasticsearch with unknown type: " + id);
×
206
                          yield (BaseDTO) null;
×
207
                        }
208
                      };
209
                    } catch (EInnsynException e) {
×
210
                      log.warn("Found non-existing object in elasticsearch: " + id);
×
211
                      return (BaseDTO) null;
×
212
                    }
213
                  })
214
              .filter(Objects::nonNull)
1✔
215
              .toList();
2✔
216

217
      response.setItems(searchResultItemList);
3✔
218
      return response;
2✔
219
    } catch (ElasticsearchException e) {
×
220
      log.error(e.response().toString());
×
221
      throw new InternalServerErrorException("Elasticsearch error", e);
×
222
    } catch (IOException e) {
×
223
      throw new InternalServerErrorException("Elasticsearch IOException", e);
×
224
    }
225
  }
226

227
  SearchRequest getSearchRequest(SearchParameters searchParams) throws EInnsynException {
228
    var sortBy = searchParams.getSortBy() != null ? searchParams.getSortBy() : SORT_BY_SCORE;
7!
229
    var sortByScore = isSortByScore(searchParams);
4✔
230
    var boolQuery = searchQueryService.getQueryBuilder(searchParams).build();
6✔
231
    var query =
232
        sortByScore
2✔
233
            ? FunctionScoreQuery.of(
1✔
234
                    f ->
235
                        f.query(boolQuery._toQuery())
6✔
236
                            .boostMode(FunctionBoostMode.Multiply)
4✔
237
                            .functions(RECENT_DOCUMENT_BOOST_FUNCTION))
1✔
238
                ._toQuery()
4✔
239
            : boolQuery._toQuery();
3✔
240

241
    var searchRequestBuilder = new SearchRequest.Builder();
4✔
242

243
    // Add the query to our search source
244
    searchRequestBuilder.query(query);
4✔
245

246
    // Sort the results by searchParams.sortBy and searchParams.sortDirection
247
    var sortOrder = searchParams.getSortOrder();
3✔
248

249
    // If the request doesn't include a scoring query, sorting by score is meaningless (all docs
250
    // will get the same score). Fall back to publisertDato so results are ordered chronologically
251
    // rather than grouped by entity type (which happens with id sort due to entity prefixes).
252
    if (SORT_BY_SCORE.equals(sortBy) && !sortByScore) {
6✔
253
      sortBy = "publisertDato";
2✔
254
    }
255

256
    // Ensure correct sort order
257
    if (SORT_BY_SCORE.equals(sortBy)) {
4✔
258
      // Add preference hash to hit the same shard
259
      searchRequestBuilder.preference(hashQuery(query));
6✔
260
      if ("dfs_query_then_fetch".equals(defaultSearchType)) {
5✔
261
        searchRequestBuilder.searchType(SearchType.DfsQueryThenFetch);
4✔
262
      }
263
    }
264

265
    // Limit the number of results
266
    var size = searchParams.getLimit() != null ? searchParams.getLimit() : defaultSearchLimit;
8!
267
    // Get one more than the limit to check if there are more results left to paginate
268
    searchRequestBuilder.size(size + 1);
7✔
269
    searchRequestBuilder.index(elasticsearchIndex);
7✔
270

271
    // Add searchAfter for pagination
272
    var startingAfter = searchParams.getStartingAfter();
3✔
273
    var endingBefore = searchParams.getEndingBefore();
3✔
274
    if (startingAfter != null && !startingAfter.isEmpty()) {
5!
275
      var fieldValue = SortByMapper.toFieldValue(sortBy, startingAfter.get(0));
7✔
276
      if (fieldValue == null) {
2!
277
        throw new BadRequestException("Invalid startingAfter value: " + startingAfter.get(0));
×
278
      }
279
      var fieldValueList = List.of(fieldValue, FieldValue.of(startingAfter.get(1)));
8✔
280
      searchRequestBuilder.searchAfter(fieldValueList);
4✔
281
    }
1✔
282

283
    // We need to reverse the list in order to get endingBefore. Elasticsearch only supports
284
    // searchAfter
285
    else if (endingBefore != null && !endingBefore.isEmpty()) {
5!
286
      var fieldValueList =
3✔
287
          List.of(
2✔
288
              SortByMapper.toFieldValue(sortBy, endingBefore.get(0)),
5✔
289
              FieldValue.of(endingBefore.get(1)));
3✔
290
      searchRequestBuilder.searchAfter(fieldValueList);
4✔
291
      // Reverse sort order (the reverse it again when returning the result)
292
      if ("desc".equalsIgnoreCase(sortOrder)) {
4✔
293
        sortOrder = "asc";
3✔
294
      } else {
295
        sortOrder = "desc";
2✔
296
      }
297
    }
298

299
    searchRequestBuilder.sort(getSortOptions(sortBy, sortOrder));
9✔
300
    searchRequestBuilder.sort(getSortOptions("id", sortOrder));
9✔
301

302
    // We only need the ID of each match, so don't fetch sources
303
    searchRequestBuilder.source(b -> b.fetch(false));
9✔
304

305
    // We don't need total hits for pagination
306
    searchRequestBuilder.trackTotalHits(track -> track.enabled(false));
9✔
307

308
    return searchRequestBuilder.build();
3✔
309
  }
310

311
  private boolean isSortByScore(SearchParameters searchParams) {
312
    return SORT_BY_SCORE.equals(searchParams.getSortBy())
7✔
313
        && (StringUtils.hasText(searchParams.getQuery())
4✔
314
            || !CollectionUtils.isEmpty(searchParams.getKorrespondansepartNavn())
4✔
315
            || !CollectionUtils.isEmpty(searchParams.getTittel())
4✔
316
            || !CollectionUtils.isEmpty(searchParams.getSkjermingshjemmel()));
6✔
317
  }
318

319
  SortOptions getSortOptions(String sortBy, String sortOrder) {
320
    var sort = SortByMapper.resolve(sortBy);
3✔
321
    var order = "desc".equalsIgnoreCase(sortOrder) ? SortOrder.Desc : SortOrder.Asc;
8✔
322
    return SortOptions.of(
5✔
323
        b ->
324
            b.field(
6✔
325
                f -> {
326
                  f.field(sort);
4✔
327
                  f.order(order);
4✔
328
                  // .missing can't be added to built-in fields like _score
329
                  if (sort != null && !sort.startsWith("_")) {
6!
330
                    f.missing("_last");
4✔
331
                  }
332
                  return f;
2✔
333
                }));
334
  }
335

336
  /**
337
   * Creates a hash of the query string to use as preference for consistent sorting.
338
   *
339
   * @param query the Elasticsearch query
340
   * @return hashed query string
341
   */
342
  private String hashQuery(Query query) {
343
    var jsonString = JsonpUtils.toJsonString(query, new JacksonJsonpMapper());
6✔
344
    try {
345
      var digest = MessageDigest.getInstance("SHA-256");
3✔
346
      var hash = digest.digest(jsonString.getBytes(StandardCharsets.UTF_8));
6✔
347
      return HEX_FORMAT.formatHex(hash);
4✔
348
    } catch (NoSuchAlgorithmException e) {
×
349
      log.warn("SHA-256 algorithm not available, using query toString as preference", e);
×
350
      return query.toString();
×
351
    }
352
  }
353
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc