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

felleslosninger / einnsyn-backend / 22181605149

19 Feb 2026 12:22PM UTC coverage: 85.061% (+0.1%) from 84.953%
22181605149

push

github

web-flow
Merge pull request #599 from felleslosninger/EIN-4789-konvertering-reindeksering-av-gamle-lagret-soek-3

EIN-4789: Improve cleanup logging

2497 of 3359 branches covered (74.34%)

Branch coverage included in aggregate %.

7587 of 8496 relevant lines covered (89.3%)

3.74 hits per line

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

81.07
src/main/java/no/einnsyn/backend/entities/base/BaseService.java
1
package no.einnsyn.backend.entities.base;
2

3
import co.elastic.clients.elasticsearch.ElasticsearchClient;
4
import co.elastic.clients.elasticsearch._types.ElasticsearchException;
5
import com.google.gson.Gson;
6
import io.micrometer.core.instrument.Counter;
7
import io.micrometer.core.instrument.MeterRegistry;
8
import io.micrometer.tracing.annotation.NewSpan;
9
import jakarta.annotation.PostConstruct;
10
import jakarta.persistence.EntityManager;
11
import jakarta.servlet.http.HttpServletRequest;
12
import java.time.Instant;
13
import java.util.ArrayList;
14
import java.util.Collections;
15
import java.util.Comparator;
16
import java.util.HashSet;
17
import java.util.List;
18
import java.util.Set;
19
import lombok.Getter;
20
import lombok.Setter;
21
import lombok.extern.slf4j.Slf4j;
22
import no.einnsyn.backend.authentication.AuthenticationService;
23
import no.einnsyn.backend.common.exceptions.models.AuthorizationException;
24
import no.einnsyn.backend.common.exceptions.models.BadRequestException;
25
import no.einnsyn.backend.common.exceptions.models.ConflictException;
26
import no.einnsyn.backend.common.exceptions.models.EInnsynException;
27
import no.einnsyn.backend.common.exceptions.models.InternalServerErrorException;
28
import no.einnsyn.backend.common.exceptions.models.NotFoundException;
29
import no.einnsyn.backend.common.expandablefield.ExpandableField;
30
import no.einnsyn.backend.common.indexable.Indexable;
31
import no.einnsyn.backend.common.indexable.IndexableRepository;
32
import no.einnsyn.backend.common.paginators.Paginators;
33
import no.einnsyn.backend.common.queryparameters.models.GetParameters;
34
import no.einnsyn.backend.common.queryparameters.models.ListParameters;
35
import no.einnsyn.backend.common.responses.models.PaginatedList;
36
import no.einnsyn.backend.entities.apikey.ApiKeyService;
37
import no.einnsyn.backend.entities.arkiv.ArkivService;
38
import no.einnsyn.backend.entities.arkivdel.ArkivdelService;
39
import no.einnsyn.backend.entities.base.models.Base;
40
import no.einnsyn.backend.entities.base.models.BaseDTO;
41
import no.einnsyn.backend.entities.base.models.BaseES;
42
import no.einnsyn.backend.entities.behandlingsprotokoll.BehandlingsprotokollService;
43
import no.einnsyn.backend.entities.bruker.BrukerService;
44
import no.einnsyn.backend.entities.dokumentbeskrivelse.DokumentbeskrivelseService;
45
import no.einnsyn.backend.entities.dokumentobjekt.DokumentobjektService;
46
import no.einnsyn.backend.entities.enhet.EnhetService;
47
import no.einnsyn.backend.entities.identifikator.IdentifikatorService;
48
import no.einnsyn.backend.entities.innsynskrav.InnsynskravService;
49
import no.einnsyn.backend.entities.innsynskravbestilling.InnsynskravBestillingService;
50
import no.einnsyn.backend.entities.journalpost.JournalpostService;
51
import no.einnsyn.backend.entities.klasse.KlasseService;
52
import no.einnsyn.backend.entities.klassifikasjonssystem.KlassifikasjonssystemService;
53
import no.einnsyn.backend.entities.korrespondansepart.KorrespondansepartService;
54
import no.einnsyn.backend.entities.lagretsak.LagretSakService;
55
import no.einnsyn.backend.entities.lagretsoek.LagretSoekService;
56
import no.einnsyn.backend.entities.moetedeltaker.MoetedeltakerService;
57
import no.einnsyn.backend.entities.moetedokument.MoetedokumentService;
58
import no.einnsyn.backend.entities.moetemappe.MoetemappeService;
59
import no.einnsyn.backend.entities.moetesak.MoetesakService;
60
import no.einnsyn.backend.entities.moetesaksbeskrivelse.MoetesaksbeskrivelseService;
61
import no.einnsyn.backend.entities.saksmappe.SaksmappeService;
62
import no.einnsyn.backend.entities.skjerming.SkjermingService;
63
import no.einnsyn.backend.entities.tilbakemelding.TilbakemeldingService;
64
import no.einnsyn.backend.entities.utredning.UtredningService;
65
import no.einnsyn.backend.entities.vedtak.VedtakService;
66
import no.einnsyn.backend.entities.votering.VoteringService;
67
import no.einnsyn.backend.tasks.events.DeleteEvent;
68
import no.einnsyn.backend.tasks.events.GetEvent;
69
import no.einnsyn.backend.tasks.events.IndexEvent;
70
import no.einnsyn.backend.tasks.events.InsertEvent;
71
import no.einnsyn.backend.tasks.events.UpdateEvent;
72
import no.einnsyn.backend.tasks.handlers.index.ElasticsearchIndexQueue;
73
import no.einnsyn.backend.utils.ExpandPathResolver;
74
import no.einnsyn.backend.utils.TimeConverter;
75
import no.einnsyn.backend.utils.id.IdUtils;
76
import org.springframework.beans.factory.annotation.Autowired;
77
import org.springframework.beans.factory.annotation.Qualifier;
78
import org.springframework.beans.factory.annotation.Value;
79
import org.springframework.context.ApplicationEventPublisher;
80
import org.springframework.context.annotation.Lazy;
81
import org.springframework.data.domain.PageRequest;
82
import org.springframework.data.util.Pair;
83
import org.springframework.orm.ObjectOptimisticLockingFailureException;
84
import org.springframework.retry.annotation.Backoff;
85
import org.springframework.retry.annotation.Retryable;
86
import org.springframework.transaction.annotation.Propagation;
87
import org.springframework.transaction.annotation.Transactional;
88
import org.springframework.web.context.request.RequestContextHolder;
89
import org.springframework.web.util.UriComponentsBuilder;
90

91
/**
92
 * Abstract base service class providing generic functionalities for entity services. This class is
93
 * designed to be extended by entity service implementations, providing a common framework for
94
 * handling entities and their data transfer objects (DTOs).
95
 *
96
 * @param <O> the type of the entity object
97
 * @param <D> the type of the data transfer object (DTO)
98
 */
