From 2994a91d778a4ce3344efad600b015737f9bbd87 Mon Sep 17 00:00:00 2001 From: Axel RICHARD Date: Thu, 21 Nov 2024 17:58:13 +0100 Subject: [PATCH] [4233] Add Cursor-based pagination in Project related GET REST APIs Bug: https://github.com/eclipse-sirius/sirius-web/issues/4233 Signed-off-by: Axel RICHARD --- CHANGELOG.adoc | 17 ++ .../cypress/support/serverCommands.js | 6 +- .../controllers/ProjectRestController.java | 64 +++++- .../ViewerProjectsDataFetcher.java | 102 ++++++-- .../services/ProjectApplicationService.java | 11 +- .../api/IProjectApplicationService.java | 8 +- .../main/resources/schema/siriusweb.graphqls | 3 +- .../repositories/IProjectRepository.java | 6 +- .../repositories/ProjectSearchRepository.java | 11 +- .../ProjectSearchRepositoryDelegate.java | 91 +++++++- .../ProjectSearchRepositoryImpl.java | 14 +- .../services/ProjectSearchService.java | 45 +++- .../project/services/Window.java | 82 +++++++ .../services/api/IProjectSearchService.java | 8 +- .../tests/graphql/ProjectsQueryRunner.java | 7 +- .../ProjectControllerIntegrationTests.java | 194 +++++++++++++++- ...ProjectRestControllerIntegrationTests.java | 217 +++++++++++++++++- .../ProjectSearchControllerConfiguration.java | 24 +- ...ojectSearchControllerIntegrationTests.java | 4 +- .../list-projects-area/ListProjectsArea.tsx | 83 +++++-- .../ListProjectsArea.types.ts | 9 +- .../list-projects-area/ProjectsTable.tsx | 58 ++++- .../list-projects-area/ProjectsTable.types.ts | 12 +- .../useProjects.fragments.ts | 7 +- .../list-projects-area/useProjects.ts | 13 +- .../list-projects-area/useProjects.types.ts | 11 +- vscode-extension/src/data/ServerData.ts | 8 +- 27 files changed, 964 insertions(+), 151 deletions(-) create mode 100644 packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/services/Window.java diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 91340e15b4..b267d60ec3 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -57,6 +57,23 @@ Specifiers can contribute dedicated AQL services for this feature using implemen - https://github.com/eclipse-sirius/sirius-web/issues/1047[#1047] [sirius-web] In the _Domain_ diagram, when using direct-edit on a relatin edge, the initial text now only includes the name of the relation (without the cardinality) - https://github.com/eclipse-sirius/sirius-web/issues/4095[#4095] [tree] Add conditional tree item label element description - https://github.com/eclipse-sirius/sirius-web/issues/4101[#4101] [tree] Add loop tree item label element description +- 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. + == v2025.1.0 diff --git a/integration-tests/cypress/support/serverCommands.js b/integration-tests/cypress/support/serverCommands.js index 3b691b7349..4f0ddfc239 100644 --- a/integration-tests/cypress/support/serverCommands.js +++ b/integration-tests/cypress/support/serverCommands.js @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2021, 2024 Obeo. + * Copyright (c) 2021, 2025 Obeo. * This program and the accompanying materials * are made available under the erms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -16,9 +16,9 @@ const url = Cypress.env('baseAPIUrl') + '/api/graphql'; Cypress.Commands.add('deleteAllProjects', () => { const getProjectsQuery = ` - 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 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 56ee4b52f6..a59b9268fd 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -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,7 +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; import io.swagger.v3.oas.annotations.Operation; /** @@ -53,6 +60,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; @@ -63,12 +72,26 @@ public ProjectRestController(IProjectApplicationService projectApplicationServic @Operation(description = "Get all projects.") @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); } @Operation(description = "Get project with the given id (projectId).") @@ -130,4 +153,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 63fc9c100a..a9dbc5325e 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -12,16 +12,22 @@ *******************************************************************************/ package org.eclipse.sirius.web.application.project.controllers; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; import org.eclipse.sirius.components.annotations.spring.graphql.QueryDataFetcher; import org.eclipse.sirius.components.core.graphql.dto.PageInfoWithCount; 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.eclipse.sirius.web.domain.boundedcontexts.project.services.Window; +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.ScrollPosition; import graphql.relay.Connection; import graphql.relay.ConnectionCursor; @@ -40,9 +46,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,24 +64,65 @@ 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) { - var edges = projectPage.stream().map(projectDTO -> { + private Connection toConnection(Window projectPage, KeysetScrollPosition position) { + List> edges = projectPage.stream().map(projectDTO -> { var globalId = new Relay().toGlobalId("Project", projectDTO.id().toString()); var cursor = new DefaultConnectionCursor(globalId); return (Edge) new DefaultEdge<>(projectDTO, cursor); - }).toList(); + }).collect(Collectors.toCollection(ArrayList::new)); + + if (position.scrollsBackward()) { + Collections.reverse(edges); + } ConnectionCursor startCursor = edges.stream().findFirst() .map(Edge::getCursor) @@ -78,7 +131,18 @@ 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 hasNextPage = false; + boolean hasPreviousPage = false; + if (position.scrollsForward()) { + hasNextPage = projectPage.hasNext(); + hasPreviousPage = projectPage.hasPrevious(); + } + if (position.scrollsBackward()) { + hasNextPage = projectPage.hasNext(); + hasPreviousPage = projectPage.hasPrevious(); + } + 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 9992ab320d..e8e1550377 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -28,14 +28,14 @@ import org.eclipse.sirius.web.application.project.services.api.IProjectApplicationService; import org.eclipse.sirius.web.application.project.services.api.IProjectMapper; import org.eclipse.sirius.web.domain.boundedcontexts.project.Project; +import org.eclipse.sirius.web.domain.boundedcontexts.project.services.Window; import org.eclipse.sirius.web.domain.boundedcontexts.project.services.api.IProjectCreationService; import org.eclipse.sirius.web.domain.boundedcontexts.project.services.api.IProjectDeletionService; import org.eclipse.sirius.web.domain.boundedcontexts.project.services.api.IProjectSearchService; 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.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -72,8 +72,9 @@ 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) { + var window = this.projectSearchService.findAll(position, limit); + return new Window<>(window.map(this.projectMapper::toDTO), window.hasPrevious()); } @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 c837cb2f23..dff4120e42 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -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.eclipse.sirius.web.domain.boundedcontexts.project.services.Window; +import org.springframework.data.domain.KeysetScrollPosition; /** * 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 df24e99f24..23f47738fc 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 566993fb6d..70a532435d 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -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 147bcec918..01bc5c0a4b 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -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 edc270f4b7..67bbd38e27 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -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, p.name + 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, p.name + 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 87c71347f2..5de3c1e54c 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -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 4eccfb909b..997a761020 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -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,7 @@ 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.stereotype.Service; /** @@ -48,7 +48,42 @@ 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 = new Window<>(List.of(), (i) -> position, false, 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; + boolean hasPrevious = !this.projectRepository.findAllBefore(cursorProjectUUID.get(), 1).isEmpty(); + window = new Window<>(projects.subList(0, Math.min(projects.size(), limit)), (i) -> position, hasNext, hasPrevious); + } else if (position.scrollsBackward()) { + var projects = this.projectRepository.findAllBefore(cursorProjectUUID.get(), limit + 1); + boolean hasPrevious = projects.size() > limit; + boolean hasNext = !this.projectRepository.findAllAfter(cursorProjectUUID.get(), 1).isEmpty(); + window = new Window<>(projects.subList(0, Math.min(projects.size(), limit)), (i) -> position, hasNext, hasPrevious); + } + } + } else { + var projects = this.projectRepository.findAllAfter(null, limit + 1); + boolean hasNext = projects.size() > limit; + boolean hasPrevious = false; + window = new Window<>(projects.subList(0, Math.min(projects.size(), limit)), (i) -> position, hasNext, hasPrevious); + } + } + 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/Window.java b/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/services/Window.java new file mode 100644 index 0000000000..e40ac9f3d8 --- /dev/null +++ b/packages/sirius-web/backend/sirius-web-domain/src/main/java/org/eclipse/sirius/web/domain/boundedcontexts/project/services/Window.java @@ -0,0 +1,82 @@ +/******************************************************************************* + * Copyright (c) 2025 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.sirius.web.domain.boundedcontexts.project.services; + +import java.util.Iterator; +import java.util.List; +import java.util.function.Function; +import java.util.function.IntFunction; + +import org.springframework.data.domain.ScrollPosition; + +/** + * Custom Window with additional hasPrevious method. + * + * @param The type containing by the window. + * + * @author arichard + */ +public class Window implements org.springframework.data.domain.Window { + + private final org.springframework.data.domain.Window delegate; + private final boolean hasPrevious; + + public Window(List items, IntFunction positionFunction, boolean hasNext, boolean hasPrevious) { + this.delegate = org.springframework.data.domain.Window.from(items, positionFunction, hasNext); + this.hasPrevious = hasPrevious; + } + + public Window(org.springframework.data.domain.Window delegate, boolean hasPrevious) { + this.delegate = delegate; + this.hasPrevious = hasPrevious; + } + + @Override + public Iterator iterator() { + return this.delegate.iterator(); + } + + @Override + public int size() { + return this.delegate.size(); + } + + @Override + public boolean isEmpty() { + return this.delegate.isEmpty(); + } + + @Override + public List getContent() { + return this.delegate.getContent(); + } + + @Override + public boolean hasNext() { + return this.delegate.hasNext(); + } + + @Override + public ScrollPosition positionAt(int index) { + return this.delegate.positionAt(index); + } + + @Override + public org.springframework.data.domain.Window map(Function converter) { + return this.delegate.map(converter); + } + + public boolean hasPrevious() { + return this.hasPrevious; + } +} 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 37b8057aae..eceaa3deae 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -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.eclipse.sirius.web.domain.boundedcontexts.project.services.Window; +import org.springframework.data.domain.KeysetScrollPosition; /** * 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 9dc1d32901..577a4a60bc 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -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 648d5b69fd..96d07c25ba 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -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,12 +49,13 @@ 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; import org.springframework.transaction.annotation.Transactional; +import graphql.relay.Relay; import reactor.test.StepVerifier; /** @@ -130,11 +131,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,7 +151,138 @@ 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 set of projects, when a valid after 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 givenSetOfProjectsWhenValidAfterQueryIsPerformedThenTheProjectsAreReturned() { + var cursorProjectId = new Relay().toGlobalId("Project", TestIdentifiers.UML_SAMPLE_PROJECT.toString()); + Map variables = Map.of("after", cursorProjectId); + var result = this.projectsQueryRunner.run(variables); + + boolean hasPreviousPage = JsonPath.read(result, "$.data.viewer.projects.pageInfo.hasPreviousPage"); + assertThat(hasPreviousPage).isTrue(); + + 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).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 valid before 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 givenSetOfProjectsWhenValidBeforeQueryIsPerformedThenTheProjectsAreReturned() { + var cursorProjectId = new Relay().toGlobalId("Project", TestIdentifiers.UML_SAMPLE_PROJECT.toString()); + Map variables = Map.of("before", cursorProjectId); + 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); @@ -160,8 +292,9 @@ public void givenSetOfProjectsWhenQueryIsPerformedThenTheProjectsAreReturned() { @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 +316,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.ECORE_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.ECORE_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 9e2c7ce9f5..26c51916f2 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -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,218 @@ 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 empty list") + @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 givenSiriusWebRestAPIWhenWeAskForAllProjectsWithAPageSizeEq0ThenItShouldReturnAnEmptyList() { + var webTestClient = WebTestClient.bindToServer() + .baseUrl(this.getHTTPBaseUrl()) + .build(); + + var uri = "/api/rest/projects?page[size]=0"; + webTestClient .get() .uri(uri) - .exchange(); + .exchange() + .expectStatus() + .isOk() + .expectBodyList(RestProject.class) + .hasSize(0); + } - response.expectStatus().isOk(); - response.expectBodyList(RestProject.class).hasSize(3); + @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.ECORE_SAMPLE_PROJECT, restProjects.get(0).id()); + assertEquals(TestIdentifiers.SYSML_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.ECORE_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.ECORE_SAMPLE_PROJECT, restProjects.get(0).id()); + assertEquals(TestIdentifiers.SYSML_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.ECORE_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 empty list") + @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 givenSiriusWebRestAPIWhenWeAskForAllProjectsAfterAnUnknownThenItShouldReturnAnEmptyList() { + 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() + .expectStatus() + .isOk() + .expectBodyList(RestProject.class) + .hasSize(0); + } + + @Test + @DisplayName("Given the Sirius Web REST API, when we ask for all projects before an unkwnown, then it should return an empty list") + @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 givenSiriusWebRestAPIWhenWeAskForAllProjectsBeforeAnUnknownThenItShouldReturnAnEmptyList() { + 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[before]=%s", link); + webTestClient + .get() + .uri(uri) + .exchange() + .expectStatus() + .isOk() + .expectBodyList(RestProject.class) + .hasSize(0); } @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 1cad23e6df..42b4856635 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -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 9afbda0a1e..39703a5aab 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -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"); diff --git a/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/ListProjectsArea.tsx b/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/ListProjectsArea.tsx index 23477d8439..533ecb31f8 100644 --- a/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/ListProjectsArea.tsx +++ b/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/ListProjectsArea.tsx @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -12,12 +12,11 @@ *******************************************************************************/ import Typography from '@mui/material/Typography'; -import { makeStyles } from 'tss-react/mui'; import { useState } from 'react'; +import { makeStyles } from 'tss-react/mui'; import { ListProjectsAreaProps, ListProjectsAreaState, NoProjectsFoundProps } from './ListProjectsArea.types'; import { ProjectsTable } from './ProjectsTable'; import { useProjects } from './useProjects'; -import { GQLProject } from './useProjects.types'; const useListProjectsAreaStyles = makeStyles()((theme) => ({ listProjectsArea: { @@ -34,37 +33,73 @@ const useListProjectsAreaStyles = makeStyles()((theme) => ({ })); export const ListProjectsArea = ({}: ListProjectsAreaProps) => { + const { classes } = useListProjectsAreaStyles(); + const [state, setState] = useState({ - page: 0, - limit: 20, + pageSize: 20, + startCursor: null, + endCursor: null, }); - const onPageChange = (page: number) => setState((prevState) => ({ ...prevState, page })); + const { data, loading, refreshProjects } = useProjects(state.startCursor, state.endCursor, state.pageSize); - const { data, refreshProjects } = useProjects(state.page, state.limit); - const projects: GQLProject[] = data?.viewer.projects.edges.map((edge) => edge.node) ?? []; - const count: number = data?.viewer.projects.pageInfo.count ?? 0; + const onPreviousPage = () => { + setState((prevState) => ({ + ...prevState, + startCursor: null, + endCursor: data.viewer.projects.pageInfo.startCursor ?? null, + })); + }; - const { classes } = useListProjectsAreaStyles(); + const onNextPage = () => { + setState((prevState) => ({ + ...prevState, + startCursor: data.viewer.projects.pageInfo.endCursor ?? null, + endCursor: null, + })); + }; + + const onPageSizeChange = (pageSize: number) => + setState((prevState) => ({ + ...prevState, + pageSize, + startCursor: null, + endCursor: null, + })); + + const onRefreshProjects = () => { + refreshProjects(); + }; + + let projectsComponent: JSX.Element | null; + if (loading) { + projectsComponent = null; + } else if (data) { + if (data.viewer.projects.edges.length === 0) { + projectsComponent = ; + } else { + const hasPrev = data.viewer.projects.pageInfo.hasPreviousPage; + const hasNext = data.viewer.projects.pageInfo.hasNextPage; + projectsComponent = ( + edge.node)} + hasPrev={hasPrev} + hasNext={hasNext} + onPrev={onPreviousPage} + onNext={onNextPage} + pageSize={state.pageSize} + onChange={onRefreshProjects} + onPageSizeChange={onPageSizeChange} + /> + ); + } + } return (
Existing Projects
-
- {projects.length === 0 ? ( - - ) : ( - refreshProjects()} - onPageChange={onPageChange} - /> - )} -
+
{projectsComponent}
); }; diff --git a/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/ListProjectsArea.types.ts b/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/ListProjectsArea.types.ts index f65763681e..584f01d14d 100644 --- a/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/ListProjectsArea.types.ts +++ b/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/ListProjectsArea.types.ts @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -14,8 +14,11 @@ export interface ListProjectsAreaProps {} export interface ListProjectsAreaState { - page: number; - limit: number; + pageSize: number; + startCursor: string | null; + endCursor: string | null; } export interface NoProjectsFoundProps {} + +export interface FetchingProjectsProps {} diff --git a/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/ProjectsTable.tsx b/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/ProjectsTable.tsx index beeb5a70f8..980525629c 100644 --- a/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/ProjectsTable.tsx +++ b/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/ProjectsTable.tsx @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -12,18 +12,35 @@ *******************************************************************************/ import { useComponent } from '@eclipse-sirius/sirius-components-core'; +import ArrowBack from '@mui/icons-material/ArrowBack'; +import ArrowForward from '@mui/icons-material/ArrowForward'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import MenuItem from '@mui/material/MenuItem'; import Paper from '@mui/material/Paper'; +import Select from '@mui/material/Select'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; -import TablePagination from '@mui/material/TablePagination'; import TableRow from '@mui/material/TableRow'; +import Typography from '@mui/material/Typography'; + +import FormControl from '@mui/material/FormControl'; import { ProjectsTableProps } from './ProjectsTable.types'; import { projectsTableRowExtensionPoint } from './ProjectsTableExtensionPoints'; -export const ProjectsTable = ({ projects, page, limit, count, onChange, onPageChange }: ProjectsTableProps) => { +export const ProjectsTable = ({ + projects, + hasPrev, + hasNext, + onPrev, + onNext, + pageSize, + onChange, + onPageSizeChange, +}: ProjectsTableProps) => { const { Component: ProjectRow } = useComponent(projectsTableRowExtensionPoint); return ( @@ -47,14 +64,33 @@ export const ProjectsTable = ({ projects, page, limit, count, onChange, onPageCh - onPageChange(page)} - count={count} - /> + + + + Projects per page: + + + + + + + + + + + + + + ); }; diff --git a/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/ProjectsTable.types.ts b/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/ProjectsTable.types.ts index fde13e0075..e129909c9e 100644 --- a/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/ProjectsTable.types.ts +++ b/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/ProjectsTable.types.ts @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -15,9 +15,11 @@ import { GQLProject } from './useProjects.types'; export interface ProjectsTableProps { projects: GQLProject[]; - page: number; - limit: number; - count: number; + hasPrev: boolean; + hasNext: boolean; + onPrev: () => void; + onNext: () => void; + pageSize: number; onChange: () => void; - onPageChange: (page: number) => void; + onPageSizeChange: (page: number) => void; } diff --git a/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/useProjects.fragments.ts b/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/useProjects.fragments.ts index 624fbe30d9..12706b0c16 100644 --- a/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/useProjects.fragments.ts +++ b/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/useProjects.fragments.ts @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -15,15 +15,18 @@ import { gql } from '@apollo/client'; export const ViewerProjectsFragment = gql` fragment ViewerProjects on Viewer { - projects(page: $page, limit: $limit) { + projects(after: $after, before: $before, first: $first, last: $last) { edges { node { ...Project } + cursor } pageInfo { hasNextPage hasPreviousPage + startCursor + endCursor count } } diff --git a/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/useProjects.ts b/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/useProjects.ts index 0e5eac3d31..b54b1f8180 100644 --- a/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/useProjects.ts +++ b/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/useProjects.ts @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -17,17 +17,19 @@ import { useEffect } from 'react'; import { GQLGetProjectsQueryData, GQLGetProjectsQueryVariables, UseProjectsValue } from './useProjects.types'; const getProjectsQuery = gql` - query getProjects($page: Int!, $limit: Int!) { + query getProjects($after: String, $before: String, $first: Int, $last: Int) { viewer { ...ViewerProjects } } `; -export const useProjects = (page: number, limit: number): UseProjectsValue => { +export const useProjects = (after: string, before: string, pageSize: number): UseProjectsValue => { const variables: GQLGetProjectsQueryVariables = { - page, - limit, + after, + before, + first: after ? pageSize : before ? null : pageSize, + last: before ? pageSize : null, }; const { data, loading, error, refetch } = useQuery( getProjectsQuery, @@ -35,7 +37,6 @@ export const useProjects = (page: number, limit: number): UseProjectsValue => { variables, } ); - const { addErrorMessage } = useMultiToast(); useEffect(() => { if (error) { diff --git a/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/useProjects.types.ts b/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/useProjects.types.ts index e0dc18e202..66446c3a87 100644 --- a/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/useProjects.types.ts +++ b/packages/sirius-web/frontend/sirius-web-application/src/views/project-browser/list-projects-area/useProjects.types.ts @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024 Obeo. + * Copyright (c) 2024, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -18,8 +18,10 @@ export interface UseProjectsValue { } export interface GQLGetProjectsQueryVariables { - page: number; - limit: number; + after: string | null; + before: string | null; + first: number | null; + last: number | null; } export interface GQLGetProjectsQueryData { @@ -37,6 +39,7 @@ export interface GQLViewerProjectConnection { export interface GQLViewerProjectEdge { node: GQLProject; + cursor: string; } export interface GQLProject { @@ -47,5 +50,7 @@ export interface GQLProject { export interface GQLPageInfo { hasPreviousPage: boolean; hasNextPage: boolean; + startCursor: string | null; + endCursor: string | null; count: number; } diff --git a/vscode-extension/src/data/ServerData.ts b/vscode-extension/src/data/ServerData.ts index e4c5cad737..18ed6e8bee 100644 --- a/vscode-extension/src/data/ServerData.ts +++ b/vscode-extension/src/data/ServerData.ts @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2022, 2024 Obeo. + * Copyright (c) 2022, 2025 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -51,9 +51,9 @@ export class ServerData { private fetchProjects(): Promise { const graphQLQuery = ` - 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 @@ -71,7 +71,7 @@ export class ServerData { queryURL, { query: graphQLQuery, - variables: { page: 0, limit: 50 }, + variables: { first: 50 }, }, headers )