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

felleslosninger / einnsyn-backend / 21714342732

05 Feb 2026 01:59PM UTC coverage: 83.688% (+0.4%) from 83.243%
21714342732

push

github

web-flow
Merge pull request #559 from felleslosninger/EIN-4795-optimere-sok-gne

EIN-4795: Custom search query parser with loose / exact weighting

2444 of 3344 branches covered (73.09%)

Branch coverage included in aggregate %.

7453 of 8482 relevant lines covered (87.87%)

3.67 hits per line

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

92.61
src/main/java/no/einnsyn/backend/common/search/SearchQueryService.java
1
package no.einnsyn.backend.common.search;
2

3
import co.elastic.clients.elasticsearch._types.FieldValue;
4
import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery;
5
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
6
import co.elastic.clients.elasticsearch._types.query_dsl.RangeQuery;
7
import co.elastic.clients.elasticsearch._types.query_dsl.TermQuery;
8
import co.elastic.clients.elasticsearch._types.query_dsl.TermsQuery;
9
import co.elastic.clients.elasticsearch._types.query_dsl.TermsQueryField;
10
import java.time.LocalDate;
11
import java.time.LocalDateTime;
12
import java.time.ZoneId;
13
import java.time.ZonedDateTime;
14
import java.time.format.DateTimeFormatter;
15
import java.time.format.DateTimeParseException;
16
import java.time.temporal.ChronoUnit;
17
import java.util.ArrayList;
18
import java.util.List;
19
import no.einnsyn.backend.authentication.AuthenticationService;
20
import no.einnsyn.backend.common.exceptions.models.BadRequestException;
21
import no.einnsyn.backend.common.exceptions.models.EInnsynException;
22
import no.einnsyn.backend.common.queryparameters.models.FilterParameters;
23
import no.einnsyn.backend.entities.enhet.EnhetService;
24
import org.springframework.stereotype.Service;
25
import org.springframework.util.StringUtils;
26