99
@SuppressWarnings({"java:S6813", "java:S1192", "LoggingPlaceholderCountMatchesArgumentCount"})
100
@Slf4j
4✔
101
public abstract class BaseService<O extends Base, D extends BaseDTO> {
2✔
102

103
  // This service is the base of all entity-services. Since we have a nested data model,
104
  // we're bound to get circular dependencies, and we need to lazy-load them. We load all entity
105
  // services here, so that all entities can handle each other, and we avoid having lazy-loaded
106
  // beans elsewhere.
107
  @Lazy @Autowired protected ApiKeyService apiKeyService;
108
  @Lazy @Autowired protected ArkivService arkivService;
109
  @Lazy @Autowired protected ArkivdelService arkivdelService;
110
  @Lazy @Autowired protected BehandlingsprotokollService behandlingsprotokollService;
111
  @Lazy @Autowired protected BrukerService brukerService;
112
  @Lazy @Autowired protected DokumentbeskrivelseService dokumentbeskrivelseService;
113
  @Lazy @Autowired protected DokumentobjektService dokumentobjektService;
114
  @Lazy @Autowired protected EnhetService enhetService;
115
  @Lazy @Autowired protected IdentifikatorService identifikatorService;
116
  @Lazy @Autowired protected InnsynskravBestillingService innsynskravBestillingService;
117
  @Lazy @Autowired protected InnsynskravService innsynskravService;
118
  @Lazy @Autowired protected JournalpostService journalpostService;
119
  @Lazy @Autowired protected KlasseService klasseService;
120
  @Lazy @Autowired protected KlassifikasjonssystemService klassifikasjonssystemService;
121
  @Lazy @Autowired protected KorrespondansepartService korrespondansepartService;
122
  @Lazy @Autowired protected LagretSakService lagretSakService;
123
  @Lazy @Autowired protected LagretSoekService lagretSoekService;
124
  @Lazy @Autowired protected MoetedeltakerService moetedeltakerService;
125
  @Lazy @Autowired protected MoetedokumentService moetedokumentService;
126
  @Lazy @Autowired protected MoetemappeService moetemappeService;
127
  @Lazy @Autowired protected MoetesakService moetesakService;
128
  @Lazy @Autowired protected MoetesaksbeskrivelseService moetesaksbeskrivelseService;
129
  @Lazy @Autowired protected SaksmappeService saksmappeService;
130
  @Lazy @Autowired protected SkjermingService skjermingService;
131
  @Lazy @Autowired protected TilbakemeldingService tilbakemeldingService;
132
  @Lazy @Autowired protected UtredningService utredningService;
133
  @Lazy @Autowired protected VedtakService vedtakService;
134
  @Lazy @Autowired protected VoteringService voteringService;
135
  @Lazy @Autowired protected AuthenticationService authenticationService;
136

137
  protected abstract BaseRepository<O> getRepository();
138

139
  protected abstract BaseService<O, D> getProxy();
140

141
  // These beans are autowired instead of injected in the constructor, so that we don't need to
142
  // handle them in all subclasses' constructors.
143
  @Autowired protected HttpServletRequest request;
144
  @Autowired protected EntityManager entityManager;
145
  @Autowired protected ApplicationEventPublisher eventPublisher;
146

147
  @Autowired
148
  @Qualifier("compact")
149
  protected Gson gson;
150

151
  @Autowired
152
  @Qualifier("pretty")
153
  protected Gson gsonPretty;
154

155
  @Autowired private MeterRegistry meterRegistry;
156
  private Counter insertCounter;
157
  private Counter updateCounter;
158
  private Counter getCounter;
159
  private Counter deleteCounter;
160

161
  protected final Class<? extends Base> objectClass = newObject().getClass();
5✔
162
  protected final String objectClassName = objectClass.getSimpleName();
5✔
163
  protected final String idPrefix = IdUtils.getPrefix(objectClass.getSimpleName()) + "_";
8✔
164

165
  // Elasticsearch indexing
166
  @Getter
167
  @Setter
168
  @Value("${application.elasticsearch.index}")
169
  protected String elasticsearchIndex;
170

171
  @Autowired protected ElasticsearchClient esClient;
172
  @Autowired private ElasticsearchIndexQueue esQueue;
173

174
  /**
175
   * Initialize the counters in PostConstruct. We won't use a constructor in the superclass, since
176
   * this complicates the subclass constructors.
177
   */
178
  @PostConstruct
179
  void initCounters() {
180
    var name = objectClassName.toLowerCase();
4✔
181
    insertCounter = meterRegistry.counter("ein_action", "entity", name, "type", "insert");
24✔
182
    updateCounter = meterRegistry.counter("ein_action", "entity", name, "type", "update");
24✔
183
    getCounter = meterRegistry.counter("ein_action", "entity", name, "type", "get");
24✔
184
    deleteCounter = meterRegistry.counter("ein_action", "entity", name, "type", "delete");
24✔
185
  }
1✔
186

187
  /**
188
   * Creates a new instance of the Data Transfer Object (DTO) associated with this service. This
189
   * method is typically used to instantiate a DTO for data conversion or initial population.
190
   *
191
   * @return a new instance of the DTO type associated with this service
192
   */
193
  public abstract D newDTO();
194

195
  /**
196
   * Creates a new instance of the entity object associated with this service. This method is
197
   * commonly used to instantiate an entity before persisting it to the database, or before
198
   * populating it with data for further processing.
199
   *
200
   * @return a new instance of the entity type associated with this service
201
   */
202
  public abstract O newObject();
203

204
  /**
205
   * Given an unique identifier (systemId, orgnummer, id, ...), resolve the entity ID.
206
   *
207
   * @param identifier the unique identifier to resolve
208
   * @return the entity ID
209
   */
210
  public String resolveId(String identifier) {
211
    if (objectClassName.equals(IdUtils.resolveEntity(identifier))) {
6✔
212
      return identifier;
2✔
213
    }
214
    var object = getProxy().findById(identifier);
5✔
215
    if (object != null) {
2✔
216
      return object.getId();
3✔
217
    }
218
    return null;
2✔
219
  }
220

221
  /**
222
   * Finds an entity by its unique identifier. If the ID does not start with the current entity's ID
223
   * prefix, it is treated as an external ID or a system ID. This method can be extended by entity
224
   * services to provide additional lookup logic, for instance lookup by email address.
225
   *
226
   * @param id The unique identifier of the entity
227
   * @return the entity object if found, or null
228
   */
229
  @Transactional(readOnly = true)
230
  public O findById(String id) {
231
    var repository = getRepository();
3✔
232
    // If the ID doesn't start with our prefix, it is an external ID or a system ID
233
    if (!id.startsWith(idPrefix)) {
5✔
234
      var object = repository.findByExternalId(id);
4✔
235
      log.trace("findByExternalId {}:{}, {}", objectClassName, id, object);
18✔
236
      if (object != null) {
2✔
237
        return object;
2✔
238
      }
239
    }
240

241
    var object = repository.findById(id).orElse(null);
7✔
242
    log.trace("findById {}:{}, {}", objectClassName, id, object);
18✔
243
    return object;
2✔
244
  }
245

246
  /**
247
   * Wrapper for findById() that throws a NotFoundException if the object is not found.
248
   *
249
   * @param id The ID of the object to find
250
   * @return The object with the given ID
251
   * @throws BadRequestException if the object is not found
252
   */
253
  public O findByIdOrThrow(String id) throws BadRequestException {
254
    return findByIdOrThrow(id, BadRequestException.class);
5✔
255
  }
256

257
  /**
258
   * Wrapper for findById() that throws a NotFoundException if the object is not found.
259
   *
260
   * @param id The ID of the object to find
261
   * @param exceptionClass The class of the exception to throw
262
   * @return The object with the given ID
263
   * @throws Exception if the object is not found
264
   */
265
  public <E extends Exception> O findByIdOrThrow(String id, Class<E> exceptionClass) throws E {
266
    var obj = getProxy().findById(id);
5✔
267
    if (obj == null) {
2✔
268
      try {
269
        throw exceptionClass
7✔
270
            .getDeclaredConstructor(String.class)
10✔
271
            .newInstance("No " + objectClassName + " found with id " + id);
3✔
272
      } catch (ReflectiveOperationException e) {
×
273
        throw new RuntimeException(
×
274
            "Failed to instantiate exception of type " + exceptionClass.getName(), e);
×
275
      }
276
    }
277
    return obj;
2✔
278
  }
279

280
  /**
281
   * Look up an entity based on known unique fields in a DTO. This method is intended to be extended
282
   * by subclasses.
283
   *
284
   * @param dto The DTO to look up.
285
   * @return The matching object if found, or null if not found.
286
   */
287
  public O findByDTO(BaseDTO dto) {
288
    var keyAndObject = getProxy().findPropertyAndObjectByDTO(dto);
5✔
289
    if (keyAndObject == null) {
2✔
290
      return null;
2✔
291
    }
292
    return keyAndObject.getSecond();
4✔
293
  }
294

295
  /**
296
   * Look up an entity based on known unique fields in a DTO. This method is intended to be extended
297
   * by subclasses.
298
   *
299
   * @param dto The DTO to look up.
300
   * @return A pair containing the matching property and the object if found, or null if not found.
301
   */
302
  @Transactional(readOnly = true)
303
  public Pair<String, O> findPropertyAndObjectByDTO(BaseDTO dto) {
304
    var repository = getRepository();
3✔
305
    if (dto.getId() != null) {
3!
306
      var obj = repository.findById(dto.getId()).orElse(null);
×
307
      if (obj != null) {
×
308
        return Pair.of("id", obj);
×
309
      }
310
    }
311

312
    if (dto.getExternalId() != null) {
3✔
313
      var obj = repository.findByExternalId(dto.getExternalId());
5✔
314
      if (obj != null) {
2✔
315
        return Pair.of("externalId", obj);
4✔
316
      }
317
    }
318

319
    return null;
2✔
320
  }
321

322
  /**
323
   * Retrieves a DTO representation of an object based on a unique identifier.
324
   *
325
   * @param id The unique identifier of the entity
326
   * @return DTO object
327
   * @throws EInnsynException if the entity is not found
328
   */
329
  public D get(String id) throws EInnsynException {
330
    return getProxy().get(id, new GetParameters());
8✔
331
  }
332

333
  /**
334
   * Retrieves a DTO representation of an object based on a unique identifier.
335
   *
336
   * @param id The unique identifier of the entity
337
   * @return the DTO of the entity if found
338
   * @throws EInnsynException if the entity is not found
339
   */
340
  @NewSpan
341
  @Transactional(readOnly = true)
342
  public D get(String id, GetParameters query) throws EInnsynException {
343
    log.debug("get {}:{}", objectClassName, id);
6✔
344
    authorizeGet(id);
3✔
345

346
    var proxy = getProxy();
3✔
347
    var obj = proxy.findByIdOrThrow(id, NotFoundException.class);
5✔
348

349
    var expandSet = expandListToSet(query.getExpand());
5✔
350
    var dto = proxy.toDTO(obj, expandSet);
5✔
351
    if (log.isDebugEnabled()) {
3!
352
      log.atDebug()
×
353
          .setMessage("got {}:{}")
×
354
          .addArgument(objectClassName)
×
355
          .addArgument(id)
×
356
          .addKeyValue("payload", gson.toJson(dto))
×
357
          .log();
×
358
    }
359
    getCounter.increment();
3✔
360

361
    eventPublisher.publishEvent(new GetEvent(this, dto));
8✔
362

363
    return dto;
2✔
364
  }
365

366
  /**
367
   * Adds a new entity to the database. This is currently a wrapper for addOrUpdate() method, which
368
   * handles both new objects and updates.
369
   *
370
   * @param dto The entity object to add
371
   * @return the added entity
372
   */
373
  @NewSpan
374
  @Transactional(rollbackFor = Exception.class)
375
  @Retryable(
376
      retryFor = {ObjectOptimisticLockingFailureException.class},
377
      backoff = @Backoff(delay = 100, random = true))
378
  public D add(D dto) throws EInnsynException {
379
    authorizeAdd(dto);
3✔
380

381
    // Make sure the object doesn't already exist
382
    var existingObjectPair = getProxy().findPropertyAndObjectByDTO(dto);
5✔
383
    if (existingObjectPair != null) {
2✔
384
      var property = existingObjectPair.getFirst();
4✔
385
      var existingObject = existingObjectPair.getSecond();
4✔
386
      var cause = new Exception(gsonPretty.toJson(dto));
8✔
387
      throw new ConflictException(
3✔
388
          "A conflicting object ("
389
              + existingObject.getId()
6✔
390
              + ") already exists. Duplicate value in the field `"
391
              + property
392
              + "`.",
393
          cause);
394
    }
395

396
    var paths = ExpandPathResolver.resolve(dto);
3✔
397
    var addedObj = addEntity(dto);
4✔
398

399
    scheduleIndex(addedObj.getId());
4✔
400
    return getProxy().toDTO(addedObj, paths);
6✔
401
  }
402

403
  /**
404
   * Updates an entity. This is currently a wrapper for addOrUpdate() method, which handles both new
405
   * objects and updates.
406
   *
407
   * @param id ID of the object to update
408
   * @param dto The entity object to add
409
   * @return the added entity
410
   */
411
  @NewSpan
412
  @Transactional(rollbackFor = Exception.class)
413
  @Retryable(
414
      retryFor = {ObjectOptimisticLockingFailureException.class},
415
      backoff = @Backoff(delay = 100, random = true))
416
  public D update(String id, D dto) throws EInnsynException {
417
    authorizeUpdate(id, dto);
4✔
418

419
    var paths = ExpandPathResolver.resolve(dto);
3✔
420
    var obj = getProxy().findByIdOrThrow(id);
5✔
421
    var updatedObj = updateEntity(obj, dto);
5✔
422

423
    scheduleIndex(obj.getId());
4✔
424
    return getProxy().toDTO(updatedObj, paths);
6✔
425
  }
426

427
  /**
428
   * Deletes an entity based on its ID. The method finds the entity, delegates to the abstract
429
   * delete method, and returns the deleted entity's DTO.
430
   *
431
   * @param id The unique identifier of the entity to delete
432
   * @return the DTO of the deleted entity
433
   */
434
  @NewSpan
435
  @Transactional(rollbackFor = Exception.class)
436
  @Retryable(
437
      retryFor = {ObjectOptimisticLockingFailureException.class},
438
      backoff = @Backoff(delay = 100, random = true))
439
  public D delete(String id) throws EInnsynException {
440
    authorizeDelete(id);
3✔
441
    var obj = getProxy().findByIdOrThrow(id);
5✔
442

443
    // Schedule reindex before deleting, when we still have access to relations
444
    getProxy().scheduleIndex(obj.getId());
5✔
445

446
    // Create a DTO before it is deleted, so we can return it
447
    var dto = toDTO(obj);
4✔
448
    dto.setDeleted(true);
4✔
449

450
    deleteEntity(obj);
3✔
451

452
    return dto;
2✔
453
  }
454

455
  /**
456
   * Deletes an entity based on its ID. The method finds the entity, delegates to the abstract
457
   * delete method, and returns the deleted entity's DTO.
458
   *
459
   * @param id The unique identifier of the entity to delete
460
   * @return the DTO of the deleted entity
461
   */
462
  @Transactional(propagation = Propagation.REQUIRES_NEW)
463
  public D deleteInNewTransaction(String id) throws EInnsynException {
464
    return getProxy().delete(id);
5✔
465
  }
466

467
  /**
468
   * Create and persist a new object. The method will handle persisting to the database, indexing to
469
   * ElasticSearch, and returning the updated entity's DTO.
470
   *
471
   * @param dto The DTO representation of the entity to update or add
472
   * @return the created entity object
473
   */
474
  protected O addEntity(D dto) throws EInnsynException {
475
    var repository = getRepository();
3✔
476
    var jsonPayload = gson.toJson(dto);
5✔
477
    var startTime = System.currentTimeMillis();
2✔
478
    if (log.isDebugEnabled()) {
3!
479
      log.atDebug()
×
480
          .setMessage("add {}")
×
481
          .addArgument(objectClassName)
×
482
          .addKeyValue("payload", jsonPayload)
×
483
          .log();
×
484
    }
485

486
    // Generate database object from JSON
487
    var obj = fromDTO(dto, newObject());
6✔
488
    if (log.isTraceEnabled()) {
3!
489
      log.atTrace()
×
490
          .setMessage("addEntity saveAndFlush {}")
×
491
          .addArgument(objectClassName)
×
492
          .addKeyValue("payload", jsonPayload)
×
493
          .log();
×
494
    }
495
    repository.saveAndFlush(obj);
4✔
496

497
    var duration = System.currentTimeMillis() - startTime;
4✔
498
    log.atInfo()
3✔
499
        .setMessage("added {}:{}")
3✔
500
        .addArgument(objectClassName)
2✔
501
        .addArgument(obj.getId())
4✔
502
        .addKeyValue("payload", jsonPayload)
3✔
503
        .addKeyValue("duration", duration)
2✔
504
        .log();
1✔
505
    insertCounter.increment();
3✔
506

507
    eventPublisher.publishEvent(new InsertEvent(this, dto));
8✔
508

509
    return obj;
2✔
510
  }
511

512
  /**
513
   * Update an entity object in the database. This method will handle updating the database,
514
   * indexing to ElasticSearch, and returning the updated entity's DTO.
515
   *
516
   * @param id ID of the object to update
517
   * @param dto The DTO representation of the entity to update or add
518
   * @return Updated entity object
519
   * @throws EInnsynException if the update fails
520
   */
521
  protected O updateEntity(O obj, D dto) throws EInnsynException {
522
    var repository = getRepository();
3✔
523
    var jsonPayload = gson.toJson(dto);
5✔
524
    var startTime = System.currentTimeMillis();
2✔
525
    if (log.isDebugEnabled()) {
3!
526
      log.atDebug()
×
527
          .setMessage("update {}:{}")
×
528
          .addArgument(objectClassName)
×
529
          .addArgument(obj.getId())
×
530
          .addKeyValue("payload", jsonPayload)
×
531
          .log();
×
532
    }
533

534
    // Generate database object from JSON
535
    obj = fromDTO(dto, obj);
5✔
536
    if (log.isTraceEnabled()) {
3!
537
      log.atTrace()
×
538
          .setMessage("updateEntity saveAndFlush {}:{}")
×
539
          .addArgument(objectClassName)
×
540
          .addArgument(obj.getId())
×
541
          .addKeyValue("payload", jsonPayload)
×
542
          .log();
×
543
    }
544
    repository.saveAndFlush(obj);
4✔
545

546
    var duration = System.currentTimeMillis() - startTime;
4✔
547
    log.atInfo()
3✔
548
        .setMessage("updated {}:{}")
3✔
549
        .addArgument(objectClassName)
2✔
550
        .addArgument(obj.getId())
4✔
551
        .addKeyValue("payload", jsonPayload)
3✔
552
        .addKeyValue("duration", duration)
2✔
553
        .log();
1✔
554
    updateCounter.increment();
3✔
555

556
    eventPublisher.publishEvent(new UpdateEvent(this, dto));
8✔
557

558
    return obj;
2✔
559
  }
560

561
  /**
562
   * Delete an entity object from the database.
563
   *
564
   * @param obj The entity object to be deleted
565
   * @throws EInnsynException if the deletion fails
566
   */
567
  protected void deleteEntity(O obj) throws EInnsynException {
568
    var repository = getRepository();
3✔
569
    var startTime = System.currentTimeMillis();
2✔
570
    log.debug("delete {}:{}", objectClassName, obj.getId());
7✔
571

572
    try {
573
      repository.delete(obj);
3✔
574
    } catch (Exception e) {
×
575
      throw new InternalServerErrorException(
×
576
          "Could not delete " + objectClassName + " object with id " + obj.getId(), e);
×
577
    }
1✔
578

579
    var duration = System.currentTimeMillis() - startTime;
4✔
580
    log.atInfo()
3✔
581
        .setMessage("deleted {}:{}")
3✔
582
        .addArgument(objectClassName)
2✔
583
        .addArgument(obj.getId())
4✔
584
        .addKeyValue("duration", duration)
2✔
585
        .log();
1✔
586
    deleteCounter.increment();
3✔
587

588
    eventPublisher.publishEvent(new DeleteEvent(this, toDTO(obj)));
10✔
589
  }
1✔
590

591
  /**
592
   * Takes an expandable field, inserts the object if it's a new object, or returns the existing
593
   * object if it's an existing object. This is a helper method for the fromDTO method, to handle
594
   * nested objects.
595
   *
596
   * @param dtoField Expandable DTO field
597
   * @return the created or existing entity
598
   */
599
  @Transactional(propagation = Propagation.MANDATORY)
600
  public O createOrReturnExisting(ExpandableField<D> dtoField) throws EInnsynException {
601
    var id = dtoField.getId();
3✔
602
    var dto = dtoField.getExpandedObject();
4✔
603

604
    if (log.isTraceEnabled()) {
3!
605
      log.atTrace()
×
606
          .setMessage("createOrReturnExisting {}")
×
607
          .addArgument(id == null ? objectClassName : objectClassName + ":" + id)
×
608
          .addKeyValue("payload", gson.toJson(dtoField))
×
609
          .log();
×
610
    }
611

612
    // If an ID is given, return the object
613
    var obj = id != null ? getProxy().findById(id) : getProxy().findByDTO(dto);
12✔
614

615
    // Verify that we're allowed to modify the found object
616
    if (obj != null) {
2✔
617
      try {
618
        getProxy().authorizeUpdate(obj.getId(), dto);
6✔
619
      } catch (AuthorizationException e) {
1✔
620
        throw new AuthorizationException(
5✔
621
            "Not authorized to relate to " + objectClassName + ":" + obj.getId());
4✔
622
      }
1✔
623

624
      // Update the object with the new DTO
625
      if (dto != null) {
2✔
626
        obj = updateEntity(obj, dto);
5✔
627
      }
628

629
      return obj;
2✔
630
    }
631

632
    return addEntity(dto);
4✔
633
  }
634

635
  /**
636
   * Takes an expandable field, inserts the object if it's a new object, throws if not. This is a
637
   * helper method for the fromDTO method, to handle nested objects.
638
   *
639
   * @param dtoField Expandable DTO field
640
   * @throws EInnsynException if the object is not found
641
   */
642
  @Transactional(propagation = Propagation.MANDATORY)
643
  public O createOrThrow(ExpandableField<D> dtoField) throws EInnsynException {
644

645
    if (log.isTraceEnabled()) {
3!
646
      log.atTrace()
×
647
          .setMessage("createOrThrow {}")
×
648
          .addArgument(objectClassName)
×
649
          .addKeyValue("payload", gson.toJson(dtoField))
×
650
          .log();
×
651
    }
652

653
    if (dtoField.getId() != null) {
3!
654
      throw new BadRequestException("Cannot create an object with an ID set: " + dtoField.getId());
×
655
    }
656

657
    // Make sure the object doesn't already exist
658
    var dto = dtoField.getExpandedObject();
4✔
659
    // Make sure the object doesn't already exist
660
    var existingObjectPair = getProxy().findPropertyAndObjectByDTO(dto);
5✔
661
    if (existingObjectPair != null) {
2!
662
      var property = existingObjectPair.getFirst();
×
663
      var existingObject = existingObjectPair.getSecond();
×
664
      var cause = new Exception(gsonPretty.toJson(dto));
×
665
      throw new ConflictException(
×
666
          "A conflicting object ("
667
              + existingObject.getId()
×
668
              + ") already exists. Duplicate value in the field `"
669
              + property
670
              + "`.",
671
          cause);
672
    }
673

674
    return addEntity(dto);
4✔
675
  }
676

677
  /**
678
   * Takes an expandable field, returns the object if it's an existing object, or throws if it's a
679
   * new object. This is a helper method for the fromDTO method, to handle nested objects.
680
   *
681
   * @param dtoField Expandable DTO field
682
   * @throws EInnsynException if the object is not found
683
   */
684
  @Transactional(propagation = Propagation.MANDATORY)
685
  public O returnExistingOrThrow(ExpandableField<D> dtoField) throws EInnsynException {
686
    var id = dtoField.getId();
3✔
687
    var dto = dtoField.getExpandedObject();
4✔
688

689
    if (log.isTraceEnabled()) {
3!
690
      log.atTrace()
×
691
          .setMessage("returnExistingOrThrow {}")
×
692
          .addArgument(id == null ? objectClassName : objectClassName + ":" + id)
×
693
          .addKeyValue("payload", gson.toJson(dtoField))
×
694
          .log();
×
695
    }
696

697
    var obj = id != null ? getProxy().findById(id) : getProxy().findByDTO(dto);
8!
698

699
    if (obj == null) {
2!
700
      throw new BadRequestException("Cannot return a new object");
×
701
    }
702

703
    return obj;
2✔
704
  }
705

706
  /**
707
   * Schedule a (re)index of a given object. The object will be indexed at the end of the current
708
   * request.
709
   *
710
   * @param id ID of the entity object to index
711
   */
712
  public void scheduleIndex(String id) {
713
    scheduleIndex(id, 0);
5✔
714
  }
1✔
715

716
  /**
717
   * Schedule a (re)index of a given object. The object will be indexed at the end of the current
718
   * request.
719
   *
720
   * @param id ID of the entity object to index
721
   * @param recurseDirection -1 for parents, 1 for children, 0 for both
722
   */
723
  public boolean scheduleIndex(String id, int recurseDirection) {
724

725
    // Only access esQueue when we're in a request scope. This guard applies to any execution
726
    // outside of a request context, including scheduled tasks and service tests.
727
    if (RequestContextHolder.getRequestAttributes() == null) {
2✔
728
      return false;
2✔
729
    }
730

731
    if (esQueue.isScheduled(id, recurseDirection)) {
6✔
732
      return true;
2✔
733
    }
734

735
    if (Indexable.class.isAssignableFrom(objectClass)) {
5✔
736
      esQueue.add(id, recurseDirection);
5✔
737
    }
738

739
    return false;
2✔
740
  }
741

742
  /**
743
   * Execute a scheduled reindex of an object. This method is called by the ElasticSearchIndexQueue
744
   * after the object has been added to the queue.
745
   *
746
   * @param id the ID of the object to index
747
   * @param timestamp the timestamp when the indexing was scheduled
748
   */
749
  @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true)
