Skip to content

Commit

Permalink
Merge pull request #2 from akaene/record-manager-ui#71-filtering-pagi…
Browse files Browse the repository at this point in the history
…ng-sorting

Record manager UI#71 filtering paging sorting
  • Loading branch information
ledsoft authored Jan 31, 2024
2 parents 929aecd + 5d62bac commit db451c8
Show file tree
Hide file tree
Showing 22 changed files with 965 additions and 174 deletions.
4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId>
</dependency>

<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ public List<ActionHistory> findAllWithParams(String typeFilter, User author, int
ActionHistory.class)
.setParameter("type", typeUri)
.setParameter("isCreated", URI.create(Vocabulary.s_p_created))
.setFirstResult((pageNumber - 1) * Constants.ACTIONS_PER_PAGE)
.setMaxResults(Constants.ACTIONS_PER_PAGE + 1);
.setFirstResult((pageNumber - 1) * Constants.DEFAULT_PAGE_SIZE)
.setMaxResults(Constants.DEFAULT_PAGE_SIZE + 1);

if (author != null) {
q.setParameter("hasOwner", URI.create(Vocabulary.s_p_has_owner))
Expand Down
110 changes: 88 additions & 22 deletions src/main/java/cz/cvut/kbss/study/persistence/dao/PatientRecordDao.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@
import cz.cvut.kbss.study.model.Vocabulary;
import cz.cvut.kbss.study.persistence.dao.util.QuestionSaver;
import cz.cvut.kbss.study.persistence.dao.util.RecordFilterParams;
import cz.cvut.kbss.study.persistence.dao.util.RecordSort;
import cz.cvut.kbss.study.util.Constants;
import cz.cvut.kbss.study.util.IdentificationUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Repository;

import java.math.BigInteger;
Expand Down Expand Up @@ -177,52 +182,97 @@ public void requireUniqueNonEmptyLocalName(PatientRecord entity) {
em.clear();
}

/**
* Retrieves DTOs of records matching the specified filtering criteria.
* <p>
* Note that since the record modification is tracked by a timestamp and the filter uses dates, this method uses
* beginning of the min date and end of the max date.
* <p>
* The returned page contains also information about total number of matching records.
*
* @param filters Record filtering criteria
* @param pageSpec Specification of page and sorting
* @return Page with matching records
* @see #findAllRecordsFull(RecordFilterParams, Pageable)
*/
public Page<PatientRecordDto> findAllRecords(RecordFilterParams filters, Pageable pageSpec) {
Objects.requireNonNull(filters);
Objects.requireNonNull(pageSpec);
return findRecords(filters, pageSpec, PatientRecordDto.class);
}

/**
* Retrieves records matching the specified filtering criteria.
* <p>
* Note that since the record modification is tracked by a timestamp and the filter uses dates, this method uses
* beginning of the min date and end of the max date.
* <p>
* The returned page contains also information about total number of matching records.
*
* @param filterParams Record filtering criteria
* @return List of matching records
* @param filters Record filtering criteria
* @param pageSpec Specification of page and sorting
* @return Page with matching records
* @see #findAllRecords(RecordFilterParams, Pageable)
*/
public List<PatientRecord> findAllFull(RecordFilterParams filterParams) {
Objects.requireNonNull(filterParams);
public Page<PatientRecord> findAllRecordsFull(RecordFilterParams filters, Pageable pageSpec) {
Objects.requireNonNull(filters);
Objects.requireNonNull(pageSpec);
return findRecords(filters, pageSpec, PatientRecord.class);
}

private <T> Page<T> findRecords(RecordFilterParams filters, Pageable pageSpec, Class<T> resultClass) {
final Map<String, Object> queryParams = new HashMap<>();
final String whereClause = constructWhereClause(filters, queryParams);
final String queryString = "SELECT ?r WHERE " + whereClause + resolveOrderBy(pageSpec.getSortOr(RecordSort.defaultSort()));
final TypedQuery<T> query = em.createNativeQuery(queryString, resultClass);
setQueryParameters(query, queryParams);
if (pageSpec.isPaged()) {
query.setFirstResult((int) pageSpec.getOffset());
query.setMaxResults(pageSpec.getPageSize());
}
final List<T> records = query.getResultList();
final TypedQuery<Integer> countQuery = em.createNativeQuery("SELECT (COUNT(?r) as ?cnt) WHERE " + whereClause, Integer.class);
setQueryParameters(countQuery, queryParams);
final Integer totalCount = countQuery.getSingleResult();
return new PageImpl<>(records, pageSpec, totalCount);
}

private void setQueryParameters(TypedQuery<?> query, Map<String, Object> queryParams) {
query.setParameter("type", typeUri)
.setParameter("hasPhase", URI.create(Vocabulary.s_p_has_phase))
.setParameter("hasInstitution",
URI.create(Vocabulary.s_p_was_treated_at))
.setParameter("hasKey", URI.create(Vocabulary.s_p_key))
.setParameter("hasCreatedDate", URI.create(Vocabulary.s_p_created))
.setParameter("hasLastModified", URI.create(Vocabulary.s_p_modified));
queryParams.forEach(query::setParameter);
}

private static String constructWhereClause(RecordFilterParams filters, Map<String, Object> queryParams) {
// Could not use Criteria API because it does not support OPTIONAL
String queryString = "SELECT ?r WHERE {" +
String whereClause = "{" +
"?r a ?type ; " +
"?hasCreatedDate ?created ; " +
"?hasInstitution ?institution . " +
"?institution ?hasKey ?institutionKey ." +
"OPTIONAL { ?r ?hasPhase ?phase . } " +
"OPTIONAL { ?r ?hasLastModified ?lastModified . } " +
"BIND (IF (BOUND(?lastModified), ?lastModified, ?created) AS ?edited) ";
final Map<String, Object> queryParams = new HashMap<>();
queryString += mapParamsToQuery(filterParams, queryParams);
queryString += "} ORDER BY ?edited";

final TypedQuery<PatientRecord> query = em.createNativeQuery(queryString, PatientRecord.class)
.setParameter("type", typeUri)
.setParameter("hasPhase", URI.create(Vocabulary.s_p_has_phase))
.setParameter("hasInstitution",
URI.create(Vocabulary.s_p_was_treated_at))
.setParameter("hasKey", URI.create(Vocabulary.s_p_key))
.setParameter("hasCreatedDate", URI.create(Vocabulary.s_p_created))
.setParameter("hasLastModified", URI.create(Vocabulary.s_p_modified));
queryParams.forEach(query::setParameter);
return query.getResultList();
"BIND (COALESCE(?lastModified, ?created) AS ?date) ";
whereClause += mapParamsToQuery(filters, queryParams);
whereClause += "}";
return whereClause;
}

private static String mapParamsToQuery(RecordFilterParams filterParams, Map<String, Object> queryParams) {
final List<String> filters = new ArrayList<>();
filterParams.getInstitutionKey()
.ifPresent(key -> queryParams.put("institutionKey", new LangString(key, Constants.PU_LANGUAGE)));
filterParams.getMinModifiedDate().ifPresent(date -> {
filters.add("FILTER (?edited >= ?minDate)");
filters.add("FILTER (?date >= ?minDate)");
queryParams.put("minDate", date.atStartOfDay(ZoneOffset.UTC).toInstant());
});
filterParams.getMaxModifiedDate().ifPresent(date -> {
filters.add("FILTER (?edited < ?maxDate)");
filters.add("FILTER (?date < ?maxDate)");
queryParams.put("maxDate", date.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant());
});
if (!filterParams.getPhaseIds().isEmpty()) {
Expand All @@ -232,4 +282,20 @@ private static String mapParamsToQuery(RecordFilterParams filterParams, Map<Stri
}
return String.join(" ", filters);
}

private static String resolveOrderBy(Sort sort) {
if (sort.isUnsorted()) {
return "";
}
final StringBuilder sb = new StringBuilder(" ORDER BY");
for (Sort.Order o : sort) {
if (!RecordSort.SORTING_PROPERTIES.contains(o.getProperty())) {
throw new IllegalArgumentException("Unsupported record sorting property '" + o.getProperty() + "'.");
}
sb.append(' ');
sb.append(o.isAscending() ? "ASC(" : "DESC(");
sb.append('?').append(o.getProperty()).append(')');
}
return sb.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ public class RecordFilterParams {
public RecordFilterParams() {
}

public RecordFilterParams(String institutionKey) {
this.institutionKey = institutionKey;
}

// This one mainly is for test data setup
public RecordFilterParams(String institutionKey, LocalDate minModifiedDate, LocalDate maxModifiedDate,
Set<String> phaseIds) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package cz.cvut.kbss.study.persistence.dao.util;

import org.springframework.data.domain.Sort;

import java.util.Set;

/**
* Provides constants for sorting records.
*/
public class RecordSort {

/**
* Property used to sort records by date of last modification (if available) or creation.
*/
public static final String SORT_DATE_PROPERTY = "date";

/**
* Supported sorting properties.
*/
public static final Set<String> SORTING_PROPERTIES = Set.of(SORT_DATE_PROPERTY);

private RecordSort() {
throw new AssertionError();
}

/**
* Returns the default sort for retrieving records.
* <p>
* By default, records are sorted by date of last modification/creation in descending order.
*
* @return Default sort
*/
public static Sort defaultSort() {
return Sort.by(Sort.Order.desc("date"));
}
}
19 changes: 16 additions & 3 deletions src/main/java/cz/cvut/kbss/study/rest/InstitutionController.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,27 @@
import cz.cvut.kbss.study.security.SecurityConstants;
import cz.cvut.kbss.study.service.InstitutionService;
import cz.cvut.kbss.study.service.PatientRecordService;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import java.util.Comparator;
import java.util.List;

import static cz.cvut.kbss.study.rest.util.RecordFilterMapper.constructRecordFilter;

@RestController
@PreAuthorize("hasRole('" + SecurityConstants.ROLE_USER + "')")
@RequestMapping("/institutions")
Expand Down Expand Up @@ -59,8 +70,9 @@ private Institution findInternal(String key) {
@PreAuthorize("hasRole('" + SecurityConstants.ROLE_ADMIN + "') or @securityUtils.isRecordInUsersInstitution(#key)")
@GetMapping(value = "/{key}/patients", produces = MediaType.APPLICATION_JSON_VALUE)
public List<PatientRecordDto> getTreatedPatientRecords(@PathVariable("key") String key) {
final Institution institution = findInternal(key);
return recordService.findByInstitution(institution);
final Institution inst = findInternal(key);
assert inst != null;
return recordService.findAll(constructRecordFilter("institution", key), Pageable.unpaged()).getContent();
}

@PreAuthorize("hasRole('" + SecurityConstants.ROLE_ADMIN + "')")
Expand All @@ -84,6 +96,7 @@ public void updateInstitution(@PathVariable("key") String key, @RequestBody Inst
throw new BadRequestException("The passed institution's key is different from the specified one.");
}
final Institution original = findInternal(key);
assert original != null;

institutionService.update(institution);
if (LOG.isTraceEnabled()) {
Expand Down
41 changes: 22 additions & 19 deletions src/main/java/cz/cvut/kbss/study/rest/PatientRecordController.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
import cz.cvut.kbss.study.dto.PatientRecordDto;
import cz.cvut.kbss.study.dto.RecordImportResult;
import cz.cvut.kbss.study.exception.NotFoundException;
import cz.cvut.kbss.study.model.Institution;
import cz.cvut.kbss.study.model.PatientRecord;
import cz.cvut.kbss.study.model.RecordPhase;
import cz.cvut.kbss.study.rest.event.PaginatedResultRetrievedEvent;
import cz.cvut.kbss.study.rest.exception.BadRequestException;
import cz.cvut.kbss.study.rest.util.RecordFilterMapper;
import cz.cvut.kbss.study.rest.util.RestUtils;
import cz.cvut.kbss.study.security.SecurityConstants;
import cz.cvut.kbss.study.service.InstitutionService;
import cz.cvut.kbss.study.service.PatientRecordService;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
Expand All @@ -28,6 +30,7 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.List;

Expand All @@ -38,36 +41,36 @@ public class PatientRecordController extends BaseController {

private final PatientRecordService recordService;

private final InstitutionService institutionService;
private final ApplicationEventPublisher eventPublisher;

public PatientRecordController(PatientRecordService recordService, InstitutionService institutionService) {
public PatientRecordController(PatientRecordService recordService, ApplicationEventPublisher eventPublisher) {
this.recordService = recordService;
this.institutionService = institutionService;
this.eventPublisher = eventPublisher;
}

@PreAuthorize("hasRole('" + SecurityConstants.ROLE_ADMIN + "') or @securityUtils.isMemberOfInstitution(#institutionKey)")
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public List<PatientRecordDto> getRecords(
@RequestParam(value = "institution", required = false) String institutionKey) {
return institutionKey != null ? recordService.findByInstitution(getInstitution(institutionKey)) :
recordService.findAllRecords();
}

private Institution getInstitution(String institutionKey) {
final Institution institution = institutionService.findByKey(institutionKey);
if (institution == null) {
throw NotFoundException.create("Institution", institutionKey);
}
return institution;
@RequestParam(value = "institution", required = false) String institutionKey,
@RequestParam MultiValueMap<String, String> params,
UriComponentsBuilder uriBuilder, HttpServletResponse response) {
final Page<PatientRecordDto> result = recordService.findAll(RecordFilterMapper.constructRecordFilter(params),
RestUtils.resolvePaging(params));
eventPublisher.publishEvent(new PaginatedResultRetrievedEvent(this, uriBuilder, response, result));
return result.getContent();
}

@PreAuthorize(
"hasRole('" + SecurityConstants.ROLE_ADMIN + "') or @securityUtils.isMemberOfInstitution(#institutionKey)")
@GetMapping(value = "/export", produces = MediaType.APPLICATION_JSON_VALUE)
public List<PatientRecord> exportRecords(
@RequestParam(name = "institutionKey", required = false) String institutionKey,
@RequestParam MultiValueMap<String, String> params) {
return recordService.findAllFull(RecordFilterMapper.constructRecordFilter(params));
@RequestParam(name = "institution", required = false) String institutionKey,
@RequestParam MultiValueMap<String, String> params,
UriComponentsBuilder uriBuilder, HttpServletResponse response) {
final Page<PatientRecord> result = recordService.findAllFull(RecordFilterMapper.constructRecordFilter(params),
RestUtils.resolvePaging(params));
eventPublisher.publishEvent(new PaginatedResultRetrievedEvent(this, uriBuilder, response, result));
return result.getContent();
}

@PreAuthorize("hasRole('" + SecurityConstants.ROLE_ADMIN + "') or @securityUtils.isRecordInUsersInstitution(#key)")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package cz.cvut.kbss.study.rest.event;

import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.ApplicationEvent;
import org.springframework.data.domain.Page;
import org.springframework.web.util.UriComponentsBuilder;

/**
* Fired when a paginated result is retrieved by a REST controller, so that HATEOAS headers can be added to the
* response.
*/
public class PaginatedResultRetrievedEvent extends ApplicationEvent {

private final UriComponentsBuilder uriBuilder;
private final HttpServletResponse response;
private final Page<?> page;

public PaginatedResultRetrievedEvent(Object source, UriComponentsBuilder uriBuilder, HttpServletResponse response,
Page<?> page) {
super(source);
this.uriBuilder = uriBuilder;
this.response = response;
this.page = page;
}

public UriComponentsBuilder getUriBuilder() {
return uriBuilder;
}

public HttpServletResponse getResponse() {
return response;
}

public Page<?> getPage() {
return page;
}
}
Loading

0 comments on commit db451c8

Please sign in to comment.