27
@Service
28
@SuppressWarnings("java:S1192") // Allow string literals
29
public class SearchQueryService {
30

31
  public enum DateBoundary {
3✔
32
    NONE,
6✔
33
    START_OF_DAY,
6✔
34
    END_OF_DAY
6✔
35
  }
36

37
  private static final List<String> allowedEntities =
4✔
38
      List.of("Journalpost", "Saksmappe", "Moetemappe", "Moetesak");
2✔
39
  public static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
2✔
40
  private static final ZoneId NORWEGIAN_ZONE = ZoneId.of("Europe/Oslo");
4✔
41

42
  private final AuthenticationService authenticationService;
43
  private final EnhetService enhetService;
44

45
  public SearchQueryService(
46
      AuthenticationService authenticationService, EnhetService enhetService) {
2✔
47
    this.authenticationService = authenticationService;
3✔
48
    this.enhetService = enhetService;
3✔
49
  }
1✔
50

51
  private String toIsoDateTime(String dateString, DateBoundary boundary) {
52
    if (dateString == null) {
2!
53
      return null;
×
54
    }
55

56
    ZonedDateTime zonedDateTime;
57

58
    // DateTime
59
    if (dateString.contains("T")) {
4✔
60
      // Try parsing zoned first; if no zone/offset is present, assume system default zone
61
      try {
62
        zonedDateTime = ZonedDateTime.parse(dateString);
3✔
63
      } catch (DateTimeParseException e) {
×
64
        var localDateTime = LocalDateTime.parse(dateString);
×
65
        zonedDateTime = localDateTime.atZone(NORWEGIAN_ZONE);
×
66
      }
1✔
67
    }
68

69
    // Date (no timestamp)
70
    else {
71
      zonedDateTime = LocalDate.parse(dateString).atStartOfDay(NORWEGIAN_ZONE);
5✔
72
      // Adjust to start or end of day if needed
73
      zonedDateTime =
1✔
74
          switch (boundary) {
3!
75
            case START_OF_DAY -> zonedDateTime.withHour(0).withMinute(0).withSecond(0).withNano(0);
×
76
            case END_OF_DAY ->
77
                zonedDateTime.withHour(23).withMinute(59).withSecond(59).withNano(999_999_999);
10✔
78
            case NONE -> zonedDateTime;
1✔
79
          };
80
    }
81

82
    return zonedDateTime.format(FORMATTER);
4✔
83
  }
84

85
  /**
86
   * Resolve IDs from identifiers like orgnummer, email, ...
87
   *
88
   * @param enhetIdentifiers the list of identifiers to resolve
89
   * @return the list of resolved Enhet IDs
90
   * @throws BadRequestException if an Enhet is not found
91
   */
92
  private List<String> resolveEnhetIds(List<String> enhetIdentifiers) throws BadRequestException {
93
    var enhetIds = new ArrayList<String>(enhetIdentifiers.size());
6✔
94
    for (var identifier : enhetIdentifiers) {
10✔
95
      var enhetId = enhetService.resolveId(identifier);
5✔
96
      if (enhetId == null) {
2!
97
        throw new BadRequestException("Enhet not found: " + identifier);
×
98
      }
99
      enhetIds.add(enhetId);
4✔
100
    }
1✔
101
    return enhetIds;
2✔
102
  }
103

104
  /**
105
   * Adds a filter to the bool query.
106
   *
107
   * @param bqb the bool query builder
108
   * @param propertyName the name of the property to filter on
109
   * @param list the list of values to filter by
110
   */
111
  private void addFilter(BoolQuery.Builder bqb, String propertyName, List<String> list) {
112
    if (list != null && !list.isEmpty()) {
5!
113
      var fieldValueList = list.stream().map(FieldValue::of).toList();
6✔
114
      bqb.filter(
6✔
115
          TermsQuery.of(tqb -> tqb.field(propertyName).terms(tqfb -> tqfb.value(fieldValueList)))
12✔
116
              ._toQuery());
3✔
117
    }
118
  }
1✔
119

120
  /**
121
   * Adds a must-not clause to the bool query.
122
   *
123
   * @param bqb the bool query builder
124
   * @param propertyName the name of the property to exclude
125
   * @param list the list of values to exclude
126
   */
127
  private void addMustNot(BoolQuery.Builder bqb, String propertyName, List<String> list) {
128
    if (list != null && !list.isEmpty()) {
5!
129
      var fieldValueList = list.stream().map(FieldValue::of).toList();
6✔
130
      bqb.mustNot(
6✔
131
          TermsQuery.of(tqb -> tqb.field(propertyName).terms(tqfb -> tqfb.value(fieldValueList)))
12✔
132
              ._toQuery());
3✔
133
    }
134
  }
1✔
135

136
  /**
137
   * Build a ES Query from the given search parameters.
138
   *
139
   * @param filterParameters the filter parameters
140
   * @return the bool query builder
141
   * @throws EInnsynException if an error occurs
142
   */
143
  public BoolQuery.Builder getQueryBuilder(FilterParameters filterParameters)
144
      throws EInnsynException {
145
    return getQueryBuilder(filterParameters, false);
5✔
146
  }
147

148
  /**
149
   * Build a ES Query from the given search parameters.
150
   *
151
   * @param filterParameters the filter parameters
152
   * @param uncensored whether to exclude sensitive fields or not
153
   * @return the bool query builder
154
   * @throws EInnsynException if an error occurs
155
   */
156
  public BoolQuery.Builder getQueryBuilder(FilterParameters filterParameters, boolean uncensored)
157
      throws EInnsynException {
158
    var rootBoolQueryBuilder = new BoolQuery.Builder();
4✔
159

160
    // Filter by entity. We don't want unexpected entities (Innsynskrav, Downloads, ...), so we'll
161
    // always filter by entities.
162
    if (filterParameters.getEntity() != null) {
3✔
163
      addFilter(rootBoolQueryBuilder, "type", filterParameters.getEntity());
7✔
164
    } else {
165
      addFilter(rootBoolQueryBuilder, "type", allowedEntities);
5✔
166
    }
167

168
    // Exclude hidden enhets and unaccessible documents
169
    if (!uncensored) {
2✔
170
      var authenticatedEnhetId = authenticationService.getEnhetId();
4✔
171
      var authenticatedSubtreeIdList = enhetService.getSubtreeIdList(authenticatedEnhetId);
5✔
172

173
      // Filter hidden enhets that the user is not authenticated for
174
      var hiddenEnhetList = enhetService.findHidden();
4✔
175
      var hiddenIdList =
1✔
176
          hiddenEnhetList.stream()
2✔
177
              .map(e -> e.getId())
3✔
178
              .filter(e -> !authenticatedSubtreeIdList.contains(e))
1!
179
              .toList();
2✔
180
      if (!hiddenIdList.isEmpty()) {
3!
181
        addMustNot(rootBoolQueryBuilder, "administrativEnhetTransitive", hiddenIdList);
×
182
      }
183
    }
184

185
    // Exclude unaccessible documents
186
    if (!uncensored) {
2✔
187
      var authenticatedEnhetId = authenticationService.getEnhetId();
4✔
188
      var accessibleAfterBoolQueryBuilder = new BoolQuery.Builder();
4✔
189
      accessibleAfterBoolQueryBuilder.minimumShouldMatch("1");
4✔
190

191
      // Allow documents with a valid accessibleAfter
192
      accessibleAfterBoolQueryBuilder.should(
4✔
193
          RangeQuery.of(r -> r.date(d -> d.field("accessibleAfter").lte("now")))._toQuery());
15✔
194

195
      // If logged in, allow documents with a valid administrativEnhet
196
      if (authenticatedEnhetId != null) {
2✔
197
        var authenticatedEnhetFieldValues = List.of(FieldValue.of(authenticatedEnhetId));
4✔
198
        accessibleAfterBoolQueryBuilder.should(
7✔
199
            new TermsQuery.Builder()
200
                .field("administrativEnhetTransitive")
5✔
201
                .terms(new TermsQueryField.Builder().value(authenticatedEnhetFieldValues).build())
4✔
202
                .build()
1✔
203
                ._toQuery());
3✔
204
      }
205

206
      rootBoolQueryBuilder.filter(accessibleAfterBoolQueryBuilder.build()._toQuery());
8✔
207
    }
208

209
    // Filter by search query
210
    var queryString = filterParameters.getQuery();
3✔
211
    if (StringUtils.hasText(queryString)) {
3✔
212
      rootBoolQueryBuilder.must(
3✔
213
          uncensored
2✔
214
              ? getSearchStringQuery(
8✔
215
                  queryString,
216
                  List.of(
3✔
217
                      "search_id",
218
                      "search_innhold",
219
                      "search_innhold_SENSITIV",
220
                      "search_tittel^3",
221
                      "search_tittel_SENSITIV^3"),
222
                  3.0f,
223
                  1.0f)
224
              : getSearchStringQuery(
7✔
225
                  queryString,
226
                  List.of("search_id", "search_innhold_SENSITIV", "search_tittel_SENSITIV^3"),
4✔
227
                  List.of("search_id", "search_innhold", "search_tittel^3"),
3✔
228
                  3.0f,
229
                  2.0f));
230
    }
231

232
    // Filter by tittel
233
    if (filterParameters.getTittel() != null) {
3✔
234
      for (var tittel : filterParameters.getTittel()) {
11✔
235
        if (StringUtils.hasText(tittel)) {
3!
236
          rootBoolQueryBuilder.filter(
3✔
237
              uncensored
2!
238
                  ? getSearchStringQuery(tittel, List.of("search_tittel", "search_tittel_SENSITIV"))
×
239
                  : getSearchStringQuery(
5✔
240
                      tittel, List.of("search_tittel_SENSITIV"), List.of("search_tittel")));
3✔
241
        }
242
      }
1✔
243
    }
244

245
    // Filter by skjermingshjemmel
246
    if (filterParameters.getSkjermingshjemmel() != null) {
3✔
247
      for (var skjermingshjemmel : filterParameters.getSkjermingshjemmel()) {
11✔
248
        if (StringUtils.hasText(skjermingshjemmel)) {
3!
249
          rootBoolQueryBuilder.filter(
5✔
250
              getSearchStringQuery(skjermingshjemmel, List.of("skjerming.skjermingshjemmel")));
4✔
251
        }
252
      }
1✔
253
    }
254

255
    // Filter by korrespondansepartNavn
256
    if (filterParameters.getKorrespondansepartNavn() != null) {
3✔
257
      for (var korrespondansepartNavn : filterParameters.getKorrespondansepartNavn()) {
11✔
258
        if (StringUtils.hasText(korrespondansepartNavn)) {
3!
259
          rootBoolQueryBuilder.filter(
5✔
260
              getSearchStringQuery(
3✔
261
                  korrespondansepartNavn,
262
                  List.of("korrespondansepart.korrespondansepartNavn_SENSITIV"),
2✔
263
                  List.of("korrespondansepart.korrespondansepartNavn")));
1✔
264
        }
265
      }
1✔
266
    }
267

268
    // Filter by saksaar
269
    addFilter(rootBoolQueryBuilder, "saksaar", filterParameters.getSaksaar());
6✔
270

271
    // Filter by sakssekvensnummer
272
    addFilter(rootBoolQueryBuilder, "sakssekvensnummer", filterParameters.getSakssekvensnummer());
6✔
273

274
    // Filter by saksnummer
275
    addFilter(rootBoolQueryBuilder, "saksnummer", filterParameters.getSaksnummer());
6✔
276

277
    // Filter by journalpostnummer
278
    addFilter(rootBoolQueryBuilder, "journalpostnummer", filterParameters.getJournalpostnummer());
6✔
279

280
    // Filter by journalsekvensnummer
281
    addFilter(
5✔
282
        rootBoolQueryBuilder, "journalsekvensnummer", filterParameters.getJournalsekvensnummer());
1✔
283

284
    // Filter by moetesaksaar
285
    addFilter(rootBoolQueryBuilder, "møtesaksår", filterParameters.getMoetesaksaar());
6✔
286

287
    // Filter by moetesakssekvensnummer
288
    addFilter(
5✔
289
        rootBoolQueryBuilder,
290
        "møtesakssekvensnummer",
291
        filterParameters.getMoetesakssekvensnummer());
1✔
292

293
    // Matches against administrativEnhet or children
294
    if (filterParameters.getAdministrativEnhet() != null) {
3✔
295
      var enhetList = resolveEnhetIds(filterParameters.getAdministrativEnhet());
5✔
296
      addFilter(rootBoolQueryBuilder, "administrativEnhetTransitive", enhetList);
5✔
297
    }
298

299
    // Exact matches against administrativEnhet
300
    if (filterParameters.getAdministrativEnhetExact() != null) {
3✔
301
      var enhetList = resolveEnhetIds(filterParameters.getAdministrativEnhetExact());
5✔
302
      addFilter(rootBoolQueryBuilder, "administrativEnhet", enhetList);
5✔
303
    }
304

305
    // Exclude documents from given administrativEnhet or children
306
    if (filterParameters.getExcludeAdministrativEnhet() != null) {
3✔
307
      var enhetList = resolveEnhetIds(filterParameters.getExcludeAdministrativEnhet());
5✔
308
      addMustNot(rootBoolQueryBuilder, "administrativEnhetTransitive", enhetList);
5✔
309
    }
310

311
    // Exclude documents from given administrativEnhet
312
    if (filterParameters.getExcludeAdministrativEnhetExact() != null) {
3✔
313
      var enhetList = resolveEnhetIds(filterParameters.getExcludeAdministrativEnhetExact());
5✔
314
      addMustNot(rootBoolQueryBuilder, "administrativEnhet", enhetList);
5✔
315
    }
316

317
    // Filter by publisertDatoTo
318
    if (filterParameters.getPublisertDatoTo() != null) {
3✔
319
      var date = toIsoDateTime(filterParameters.getPublisertDatoTo(), DateBoundary.END_OF_DAY);
6✔
320
      rootBoolQueryBuilder.filter(
5✔
321
          RangeQuery.of(r -> r.date(d -> d.field("publisertDato").lte(date)))._toQuery());
16✔
322
    }
323

324
    // Filter by publisertDatoFrom
325
    if (filterParameters.getPublisertDatoFrom() != null) {
3✔
326
      var date = toIsoDateTime(filterParameters.getPublisertDatoFrom(), DateBoundary.NONE);
6✔
327
      rootBoolQueryBuilder.filter(
5✔
328
          RangeQuery.of(r -> r.date(d -> d.field("publisertDato").gte(date)))._toQuery());
16✔
329
    }
330

331
    // Filter by oppdatertDatoTo
332
    if (filterParameters.getOppdatertDatoTo() != null) {
3✔
333
      var date = toIsoDateTime(filterParameters.getOppdatertDatoTo(), DateBoundary.END_OF_DAY);
6✔
334
      rootBoolQueryBuilder.filter(
5✔
335
          RangeQuery.of(r -> r.date(d -> d.field("oppdatertDato").lte(date)))._toQuery());
16✔
336
    }
337

338
    // Filter by oppdatertDatoFrom
339
    if (filterParameters.getOppdatertDatoFrom() != null) {
3✔
340
      var date = toIsoDateTime(filterParameters.getOppdatertDatoFrom(), DateBoundary.NONE);
6✔
341
      rootBoolQueryBuilder.filter(
5✔
342
          RangeQuery.of(r -> r.date(d -> d.field("oppdatertDato").gte(date)))._toQuery());
16✔
343
    }
344

345
    // Filter by dokumentetsDatoTo
346
    if (filterParameters.getDokumentetsDatoTo() != null) {
3✔
347
      var date = toIsoDateTime(filterParameters.getDokumentetsDatoTo(), DateBoundary.END_OF_DAY);
6✔
348
      rootBoolQueryBuilder.filter(
5✔
349
          RangeQuery.of(r -> r.date(d -> d.field("dokumentetsDato").lte(date)))._toQuery());
16✔
350
    }
351

352
    // Filter by dokumentetsDatoFrom
353
    if (filterParameters.getDokumentetsDatoFrom() != null) {
3✔
354
      var date = toIsoDateTime(filterParameters.getDokumentetsDatoFrom(), DateBoundary.NONE);
6✔
355
      rootBoolQueryBuilder.filter(
5✔
356
          RangeQuery.of(r -> r.date(d -> d.field("dokumentetsDato").gte(date)))._toQuery());
16✔
357
    }
358

359
    // Filter by journaldatoTo
360
    if (filterParameters.getJournaldatoTo() != null) {
3✔
361
      var date = toIsoDateTime(filterParameters.getJournaldatoTo(), DateBoundary.END_OF_DAY);
6✔
362
      rootBoolQueryBuilder.filter(
5✔
363
          RangeQuery.of(r -> r.date(d -> d.field("journaldato").lte(date)))._toQuery());
16✔
364
    }
365

366
    // Filter by journaldatoFrom
367
    if (filterParameters.getJournaldatoFrom() != null) {
3✔
368
      var date = toIsoDateTime(filterParameters.getJournaldatoFrom(), DateBoundary.NONE);
6✔
369
      rootBoolQueryBuilder.filter(
5✔
370
          RangeQuery.of(r -> r.date(d -> d.field("journaldato").gte(date)))._toQuery());
16✔
371
    }
372

373
    // Filter by moetedatoTo
374
    if (filterParameters.getMoetedatoTo() != null) {
3✔
375
      var date = toIsoDateTime(filterParameters.getMoetedatoTo(), DateBoundary.END_OF_DAY);
6✔
376
      rootBoolQueryBuilder.filter(
5✔
377
          RangeQuery.of(r -> r.date(d -> d.field("moetedato").lte(date)))._toQuery());
16✔
378
    }
379

380
    // Filter by moetedatoFrom
381
    if (filterParameters.getMoetedatoFrom() != null) {
3✔
382
      var date = toIsoDateTime(filterParameters.getMoetedatoFrom(), DateBoundary.NONE);
6✔
383
      rootBoolQueryBuilder.filter(
5✔
384
          RangeQuery.of(r -> r.date(d -> d.field("moetedato").gte(date)))._toQuery());
16✔
385
    }
386

387
    // Filter by fulltext
388
    if (filterParameters.getFulltext() != null) {
3✔
389
      rootBoolQueryBuilder.filter(
5✔
390
          TermQuery.of(tqb -> tqb.field("fulltext").value(filterParameters.getFulltext()))
9✔
391
              ._toQuery());
3✔
392
    }
393

394
    // Filter by journalposttype
395
    if (filterParameters.getJournalposttype() != null) {
3✔
396
      addFilter(rootBoolQueryBuilder, "journalposttype", filterParameters.getJournalposttype());
6✔
397
    }
398

399
    // Get specific IDs
400
    addFilter(rootBoolQueryBuilder, "id", filterParameters.getIds());
6✔
401

402
    return rootBoolQueryBuilder;
2✔
403
  }
404

405
  /**
406
   * /** Get a sensitive query that handles uncensored/censored searches.
407
   *
408
   * @param queryString the query string to search for
409
   * @param sensitiveFields the list of sensitive fields
410
   * @param nonSensitiveFields the list of non-sensitive fields
411
   * @return the constructed query
412
   */
413
  private static Query getSearchStringQuery(
414
      String queryString, List<String> sensitiveFields, List<String> nonSensitiveFields) {
415
    return getSearchStringQuery(queryString, sensitiveFields, nonSensitiveFields, 1.0f, 1.0f);
7✔
416
  }
417

418
  /**
419
   * Get a sensitive query that handles uncensored/censored searches.
420
   *
421
   * @param queryString the search query string
422
   * @param sensitiveFields the list of sensitive field names to search in
423
   * @param nonSensitiveFields the list of non-sensitive field names to search in
424
   * @param exactBoost the boost factor for exact matches
425
   * @param looseBoost the boost factor for loose matches
426
   * @return the constructed query
427
   */
428
  private static Query getSearchStringQuery(
429
      String queryString,
430
      List<String> sensitiveFields,
431
      List<String> nonSensitiveFields,
432
      float exactBoost,
433
      float looseBoost) {
434
    var boolQueryBuilder = new BoolQuery.Builder();
4✔
435
    boolQueryBuilder.minimumShouldMatch("1");
4✔
436

437
    // Match non-sensitive fields for all documents
438
    boolQueryBuilder.should(
7✔
439
        getSearchStringQuery(queryString, nonSensitiveFields, exactBoost, looseBoost));
3✔
440

441
    // Match sensitive fields for documents from the past year only
442
    // Round to start of day to ensure consistent query hashing for preference-based shard routing
443
    var lastYear =
1✔
444
        ZonedDateTime.now(NORWEGIAN_ZONE)
2✔
445
            .truncatedTo(ChronoUnit.DAYS)
2✔
446
            .minusYears(1)
2✔
447
            .format(FORMATTER);
2✔
448
    var gteLastYear = RangeQuery.of(r -> r.date(d -> d.field("publisertDato").gte(lastYear)));
16✔
449
    var recentDocumentsQuery =
5✔
450
        new BoolQuery.Builder()
451
            .filter(q -> q.range(gteLastYear))
9✔
452
            .must(getSearchStringQuery(queryString, sensitiveFields, exactBoost, looseBoost))
4✔
453
            .build();
2✔
454
    boolQueryBuilder.should(b -> b.bool(recentDocumentsQuery));
9✔
455

456
    return boolQueryBuilder.build()._toQuery();
4✔
457
  }
458

459
  /**
460
   * A direct wrapper around SearchQueryParser that doesn't consider sensitive fields.
461
   *
462
   * @param searchString the search string
463
   * @param fields the fields to search in
464
   * @return the constructed query
465
   */
466
  private static Query getSearchStringQuery(String searchString, List<String> fields) {
467
    return SearchQueryParser.parse(searchString, fields);
4✔
468
  }
469

470
  /**
471
   * A direct wrapper around SearchQueryParser that doesn't consider sensitive fields.
472
   *
473
   * @param searchString the search query string
474
   * @param fields the list of field names to search in
475
   * @param exactBoost the boost factor for exact matches
476
   * @param looseBoost the boost factor for loose matches
477
   * @return the constructed query
478
   */
479
  private static Query getSearchStringQuery(
480
      String searchString, List<String> fields, float exactBoost, float looseBoost) {
481
    return SearchQueryParser.parse(searchString, fields, exactBoost, looseBoost);
6✔
482
  }
483
}
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