750
  public void index(String id, Instant timestamp) {
751
    var proxy = getProxy();
3✔
752
    var object = proxy.findById(id);
4✔
753
    var esParent = proxy.getESParent(object, id);
5✔
754

755
    // Insert / update document if the object exists
756
    if (object instanceof Indexable indexable) {
6✔
757
      // Do nothing if the object has been indexed after `scheduledTimestamp`
758
      if (indexable.getLastIndexed() != null && indexable.getLastIndexed().isAfter(timestamp)) {
8✔
759
        log.debug(
18✔
760
            "Not indexing {} : {} , it has already been indexed after {}",
761
            objectClassName,
762
            id,
763
            timestamp);
764
        return;
1✔
765
      }
766

767
      var isInsert = false;
2✔
768
      // Persist lastIndexed as the row version we actually indexed, not wall clock time.
769
      // This keeps stale detection race-safe.
770
      var lastIndexedTimestamp = object.getUpdated() != null ? object.getUpdated() : Instant.now();
7!
771
      var esDocument = proxy.toLegacyES(object);
4✔
772

773
      // If esDocument is null, remove any existing ES document and update lastIndexed
774
      if (esDocument == null) {
2✔
775
        log.debug("Not indexing {} : {} , ES document is null", objectClassName, id);
6✔
776
        try {
777
          esClient.delete(d -> d.index(elasticsearchIndex).id(id).routing(esParent));
17✔
778
        } catch (Exception e) {
×
779
          log.error(
×
780
              "Could not delete {} : {} from ElasticSearch: {}",
781
              objectClassName,
782
              id,
783
              e.getMessage(),
×
784
              e);
785
        }
1✔
786
        var repository = getRepository();
3✔
787
        if (repository instanceof IndexableRepository<?> indexableRepository) {
6!
788
          indexableRepository.updateLastIndexed(id, lastIndexedTimestamp);
4✔
789
        }
790
        return;
1✔
791
      }
792

793
      var lastIndexed = indexable.getLastIndexed();
3✔
794
      var accessibleAfter = esDocument.getAccessibleAfter();
3✔
795
      log.debug(
22✔
796
          "index {} : {} routing: {} lastIndexed: {}", objectClassName, id, esParent, lastIndexed);
797
      try {
798
        esClient.index(
9✔
799
            i -> i.index(elasticsearchIndex).id(id).document(esDocument).routing(esParent));
11✔
800

801
        // Mark as insert if the object has never been indexed before
802
        if (lastIndexed == null) {
2✔
803
          isInsert = true;
3✔
804
        }
805

806
        // Mark as insert if the object has been made accessible after the last index
807
        else if (accessibleAfter != null) {
2!
808
          // TODO: We should consider adding logic that checks if the object has been marked as an
809
          // insert before, to avoid sending subscription email multiple times for the same document
810
          isInsert = lastIndexed.isBefore(Instant.parse(accessibleAfter));
5✔
811
        }
812
      } catch (Exception e) {
1✔
813
        // Don't throw in Async
814
        log.error("Could not index {} : {} to ElasticSearch", objectClassName, id, e);
18✔
815
        if (e instanceof ElasticsearchException elasticsearchException) {
3!
816
          log.error(elasticsearchException.response().toString());
×
817
        }
818
        return;
1✔
819
      }
1✔
820

821
      // Update lastIndexed timestamp in the database
822
      try {
823
        var repository = getRepository();
3✔
824
        if (repository instanceof IndexableRepository<?> indexableRepository) {
6!
825
          indexableRepository.updateLastIndexed(id, lastIndexedTimestamp);
4✔
826
        }
827
        eventPublisher.publishEvent(new IndexEvent(this, esDocument, isInsert));
9✔
828
        log.info(
22✔
829
            "indexed {} : {} routing: {} lastIndexed: {}",
830
            objectClassName,
831
            id,
832
            esParent,
833
            lastIndexedTimestamp);
834
      } catch (Exception e) {
×
835
        // Don't throw in Async
836
        log.error(
×
837
            "Could not update lastIndexed for {} : {} : {}",
838
            objectClassName,
839
            id,
840
            e.getMessage(),
×
841
            e);
842
      }
1✔
843
    }
1✔
844

845
    // Delete ES document if the object doesn't exist
846
    else {
847
      log.debug("delete from index {} : {}", objectClassName, id);
6✔
848
      try {
849
        esClient.delete(d -> d.index(elasticsearchIndex).id(id).routing(esParent));
17✔
850
        log.info("deleted {} : {} routing: {}", objectClassName, id, esParent);
18✔
851
      } catch (Exception e) {
1✔
852
        // Don't throw in Async
853
        log.error(
6✔
854
            "Could not delete "
855
                + objectClassName
856
                + ":"
857
                + id
858
                + " from ElasticSearch: "
859
                + e.getMessage(),
3✔
860
            e);
861
      }
1✔
862
    }
863
  }
