diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 84e8ed9f684..e588ec4faaa 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -54,6 +54,22 @@ A new `IProjectDataVersioningRestServiceDelegate` interface is available, allowi Specifiers can implement this new interface with a spring `Service`. A new `IRestDataVersionPayloadSerializerService` interface is available, allowing to customize the default implementation of the JSON serialization of the payload object of `RestDataVersion`. Specifiers are also encouraged to implement their own `IRestDataVersionPayloadSerializerService` for their domains, as the default one may not return expected results. +- https://github.com/eclipse-sirius/sirius-web/issues/4233[#4233] [core] Add cursor-based pagination for Project related GET REST APIs. +New optional attributes are available on all Project related GET REST APIs returning a list of objects, allowing to paginate the data returned by those APIs. +The following APIs are concerned: +** getProjects (`GET /api/rest/projects`) +The new optional attributes are: +** `page[size]` specifies the maximum number of records that will be returned per page in the response +** `page[before]` specifies the URL of the page succeeding the page being requested +** `page[after]` specifies the URL of a page preceding the page being requested +If neither `page[before]` nor `page[after]` is specified, the first page is returned with the same number of records as specified in the `page[size]` query parameter. +If the `page[size]` parameter is not specified, then the default page size is used, which is 20. +Example: + ** `http://my-sirius-web-server:8080/api/rest/projects? +page[after]=MTYxODg2MTQ5NjYzMnwyMDEwOWY0MC00ODI1LTQxNmEtODZmNi03NTA4YWM0MmEwMjE& +page[size]=3` will ask for the 3 projects following the one identified by the URL of the page succeeding the page being requested `MTYxODg2MTQ5NjYzMnwyMDEwOWY0MC00ODI1LTQxNmEtODZmNi03NTA4YWM0MmEwMjE`. +Note that you can retrieve the URL of a page in the response header of `GET /api/rest/projects`. +Note that you may need to encode special characters like `[`(by `%5B`) and `]` (by `%5D`) in your requests. === Improvements diff --git a/packages/sirius-web/backend/sirius-web-application/src/main/java/org/eclipse/sirius/web/application/project/controllers/ProjectRestController.java b/packages/sirius-web/backend/sirius-web-application/src/main/java/org/eclipse/sirius/web/application/project/controllers/ProjectRestController.java index e7b886685a2..111e38e9e30 100644 --- a/packages/sirius-web/backend/sirius-web-application/src/main/java/org/eclipse/sirius/web/application/project/controllers/ProjectRestController.java +++ b/packages/sirius-web/backend/sirius-web-application/src/main/java/org/eclipse/sirius/web/application/project/controllers/ProjectRestController.java @@ -16,6 +16,7 @@ import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -30,9 +31,12 @@ import org.eclipse.sirius.web.application.project.dto.RenameProjectSuccessPayload; import org.eclipse.sirius.web.application.project.dto.RestProject; import org.eclipse.sirius.web.application.project.services.api.IProjectApplicationService; -import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -41,6 +45,10 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import org.springframework.web.util.UriComponents; + +import graphql.relay.Relay; /** * REST Controller for the Project Endpoint. @@ -51,6 +59,8 @@ @RequestMapping("/api/rest/projects") public class ProjectRestController { + private static final int DEFAULT_PAGE_SIZE = 20; + private static final OffsetDateTime DEFAULT_CREATED = Instant.EPOCH.atOffset(ZoneOffset.UTC); private final IProjectApplicationService projectApplicationService; @@ -60,12 +70,26 @@ public ProjectRestController(IProjectApplicationService projectApplicationServic } @GetMapping - public ResponseEntity> getProjects() { - var restProjects = this.projectApplicationService.findAll(PageRequest.of(0, 20)) + public ResponseEntity> getProjects(@RequestParam(name = "page[size]") Optional pageSize, @RequestParam(name = "page[after]") Optional pageAfter, @RequestParam(name = "page[before]") Optional pageBefore) { + final KeysetScrollPosition position; + if (pageAfter.isPresent() && pageBefore.isEmpty()) { + var cursorProjectId = new Relay().fromGlobalId(pageAfter.get()).getId(); + position = ScrollPosition.forward(Map.of("id", cursorProjectId)); + } else if (pageBefore.isPresent() && pageAfter.isEmpty()) { + var cursorProjectId = new Relay().fromGlobalId(pageBefore.get()).getId(); + position = ScrollPosition.backward(Map.of("id", cursorProjectId)); + } else if (pageBefore.isPresent() && pageAfter.isPresent()) { + position = ScrollPosition.keyset(); + } else { + position = ScrollPosition.keyset(); + } + int limit = pageSize.orElse(DEFAULT_PAGE_SIZE); + var window = this.projectApplicationService.findAll(position, limit); + var restProjects = window .map(project -> new RestProject(project.id(), DEFAULT_CREATED, new Identified(project.id()), null, project.name())) .toList(); - - return new ResponseEntity<>(restProjects, HttpStatus.OK); + var headers = this.handleLinkResponseHeader(restProjects, position, window.hasNext(), limit); + return new ResponseEntity<>(restProjects, headers, HttpStatus.OK); } @GetMapping(path = "/{projectId}") @@ -123,4 +147,33 @@ public ResponseEntity deleteProject(@PathVariable UUID projectId) { return new ResponseEntity<>(restProject, HttpStatus.OK); } + + private MultiValueMap handleLinkResponseHeader(List projects, KeysetScrollPosition position, boolean hasNext, int limit) { + MultiValueMap headers = new HttpHeaders(); + int projectsSize = projects.size(); + if (projectsSize > 0 && position.scrollsForward() && hasNext) { + var headerLink = this.createHeaderLink(projects, limit, "after", "next"); + headers.add(HttpHeaders.LINK, headerLink); + } else if (projectsSize > 0 && position.scrollsBackward() && hasNext) { + var headerLink = this.createHeaderLink(projects, limit, "before", "prev"); + headers.add(HttpHeaders.LINK, headerLink); + } + return headers; + } + + private String createHeaderLink(List projects, int limit, String beforeOrAfterPage, String relationType) { + var header = new StringBuilder(); + var lastProject = projects.get(Math.min(projects.size() - 1, limit - 1)); + var cursorId = new Relay().toGlobalId("Project", lastProject.id().toString()); + UriComponents uriComponents = ServletUriComponentsBuilder.fromCurrentRequestUri() + .queryParam("page[" + beforeOrAfterPage + "]", cursorId) + .queryParam("page[size]", limit) + .build(); + header.append("<"); + header.append(uriComponents.toUriString()); + header.append(">; rel=\""); + header.append(relationType); + header.append("\""); + return header.toString(); + } } diff --git a/packages/sirius-web/backend/sirius-web-application/src/main/java/org/eclipse/sirius/web/application/project/controllers/ViewerProjectsDataFetcher.java b/packages/sirius-web/backend/sirius-web-application/src/main/java/org/eclipse/sirius/web/application/project/controllers/ViewerProjectsDataFetcher.java index 63fc9c100a7..5345b905cdd 100644 --- a/packages/sirius-web/backend/sirius-web-application/src/main/java/org/eclipse/sirius/web/application/project/controllers/ViewerProjectsDataFetcher.java +++ b/packages/sirius-web/backend/sirius-web-application/src/main/java/org/eclipse/sirius/web/application/project/controllers/ViewerProjectsDataFetcher.java @@ -12,6 +12,7 @@ *******************************************************************************/ package org.eclipse.sirius.web.application.project.controllers; +import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -20,8 +21,9 @@ import org.eclipse.sirius.components.graphql.api.IDataFetcherWithFieldCoordinates; import org.eclipse.sirius.web.application.project.dto.ProjectDTO; import org.eclipse.sirius.web.application.project.services.api.IProjectApplicationService; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Window; import graphql.relay.Connection; import graphql.relay.ConnectionCursor; @@ -40,9 +42,15 @@ @QueryDataFetcher(type = "Viewer", field = "projects") public class ViewerProjectsDataFetcher implements IDataFetcherWithFieldCoordinates> { - private static final String PAGE_ARGUMENT = "page"; + private static final int DEFAULT_PAGE_SIZE = 20; - private static final String LIMIT_ARGUMENT = "limit"; + private static final String FIRST_ARGUMENT = "first"; + + private static final String LAST_ARGUMENT = "last"; + + private static final String AFTER_ARGUMENT = "after"; + + private static final String BEFORE_ARGUMENT = "before"; private final IProjectApplicationService projectApplicationService; @@ -52,19 +60,56 @@ public ViewerProjectsDataFetcher(IProjectApplicationService projectApplicationSe @Override public Connection get(DataFetchingEnvironment environment) throws Exception { - int page = Optional. ofNullable(environment.getArgument(PAGE_ARGUMENT)) - .filter(pageArgument -> pageArgument > 0) - .orElse(0); - int limit = Optional. ofNullable(environment.getArgument(LIMIT_ARGUMENT)) - .filter(limitArgument -> limitArgument > 0) - .orElse(20); - - var pageable = PageRequest.of(page, limit); - var projectPage = this.projectApplicationService.findAll(pageable); - return this.toConnection(projectPage); + Optional first = Optional. ofNullable(environment.getArgument(FIRST_ARGUMENT)); + Optional last = Optional. ofNullable(environment.getArgument(LAST_ARGUMENT)); + Optional after = Optional. ofNullable(environment.getArgument(AFTER_ARGUMENT)); + Optional before = Optional. ofNullable(environment.getArgument(BEFORE_ARGUMENT)); + + final KeysetScrollPosition position; + final int limit; + if (after.isPresent() && before.isEmpty()) { + var projectId = after.get(); + var cursorProjectId = new Relay().fromGlobalId(projectId).getId(); + position = ScrollPosition.forward(Map.of("id", cursorProjectId)); + if (last.isPresent()) { + limit = 0; + } else if (first.isPresent()) { + limit = first.get(); + } else { + limit = DEFAULT_PAGE_SIZE; + } + } else if (before.isPresent() && after.isEmpty()) { + var projectId = before.get(); + var cursorProjectId = new Relay().fromGlobalId(projectId).getId(); + position = ScrollPosition.backward(Map.of("id", cursorProjectId)); + if (first.isPresent()) { + limit = 0; + } else if (last.isPresent()) { + limit = last.get(); + } else { + limit = DEFAULT_PAGE_SIZE; + } + } else if (before.isPresent() && after.isPresent()) { + position = ScrollPosition.keyset(); + limit = 0; + } else { + position = ScrollPosition.keyset(); + if (first.isPresent() && last.isPresent()) { + limit = 0; + } else if (first.isPresent()) { + limit = first.get(); + } else if (last.isPresent()) { + limit = last.get(); + } else { + limit = DEFAULT_PAGE_SIZE; + } + } + + var projectPage = this.projectApplicationService.findAll(position, limit); + return this.toConnection(projectPage, position); } - private Connection toConnection(Page projectPage) { + private Connection toConnection(Window projectPage, KeysetScrollPosition position) { var edges = projectPage.stream().map(projectDTO -> { var globalId = new Relay().toGlobalId("Project", projectDTO.id().toString()); var cursor = new DefaultConnectionCursor(globalId); @@ -78,7 +123,15 @@ private Connection toConnection(Page projectPage) { if (!edges.isEmpty()) { endCursor = edges.get(edges.size() - 1).getCursor(); } - var pageInfo = new PageInfoWithCount(startCursor, endCursor, projectPage.hasPrevious(), projectPage.hasNext(), projectPage.getTotalElements()); + boolean hasPreviousPage = false; + if (position.scrollsBackward()) { + hasPreviousPage = projectPage.hasNext(); + } + boolean hasNextPage = false; + if (position.scrollsForward()) { + hasNextPage = projectPage.hasNext(); + } + var pageInfo = new PageInfoWithCount(startCursor, endCursor, hasPreviousPage, hasNextPage, projectPage.size()); return new DefaultConnection<>(edges, pageInfo); } } diff --git a/packages/sirius-web/backend/sirius-web-application/src/main/java/org/eclipse/sirius/web/application/project/services/ProjectApplicationService.java b/packages/sirius-web/backend/sirius-web-application/src/main/java/org/eclipse/sirius/web/application/project/services/ProjectApplicationService.java index 9992ab320da..3ffa2af5796 100644 --- a/packages/sirius-web/backend/sirius-web-application/src/main/java/org/eclipse/sirius/web/application/project/services/ProjectApplicationService.java +++ b/packages/sirius-web/backend/sirius-web-application/src/main/java/org/eclipse/sirius/web/application/project/services/ProjectApplicationService.java @@ -34,8 +34,8 @@ import org.eclipse.sirius.web.domain.boundedcontexts.project.services.api.IProjectUpdateService; import org.eclipse.sirius.web.domain.services.Failure; import org.eclipse.sirius.web.domain.services.Success; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.Window; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -72,8 +72,8 @@ public Optional findById(UUID projectId) { @Override @Transactional(readOnly = true) - public Page findAll(Pageable pageable) { - return this.projectSearchService.findAll(pageable).map(this.projectMapper::toDTO); + public Window findAll(KeysetScrollPosition position, int limit) { + return this.projectSearchService.findAll(position, limit).map(this.projectMapper::toDTO); } @Override diff --git a/packages/sirius-web/backend/sirius-web-application/src/main/java/org/eclipse/sirius/web/application/project/services/api/IProjectApplicationService.java b/packages/sirius-web/backend/sirius-web-application/src/main/java/org/eclipse/sirius/web/application/project/services/api/IProjectApplicationService.java index c837cb2f233..c40afcae56a 100644 --- a/packages/sirius-web/backend/sirius-web-application/src/main/java/org/eclipse/sirius/web/application/project/services/api/IProjectApplicationService.java +++ b/packages/sirius-web/backend/sirius-web-application/src/main/java/org/eclipse/sirius/web/application/project/services/api/IProjectApplicationService.java @@ -20,8 +20,8 @@ import org.eclipse.sirius.web.application.project.dto.DeleteProjectInput; import org.eclipse.sirius.web.application.project.dto.ProjectDTO; import org.eclipse.sirius.web.application.project.dto.RenameProjectInput; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.Window; /** * Application services used to manipulate projects. @@ -31,7 +31,7 @@ public interface IProjectApplicationService { Optional findById(UUID id); - Page findAll(Pageable pageable); + Window findAll(KeysetScrollPosition position, int limit); IPayload createProject(CreateProjectInput input); diff --git a/packages/sirius-web/backend/sirius-web-application/src/main/resources/schema/siriusweb.graphqls b/packages/sirius-web/backend/sirius-web-application/src/main/resources/schema/siriusweb.graphqls index df24e99f245..23f47738fc1 100644 --- a/packages/sirius-web/backend/sirius-web-application/src/main/resources/schema/siriusweb.graphqls +++ b/packages/sirius-web/backend/sirius-web-application/src/main/resources/schema/siriusweb.graphqls @@ -1,6 +1,6 @@ extend type Viewer { project(projectId: ID!): Project - projects(page: Int!, limit: Int!): ViewerProjectsConnection! + projects(after: String, before: String, first: Int, last: Int): ViewerProjectsConnection! projectTemplates(page: Int!, limit: Int!): ViewerProjectTemplatesConnection! } @@ -11,6 +11,7 @@ type ViewerProjectsConnection { type ViewerProjectsEdge { node: Project! + cursor: String! } type Project { diff --git a/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/repositories/IProjectRepository.java b/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/repositories/IProjectRepository.java index 566993fb6d2..0a1901cd701 100644 --- a/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/repositories/IProjectRepository.java +++ b/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/repositories/IProjectRepository.java @@ -16,14 +16,14 @@ import org.eclipse.sirius.web.domain.boundedcontexts.project.Project; import org.springframework.data.repository.ListCrudRepository; -import org.springframework.data.repository.ListPagingAndSortingRepository; import org.springframework.stereotype.Repository; + /** * Repository used to persist the project aggregate. * * @author sbegaudeau */ @Repository -public interface IProjectRepository extends ListPagingAndSortingRepository, ListCrudRepository, ProjectSearchRepository { +public interface IProjectRepository extends ListCrudRepository, ProjectSearchRepository { } diff --git a/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/repositories/ProjectSearchRepository.java b/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/repositories/ProjectSearchRepository.java index 147bcec9188..da56dc97595 100644 --- a/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/repositories/ProjectSearchRepository.java +++ b/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/repositories/ProjectSearchRepository.java @@ -12,11 +12,12 @@ *******************************************************************************/ package org.eclipse.sirius.web.domain.boundedcontexts.project.repositories; +import java.util.List; import java.util.Optional; +import java.util.UUID; import org.eclipse.sirius.components.annotations.RepositoryFragment; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import org.eclipse.sirius.web.domain.boundedcontexts.project.Project; /** * Fragment interface used to search projects. @@ -32,5 +33,7 @@ public interface ProjectSearchRepository { Optional findById(ID id); - Page findAll(Pageable pageable); + List findAllBefore(UUID cursorProjectId, int limit); + + List findAllAfter(UUID cursorProjectId, int limit); } diff --git a/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/repositories/ProjectSearchRepositoryDelegate.java b/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/repositories/ProjectSearchRepositoryDelegate.java index edc270f4b79..57b1f670e43 100644 --- a/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/repositories/ProjectSearchRepositoryDelegate.java +++ b/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/repositories/ProjectSearchRepositoryDelegate.java @@ -15,16 +15,16 @@ import static org.springframework.data.relational.core.query.Criteria.where; import static org.springframework.data.relational.core.query.Query.query; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.UUID; import org.eclipse.sirius.web.domain.boundedcontexts.project.Project; import org.eclipse.sirius.web.domain.boundedcontexts.project.repositories.api.IProjectSearchRepositoryDelegate; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jdbc.core.JdbcAggregateOperations; import org.springframework.data.relational.core.query.Query; +import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Service; /** @@ -35,26 +35,101 @@ @Service public class ProjectSearchRepositoryDelegate implements IProjectSearchRepositoryDelegate { + private static final String ID = "id"; + + private static final String CURSOR_PROJECT_ID = "cursorProjectId"; + + private static final String LIMIT = "limit"; + + private static final String FIND_ALL_BEFORE = """ + select + p.* + from + project p + where + (cast(:cursorProjectId as uuid) is null + or (p.id <> :cursorProjectId + and p.created_on <= ( + select + created_on + from + project + where + project.id = :cursorProjectId)) + ) + order by + p.created_on desc + limit :limit; + """; + + private static final String FIND_ALL_AFTER = """ + select + p.* + from + project p + where + (cast(:cursorProjectId as uuid) is null + or (p.id <> :cursorProjectId + and p.created_on >= ( + select + created_on + from + project + where + project.id = :cursorProjectId)) + ) + order by + p.created_on asc + limit :limit; + """; + private final JdbcAggregateOperations jdbcAggregateOperations; - public ProjectSearchRepositoryDelegate(JdbcAggregateOperations jdbcAggregateOperations) { + private final JdbcClient jdbcClient; + + public ProjectSearchRepositoryDelegate(JdbcAggregateOperations jdbcAggregateOperations, JdbcClient jdbcClient) { this.jdbcAggregateOperations = Objects.requireNonNull(jdbcAggregateOperations); + this.jdbcClient = Objects.requireNonNull(jdbcClient); } @Override public boolean existsById(UUID projectId) { - Query query = query(where("id").is(projectId)); + Query query = query(where(ID).is(projectId)); return this.jdbcAggregateOperations.exists(query, Project.class); } @Override public Optional findById(UUID projectId) { - Query query = query(where("id").is(projectId)); + Query query = query(where(ID).is(projectId)); return this.jdbcAggregateOperations.findOne(query, Project.class); } @Override - public Page findAll(Pageable pageable) { - return this.jdbcAggregateOperations.findAll(Project.class, pageable); + public List findAllBefore(UUID cursorProjectId, int limit) { + List projectsBefore = null; + if (limit > 0) { + var projects = this.getAllProjectsQuery(FIND_ALL_BEFORE, cursorProjectId, limit + 1); + projectsBefore = projects.subList(0, Math.min(projects.size(), limit)); + } + return projectsBefore; + } + + @Override + public List findAllAfter(UUID cursorProjectId, int limit) { + List projectsAfter = null; + if (limit > 0) { + var projects = this.getAllProjectsQuery(FIND_ALL_AFTER, cursorProjectId, limit + 1); + projectsAfter = projects.subList(0, Math.min(projects.size(), limit)); + } + return projectsAfter; + } + + private List getAllProjectsQuery(String sqlQuery, UUID cursorProjectId, int limit) { + return this.jdbcClient + .sql(sqlQuery) + .param(CURSOR_PROJECT_ID, cursorProjectId) + .param(LIMIT, limit) + .query(Project.class) + .list(); } } diff --git a/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/repositories/ProjectSearchRepositoryImpl.java b/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/repositories/ProjectSearchRepositoryImpl.java index 87c71347f23..a2abb873123 100644 --- a/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/repositories/ProjectSearchRepositoryImpl.java +++ b/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/repositories/ProjectSearchRepositoryImpl.java @@ -12,14 +12,13 @@ *******************************************************************************/ package org.eclipse.sirius.web.domain.boundedcontexts.project.repositories; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.UUID; import org.eclipse.sirius.web.domain.boundedcontexts.project.Project; import org.eclipse.sirius.web.domain.boundedcontexts.project.repositories.api.IProjectSearchRepositoryDelegate; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; /** @@ -47,7 +46,12 @@ public Optional findById(UUID projectId) { } @Override - public Page findAll(Pageable pageable) { - return this.projectSearchRepositoryDelegate.findAll(pageable); + public List findAllAfter(UUID cursorProjectId, int limit) { + return this.projectSearchRepositoryDelegate.findAllAfter(cursorProjectId, limit); + } + + @Override + public List findAllBefore(UUID cursorProjectId, int limit) { + return this.projectSearchRepositoryDelegate.findAllBefore(cursorProjectId, limit); } } diff --git a/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/services/ProjectSearchService.java b/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/services/ProjectSearchService.java index 4eccfb909b7..8845f67ef54 100644 --- a/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/services/ProjectSearchService.java +++ b/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/services/ProjectSearchService.java @@ -12,6 +12,7 @@ *******************************************************************************/ package org.eclipse.sirius.web.domain.boundedcontexts.project.services; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -19,8 +20,8 @@ import org.eclipse.sirius.web.domain.boundedcontexts.project.Project; import org.eclipse.sirius.web.domain.boundedcontexts.project.repositories.IProjectRepository; import org.eclipse.sirius.web.domain.boundedcontexts.project.services.api.IProjectSearchService; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.Window; import org.springframework.stereotype.Service; /** @@ -48,7 +49,39 @@ public Optional findById(UUID projectId) { } @Override - public Page findAll(Pageable pageable) { - return this.projectRepository.findAll(pageable); + public Window findAll(KeysetScrollPosition position, int limit) { + Window window = Window.from(List.of(), (i) -> position, false); + if (limit > 0) { + var cursorProjectKey = position.getKeys().get("id"); + if (cursorProjectKey instanceof String cursorProjectId) { + var cursorProjectUUID = this.parse(cursorProjectId); + if (cursorProjectUUID.isPresent() && this.existsById(cursorProjectUUID.get())) { + if (position.scrollsForward()) { + var projects = this.projectRepository.findAllAfter(cursorProjectUUID.get(), limit + 1); + boolean hasNext = projects.size() > limit; + window = Window.from(projects.subList(0, Math.min(projects.size(), limit)), (i) -> position, hasNext); + } else if (position.scrollsBackward()) { + var projects = this.projectRepository.findAllBefore(cursorProjectUUID.get(), limit + 1); + boolean hasPrevious = projects.size() > limit; + window = Window.from(projects.subList(0, Math.min(projects.size(), limit)), (i) -> position, hasPrevious); + } + } + } else { + var projects = this.projectRepository.findAllAfter(null, limit + 1); + boolean hasNext = projects.size() > limit; + window = Window.from(projects.subList(0, Math.min(projects.size(), limit)), (i) -> position, hasNext); + } + } + return window; + } + + private Optional parse(String id) { + try { + UUID uuid = UUID.fromString(id); + return Optional.of(uuid); + } catch (IllegalArgumentException exception) { + // Ignore, the information that the id is invalid is returned as an empty Optional. + } + return Optional.empty(); } } diff --git a/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/services/api/IProjectSearchService.java b/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/services/api/IProjectSearchService.java index 37b8057aae6..15874ce1ca4 100644 --- a/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/services/api/IProjectSearchService.java +++ b/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/services/api/IProjectSearchService.java @@ -16,8 +16,8 @@ import java.util.UUID; import org.eclipse.sirius.web.domain.boundedcontexts.project.Project; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.Window; /** * Used to retrieve projects. @@ -30,5 +30,5 @@ public interface IProjectSearchService { Optional findById(UUID projectId); - Page findAll(Pageable pageable); + Window findAll(KeysetScrollPosition position, int limit); } diff --git a/packages/sirius-web/backend/sirius-web-tests/src/main/java/org/eclipse/sirius/web/tests/graphql/ProjectsQueryRunner.java b/packages/sirius-web/backend/sirius-web-tests/src/main/java/org/eclipse/sirius/web/tests/graphql/ProjectsQueryRunner.java index 9dc1d329010..aa711b18b88 100644 --- a/packages/sirius-web/backend/sirius-web-tests/src/main/java/org/eclipse/sirius/web/tests/graphql/ProjectsQueryRunner.java +++ b/packages/sirius-web/backend/sirius-web-tests/src/main/java/org/eclipse/sirius/web/tests/graphql/ProjectsQueryRunner.java @@ -28,13 +28,14 @@ public class ProjectsQueryRunner implements IQueryRunner { private static final String PROJECTS_QUERY = """ - query getProjects($page: Int!, $limit: Int!) { + query getProjects($after: String, $before: String, $first: Int, $last: Int) { viewer { - projects(page: $page, limit: $limit) { + projects(after: $after, before: $before, first: $first, last: $last) { edges { node { id } + cursor } pageInfo { hasPreviousPage diff --git a/packages/sirius-web/backend/sirius-web/src/test/java/org/eclipse/sirius/web/application/controllers/projects/ProjectControllerIntegrationTests.java b/packages/sirius-web/backend/sirius-web/src/test/java/org/eclipse/sirius/web/application/controllers/projects/ProjectControllerIntegrationTests.java index 648d5b69fda..f65727fcfea 100644 --- a/packages/sirius-web/backend/sirius-web/src/test/java/org/eclipse/sirius/web/application/controllers/projects/ProjectControllerIntegrationTests.java +++ b/packages/sirius-web/backend/sirius-web/src/test/java/org/eclipse/sirius/web/application/controllers/projects/ProjectControllerIntegrationTests.java @@ -26,7 +26,6 @@ import org.eclipse.sirius.components.core.api.ErrorPayload; import org.eclipse.sirius.components.core.api.SuccessPayload; import org.eclipse.sirius.web.AbstractIntegrationTests; -import org.eclipse.sirius.web.data.TestIdentifiers; import org.eclipse.sirius.web.application.project.dto.CreateProjectInput; import org.eclipse.sirius.web.application.project.dto.CreateProjectSuccessPayload; import org.eclipse.sirius.web.application.project.dto.DeleteProjectInput; @@ -34,6 +33,7 @@ import org.eclipse.sirius.web.application.project.dto.ProjectRenamedEventPayload; import org.eclipse.sirius.web.application.project.dto.RenameProjectInput; import org.eclipse.sirius.web.application.project.dto.RenameProjectSuccessPayload; +import org.eclipse.sirius.web.data.TestIdentifiers; import org.eclipse.sirius.web.domain.boundedcontexts.project.events.ProjectCreatedEvent; import org.eclipse.sirius.web.domain.boundedcontexts.project.events.ProjectDeletedEvent; import org.eclipse.sirius.web.domain.boundedcontexts.project.services.api.IProjectSearchService; @@ -49,7 +49,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.ScrollPosition; import org.springframework.test.context.jdbc.Sql; import org.springframework.test.context.jdbc.SqlConfig; import org.springframework.test.context.transaction.TestTransaction; @@ -130,11 +130,11 @@ public void givenAnInvalidProjectWhenQueryIsPerformedThenNullIsReturned() { } @Test - @DisplayName("Given a set of projects, when a query is performed, then the projects are returned") + @DisplayName("Given a set of projects, when a valid first query is performed, then the projects are returned") @Sql(scripts = {"/scripts/initialize.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) - public void givenSetOfProjectsWhenQueryIsPerformedThenTheProjectsAreReturned() { - Map variables = Map.of("page", 0, "limit", 2); + public void givenSetOfProjectsWhenValidFirstQueryIsPerformedThenTheProjectsAreReturned() { + Map variables = Map.of("first", 2); var result = this.projectsQueryRunner.run(variables); boolean hasPreviousPage = JsonPath.read(result, "$.data.viewer.projects.pageInfo.hasPreviousPage"); @@ -150,18 +150,94 @@ public void givenSetOfProjectsWhenQueryIsPerformedThenTheProjectsAreReturned() { assertThat(endCursor).isNotBlank(); int count = JsonPath.read(result, "$.data.viewer.projects.pageInfo.count"); - assertThat(count).isGreaterThan(2); + assertThat(count).isEqualTo(2); List projectIds = JsonPath.read(result, "$.data.viewer.projects.edges[*].node.id"); assertThat(projectIds).hasSize(2); } + @Test + @DisplayName("Given a set of projects, when a valid first query is performed, then the projects are returned") + @Sql(scripts = {"/scripts/initialize.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenSetOfProjectsWhenValidLastQueryIsPerformedThenTheProjectsAreReturned() { + Map variables = Map.of("last", 2); + var result = this.projectsQueryRunner.run(variables); + + boolean hasPreviousPage = JsonPath.read(result, "$.data.viewer.projects.pageInfo.hasPreviousPage"); + assertThat(hasPreviousPage).isFalse(); + + boolean hasNextPage = JsonPath.read(result, "$.data.viewer.projects.pageInfo.hasNextPage"); + assertThat(hasNextPage).isTrue(); + + String startCursor = JsonPath.read(result, "$.data.viewer.projects.pageInfo.startCursor"); + assertThat(startCursor).isNotBlank(); + + String endCursor = JsonPath.read(result, "$.data.viewer.projects.pageInfo.endCursor"); + assertThat(endCursor).isNotBlank(); + + int count = JsonPath.read(result, "$.data.viewer.projects.pageInfo.count"); + assertThat(count).isEqualTo(2); + + List projectIds = JsonPath.read(result, "$.data.viewer.projects.edges[*].node.id"); + assertThat(projectIds).hasSize(2); + } + + @Test + @DisplayName("Given a set of projects, when a 0 first query is performed, then the projects are returned") + @Sql(scripts = {"/scripts/initialize.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenSetOfProjectsWhenA0FirstProjectsQueryIsPerformedThenTheProjectsAreReturned() { + Map variables = Map.of("first", 0); + var result = this.projectsQueryRunner.run(variables); + + boolean hasPreviousPage = JsonPath.read(result, "$.data.viewer.projects.pageInfo.hasPreviousPage"); + assertThat(hasPreviousPage).isFalse(); + + boolean hasNextPage = JsonPath.read(result, "$.data.viewer.projects.pageInfo.hasNextPage"); + assertThat(hasNextPage).isFalse(); + + String startCursor = JsonPath.read(result, "$.data.viewer.projects.pageInfo.startCursor"); + assertThat(startCursor).isBlank(); + + String endCursor = JsonPath.read(result, "$.data.viewer.projects.pageInfo.endCursor"); + assertThat(endCursor).isBlank(); + + int count = JsonPath.read(result, "$.data.viewer.projects.pageInfo.count"); + assertThat(count).isEqualTo(0); + } + + @Test + @DisplayName("Given a set of projects, when a 0 last query is performed, then the projects are returned") + @Sql(scripts = {"/scripts/initialize.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenSetOfProjectsWhenA0LastProjectsQueryIsPerformedThenTheProjectsAreReturned() { + Map variables = Map.of("last", 0); + var result = this.projectsQueryRunner.run(variables); + + boolean hasPreviousPage = JsonPath.read(result, "$.data.viewer.projects.pageInfo.hasPreviousPage"); + assertThat(hasPreviousPage).isFalse(); + + boolean hasNextPage = JsonPath.read(result, "$.data.viewer.projects.pageInfo.hasNextPage"); + assertThat(hasNextPage).isFalse(); + + String startCursor = JsonPath.read(result, "$.data.viewer.projects.pageInfo.startCursor"); + assertThat(startCursor).isBlank(); + + String endCursor = JsonPath.read(result, "$.data.viewer.projects.pageInfo.endCursor"); + assertThat(endCursor).isBlank(); + + int count = JsonPath.read(result, "$.data.viewer.projects.pageInfo.count"); + assertThat(count).isEqualTo(0); + } + @Test @DisplayName("Given a valid project to create, when the mutation is performed, then the project is created") @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) public void givenValidProjectToCreateWhenMutationIsPerformedThenProjectIsCreated() { - var page = this.projectSearchService.findAll(PageRequest.of(1, 1)); - assertThat(page.getTotalElements()).isZero(); + var window = this.projectSearchService.findAll(ScrollPosition.keyset(), 1); + assertThat(window).isNotNull(); + assertThat(window.size()).isZero(); var input = new CreateProjectInput(UUID.randomUUID(), "New Project", List.of()); var result = this.createProjectMutationRunner.run(input); @@ -183,6 +259,49 @@ public void givenValidProjectToCreateWhenMutationIsPerformedThenProjectIsCreated assertThat(event).isInstanceOf(ProjectCreatedEvent.class); } + @Test + @DisplayName("Given a valid input, when a forward findAll is performed, then the returned window contains the projects after the input project") + @Sql(scripts = {"/scripts/initialize.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenAValidInputWhenAForwardFindallIsPerformedThenTheReturnedWindowContainsTheProjectsAfterTheInputProject() { + var keyset = ScrollPosition.forward(Map.of("id", TestIdentifiers.UML_SAMPLE_PROJECT.toString())); + var window = this.projectSearchService.findAll(keyset, 1); + assertThat(window).isNotNull(); + assertThat(window.size()).isOne(); + assertThat(window.getContent().get(0).getId()).isEqualByComparingTo(TestIdentifiers.SYSML_SAMPLE_PROJECT); + } + + @Test + @DisplayName("Given a valid input, when a forward findAll is performed, then the returned window contains the projects after the input project") + @Sql(scripts = {"/scripts/initialize.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenAValidInputWhenABackwardFindallIsPerformedThenTheReturnedWindowContainsTheProjectsAfterTheInputProject() { + var keyset = ScrollPosition.backward(Map.of("id", TestIdentifiers.UML_SAMPLE_PROJECT.toString())); + var window = this.projectSearchService.findAll(keyset, 1); + assertThat(window).isNotNull(); + assertThat(window.size()).isOne(); + assertThat(window.getContent().get(0).getId()).isEqualByComparingTo(TestIdentifiers.SYSML_SAMPLE_PROJECT); + } + + @Test + @DisplayName("Given an invalid project id, when findAll is performed, then the returned window is empty") + @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenAnInvalidProjectIdWhenFindallIsPerformedThenTheReturnedWindowIsNull() { + var keyset = ScrollPosition.forward(Map.of("id", "invalid-id")); + var window = this.projectSearchService.findAll(keyset, 1); + assertThat(window).isNotNull(); + assertThat(window.size()).isZero(); + } + + @Test + @DisplayName("Given an invalid limit, when findAll is performed, then the returned window is empty") + @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenAnInvalidLimitWhenFindallIsPerformedThenTheReturnedWindowIsNull() { + var window = this.projectSearchService.findAll(ScrollPosition.keyset(), 0); + assertThat(window).isNotNull(); + assertThat(window.size()).isZero(); + } + @Test @DisplayName("Given a valid project to create, when the mutation is performed, then the semantic data are created") @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) diff --git a/packages/sirius-web/backend/sirius-web/src/test/java/org/eclipse/sirius/web/application/controllers/projects/ProjectRestControllerIntegrationTests.java b/packages/sirius-web/backend/sirius-web/src/test/java/org/eclipse/sirius/web/application/controllers/projects/ProjectRestControllerIntegrationTests.java index 9e2c7ce9f58..5ae3fa0d119 100644 --- a/packages/sirius-web/backend/sirius-web/src/test/java/org/eclipse/sirius/web/application/controllers/projects/ProjectRestControllerIntegrationTests.java +++ b/packages/sirius-web/backend/sirius-web/src/test/java/org/eclipse/sirius/web/application/controllers/projects/ProjectRestControllerIntegrationTests.java @@ -29,6 +29,8 @@ import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.transaction.annotation.Transactional; +import graphql.relay.Relay; + /** * Integration tests of the project REST controller. * @@ -64,13 +66,212 @@ public void givenSiriusWebRestAPIWhenWeAskForAllProjectsThenItShouldReturnAllPro .build(); var uri = "/api/rest/projects"; - var response = webTestClient + webTestClient + .get() + .uri(uri) + .exchange() + .expectStatus() + .isOk() + .expectBodyList(RestProject.class) + .hasSize(3); + } + + @Test + @DisplayName("Given the Sirius Web REST API, when we ask for all projects with a page size, then it should return a max number of projects corresponding to the size") + @Sql(scripts = {"/scripts/initialize.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenSiriusWebRestAPIWhenWeAskForAllProjectsWithAPageSizeThenItShouldReturnAMaxNumberOfProjectsCorrespondingToTheSize() { + var webTestClient = WebTestClient.bindToServer() + .baseUrl(this.getHTTPBaseUrl()) + .build(); + + var uri = "/api/rest/projects?page[size]=1"; + webTestClient + .get() + .uri(uri) + .exchange() + .expectStatus() + .isOk() + .expectBodyList(RestProject.class) + .hasSize(1); + } + + @Test + @DisplayName("Given the Sirius Web REST API, when we ask for all projects with a page size > projects size, then it should return a max number of projects corresponding to the projects size") + @Sql(scripts = {"/scripts/initialize.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenSiriusWebRestAPIWhenWeAskForAllProjectsWithAPageSizeSupToProjectsSizeThenItShouldReturnAMaxNumberOfProjectsCorrespondingToTheProjectsSize() { + var webTestClient = WebTestClient.bindToServer() + .baseUrl(this.getHTTPBaseUrl()) + .build(); + + var uri = "/api/rest/projects?page[size]=10"; + webTestClient + .get() + .uri(uri) + .exchange() + .expectStatus() + .isOk() + .expectBodyList(RestProject.class) + .hasSize(3); + } + + @Test + @DisplayName("Given the Sirius Web REST API, when we ask for all projects with a page size = 0, then it should return an error") + @Sql(scripts = {"/scripts/initialize.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenSiriusWebRestAPIWhenWeAskForAllProjectsWithAPageSizeEq0ThenItShouldReturnAnError() { + var webTestClient = WebTestClient.bindToServer() + .baseUrl(this.getHTTPBaseUrl()) + .build(); + + var uri = "/api/rest/projects?page[size]=0"; + webTestClient + .get() + .uri(uri) + .exchange() + .expectStatus() + .is5xxServerError(); + } + + @Test + @DisplayName("Given the Sirius Web REST API, when we ask for all projects after a specific one, then it should return all projects after the specific one") + @Sql(scripts = {"/scripts/initialize.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenSiriusWebRestAPIWhenWeAskForAllProjectsAfterASpecificOneThenItShouldReturnAllProjectsAfterTheSpecificOne() { + var webTestClient = WebTestClient.bindToServer() + .baseUrl(this.getHTTPBaseUrl()) + .build(); + + var link = new Relay().toGlobalId("Project", TestIdentifiers.UML_SAMPLE_PROJECT.toString()); + var uri = String.format("/api/rest/projects?page[after]=%s", link); + webTestClient + .get() + .uri(uri) + .exchange() + .expectStatus() + .isOk() + .expectBodyList(RestProject.class) + .hasSize(2) + .consumeWith(result -> { + var restProjects = result.getResponseBody(); + assertEquals(TestIdentifiers.SYSML_SAMPLE_PROJECT, restProjects.get(0).id()); + assertEquals(TestIdentifiers.ECORE_SAMPLE_PROJECT, restProjects.get(1).id()); + }); + } + + @Test + @DisplayName("Given the Sirius Web REST API, when we ask for N projects after a specific one, then it should return N projects after the specific one") + @Sql(scripts = {"/scripts/initialize.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenSiriusWebRestAPIWhenWeAskForNProjectsAfterASpecificOneThenItShouldReturnNProjectsAfterTheSpecificOne() { + var webTestClient = WebTestClient.bindToServer() + .baseUrl(this.getHTTPBaseUrl()) + .build(); + + var link = new Relay().toGlobalId("Project", TestIdentifiers.UML_SAMPLE_PROJECT.toString()); + var uri = String.format("/api/rest/projects?page[after]=%s&page[size]=1", link); + webTestClient + .get() + .uri(uri) + .exchange() + .expectStatus() + .isOk() + .expectBodyList(RestProject.class) + .hasSize(1) + .consumeWith(result -> { + var restProjects = result.getResponseBody(); + assertEquals(TestIdentifiers.SYSML_SAMPLE_PROJECT, restProjects.get(0).id()); + }); + } + + @Test + @DisplayName("Given the Sirius Web REST API, when we ask for all projects before a specific one, then it should return all projects before the specific one") + @Sql(scripts = {"/scripts/initialize.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenSiriusWebRestAPIWhenWeAskForAllProjectsBeforeASpecificOneThenItShouldReturnAllProjectsBeforeTheSpecificOne() { + var webTestClient = WebTestClient.bindToServer() + .baseUrl(this.getHTTPBaseUrl()) + .build(); + + var link = new Relay().toGlobalId("Project", TestIdentifiers.UML_SAMPLE_PROJECT.toString()); + var uri = String.format("/api/rest/projects?page[before]=%s", link); + webTestClient + .get() + .uri(uri) + .exchange() + .expectStatus() + .isOk() + .expectBodyList(RestProject.class) + .hasSize(2) + .consumeWith(result -> { + var restProjects = result.getResponseBody(); + assertEquals(TestIdentifiers.SYSML_SAMPLE_PROJECT, restProjects.get(0).id()); + assertEquals(TestIdentifiers.ECORE_SAMPLE_PROJECT, restProjects.get(1).id()); + }); + } + + @Test + @DisplayName("Given the Sirius Web REST API, when we ask for N projects before a specific one, then it should return N projects after the specific one") + @Sql(scripts = {"/scripts/initialize.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenSiriusWebRestAPIWhenWeAskForNProjectsBeforeASpecificOneThenItShouldReturnNProjectsAfterTheSpecificOne() { + var webTestClient = WebTestClient.bindToServer() + .baseUrl(this.getHTTPBaseUrl()) + .build(); + + var link = new Relay().toGlobalId("Project", TestIdentifiers.UML_SAMPLE_PROJECT.toString()); + var uri = String.format("/api/rest/projects?page[before]=%s&page[size]=1", link); + webTestClient + .get() + .uri(uri) + .exchange() + .expectStatus() + .isOk() + .expectBodyList(RestProject.class) + .hasSize(1) + .consumeWith(result -> { + var restProjects = result.getResponseBody(); + assertEquals(TestIdentifiers.SYSML_SAMPLE_PROJECT, restProjects.get(0).id()); + }); + } + + @Test + @DisplayName("Given the Sirius Web REST API, when we ask for all projects after an unkwnown, then it should return an error") + @Sql(scripts = {"/scripts/initialize.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenSiriusWebRestAPIWhenWeAskForAllProjectsAfterAnUnknownThenItShouldReturnAnError() { + var webTestClient = WebTestClient.bindToServer() + .baseUrl(this.getHTTPBaseUrl()) + .build(); + + var link = new Relay().toGlobalId("Project", TestIdentifiers.INVALID_PROJECT.toString()); + var uri = String.format("/api/rest/projects?page[after]=%s", link); + webTestClient .get() .uri(uri) - .exchange(); + .exchange() + .expectStatus() + .is5xxServerError(); + } + + @Test + @DisplayName("Given the Sirius Web REST API, when we ask for all projects before an unkwnown, then it should return an error") + @Sql(scripts = {"/scripts/initialize.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenSiriusWebRestAPIWhenWeAskForAllProjectsBeforeAnUnknownThenItShouldReturnAnError() { + var webTestClient = WebTestClient.bindToServer() + .baseUrl(this.getHTTPBaseUrl()) + .build(); - response.expectStatus().isOk(); - response.expectBodyList(RestProject.class).hasSize(3); + var link = new Relay().toGlobalId("Project", TestIdentifiers.INVALID_PROJECT.toString()); + var uri = String.format("/api/rest/projects?page[before]=%s", link); + webTestClient + .get() + .uri(uri) + .exchange() + .expectStatus() + .is5xxServerError(); } @Test diff --git a/packages/sirius-web/backend/sirius-web/src/test/java/org/eclipse/sirius/web/application/controllers/projects/ProjectSearchControllerConfiguration.java b/packages/sirius-web/backend/sirius-web/src/test/java/org/eclipse/sirius/web/application/controllers/projects/ProjectSearchControllerConfiguration.java index 1cad23e6dfd..4077e75de23 100644 --- a/packages/sirius-web/backend/sirius-web/src/test/java/org/eclipse/sirius/web/application/controllers/projects/ProjectSearchControllerConfiguration.java +++ b/packages/sirius-web/backend/sirius-web/src/test/java/org/eclipse/sirius/web/application/controllers/projects/ProjectSearchControllerConfiguration.java @@ -12,6 +12,7 @@ *******************************************************************************/ package org.eclipse.sirius.web.application.controllers.projects; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -19,9 +20,6 @@ import org.eclipse.sirius.web.domain.boundedcontexts.project.repositories.api.IProjectSearchRepositoryDelegate; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; import org.springframework.jdbc.core.simple.JdbcClient; /** @@ -46,17 +44,29 @@ public Optional findById(UUID projectId) { } @Override - public Page findAll(Pageable pageable) { + public List findAllBefore(UUID cursorProjectId, int limit) { var query = """ SELECT project.* FROM project JOIN nature ON project.id = nature.project_id WHERE nature.name = 'ecore' """; - var projects = jdbcClient.sql(query) + return jdbcClient.sql(query) .query(Project.class) .list(); - return new PageImpl<>(projects, pageable, projects.size()); } + + @Override + public List findAllAfter(UUID cursorProjectId, int limit) { + var query = """ + SELECT project.* FROM project + JOIN nature ON project.id = nature.project_id + WHERE nature.name = 'ecore' + """; + return jdbcClient.sql(query) + .query(Project.class) + .list(); + } + }; } } diff --git a/packages/sirius-web/backend/sirius-web/src/test/java/org/eclipse/sirius/web/application/controllers/projects/ProjectSearchControllerIntegrationTests.java b/packages/sirius-web/backend/sirius-web/src/test/java/org/eclipse/sirius/web/application/controllers/projects/ProjectSearchControllerIntegrationTests.java index 9afbda0a1e2..6269f740080 100644 --- a/packages/sirius-web/backend/sirius-web/src/test/java/org/eclipse/sirius/web/application/controllers/projects/ProjectSearchControllerIntegrationTests.java +++ b/packages/sirius-web/backend/sirius-web/src/test/java/org/eclipse/sirius/web/application/controllers/projects/ProjectSearchControllerIntegrationTests.java @@ -59,7 +59,7 @@ public void beforeEach() { @Sql(scripts = {"/scripts/initialize.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @Sql(scripts = {"/scripts/cleanup.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) public void givenSetOfProjectsWhenQueryIsPerformedThenTheProjectsAreReturned() { - Map variables = Map.of("page", 0, "limit", 20); + Map variables = Map.of("first", 20); var result = this.projectsQueryRunner.run(variables); List projectIds = JsonPath.read(result, "$.data.viewer.projects.edges[*].node.id");