1✔
864

865
  /**
866
   * Get the "parent" of an ES document, in case it is a child. By default, the document is not a
867
   * child and we return null.
868
   *
869
   * @param id object ID
870
   * @return ID of the parent, or null
871
   */
872
  @Transactional(readOnly = true)
873
  public String getESParent(O object, String id) {
874
    return null;
2✔
875
  }
876

877
  /**
878
   * Converts a Data Transfer Object (DTO) to its corresponding entity object (O). This method is
879
   * intended for reconstructing an entity from its DTO, typically used when persisting data
880
   * received in the form of a DTO to the database.
881
   *
882
   * @param dto the DTO to be converted to an entity
883
   * @param object the entity object to be populated
884
   * @return an entity object corresponding to the DTO
885
   */
886
  @SuppressWarnings({"java:S1130"}) // Subclasses might throw EInnsynException
887
  protected O fromDTO(D dto, O object) throws EInnsynException {
888

889
    if (dto.getExternalId() != null) {
3✔
890
      object.setExternalId(dto.getExternalId());
4✔
891
    }
892
    if (dto.getAccessibleAfter() != null) {
3✔
893
      object.setAccessibleAfter(TimeConverter.timestampToInstant(dto.getAccessibleAfter()));
5✔
894
    }
895

896
    return object;
2✔
897
  }
898

899
  /**
900
   * Wrapper for toDTO with defaults for dto, paths, and currentPath.
901
   *
902
   * @param object Entity object to convert
903
   * @return DTO object
904
   */
905
  protected D toDTO(O object) {
906
    return getProxy().toDTO(object, newDTO(), new HashSet<>(), "");
11✔
907
  }
908

909
  /**
910
   * Wrapper for toDTO with defaults for dto and paths.
911
   *
912
   * @param object Entity object to convert
913
   * @param expandPaths Paths to expand
914
   * @return DTO object
915
   */
916
  protected D toDTO(O object, Set<String> expandPaths) {
917
    return getProxy().toDTO(object, newDTO(), expandPaths, "");
9✔
918
  }
919

920
  /**
921
   * Converts an entity object (O) to its corresponding Data Transfer Object (DTO).
922
   *
923
   * @param object the entity object to be converted
924
   * @param dto the target DTO object
925
   * @param expandPaths a set of paths indicating properties to expand
926
   * @param currentPath the current path in the object tree, used for nested expansions
927
   * @return a DTO representation of the entity
928
   */
929
  protected D toDTO(O object, D dto, Set<String> expandPaths, String currentPath) {
930
    log.trace(
13✔
931
        "toDTO {}:{}, expandPaths: {}, currentPath: '{}'",
932
        objectClassName,
933
        object.getId(),
10✔
934
        expandPaths,
935
        currentPath);
936

937
    dto.setId(object.getId());
4✔
938
    dto.setExternalId(object.getExternalId());
4✔
939

940
    // Only expose accessibleAfter if it's in the future
941
    if (object.getAccessibleAfter() != null && object.getAccessibleAfter().isAfter(Instant.now())) {
8!
942
      dto.setAccessibleAfter(TimeConverter.instantToTimestamp(object.getAccessibleAfter()));
5✔
943
    }
944

945
    return dto;
2✔
946
  }
947

948
  /**
949
   * Wrapper that creates a BaseES object for toLegacyES()
950
   *
951
   * @param object the entity object to convert
952
   * @return the legacy ElasticSearch document
953
   */
954
  protected BaseES toLegacyES(O object) {
955
    return toLegacyES(object, new BaseES());
×
956
  }
957

958
  /**
959
   * Converts an entity object to a legacy ElasticSearch document. This format is used by the old
960
   * API and front-end, and should likely be replaced by an extended version of the DTO model in the
961
   * future.
962
   *
963
   * @param object the entity object to convert
964
   * @param es the BaseES object to populate
965
   * @return the populated legacy ElasticSearch document
966
   */
967
  protected BaseES toLegacyES(O object, BaseES es) {
968
    es.setId(object.getId());
4✔
969
    es.setExternalId(object.getExternalId());
4✔
970
    es.setType(List.of(object.getClass().getSimpleName()));
6✔
971
    es.setCreated(TimeConverter.instantToTimestamp(object.getCreated()));
5✔
972
    es.setUpdated(TimeConverter.instantToTimestamp(object.getUpdated()));
5✔
973
    if (object.getAccessibleAfter() != null) {
3!
974
      es.setAccessibleAfter(TimeConverter.instantToTimestamp(object.getAccessibleAfter()));
5✔
975
    }
976
    return es;
2✔
977
  }
978

979
  /**
980
   * Retrieves a list of DTOs based on provided query parameters. This method uses the entity
981
   * service's getPage() implementation to get a paginated list of entities, and then converts the
982
   * page to a ResponseList.
983
   *
984
   * <p>Note: When searching using "endingBefore", the result list will be reversed. This is because
985
   * we use the "endingBefore" id as a pivot, and the DB will return the ordered list starting from
986
   * the pivot.
987
   *
988
   * @param params The query parameters for filtering and pagination
989
   * @return a ListResponse containing DTOs that match the query criteria
990
   */
991
  @Transactional(readOnly = true)
992
  @SuppressWarnings("java:S3776") // Allow complexity of 19
993
  public PaginatedList<D> list(ListParameters params) throws EInnsynException {
994
    if (log.isDebugEnabled()) {
3!
995
      log.atDebug()
×
996
          .setMessage("list {}")
×
997
          .addArgument(objectClassName)
×
998
          .addKeyValue("payload", gson.toJson(params))
×
999
          .log();
×
1000
    }
1001

1002
    authorizeList(params);
3✔
1003

1004
    var response = new PaginatedList<D>();
4✔
1005
    var startingAfter = params.getStartingAfter();
3✔
1006
    var endingBefore = params.getEndingBefore();
3✔
1007
    var limit = params.getLimit();
3✔
1008
    var hasNext = false;
2✔
1009
    var hasPrevious = false;
2✔
1010
    var uri = request.getRequestURI();
4✔
1011
    var queryString = request.getQueryString();
4✔
1012
    var uriBuilder = UriComponentsBuilder.fromUriString(uri).query(queryString);
5✔
1013

1014
    // Ask for 2 more, so we can check if there is a next / previous page
1015
    var responseList = listEntity(params, limit + 2);
8✔
1016
    if (responseList.isEmpty()) {
3✔
1017
      return response;
2✔
1018
    }
1019

1020
    if (params.getIds() != null || params.getExternalIds() != null) {
6✔
1021
      // If we have a list of IDs, we don't need to check for next / previous
1022
      hasNext = false;
2✔
1023
      hasPrevious = false;
2✔
1024
      startingAfter = null;
2✔
1025
      endingBefore = null;
2✔
1026
      limit = 0;
3✔
1027
      if (params.getIds() != null) {
3✔
1028
        limit += params.getIds().size();
8✔
1029
      }
1030
      if (params.getExternalIds() != null) {
3✔
1031
        limit += params.getExternalIds().size();
8✔
1032
      }
1033
    }
1034

1035
    // If starting after, remove the first item if it's the same as the startingAfter value
1036
    if (startingAfter != null) {
2✔
1037
      var firstItem = responseList.getFirst();
4✔
1038
      if (firstItem.getId().equals(startingAfter)) {
5✔
1039
        hasPrevious = true;
2✔
1040
        responseList = responseList.subList(1, responseList.size());
6✔
1041
      }
1042

1043
      // If there are more items, remove remaining items and set "nextId"
1044
      if (responseList.size() > limit) {
5✔
1045
        hasNext = true;
2✔
1046
        responseList = responseList.subList(0, limit);
6✔
1047
      }
1048
    }
1✔
1049

1050
    // If ending before, remove the first item if it's the same as the endingBefore value
1051
    else if (endingBefore != null) {
2✔
1052
      var lastItem = responseList.getLast();
4✔
1053
      if (lastItem.getId().equals(endingBefore)) {
5✔
1054
        hasNext = true;
2✔
1055
        responseList = responseList.subList(0, responseList.size() - 1);
8✔
1056
      }
1057

1058
      if (responseList.size() > limit) {
5✔
1059
        hasPrevious = true;
2✔
1060
        responseList = responseList.subList(responseList.size() - limit, responseList.size());
10✔
1061
      }
1062
    }
1✔
1063

1064
    // If we don't have startingAfter or endingBefore, we're at the beginning of the list, but we
1065
    // might have more items
1066
    else if (responseList.size() > limit) {
5✔
1067
      hasNext = true;
2✔
1068
      responseList = responseList.subList(0, limit);
6✔
1069
    }
1070

1071
    if (hasNext) {
2✔
1072
      var nextId = responseList.isEmpty() ? "" : responseList.getLast().getId();
10✔
1073
      uriBuilder.replaceQueryParam("endingBefore");
6✔
1074
      uriBuilder.replaceQueryParam("startingAfter", nextId);
10✔
1075
      response.setNext(uriBuilder.build().toString());
5✔
1076
    }
1077
    if (hasPrevious) {
2✔
1078
      var prevId = responseList.isEmpty() ? "" : responseList.getFirst().getId();
10✔
1079
      uriBuilder.replaceQueryParam("startingAfter");
6✔
1080
      uriBuilder.replaceQueryParam("endingBefore", prevId);
10✔
1081
      response.setPrevious(uriBuilder.build().toString());
5✔
1082
    }
1083

1084
    // Convert to DTO
1085
    var expandPaths = expandListToSet(params.getExpand());
5✔
1086
    var responseDtoList = new ArrayList<D>();
4✔
1087
    for (var responseObject : responseList) {
10✔
1088
      responseDtoList.add(toDTO(responseObject, expandPaths));
7✔
1089
    }
1✔
1090

1091
    response.setItems(responseDtoList);
3✔
1092

1093
    return response;
2✔
1094
  }
1095

1096
  /**
1097
   * This method can be overridden by subclasses, to add custom logic for pagination, i.e. filtering
1098
   * by parent. The {@link Paginators} object keeps one function for getting a page in ascending
1099
   * order, and one for descending order.
1100
   *
1101
   * @param params The query parameters for pagination
1102
   * @return a Paginators object
1103
   */
1104
  protected Paginators<O> getPaginators(ListParameters params) throws EInnsynException {
1105
    var repository = getRepository();
3✔
1106
    var startingAfter = params.getStartingAfter();
3✔
1107
    var endingBefore = params.getEndingBefore();
3✔
1108

1109
    if ((startingAfter != null && !startingAfter.isEmpty())
4!
1110
        || (endingBefore != null) && !endingBefore.isEmpty()) {
×
1111
      return new Paginators<>(
×
1112
          repository::findByIdGreaterThanEqualOrderByIdAsc,
×
1113
          repository::findByIdLessThanEqualOrderByIdDesc);
×
1114
    }
1115
    return new Paginators<>(
8✔
1116
        (pivot, pageRequest) -> repository.findAllByOrderByIdAsc(pageRequest),
4✔
1117
        (pivot, pageRequest) -> repository.findAllByOrderByIdDesc(pageRequest));
4✔
1118
  }
1119

1120
  /**
1121
   * Retrieves a paginated list of entity objects based on query parameters. Supports pagination
1122
   * through 'startingAfter' and 'endingBefore' fields in the query DTO.
1123
   *
1124
   * <p>We will always fetch one item more than "limit", to make the list() method able to detect
1125
   * whether there are more items available.
1126
   *
1127
   * @param params The query parameters for pagination
1128
   * @return a Page object containing the list of entities
1129
   */
1130
  protected List<O> listEntity(ListParameters params, int limit) throws EInnsynException {
1131
    var pageRequest = PageRequest.of(0, limit);
4✔
1132
    var startingAfter = params.getStartingAfter();
3✔
1133
    var endingBefore = params.getEndingBefore();
3✔
1134
    var sortOrder = params.getSortOrder();
3✔
1135
    var hasStartingAfter = startingAfter != null;
6✔
1136
    var hasEndingBefore = endingBefore != null;
6✔
1137
    var ascending = "asc".equals(sortOrder);
4✔
1138
    var pivot = hasStartingAfter ? startingAfter : endingBefore;
6✔
1139
    var paginators = getPaginators(params);
4✔
1140

1141
    var ids = params.getIds();
3✔
1142
    if (ids != null) {
2✔
1143
      var entityList = getRepository().findByIdIn(ids);
5✔
1144
      Collections.sort(entityList, Comparator.comparingInt(entity -> ids.indexOf(entity.getId())));
10✔
1145
      return entityList;
2✔
1146
    }
1147

1148
    var externalIds = params.getExternalIds();
3✔
1149
    if (externalIds != null) {
2✔
1150
      var entityList = getRepository().findByExternalIdIn(externalIds);
5✔
1151
      Collections.sort(
4✔
1152
          entityList,
1153
          Comparator.comparingInt(entity -> externalIds.indexOf(entity.getExternalId())));
6✔
1154
      return entityList;
2✔
1155
    }
1156

1157
    // If startingAfter / endingBefore is given but an empty string, it should match anything from
1158
    // the beginning / the end of the list
1159
    if (pivot != null && pivot.isEmpty()) {
5✔
1160
      pivot = null;
2✔
1161
    }
1162

1163
    // The DB will always return the earliest possible matches, so when using endingBefore we need
1164
    // to reverse the query, get the results immediately after the pivot, and then reverse the list.
1165
    if (hasEndingBefore) {
2✔
1166
      var page =
1167
          ascending
2✔
1168
              ? paginators.getDesc(pivot, pageRequest)
5✔
1169
              : paginators.getAsc(pivot, pageRequest);
5✔
1170
      return page.getContent().reversed();
4✔
1171
    }
1172

1173
    var page =
1174
        ascending ? paginators.getAsc(pivot, pageRequest) : paginators.getDesc(pivot, pageRequest);
12✔
1175
    return page.getContent();
3✔
1176
  }
1177

1178
  /**
1179
   * Optionally expands an entity with the given property name and expand paths. If the property is
1180
   * within the expand paths, a full DTO is provided; otherwise, only the ID is returned.
1181
   *
1182
   * @param obj The entity object to expand
1183
   * @param propertyName The property name to check for expansion
1184
   * @param expandPaths A set of paths indicating properties to expand
1185
   * @param currentPath The current path in the object tree, used for nested expansions
1186
   * @return an ExpandableField containing either a full DTO or just the ID
1187
   */
1188
  public ExpandableField<D> maybeExpand(
1189
      O obj, String propertyName, Set<String> expandPaths, String currentPath) {
1190
    if (obj == null) {
2✔
1191
      return null;
2✔
1192
    }
1193
    if (currentPath == null) {
2!
1194
      currentPath = "";
×
1195
    }
1196
    var updatedPath = currentPath.isEmpty() ? propertyName : currentPath + "." + propertyName;
9✔
1197
    var shouldExpand = expandPaths != null && expandPaths.contains(updatedPath);
10!
1198
    log.trace("maybeExpand {}:{}, {}", objectClassName, obj.getId(), shouldExpand);
20✔
1199
    var expandedObject = shouldExpand ? toDTO(obj, newDTO(), expandPaths, updatedPath) : null;
12✔
1200
    return new ExpandableField<>(obj.getId(), expandedObject);
7✔
1201
  }
1202

1203
  /**
1204
   * Wrapper around maybeExpand for lists. This method will expand all objects in the list, and
1205
   * return a list of ExpandableFields.
1206
   *
1207
   * @param objList The list of entity objects to expand
1208
   * @param propertyName The property name to check for expansion
1209
   * @param expandPaths A set of paths indicating properties to expand
1210
   * @param currentPath The current path in the object tree, used for nested expansions
1211
   * @return a list of ExpandableFields containing either full DTOs or just the IDs
1212
   */
1213
  public List<ExpandableField<D>> maybeExpand(
1214
      List<O> objList, String propertyName, Set<String> expandPaths, String currentPath) {
1215
    if (objList == null) {
2✔
1216
      return List.of();
2✔
1217
    }
1218
    return objList.stream()
8✔
1219
        .map(obj -> maybeExpand(obj, propertyName, expandPaths, currentPath))
8✔
1220
        .toList();
1✔
1221
  }
1222

1223
  /**
1224
   * Converts a list of expand strings to a set. For entries that contain a dot, the method will
1225
   * also add the parent path to the set, so you don't need to add all levels of expansion.
1226
   *
1227
   * <p>Example:
1228
   *
1229
   * <p>Input: ["journalpost.journalenhet"]
1230
   *
1231
   * <p>Output: ["journalpost", "journalpost.journalenhet"]
1232
   *
1233
   * @param list The list of expand strings
1234
   * @return a set of expand paths
1235
   */
1236
  public Set<String> expandListToSet(List<String> list) {
1237
    if (list == null || list.isEmpty()) {
5✔
1238
      return new HashSet<>();
4✔
1239
    }
1240
    var set = new HashSet<>(list);
5✔
1241
    for (var item : list) {
10✔
1242
      var dotIndex = item.indexOf('.');
4✔
1243
      while (dotIndex >= 0) {
2✔
1244
        set.add(item.substring(0, dotIndex));
7✔
1245
        dotIndex = item.indexOf('.', dotIndex + 1);
8✔
1246
      }
1247
    }
1✔
1248
    return set;
2✔
1249
  }
1250

1251
  protected void authorizeList(ListParameters params) throws EInnsynException {
1252
    throw new AuthorizationException("Not authorized to list " + objectClassName);
×
1253
  }
1254

1255
  protected void authorizeGet(String id) throws EInnsynException {
1256
    throw new AuthorizationException("Not authorized to get " + objectClassName + " with id " + id);
×
1257
  }
1258

1259
  protected void authorizeAdd(D dto) throws EInnsynException {
1260
    throw new AuthorizationException("Not authorized to add " + objectClassName);
×
1261
  }
1262

1263
  protected void authorizeUpdate(String id, D dto) throws EInnsynException {
1264
    throw new AuthorizationException(
×
1265
        "Not authorized to update " + objectClassName + " with id " + id);
1266
  }
1267

1268
  protected void authorizeDelete(String id) throws EInnsynException {
1269
    throw new AuthorizationException(
×
1270
        "Not authorized to delete " + objectClassName + " with id " + id);
1271
  }
1272
}
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