diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/Paginate.java b/elide-core/src/main/java/com/yahoo/elide/annotation/Paginate.java index 1409f6822f..33096eeec8 100644 --- a/elide-core/src/main/java/com/yahoo/elide/annotation/Paginate.java +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/Paginate.java @@ -37,4 +37,10 @@ * @return the maximum limit */ int maxPageSize() default 10000; + + /** + * The pagination modes supported such as offset pagination or cursor pagination. + * @return the pagination modes supported + */ + PaginationMode[] modes() default { PaginationMode.OFFSET }; } diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/PaginationMode.java b/elide-core/src/main/java/com/yahoo/elide/annotation/PaginationMode.java new file mode 100644 index 0000000000..1399daa7cd --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/PaginationMode.java @@ -0,0 +1,20 @@ +/* + * Copyright 2024, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.annotation; + +/** + * Pagination Mode. + */ +public enum PaginationMode { + /** + * Offset Pagination. + */ + OFFSET, + /** + * Cursor Pagination. + */ + CURSOR +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransaction.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransaction.java index b33efa3f13..4cf8e628bb 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransaction.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransaction.java @@ -25,12 +25,15 @@ import java.io.IOException; import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -307,19 +310,148 @@ private DataStoreIterable sortAndPaginateLoadedData( } if (pagination != null) { - results = paginateInMemory(results, pagination); + results = paginateInMemory(results, pagination, scope); } return new DataStoreIterableBuilder(results).build(); } - private List paginateInMemory(List records, Pagination pagination) { - int offset = pagination.getOffset(); - int limit = pagination.getLimit(); + private String getCursor(Object entity, RequestScope scope) { + return encodeCursor(getId(entity, scope)); + } + + private String getId(Object entity, RequestScope scope) { + return scope.getDictionary().getId(entity); + } + + private Integer findIndexOfDecodedCursor(List records, String cursor, RequestScope scope) { + for (int x = 0; x < records.size(); x++) { + Object entity = records.get(x); + String entityId = getId(entity, scope); + if (Objects.equals(entityId, cursor)) { + return x; + } + } + return null; + } + + private String encodeCursor(String id) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(id.getBytes(StandardCharsets.UTF_8)); + } + + private String decodeCursor(String cursor) { + if (cursor == null || "".equals(cursor)) { + return null; + } + try { + return new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8); + } catch (RuntimeException e) { + return null; + } + } + + private List paginateInMemory(List records, Pagination pagination, RequestScope scope) { if (pagination.returnPageTotals()) { pagination.setPageTotals((long) records.size()); } + int limit = pagination.getLimit(); + if (pagination.getDirection() != null) { // Cursor Pagination + int endMax = records.size() - 1; + switch (pagination.getDirection()) { + case FORWARD: + String decodedCursor = decodeCursor(pagination.getCursor()); + // First + int start = 0; + if (decodedCursor != null) { + // After + Integer cursorIndex = findIndexOfDecodedCursor(records, decodedCursor, scope); + if (cursorIndex == null) { + return Collections.emptyList(); + } + start = cursorIndex + 1; + } + int end = start + limit - 1; + if (end > endMax) { + pagination.setHasNextPage(false); + end = endMax; + } else { + pagination.setHasNextPage(true); + } + pagination.setHasPreviousPage(false); + if (end < start) { + pagination.setStartCursor(null); + pagination.setEndCursor(null); + return Collections.emptyList(); + } else { + pagination.setStartCursor(getCursor(records.get(start), scope)); + pagination.setEndCursor(getCursor(records.get(end), scope)); + return records.subList(start, end + 1); + } + case BACKWARD: + // Last + String ldecodedCursor = decodeCursor(pagination.getCursor()); + int lend = endMax; + if (ldecodedCursor != null) { + // Before + Integer cursorIndex = findIndexOfDecodedCursor(records, ldecodedCursor, scope); + if (cursorIndex == null) { + return Collections.emptyList(); + } + lend = cursorIndex - 1; + } + int lstart = lend - limit + 1; + if (lstart < 0) { + pagination.setHasPreviousPage(false); + lstart = 0; + } else { + pagination.setHasPreviousPage(true); + } + pagination.setHasNextPage(false); + if (lend < lstart) { + pagination.setStartCursor(null); + pagination.setEndCursor(null); + return Collections.emptyList(); + } else { + pagination.setStartCursor(getCursor(records.get(lstart), scope)); + pagination.setEndCursor(getCursor(records.get(lend), scope)); + return records.subList(lstart, lend + 1); + } + case BETWEEN: + String starting = decodeCursor(pagination.getAfter()); + String ending = decodeCursor(pagination.getBefore()); + Integer startingIndex = findIndexOfDecodedCursor(records, starting, scope); + Integer endingIndex = findIndexOfDecodedCursor(records, ending, scope); + if (startingIndex == null || endingIndex == null) { + pagination.setStartCursor(null); + pagination.setEndCursor(null); + return Collections.emptyList(); + } + startingIndex = startingIndex + 1; + endingIndex = endingIndex - 1; + if (endingIndex < startingIndex) { + pagination.setStartCursor(null); + pagination.setEndCursor(null); + return Collections.emptyList(); + } else { + if (startingIndex > 0) { + pagination.setHasPreviousPage(true); + } else { + pagination.setHasPreviousPage(false); + } + if (endingIndex < endMax) { + pagination.setHasNextPage(true); + } else { + pagination.setHasNextPage(false); + } + pagination.setStartCursor(getCursor(records.get(startingIndex), scope)); + pagination.setEndCursor(getCursor(records.get(endingIndex), scope)); + return records.subList(startingIndex, endingIndex + 1); + } + } + } + // Offset Pagination + int offset = pagination.getOffset(); if (offset < 0 || offset >= records.size()) { return Collections.emptyList(); } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/pagination/PaginationImpl.java b/elide-core/src/main/java/com/yahoo/elide/core/pagination/PaginationImpl.java index d94cd706ed..fb1d41ce29 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/pagination/PaginationImpl.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/pagination/PaginationImpl.java @@ -21,6 +21,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Map.Entry; import java.util.stream.Collectors; /** @@ -32,7 +33,9 @@ public class PaginationImpl implements Pagination { /** * Denotes the internal field names for paging. */ - public enum PaginationKey { offset, number, size, limit, totals } + public enum PaginationKey { + offset, number, size, limit, totals, first, after, last, before + } // For specifying which page of records is to be returned in the response public static final String PAGE_NUMBER_KEY = "page[number]"; @@ -49,16 +52,32 @@ public enum PaginationKey { offset, number, size, limit, totals } // For requesting total pages/records be included in the response page meta data public static final String PAGE_TOTALS_KEY = "page[totals]"; + // For cursor pagination with direction forward + public static final String PAGE_FIRST_KEY = "page[first]"; + + // For cursor pagination with direction forward + public static final String PAGE_AFTER_KEY = "page[after]"; + + // For cursor pagination with direction backward + public static final String PAGE_LAST_KEY = "page[last]"; + + // For cursor pagination with direction backward + public static final String PAGE_BEFORE_KEY = "page[before]"; + public static final Map PAGE_KEYS = ImmutableMap.of( PAGE_NUMBER_KEY, PaginationKey.number, PAGE_SIZE_KEY, PaginationKey.size, PAGE_OFFSET_KEY, PaginationKey.offset, PAGE_LIMIT_KEY, PaginationKey.limit, - PAGE_TOTALS_KEY, PaginationKey.totals); + PAGE_TOTALS_KEY, PaginationKey.totals, + PAGE_FIRST_KEY, PaginationKey.first, + PAGE_AFTER_KEY, PaginationKey.after, + PAGE_LAST_KEY, PaginationKey.last, + PAGE_BEFORE_KEY, PaginationKey.before); @Getter @Setter - private Long pageTotals = 0L; + private Long pageTotals = null; // By default this is null and must be explicitly set private static final String PAGE_KEYS_CSV = PAGE_KEYS.keySet().stream().collect(Collectors.joining(", ")); @@ -68,6 +87,31 @@ public enum PaginationKey { offset, number, size, limit, totals } @Getter private final int limit; + @Getter + private final String before; + + @Getter + private final String after; + + @Getter + private final Direction direction; + + @Getter + @Setter + private String startCursor; + + @Getter + @Setter + private String endCursor; + + @Getter + @Setter + private Boolean hasPreviousPage; + + @Getter + @Setter + private Boolean hasNextPage; + private final boolean generateTotals; @Getter @@ -94,7 +138,7 @@ public PaginationImpl(Class entityClass, Boolean generateTotals, Boolean pageByPages) { this(ClassType.of(entityClass), clientOffset, clientLimit, - systemDefaultLimit, systemMaxLimit, generateTotals, pageByPages); + systemDefaultLimit, systemMaxLimit, generateTotals, pageByPages, null, null, null); } /** @@ -114,9 +158,63 @@ public PaginationImpl(Type entityClass, int systemMaxLimit, Boolean generateTotals, Boolean pageByPages) { + this(entityClass, clientOffset, clientLimit, + systemDefaultLimit, systemMaxLimit, generateTotals, pageByPages, null, null, null); + } + + /** + * Constructor. + * @param entityClass The type of collection we are paginating. + * @param clientOffset The client requested offset or null if not provided. + * @param clientLimit The client requested limit or null if not provided. + * @param systemDefaultLimit The system default limit (in terms of records). + * @param systemMaxLimit The system max limit (in terms of records). + * @param generateTotals Whether to return the total number of records. + * @param pageByPages Whether to page by pages or records. + * @param before The cursor for cursor pagination. + * @param after The cursor for cursor pagination. + * @param direction The direction for cursor pagination. + */ + public PaginationImpl(Class entityClass, + Integer clientOffset, + Integer clientLimit, + int systemDefaultLimit, + int systemMaxLimit, + Boolean generateTotals, + Boolean pageByPages, + String before, + String after, + Direction direction) { + this(ClassType.of(entityClass), clientOffset, clientLimit, + systemDefaultLimit, systemMaxLimit, generateTotals, pageByPages, before, after, direction); + } + /** + * Constructor. + * @param entityClass The type of collection we are paginating. + * @param clientOffset The client requested offset or null if not provided. + * @param clientLimit The client requested limit or null if not provided. + * @param systemDefaultLimit The system default limit (in terms of records). + * @param systemMaxLimit The system max limit (in terms of records). + * @param generateTotals Whether to return the total number of records. + * @param pageByPages Whether to page by pages or records. + * @param before The cursor for cursor pagination. + * @param after The cursor for cursor pagination. + * @param direction The direction for cursor pagination. + */ + public PaginationImpl(Type entityClass, + Integer clientOffset, + Integer clientLimit, + int systemDefaultLimit, + int systemMaxLimit, + Boolean generateTotals, + Boolean pageByPages, + String before, + String after, + Direction direction) { this.entityClass = entityClass; - this.defaultInstance = (clientOffset == null && clientLimit == null && generateTotals == null); + this.defaultInstance = (clientOffset == null && clientLimit == null && generateTotals == null + && before == null && after == null); Paginate paginate = entityClass != null ? (Paginate) entityClass.getAnnotation(Paginate.class) : null; @@ -139,6 +237,10 @@ public PaginationImpl(Type entityClass, this.generateTotals = generateTotals != null && generateTotals && (paginate == null || paginate.countable()); + this.direction = direction; + this.before = before; + this.after = after; + if (pageByPages) { int pageNumber = clientOffset != null ? clientOffset : 1; if (pageNumber < 1) { @@ -182,30 +284,44 @@ public static PaginationImpl parseQueryParams(Type entityClass, } final Map pageData = new HashMap<>(); - queryParams.entrySet() - .forEach(paramEntry -> { - final String queryParamKey = paramEntry.getKey(); - if (PAGE_KEYS.containsKey(queryParamKey)) { - PaginationKey paginationKey = PAGE_KEYS.get(queryParamKey); - if (paginationKey.equals(PaginationKey.totals)) { - // page[totals] is a valueless parameter, use value of 0 just so that its presence can - // be recorded in the map - pageData.put(paginationKey, 0); - } else { - final String value = paramEntry.getValue().get(0); - try { - int intValue = Integer.parseInt(value, 10); - pageData.put(paginationKey, intValue); - } catch (NumberFormatException e) { - throw new InvalidValueException("page values must be integers"); - } - } - } else if (queryParamKey.startsWith("page[")) { - throw new InvalidValueException("Invalid Pagination Parameter. Accepted values are " - + PAGE_KEYS_CSV); + String before = null; + String after = null; + Direction direction = null; + + for (Entry> paramEntry : queryParams.entrySet()) { + final String queryParamKey = paramEntry.getKey(); + if (PAGE_KEYS.containsKey(queryParamKey)) { + PaginationKey paginationKey = PAGE_KEYS.get(queryParamKey); + if (paginationKey.equals(PaginationKey.totals)) { + // page[totals] is a valueless parameter, use value of 0 just so that its presence can + // be recorded in the map + pageData.put(paginationKey, 0); + } else if (paginationKey.equals(PaginationKey.before)) { + final String value = paramEntry.getValue().get(0); + before = value; + direction = Direction.BACKWARD; + } else if (paginationKey.equals(PaginationKey.after)) { + final String value = paramEntry.getValue().get(0); + after = value; + direction = Direction.FORWARD; + } else { + final String value = paramEntry.getValue().get(0); + try { + int intValue = Integer.parseInt(value, 10); + pageData.put(paginationKey, intValue); + } catch (NumberFormatException e) { + throw new InvalidValueException("page values must be integers"); } - }); - return getPagination(entityClass, pageData, elideSettings); + } + } else if (queryParamKey.startsWith("page[")) { + throw new InvalidValueException( + "Invalid Pagination Parameter. Accepted values are " + PAGE_KEYS_CSV); + } + } + if (before != null && after != null) { + direction = Direction.BETWEEN; + } + return getPagination(entityClass, pageData, before, after, direction, elideSettings); } @@ -214,14 +330,26 @@ public static PaginationImpl parseQueryParams(Type entityClass, * * @param entityClass The collection type. * @param pageData Map containing pagination information + * @param before the cursor + * @param after the cursor + * @param direction the cursor direction * @param elideSettings Settings containing pagination defaults * @return Pagination object */ private static PaginationImpl getPagination(Type entityClass, Map pageData, - ElideSettings elideSettings) { + String before, String after, Direction direction, ElideSettings elideSettings) { if (hasInvalidCombination(pageData)) { throw new InvalidValueException("Invalid usage of pagination parameters."); } + if (pageData.containsKey(PaginationKey.first) && pageData.containsKey(PaginationKey.last)) { + throw new InvalidValueException("page[first] and page[last] cannot be used together."); + } + if (pageData.containsKey(PaginationKey.first) && direction != null) { + throw new InvalidValueException("page[first] cannot be used together with page[before] or page[after]."); + } + if (pageData.containsKey(PaginationKey.last) && direction != null) { + throw new InvalidValueException("page[last] cannot be used together with page[before] or page[after]."); + } boolean pageByPages = false; Integer offset = pageData.getOrDefault(PaginationKey.offset, null); @@ -233,13 +361,25 @@ private static PaginationImpl getPagination(Type entityClass, Map pageData) { diff --git a/elide-core/src/main/java/com/yahoo/elide/core/request/Pagination.java b/elide-core/src/main/java/com/yahoo/elide/core/request/Pagination.java index b5a51680b3..e99b559891 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/request/Pagination.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/request/Pagination.java @@ -61,4 +61,91 @@ public interface Pagination { * @return true if pagination wasn't requested. False otherwise. */ boolean isDefaultInstance(); + + /** + * The direction for cursor pagination. + */ + enum Direction { + FORWARD, + BACKWARD, + BETWEEN + } + + /** + * Gets the cursor for cursor pagination. + * + * @return the cursor + */ + default String getCursor() { + String before = getBefore(); + String after = getAfter(); + if (before != null && after != null) { + throw new IllegalArgumentException("Both before and after cursors exist."); + } + if (before != null) { + return before; + } + return after; + } + + /** + * Gets the before cursor for cursor pagination. + * + * @return the before cursor + */ + String getBefore(); + + /** + * Gets the after cursor for cursor pagination. + * + * @return the after cursor + */ + String getAfter(); + + /** + * Gets the direction for cursor pagination. + * + * @return the direction for cursor pagination. + */ + Direction getDirection(); + + /** + * Sets the cursor for the first item for cursor pagination. + */ + void setStartCursor(String cursor); + + /** + * Sets the cursor for the last item for cursor pagination. + */ + void setEndCursor(String cursor); + + /** + * Gets the cursor for the first item for cursor pagination. + */ + String getStartCursor(); + + /** + * Gets the cursor for the last item for cursor pagination. + */ + String getEndCursor(); + + /** + * Sets whether there is a previous page for cursor pagination. + */ + void setHasPreviousPage(Boolean hasPreviousPage); + + /** + * Gets whether there is a previous page for cursor pagination. + */ + Boolean getHasPreviousPage(); + + /** + * Sets whether there is a next page for cursor pagination. + */ + void setHasNextPage(Boolean hasNextPage); + + /** + * Gets whether there is a next page for cursor pagination. + */ + Boolean getHasNextPage(); } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/sort/SortingImpl.java b/elide-core/src/main/java/com/yahoo/elide/core/sort/SortingImpl.java index 43d6ccf04e..bbc0e07847 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/sort/SortingImpl.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/sort/SortingImpl.java @@ -280,4 +280,13 @@ private LinkedHashMap replaceIdRule(String idFieldName) { public static Sorting getDefaultEmptyInstance() { return DEFAULT_EMPTY_INSTANCE; } + + /** + * Gets the sort rules. + * + * @return the sort rules + */ + public Map getSortRules() { + return this.sortRules; + } } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/CollectionTerminalState.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/CollectionTerminalState.java index 9ce96b8465..e20ef772d9 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/CollectionTerminalState.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/CollectionTerminalState.java @@ -38,7 +38,7 @@ import reactor.core.publisher.Flux; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -91,7 +91,7 @@ public Supplier> handleGet(StateContext state) { // Add pagination meta data if (!pagination.isDefaultInstance()) { - Map pageMetaData = new HashMap<>(); + Map pageMetaData = new LinkedHashMap<>(); pageMetaData.put("number", (pagination.getOffset() / pagination.getLimit()) + 1); pageMetaData.put("limit", pagination.getLimit()); @@ -102,8 +102,26 @@ public Supplier> handleGet(StateContext state) { + ((totalRecords % pagination.getLimit()) > 0 ? 1 : 0)); pageMetaData.put("totalRecords", totalRecords); } + String startCursor = pagination.getStartCursor(); + if (startCursor != null) { + pageMetaData.put("startCursor", startCursor); + pageMetaData.remove("number"); // remove page number + } + String endCursor = pagination.getEndCursor(); + if (endCursor != null) { + pageMetaData.put("endCursor", endCursor); + pageMetaData.remove("number"); // remove page number + } + Boolean hasPreviousPage = pagination.getHasPreviousPage(); + if (hasPreviousPage != null) { + pageMetaData.put("hasPreviousPage", hasPreviousPage); + } + Boolean hasNextPage = pagination.getHasNextPage(); + if (hasNextPage != null) { + pageMetaData.put("hasNextPage", hasNextPage); + } - Map allMetaData = new HashMap<>(); + Map allMetaData = new LinkedHashMap<>(); allMetaData.put("page", pageMetaData); Meta meta = new Meta(allMetaData); diff --git a/elide-core/src/test/java/com/yahoo/elide/core/pagination/PaginationImplTest.java b/elide-core/src/test/java/com/yahoo/elide/core/pagination/PaginationImplTest.java index c5fb8b152d..8246347e16 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/pagination/PaginationImplTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/pagination/PaginationImplTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -14,6 +15,7 @@ import com.yahoo.elide.annotation.Paginate; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.InvalidValueException; +import com.yahoo.elide.core.request.Pagination.Direction; import com.yahoo.elide.core.type.ClassType; import org.junit.jupiter.api.Test; @@ -79,6 +81,105 @@ public void shouldParseQueryParamsForOffsetAndLimit() { // offset is direct correlation to start field in query assertEquals(2, pageData.getOffset()); assertEquals(10, pageData.getLimit()); + assertNull(pageData.getDirection()); + } + + @Test + public void shouldParseQueryParamsFirst() { + Map> queryParams = new LinkedHashMap<>(); + add(queryParams, "page[first]", "10"); + + PaginationImpl pageData = PaginationImpl.parseQueryParams(ClassType.of(PaginationImplTest.class), + queryParams, elideSettings); + assertEquals(10, pageData.getLimit()); + assertEquals(Direction.FORWARD, pageData.getDirection()); + } + + @Test + public void shouldParseQueryParamsLast() { + Map> queryParams = new LinkedHashMap<>(); + add(queryParams, "page[last]", "10"); + + PaginationImpl pageData = PaginationImpl.parseQueryParams(ClassType.of(PaginationImplTest.class), + queryParams, elideSettings); + assertEquals(10, pageData.getLimit()); + assertEquals(Direction.BACKWARD, pageData.getDirection()); + } + + @Test + public void shouldParseQueryParamsAfterAndSize() { + Map> queryParams = new LinkedHashMap<>(); + add(queryParams, "page[after]", "cursor"); + add(queryParams, "page[size]", "10"); + + PaginationImpl pageData = PaginationImpl.parseQueryParams(ClassType.of(PaginationImplTest.class), + queryParams, elideSettings); + assertEquals(10, pageData.getLimit()); + assertEquals(Direction.FORWARD, pageData.getDirection()); + assertEquals("cursor", pageData.getCursor()); + } + + @Test + public void shouldParseQueryParamsBeforeAndSize() { + Map> queryParams = new LinkedHashMap<>(); + add(queryParams, "page[before]", "cursor"); + add(queryParams, "page[size]", "10"); + + PaginationImpl pageData = PaginationImpl.parseQueryParams(ClassType.of(PaginationImplTest.class), + queryParams, elideSettings); + assertEquals(10, pageData.getLimit()); + assertEquals(Direction.BACKWARD, pageData.getDirection()); + assertEquals("cursor", pageData.getCursor()); + } + + @Test + public void shouldThrowExceptionForFirstAndLast() { + Map> queryParams = new LinkedHashMap<>(); + add(queryParams, "page[first]", "10"); + add(queryParams, "page[last]", "10"); + + assertThrows(InvalidValueException.class, () -> PaginationImpl.parseQueryParams(ClassType.of(PaginationImplTest.class), + queryParams, elideSettings)); + } + + @Test + public void shouldThrowExceptionForFirstAndAfter() { + Map> queryParams = new LinkedHashMap<>(); + add(queryParams, "page[first]", "10"); + add(queryParams, "page[after]", "cursor"); + + assertThrows(InvalidValueException.class, () -> PaginationImpl.parseQueryParams(ClassType.of(PaginationImplTest.class), + queryParams, elideSettings)); + } + + @Test + public void shouldThrowExceptionForFirstAndBefore() { + Map> queryParams = new LinkedHashMap<>(); + add(queryParams, "page[first]", "10"); + add(queryParams, "page[before]", "cursor"); + + assertThrows(InvalidValueException.class, () -> PaginationImpl.parseQueryParams(ClassType.of(PaginationImplTest.class), + queryParams, elideSettings)); + } + + @Test + public void shouldThrowExceptionForLastAndBefore() { + Map> queryParams = new LinkedHashMap<>(); + add(queryParams, "page[last]", "10"); + add(queryParams, "page[before]", "cursor"); + + assertThrows(InvalidValueException.class, () -> PaginationImpl.parseQueryParams(ClassType.of(PaginationImplTest.class), + queryParams, elideSettings)); + } + + @Test + public void shouldThrowExceptionForLastAndAfter() { + Map> queryParams = new LinkedHashMap<>(); + add(queryParams, "page[last]", "10"); + add(queryParams, "page[after]", "cursor"); + + assertThrows(InvalidValueException.class, () -> PaginationImpl.parseQueryParams(ClassType.of(PaginationImplTest.class), + queryParams, elideSettings)); } @Test diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/ImmutablePagination.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/ImmutablePagination.java index f21a96524a..a321b53f6d 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/ImmutablePagination.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/ImmutablePagination.java @@ -20,14 +20,34 @@ public class ImmutablePagination implements Pagination { private int limit; private boolean defaultInstance; private boolean returnPageTotals; + private String before; + private String after; + private Direction direction; + + public ImmutablePagination(int offset, int limit, boolean defaultInstance, boolean returnPageTotals, String before, + String after, + Direction direction) { + super(); + this.offset = offset; + this.limit = limit; + this.defaultInstance = defaultInstance; + this.returnPageTotals = returnPageTotals; + this.before = before; + this.after = after; + this.direction = direction; + } + + public ImmutablePagination(int offset, int limit, boolean defaultInstance, boolean returnPageTotals) { + this(offset, limit, defaultInstance, returnPageTotals, null, null, null); + } public static ImmutablePagination from(Pagination src) { if (src instanceof ImmutablePagination) { return (ImmutablePagination) src; } if (src != null) { - return new ImmutablePagination( - src.getOffset(), src.getLimit(), src.isDefaultInstance(), src.returnPageTotals()); + return new ImmutablePagination(src.getOffset(), src.getLimit(), src.isDefaultInstance(), + src.returnPageTotals(), src.getBefore(), src.getAfter(), src.getDirection()); } return null; } @@ -46,4 +66,44 @@ public Long getPageTotals() { public void setPageTotals(Long pageTotals) { throw new UnsupportedOperationException("ImmutablePagination does not support setPageTotals"); } + + @Override + public void setStartCursor(String cursor) { + throw new UnsupportedOperationException("ImmutablePagination does not support setStartCursor"); + } + + @Override + public void setEndCursor(String cursor) { + throw new UnsupportedOperationException("ImmutablePagination does not support setEndCursor"); + } + + @Override + public void setHasPreviousPage(Boolean hasPreviousPage) { + throw new UnsupportedOperationException("ImmutablePagination does not support setHasPreviousPage"); + } + + @Override + public void setHasNextPage(Boolean hasNextPage) { + throw new UnsupportedOperationException("ImmutablePagination does not support setHasNextPage"); + } + + @Override + public String getStartCursor() { + return null; + } + + @Override + public String getEndCursor() { + return null; + } + + @Override + public Boolean getHasPreviousPage() { + return null; + } + + @Override + public Boolean getHasNextPage() { + return null; + } } diff --git a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/AbstractJpaTransaction.java b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/AbstractJpaTransaction.java index 796ffb6500..877cfa8df8 100644 --- a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/AbstractJpaTransaction.java +++ b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/AbstractJpaTransaction.java @@ -12,6 +12,9 @@ import com.yahoo.elide.datastores.jpa.transaction.checker.PersistentCollectionChecker; import com.yahoo.elide.datastores.jpql.JPQLTransaction; import com.yahoo.elide.datastores.jpql.porting.QueryLogger; +import com.yahoo.elide.datastores.jpql.query.CursorEncoder; +import com.yahoo.elide.datastores.jpql.query.JacksonCursorEncoder; + import org.apache.commons.collections4.CollectionUtils; import jakarta.persistence.EntityManager; @@ -50,14 +53,31 @@ public abstract class AbstractJpaTransaction extends JPQLTransaction implements * @param em The entity manager / session. * @param jpaTransactionCancel A function which can cancel a session. * @param logger Logs queries. - * @param isScrollEnabled Whether or not scrolling is enabled * @param delegateToInMemoryStore When fetching a subcollection from another multi-element collection, * whether or not to do sorting, filtering and pagination in memory - or * do N+1 queries. + * @param isScrollEnabled Whether or not scrolling is enabled */ protected AbstractJpaTransaction(EntityManager em, Consumer jpaTransactionCancel, QueryLogger logger, boolean delegateToInMemoryStore, boolean isScrollEnabled) { - super(new EntityManagerWrapper(em, logger), delegateToInMemoryStore, isScrollEnabled); + this(em, jpaTransactionCancel, logger, delegateToInMemoryStore, isScrollEnabled, new JacksonCursorEncoder()); + } + + /** + * Creates a new JPA transaction. + * + * @param em The entity manager / session. + * @param jpaTransactionCancel A function which can cancel a session. + * @param logger Logs queries. + * @param delegateToInMemoryStore When fetching a subcollection from another multi-element collection, + * whether or not to do sorting, filtering and pagination in memory - or + * do N+1 queries. + * @param isScrollEnabled Whether or not scrolling is enabled + * @param cursorEncoder the cursor encoder + */ + protected AbstractJpaTransaction(EntityManager em, Consumer jpaTransactionCancel, QueryLogger logger, + boolean delegateToInMemoryStore, boolean isScrollEnabled, CursorEncoder cursorEncoder) { + super(new EntityManagerWrapper(em, logger), delegateToInMemoryStore, isScrollEnabled, cursorEncoder); this.em = em; this.jpaTransactionCancel = jpaTransactionCancel; } diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/JPQLTransaction.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/JPQLTransaction.java index d1f147ca67..41332fa6df 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/JPQLTransaction.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/JPQLTransaction.java @@ -18,25 +18,36 @@ import com.yahoo.elide.core.filter.predicates.InPredicate; import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.request.Pagination; +import com.yahoo.elide.core.request.Pagination.Direction; import com.yahoo.elide.core.request.Relationship; import com.yahoo.elide.core.request.Sorting; +import com.yahoo.elide.core.request.Sorting.SortOrder; +import com.yahoo.elide.core.security.obfuscation.IdObfuscator; import com.yahoo.elide.core.type.Type; import com.yahoo.elide.core.utils.TimedFunction; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; import com.yahoo.elide.datastores.jpql.porting.Query; import com.yahoo.elide.datastores.jpql.porting.ScrollableIteratorBase; import com.yahoo.elide.datastores.jpql.porting.Session; import com.yahoo.elide.datastores.jpql.query.AbstractHQLQueryBuilder; +import com.yahoo.elide.datastores.jpql.query.CursorEncoder; import com.yahoo.elide.datastores.jpql.query.RelationshipImpl; import com.yahoo.elide.datastores.jpql.query.RootCollectionFetchQueryBuilder; import com.yahoo.elide.datastores.jpql.query.RootCollectionPageTotalsQueryBuilder; import com.yahoo.elide.datastores.jpql.query.SubCollectionFetchQueryBuilder; import com.yahoo.elide.datastores.jpql.query.SubCollectionPageTotalsQueryBuilder; + import java.io.Serializable; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.IdentityHashMap; import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; import java.util.Set; import java.util.function.Predicate; @@ -49,15 +60,19 @@ public abstract class JPQLTransaction implements DataStoreTransaction { private final boolean isScrollEnabled; private final Set singleElementLoads; private final boolean delegateToInMemoryStore; + private final CursorEncoder cursorEncoder; /** * Constructor. * * @param session Hibernate session + * @param delegateToInMemoryStore Whether to delegate to in memory store * @param isScrollEnabled Whether or not scrolling is enabled + * @param cursorEncoder the cursor encoder */ - protected JPQLTransaction(Session session, boolean delegateToInMemoryStore, boolean isScrollEnabled) { + protected JPQLTransaction(Session session, boolean delegateToInMemoryStore, boolean isScrollEnabled, + CursorEncoder cursorEncoder) { this.sessionWrapper = session; this.isScrollEnabled = isScrollEnabled; @@ -65,6 +80,7 @@ protected JPQLTransaction(Session session, boolean delegateToInMemoryStore, bool // same object is loaded twice from two different collections. this.singleElementLoads = Collections.newSetFromMap(new IdentityHashMap<>()); this.delegateToInMemoryStore = delegateToInMemoryStore; + this.cursorEncoder = cursorEncoder; } /** @@ -115,7 +131,7 @@ public T loadObject(EntityProjection projection, .build(); Query query = - new RootCollectionFetchQueryBuilder(projection, dictionary, sessionWrapper).build(); + new RootCollectionFetchQueryBuilder(projection, dictionary, sessionWrapper, cursorEncoder).build(); T loaded = new TimedFunction(() -> query.uniqueResult(), "Query Hash: " + query.hashCode()).get(); @@ -133,7 +149,7 @@ public DataStoreIterable loadObjects( Pagination pagination = projection.getPagination(); final Query query = - new RootCollectionFetchQueryBuilder(projection, scope.getDictionary(), sessionWrapper) + new RootCollectionFetchQueryBuilder(projection, scope.getDictionary(), sessionWrapper, cursorEncoder) .build(); Iterable results = new TimedFunction>(() -> { @@ -151,14 +167,80 @@ public DataStoreIterable loadObjects( if (pagination != null) { // Issue #1429 - if (pagination.returnPageTotals() && (hasResults || pagination.getLimit() == 0)) { - pagination.setPageTotals(getTotalRecords(projection, scope.getDictionary())); + if (pagination.returnPageTotals()) { + if ((hasResults || pagination.getLimit() == 0)) { + pagination.setPageTotals(getTotalRecords(projection, scope.getDictionary())); + } else { + pagination.setPageTotals(0L); + } + } + // Cursor Pagination handling + if (pagination.getDirection() != null && hasResults) { + List list = new ArrayList(); + results.forEach(list::add); + boolean hasNext = list.size() > pagination.getLimit(); + if (Direction.BACKWARD.equals(pagination.getDirection())) { + if (hasNext) { + // Remove the last element as it was requested to tell whether there was a next + // or previous page + list.remove(pagination.getLimit()); + } + Collections.reverse(list); + pagination.setHasPreviousPage(hasNext); + } else if (Direction.FORWARD.equals(pagination.getDirection())) { + if (hasNext) { + // Remove the last element as it was requested to tell whether there was a next + // or previous page + list.remove(pagination.getLimit()); + } + pagination.setHasNextPage(hasNext); + } + if (!list.isEmpty()) { + Object first = list.get(0); + Object last = list.get(list.size() - 1); + String startCursor = getCursor(first, projection, scope); + String endCursor = getCursor(last, projection, scope); + pagination.setStartCursor(startCursor); + pagination.setEndCursor(endCursor); + } + results = list; } } return new DataStoreIterableBuilder(addSingleElement(results)).build(); } + protected String getCursor(Object object, EntityProjection projection, + RequestScope scope) { + Map keyset = getKeyset(object, projection, scope); + return cursorEncoder.encode(keyset); + } + + protected Map getKeyset(Object object, EntityProjection projection, + RequestScope scope) { + IdObfuscator idObfuscator = scope.getDictionary().getIdObfuscator(); + String idFieldName = null; + if (idObfuscator != null) { + idFieldName = scope.getDictionary().getIdFieldName(projection.getType()); + } + Map keyset = new LinkedHashMap<>(); + Sorting sorting = projection.getSorting(); + for (Entry entry : sorting.getSortingPaths().entrySet()) { + if (entry.getKey().getPathElements().size() == 1) { + String fieldPath = entry.getKey().getFieldPath(); + String value; + Object property = scope.getDictionary().getValue(object, fieldPath, scope); + if (idObfuscator != null && fieldPath.equals(idFieldName)) { + value = idObfuscator.obfuscate(property); + } else { + value = CoerceUtil.coerce(property, String.class); + } + keyset.put(fieldPath, value); + } + } + return keyset; + } + @Override public DataStoreIterable getToManyRelation( DataStoreTransaction relationTx, @@ -197,7 +279,7 @@ public DataStoreIterable getToManyRelation( } final Query query = - new SubCollectionFetchQueryBuilder(relationship, dictionary, sessionWrapper) + new SubCollectionFetchQueryBuilder(relationship, dictionary, sessionWrapper, cursorEncoder) .build(); if (query != null) { @@ -234,7 +316,7 @@ private Long getTotalRecords(EntityProjection entityProjection, EntityDictionary Query query = - new RootCollectionPageTotalsQueryBuilder(entityProjection, dictionary, sessionWrapper) + new RootCollectionPageTotalsQueryBuilder(entityProjection, dictionary, sessionWrapper, cursorEncoder) .build(); return new TimedFunction(() -> query.uniqueResult(), "Query Hash: " + query.hashCode()).get(); @@ -251,7 +333,7 @@ private Long getTotalRecords(AbstractHQLQueryBuilder.Relationship relationship, EntityDictionary dictionary) { Query query = - new SubCollectionPageTotalsQueryBuilder(relationship, dictionary, sessionWrapper) + new SubCollectionPageTotalsQueryBuilder(relationship, dictionary, sessionWrapper, cursorEncoder) .build(); return new TimedFunction(() -> query.uniqueResult(), "Query Hash: " + query.hashCode()).get(); diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/AbstractHQLQueryBuilder.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/AbstractHQLQueryBuilder.java index 607a2fb424..a48f424518 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/AbstractHQLQueryBuilder.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/AbstractHQLQueryBuilder.java @@ -16,8 +16,13 @@ import com.yahoo.elide.core.filter.predicates.FilterPredicate; import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.request.Pagination; +import com.yahoo.elide.core.request.Pagination.Direction; import com.yahoo.elide.core.request.Sorting; +import com.yahoo.elide.core.request.Sorting.SortOrder; +import com.yahoo.elide.core.security.obfuscation.IdObfuscator; +import com.yahoo.elide.core.sort.SortingImpl; import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; import com.yahoo.elide.datastores.jpql.porting.Query; import com.yahoo.elide.datastores.jpql.porting.Session; import org.apache.commons.lang3.StringUtils; @@ -25,8 +30,10 @@ import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Map.Entry; import java.util.Set; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -37,6 +44,7 @@ public abstract class AbstractHQLQueryBuilder { protected final Session session; protected final EntityDictionary dictionary; + protected final CursorEncoder cursorEncoder; protected EntityProjection entityProjection; protected static final String SPACE = " "; protected static final String PERIOD = "."; @@ -74,10 +82,12 @@ default String getRelationshipName() { Object getParent(); } - public AbstractHQLQueryBuilder(EntityProjection entityProjection, EntityDictionary dictionary, Session session) { + public AbstractHQLQueryBuilder(EntityProjection entityProjection, EntityDictionary dictionary, Session session, + CursorEncoder cursorEncoder) { this.session = session; this.dictionary = dictionary; this.entityProjection = entityProjection; + this.cursorEncoder = cursorEncoder; } public abstract Query build(); @@ -100,6 +110,231 @@ protected void supplyFilterQueryParameters(Query query, Collection entityType = entityProjection.getType(); + // Add missing sorts + Sorting sorting = entityProjection.getSorting(); + if (sorting == null || sorting.isDefaultInstance()) { + if (Direction.BACKWARD.equals(pagination.getDirection())) { + Map sortOrder = new LinkedHashMap<>(); + sortOrder.put("id", Sorting.SortOrder.desc); + sorting = new SortingImpl(sortOrder, entityType, dictionary); + } else { + Map sortOrder = new LinkedHashMap<>(); + sortOrder.put("id", Sorting.SortOrder.asc); + sorting = new SortingImpl(sortOrder, entityType, dictionary); + } + entityProjection.setSorting(sorting); + } else { + // When moving backwards need to amend sort order + boolean hasIdSort = false; + Map sortOrder = new LinkedHashMap<>(); + for (Entry entry : ((SortingImpl) sorting).getSortRules().entrySet()) { + if (Direction.BACKWARD.equals(pagination.getDirection())) { + if (!entry.getKey().contains(".")) { + // root sort adjust direction + sortOrder.put(entry.getKey(), + SortOrder.asc.equals(entry.getValue()) ? SortOrder.desc : SortOrder.asc); + } else { + sortOrder.put(entry.getKey(), entry.getValue()); + } + } else { + // FORWARD + sortOrder.put(entry.getKey(), entry.getValue()); + } + if ("id".equals(entry.getKey())) { + hasIdSort = true; + } + } + if (!hasIdSort) { + sortOrder.put("id", Direction.BACKWARD.equals(pagination.getDirection()) ? SortOrder.desc + : SortOrder.asc); + } + sorting = new SortingImpl(sortOrder, entityType, dictionary); + entityProjection.setSorting(sorting); + } + } + + /** + * Derives the keyset pagination clause. + * + * @param entityProjection the entity projection + * @param dictionary the dictionary + * @param entityAlias the entity alias + * @return the keyset pagination clause + */ + protected String getKeysetPaginationClause(EntityProjection entityProjection, EntityDictionary dictionary, + String entityAlias) { + Pagination pagination = entityProjection.getPagination(); + if (pagination != null && pagination.getDirection() != null) { + adjustSortingForKeysetPagination(entityProjection); + + if (pagination.getBefore() != null || pagination.getAfter() != null) { + Type entityType = entityProjection.getType(); + String idField = dictionary.getIdFieldName(entityType); + + int index = 0; + StringBuilder builder = new StringBuilder(); + if (Direction.BETWEEN.equals(pagination.getDirection())) { + builder.append("("); + index = getKeysetPaginationClauseFromPaginationSort(builder, entityProjection, dictionary, idField, + Direction.FORWARD, index, entityAlias); + builder.append(") AND ("); + index = getKeysetPaginationClauseFromPaginationSort(builder, entityProjection, dictionary, idField, + Direction.BACKWARD, index, entityAlias); + builder.append(")"); + return builder.toString(); + } else { + // Direction is forward even for the backward direction as the sorts have already been reversed + builder.append("("); + index = getKeysetPaginationClauseFromPaginationSort(builder, entityProjection, dictionary, idField, + Direction.FORWARD, index, entityAlias); + builder.append(")"); + return builder.toString(); + } + } + } + return ""; + } + + protected void supplyKeysetPaginationQueryParameters(Query query, EntityProjection entityProjection, + EntityDictionary dictionary) { + Pagination pagination = entityProjection.getPagination(); + if (pagination.getDirection() != null) { + Map after = null; + Map before = null; + int index = 0; + Type entityType = entityProjection.getType(); + Sorting sorting = entityProjection.getSorting(); + + String afterCursor = pagination.getAfter(); + String beforeCursor = pagination.getBefore(); + if (afterCursor != null) { + after = cursorEncoder.decode(afterCursor); + } + if (beforeCursor != null) { + before = cursorEncoder.decode(beforeCursor); + } + + List fields = getKeysetColumns(sorting); + IdObfuscator idObfuscator = dictionary.getIdObfuscator(); + String entityIdFieldName = dictionary.getEntityIdFieldName(entityType); + String idFieldName = null; + if (idObfuscator != null || entityIdFieldName != null) { + idFieldName = dictionary.getIdFieldName(entityType); + } + if (after != null && !after.isEmpty()) { + for (KeysetColumn field : fields) { + if (entityIdFieldName != null && field.getColumn().equals(idFieldName)) { + // If the entity has entity id ignore id columns + continue; + } + Object value = getKeysetColumnValue(after, field, entityType, idFieldName, idObfuscator); + query.setParameter(KEYSET_PARAMETER_PREFIX + index++, value); + } + } + if (before != null && !before.isEmpty()) { + for (KeysetColumn field : fields) { + if (entityIdFieldName != null && field.getColumn().equals(idFieldName)) { + // If the entity has entity id ignore id columns + continue; + } + Object value = getKeysetColumnValue(before, field, entityType, idFieldName, idObfuscator); + query.setParameter(KEYSET_PARAMETER_PREFIX + index++, value); + } + } + } + } + + protected Object getKeysetColumnValue(Map keyset, KeysetColumn field, Type entityType, + String idFieldName, IdObfuscator idObfuscator) { + String keyValue = keyset.get(field.getColumn()); + Type fieldType = dictionary.getType(entityType, field.getColumn()); + if (idObfuscator != null && field.getColumn().equals(idFieldName)) { + return idObfuscator.deobfuscate(keyValue, fieldType); + } else { + return CoerceUtil.coerce(keyValue, fieldType); + } + } + + public static class KeysetColumn { + private final String column; + private final SortOrder sortOrder; + + public KeysetColumn(String column, SortOrder sortOrder) { + this.column = column; + this.sortOrder = sortOrder; + } + + public String getColumn() { + return column; + } + + public SortOrder getSortOrder() { + return sortOrder; + } + + @Override + public String toString() { + return "KeysetColumn [column=" + column + ", sortOrder=" + sortOrder + "]"; + } + } + + protected List getKeysetColumns(Sorting sorting) { + List result = new ArrayList<>(); + for (Entry entry : sorting.getSortingPaths().entrySet()) { + Path path = entry.getKey(); + if (path.getPathElements().size() == 1) { + String column = entry.getKey().getFieldPath(); + result.add(new KeysetColumn(column, entry.getValue())); + } + } + return result; + } + + protected int getKeysetPaginationClauseFromPaginationSort(StringBuilder builder, EntityProjection entityProjection, + EntityDictionary dictionary, String idField, Direction direction, int index, String entityAlias) { + Sorting sorting = entityProjection.getSorting(); + List keysetColumns = getKeysetColumns(sorting); + for (int x = 0; x < keysetColumns.size(); x++) { + KeysetColumn keysetColumn = keysetColumns.get(x); + if (x != 0) { + builder.append(" OR ("); + for (int y = 0; y < x; y++) { + KeysetColumn keysetColumnPrevious = keysetColumns.get(y); + builder.append(entityAlias + "." + keysetColumnPrevious.getColumn() + " =" + " :" + + KEYSET_PARAMETER_PREFIX + (index + y)); + builder.append(" AND "); + } + } + SortOrder order = keysetColumn.getSortOrder(); + if (Direction.BACKWARD.equals(direction)) { + if (order == SortOrder.asc) { + order = SortOrder.desc; + } else { + order = SortOrder.asc; + } + } + String operator = SortOrder.asc.equals(order) ? ">" : "<"; + builder.append(entityAlias + "." + keysetColumn.getColumn() + " " + operator + " :" + + KEYSET_PARAMETER_PREFIX + (index + x)); + if (x != 0) { + builder.append(")"); + } + } + return index + keysetColumns.size(); + } + /** * Extracts all the HQL JOIN clauses from given filter expression. * @param filterExpression the filter expression to extract a join clause from @@ -158,8 +393,19 @@ protected String getJoinClauseFromSort(Sorting sorting, boolean skipFetches) { protected void addPaginationToQuery(Query query) { Pagination pagination = entityProjection.getPagination(); if (pagination != null) { - query.setFirstResult(pagination.getOffset()); - query.setMaxResults(pagination.getLimit()); + if (Direction.FORWARD.equals(pagination.getDirection()) + || Direction.BACKWARD.equals(pagination.getDirection())) { + // Keyset pagination + // For keyset pagination adjust the limit by 1 to efficiently determine if there + // is a next page in the forward direction or a previous page in the backward + // direction + query.setFirstResult(0); + query.setMaxResults(pagination.getLimit() + 1); + } else { + // Offset pagination + query.setFirstResult(pagination.getOffset()); + query.setMaxResults(pagination.getLimit()); + } } } diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/CursorEncoder.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/CursorEncoder.java new file mode 100644 index 0000000000..a1aa0e92c9 --- /dev/null +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/CursorEncoder.java @@ -0,0 +1,29 @@ +/* + * Copyright 2024, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.datastores.jpql.query; + +import java.util.Map; + +/** + * Cursor encoder. + */ +public interface CursorEncoder { + /** + * Encode the cursor. + * + * @param keys the keys + * @return the encoded cursor + */ + String encode(Map keys); + + /** + * Decode the cursor. + * + * @param cursor the encoded cursor + * @return the keys + */ + Map decode(String cursor); +} diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/JacksonCursorEncoder.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/JacksonCursorEncoder.java new file mode 100644 index 0000000000..20c2ee9bb3 --- /dev/null +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/JacksonCursorEncoder.java @@ -0,0 +1,63 @@ +/* + * Copyright 2024, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.datastores.jpql.query; + +import com.yahoo.elide.core.exceptions.InvalidValueException; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * {@link CursorEncoder} using Jackson. + */ +public class JacksonCursorEncoder implements CursorEncoder { + private static class Holder { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + } + + private final ObjectMapper objectMapper; + + public JacksonCursorEncoder() { + this(Holder.OBJECT_MAPPER); + } + + public JacksonCursorEncoder(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public String encode(Map keys) { + try { + byte[] result = this.objectMapper.writeValueAsBytes(keys); + return Base64.getUrlEncoder().withoutPadding().encodeToString(result); + } catch (JsonProcessingException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public Map decode(String cursor) { + if (cursor == null) { + return Collections.emptyMap(); + } + try { + byte[] result = Base64.getUrlDecoder().decode(cursor); + TypeReference> typeRef = new TypeReference>() { + }; + return this.objectMapper.readValue(result, typeRef); + } catch (IOException | IllegalArgumentException e) { + throw new InvalidValueException("cursor " + cursor); + } + } +} diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RootCollectionFetchQueryBuilder.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RootCollectionFetchQueryBuilder.java index cc6a84f19f..98a46a5529 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RootCollectionFetchQueryBuilder.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RootCollectionFetchQueryBuilder.java @@ -30,8 +30,9 @@ public class RootCollectionFetchQueryBuilder extends AbstractHQLQueryBuilder { public RootCollectionFetchQueryBuilder(EntityProjection entityProjection, EntityDictionary dictionary, - Session session) { - super(entityProjection, dictionary, session); + Session session, + CursorEncoder cursorEncoder) { + super(entityProjection, dictionary, session, cursorEncoder); } /** @@ -47,6 +48,8 @@ public Query build() { Query query; FilterExpression filterExpression = entityProjection.getFilterExpression(); + String keysetPaginationClause = getKeysetPaginationClause(entityProjection, dictionary, entityAlias); + if (filterExpression != null) { //Build the JOIN clause String joinClause = getJoinClauseFromFilters(filterExpression) @@ -91,6 +94,9 @@ public Query build() { throw new InvalidValueException("Combination of pagination, sorting over relationship and" + " filtering over toMany relationships unsupported"); } + if (!"".equals(keysetPaginationClause)) { + keysetPaginationClause = " AND " + keysetPaginationClause; + } query = session.createQuery( SELECT + (requiresDistinct ? DISTINCT : "") @@ -103,13 +109,20 @@ public Query build() { + joinClause + SPACE + filterClause + + keysetPaginationClause + SPACE + getSortClause(entityProjection.getSorting()) ); //Fill in the query parameters supplyFilterQueryParameters(query, predicates); + if (!"".equals(keysetPaginationClause)) { + supplyKeysetPaginationQueryParameters(query, entityProjection, dictionary); + } } else { + if (!"".equals(keysetPaginationClause)) { + keysetPaginationClause = WHERE + keysetPaginationClause; + } query = session.createQuery(SELECT + entityAlias + FROM @@ -119,8 +132,12 @@ public Query build() { + SPACE + getJoinClauseFromSort(entityProjection.getSorting()) + extractToOneMergeJoins(entityClass, entityAlias) + + keysetPaginationClause + SPACE + getSortClause(entityProjection.getSorting())); + if (!"".equals(keysetPaginationClause)) { + supplyKeysetPaginationQueryParameters(query, entityProjection, dictionary); + } } addPaginationToQuery(query); diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RootCollectionPageTotalsQueryBuilder.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RootCollectionPageTotalsQueryBuilder.java index fa27289a60..5205297769 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RootCollectionPageTotalsQueryBuilder.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RootCollectionPageTotalsQueryBuilder.java @@ -27,8 +27,8 @@ public class RootCollectionPageTotalsQueryBuilder extends AbstractHQLQueryBuilde public RootCollectionPageTotalsQueryBuilder(EntityProjection entityProjection, EntityDictionary dictionary, - Session session) { - super(entityProjection, dictionary, session); + Session session, CursorEncoder cursorEncoder) { + super(entityProjection, dictionary, session, cursorEncoder); } /** diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/SubCollectionFetchQueryBuilder.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/SubCollectionFetchQueryBuilder.java index 313dfc3de9..c1d86f3831 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/SubCollectionFetchQueryBuilder.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/SubCollectionFetchQueryBuilder.java @@ -29,8 +29,8 @@ public class SubCollectionFetchQueryBuilder extends AbstractHQLQueryBuilder { public SubCollectionFetchQueryBuilder(Relationship relationship, EntityDictionary dictionary, - Session session) { - super(relationship.getRelationship().getProjection(), dictionary, session); + Session session, CursorEncoder cursorEncoder) { + super(relationship.getRelationship().getProjection(), dictionary, session, cursorEncoder); this.relationship = relationship; } diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/SubCollectionPageTotalsQueryBuilder.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/SubCollectionPageTotalsQueryBuilder.java index 9ceb10599c..0c7451e5bc 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/SubCollectionPageTotalsQueryBuilder.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/SubCollectionPageTotalsQueryBuilder.java @@ -34,8 +34,9 @@ public class SubCollectionPageTotalsQueryBuilder extends AbstractHQLQueryBuilder public SubCollectionPageTotalsQueryBuilder(Relationship relationship, EntityDictionary dictionary, - Session session) { - super(relationship.getRelationship().getProjection(), dictionary, session); + Session session, + CursorEncoder cursorEncoder) { + super(relationship.getRelationship().getProjection(), dictionary, session, cursorEncoder); this.relationship = relationship; } diff --git a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/AbstractHQLQueryBuilderTest.java b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/AbstractHQLQueryBuilderTest.java index 612ed160f7..16ad115938 100644 --- a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/AbstractHQLQueryBuilderTest.java +++ b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/AbstractHQLQueryBuilderTest.java @@ -29,6 +29,8 @@ import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.datastores.jpql.porting.Query; import com.yahoo.elide.datastores.jpql.query.AbstractHQLQueryBuilder; +import com.yahoo.elide.datastores.jpql.query.JacksonCursorEncoder; + import example.Author; import example.Book; import example.Chapter; @@ -57,7 +59,7 @@ public class AbstractHQLQueryBuilderTest extends AbstractHQLQueryBuilder { public AbstractHQLQueryBuilderTest() { - super(getMockEntityProjection(), EntityDictionary.builder().build(), new TestSessionWrapper()); + super(getMockEntityProjection(), EntityDictionary.builder().build(), new TestSessionWrapper(), new JacksonCursorEncoder()); dictionary.bindEntity(Author.class); dictionary.bindEntity(Book.class); dictionary.bindEntity(Chapter.class); diff --git a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/RootCollectionFetchQueryBuilderTest.java b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/RootCollectionFetchQueryBuilderTest.java index 5f8fc256ed..f47cd94c36 100644 --- a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/RootCollectionFetchQueryBuilderTest.java +++ b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/RootCollectionFetchQueryBuilderTest.java @@ -22,12 +22,15 @@ import com.yahoo.elide.core.pagination.PaginationImpl; import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.request.Pagination; +import com.yahoo.elide.core.request.Pagination.Direction; import com.yahoo.elide.core.request.Relationship; import com.yahoo.elide.core.request.Sorting; import com.yahoo.elide.core.sort.SortingImpl; import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.datastores.jpql.porting.Query; import com.yahoo.elide.datastores.jpql.porting.SingleResultQuery; +import com.yahoo.elide.datastores.jpql.query.CursorEncoder; +import com.yahoo.elide.datastores.jpql.query.JacksonCursorEncoder; import com.yahoo.elide.datastores.jpql.query.RootCollectionFetchQueryBuilder; import example.Author; import example.Book; @@ -38,7 +41,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; +import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -47,6 +52,7 @@ public class RootCollectionFetchQueryBuilderTest { private EntityDictionary dictionary; private static final String TITLE = "title"; + private static final String GENRE = "genre"; private static final String PUBLISHER = "publisher"; private static final String EDITOR = "editor"; private static final String PERIOD = "."; @@ -55,6 +61,7 @@ public class RootCollectionFetchQueryBuilderTest { private static final String PRICE = "price"; private static final String TOTAL = "total"; private RSQLFilterDialect filterParser; + private CursorEncoder cursorEncoder = new JacksonCursorEncoder(); @BeforeAll public void initialize() { @@ -76,7 +83,8 @@ public void testRootFetch() { RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( entityProjection, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); @@ -104,7 +112,8 @@ public void testRootFetchWithId() { RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( entityProjection, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); Query query = builder.build(); @@ -125,7 +134,8 @@ public void testRootFetchWithSorting() { RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( entityProjection, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); @@ -155,7 +165,8 @@ public void testRootFetchWithJoinFilter() throws ParseException { RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( entityProjection, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); @@ -196,7 +207,8 @@ public void testDistinctRootFetchWithToManyJoinFilterAndPagination() throws Pars RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( entityProjection, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); @@ -236,7 +248,8 @@ public void testRootFetchWithSortingAndFilters() { RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( entityProjection, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); @@ -266,7 +279,8 @@ public void testSortingWithJoin() { RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( entityProjection, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); @@ -301,7 +315,8 @@ public void testRootFetchWithRelationshipSortingAndFilters() { RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( entityProjection, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); @@ -320,6 +335,168 @@ public void testRootFetchWithRelationshipSortingAndFilters() { assertEquals(expected, actual); } + @Test + public void testRootFetchWithRelationshipSortingAndFiltersAndKeysetPagination() { + Map sorting = new LinkedHashMap<>(); + sorting.put(PUBLISHER + PERIOD + EDITOR + PERIOD + FIRSTNAME, Sorting.SortOrder.desc); + sorting.put(TITLE, Sorting.SortOrder.asc); + sorting.put(GENRE, Sorting.SortOrder.asc); + + Path.PathElement idPath = new Path.PathElement(Book.class, Chapter.class, "id"); + + FilterPredicate idPredicate = new InPredicate(idPath, 1); + + EntityProjection entityProjection = EntityProjection + .builder().type(Book.class) + .sorting(new SortingImpl(sorting, Book.class, dictionary)) + .filterExpression(idPredicate) + .pagination(new PaginationImpl(Book.class, null, 2, 10, 10, false, false, null, + cursorEncoder.encode(Map.of("id", "2")), Direction.FORWARD)) + .build(); + + RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( + entityProjection, + dictionary, + new TestSessionWrapper(), + cursorEncoder + ); + + TestQueryWrapper query = (TestQueryWrapper) builder.build(); + + String expected = "SELECT example_Book FROM example.Book AS example_Book" + + " LEFT JOIN example_Book.publisher example_Book_publisher" + + " LEFT JOIN example_Book_publisher.editor example_Book_publisher_editor" + + " WHERE example_Book.id IN (:id_XXX)" + + " AND " + + "(example_Book.title > :_keysetParameter_0 OR " + + "(example_Book.title = :_keysetParameter_0 AND example_Book.genre > :_keysetParameter_1) OR " + + "(example_Book.title = :_keysetParameter_0 AND example_Book.genre = :_keysetParameter_1 AND example_Book.id > :_keysetParameter_2))" + + " order by example_Book_publisher_editor.firstName desc,example_Book.title asc,example_Book.genre asc,example_Book.id asc"; + + String actual = query.getQueryText(); + actual = actual.trim().replaceAll(" +", " "); + actual = actual.replaceFirst(":id_\\w+", ":id_XXX"); + + assertEquals(expected, actual); + } + + @Test + public void testRootFetchWithRelationshipSortingAndFiltersAndKeysetPaginationBackward() { + Map sorting = new LinkedHashMap<>(); + sorting.put(PUBLISHER + PERIOD + EDITOR + PERIOD + FIRSTNAME, Sorting.SortOrder.desc); + sorting.put(TITLE, Sorting.SortOrder.asc); + sorting.put(GENRE, Sorting.SortOrder.asc); + + Path.PathElement idPath = new Path.PathElement(Book.class, Chapter.class, "id"); + + FilterPredicate idPredicate = new InPredicate(idPath, 1); + + EntityProjection entityProjection = EntityProjection + .builder().type(Book.class) + .sorting(new SortingImpl(sorting, Book.class, dictionary)) + .filterExpression(idPredicate) + .pagination(new PaginationImpl(Book.class, null, 2, 10, 10, false, false, null, + cursorEncoder.encode(Map.of("id", "2")), Direction.BACKWARD)) + .build(); + + RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( + entityProjection, + dictionary, + new TestSessionWrapper(), + cursorEncoder + ); + + TestQueryWrapper query = (TestQueryWrapper) builder.build(); + + String expected = "SELECT example_Book FROM example.Book AS example_Book" + + " LEFT JOIN example_Book.publisher example_Book_publisher" + + " LEFT JOIN example_Book_publisher.editor example_Book_publisher_editor" + + " WHERE example_Book.id IN (:id_XXX)" + + " AND " + + "(example_Book.title < :_keysetParameter_0 OR " + + "(example_Book.title = :_keysetParameter_0 AND example_Book.genre < :_keysetParameter_1) OR " + + "(example_Book.title = :_keysetParameter_0 AND example_Book.genre = :_keysetParameter_1 AND example_Book.id < :_keysetParameter_2))" + + " order by example_Book_publisher_editor.firstName desc,example_Book.title desc,example_Book.genre desc,example_Book.id desc"; + + String actual = query.getQueryText(); + actual = actual.trim().replaceAll(" +", " "); + actual = actual.replaceFirst(":id_\\w+", ":id_XXX"); + + assertEquals(expected, actual); + } + + @Test + public void testRootFetchWithRelationshipAndFiltersAndKeysetPagination() { + Path.PathElement idPath = new Path.PathElement(Book.class, Chapter.class, "id"); + + FilterPredicate idPredicate = new InPredicate(idPath, 1); + + EntityProjection entityProjection = EntityProjection + .builder().type(Book.class) + .sorting(new SortingImpl(Collections.emptyMap(), Book.class, dictionary)) + .filterExpression(idPredicate) + .pagination(new PaginationImpl(Book.class, null, 2, 10, 10, false, false, null, + cursorEncoder.encode(Map.of("id", "2")), Direction.FORWARD)) + .build(); + + RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( + entityProjection, + dictionary, + new TestSessionWrapper(), + cursorEncoder + ); + + TestQueryWrapper query = (TestQueryWrapper) builder.build(); + + String expected = "SELECT example_Book FROM example.Book AS example_Book" + + " WHERE example_Book.id IN (:id_XXX)" + + " AND " + + "(example_Book.id > :_keysetParameter_0)" + + " order by example_Book.id asc"; + + String actual = query.getQueryText(); + actual = actual.trim().replaceAll(" +", " "); + actual = actual.replaceFirst(":id_\\w+", ":id_XXX"); + + assertEquals(expected, actual); + } + + @Test + public void testRootFetchWithRelationshipAndFiltersAndKeysetPaginationBackward() { + Path.PathElement idPath = new Path.PathElement(Book.class, Chapter.class, "id"); + + FilterPredicate idPredicate = new InPredicate(idPath, 1); + + EntityProjection entityProjection = EntityProjection + .builder().type(Book.class) + .sorting(new SortingImpl(Collections.emptyMap(), Book.class, dictionary)) + .filterExpression(idPredicate) + .pagination(new PaginationImpl(Book.class, null, 2, 10, 10, false, false, null, + cursorEncoder.encode(Map.of("id", "2")), Direction.BACKWARD)) + .build(); + + RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( + entityProjection, + dictionary, + new TestSessionWrapper(), + cursorEncoder + ); + + TestQueryWrapper query = (TestQueryWrapper) builder.build(); + + String expected = "SELECT example_Book FROM example.Book AS example_Book" + + " WHERE example_Book.id IN (:id_XXX)" + + " AND " + + "(example_Book.id < :_keysetParameter_0)" + + " order by example_Book.id desc"; + + String actual = query.getQueryText(); + actual = actual.trim().replaceAll(" +", " "); + actual = actual.replaceFirst(":id_\\w+", ":id_XXX"); + + assertEquals(expected, actual); + } + @Test public void testRootFetchWithToOneRelationIncluded() { EntityProjection entityProjection = EntityProjection.builder().type(Book.class) @@ -333,7 +510,8 @@ public void testRootFetchWithToOneRelationIncluded() { RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( entityProjection, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); @@ -369,7 +547,8 @@ public void testRootFetchWithRelationshipSortingFiltersAndPagination() { RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( entityProjection, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); assertThrows(InvalidValueException.class, () -> { @@ -400,7 +579,8 @@ public void testRootFetchWithRelationshipSortingFiltersAndPaginationOnEmbedded() RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( entityProjection, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); diff --git a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/RootCollectionPageTotalsQueryBuilderTest.java b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/RootCollectionPageTotalsQueryBuilderTest.java index d3cc282d05..9dc191a492 100644 --- a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/RootCollectionPageTotalsQueryBuilderTest.java +++ b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/RootCollectionPageTotalsQueryBuilderTest.java @@ -16,6 +16,8 @@ import com.yahoo.elide.core.pagination.PaginationImpl; import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.request.Sorting; +import com.yahoo.elide.datastores.jpql.query.CursorEncoder; +import com.yahoo.elide.datastores.jpql.query.JacksonCursorEncoder; import com.yahoo.elide.datastores.jpql.query.RootCollectionPageTotalsQueryBuilder; import example.Author; import example.Book; @@ -31,6 +33,7 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class RootCollectionPageTotalsQueryBuilderTest { private EntityDictionary dictionary; + private CursorEncoder cursorEncoder = new JacksonCursorEncoder(); private static final String TITLE = "title"; private static final String BOOKS = "books"; @@ -49,7 +52,7 @@ public void initialize() { public void testRootFetch() { EntityProjection entityProjection = EntityProjection.builder().type(Book.class).build(); RootCollectionPageTotalsQueryBuilder builder = new RootCollectionPageTotalsQueryBuilder( - entityProjection, dictionary, new TestSessionWrapper() + entityProjection, dictionary, new TestSessionWrapper(), cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); @@ -72,7 +75,7 @@ public void testRootFetchWithSorting() { .sorting(sorting) .build(); TestQueryWrapper query = (TestQueryWrapper) new RootCollectionPageTotalsQueryBuilder( - entityProjection, dictionary, new TestSessionWrapper() + entityProjection, dictionary, new TestSessionWrapper(), cursorEncoder ) .build(); String expected = "SELECT COUNT(example_Book) FROM example.Book AS example_Book"; @@ -89,7 +92,7 @@ public void testRootFetchWithPagination() { .pagination(pagination) .build(); TestQueryWrapper query = (TestQueryWrapper) new RootCollectionPageTotalsQueryBuilder( - entityProjection, dictionary, new TestSessionWrapper() + entityProjection, dictionary, new TestSessionWrapper(), cursorEncoder ) .build(); String expected = "SELECT COUNT(example_Book) FROM example.Book AS example_Book"; @@ -126,7 +129,7 @@ public void testRootFetchWithJoinFilter() { .build(); RootCollectionPageTotalsQueryBuilder builder = new RootCollectionPageTotalsQueryBuilder( - entityProjection, dictionary, new TestSessionWrapper() + entityProjection, dictionary, new TestSessionWrapper(), cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); diff --git a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/SubCollectionFetchQueryBuilderTest.java b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/SubCollectionFetchQueryBuilderTest.java index edfc69b146..2e31489563 100644 --- a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/SubCollectionFetchQueryBuilderTest.java +++ b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/SubCollectionFetchQueryBuilderTest.java @@ -21,6 +21,8 @@ import com.yahoo.elide.core.request.Sorting; import com.yahoo.elide.core.sort.SortingImpl; import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.datastores.jpql.query.CursorEncoder; +import com.yahoo.elide.datastores.jpql.query.JacksonCursorEncoder; import com.yahoo.elide.datastores.jpql.query.RelationshipImpl; import com.yahoo.elide.datastores.jpql.query.SubCollectionFetchQueryBuilder; import example.Author; @@ -41,6 +43,7 @@ public class SubCollectionFetchQueryBuilderTest { private EntityDictionary dictionary; + private CursorEncoder cursorEncoder = new JacksonCursorEncoder(); private static final String AUTHORS = "authors"; private static final String TITLE = "title"; @@ -83,7 +86,8 @@ public void testSubCollectionFetch() { SubCollectionFetchQueryBuilder builder = new SubCollectionFetchQueryBuilder( relationship, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); @@ -122,7 +126,8 @@ public void testSubCollectionFetchWithIncludedRelation() { SubCollectionFetchQueryBuilder builder = new SubCollectionFetchQueryBuilder( relationship, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); @@ -167,7 +172,8 @@ public void testSubCollectionFetchWithIncludedToManyRelation() { SubCollectionFetchQueryBuilder builder = new SubCollectionFetchQueryBuilder( relationship, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); @@ -206,7 +212,8 @@ public void testSubCollectionFetchWithSorting() { SubCollectionFetchQueryBuilder builder = new SubCollectionFetchQueryBuilder( relationship, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); @@ -254,7 +261,8 @@ public void testSubCollectionFetchWithJoinFilter() { SubCollectionFetchQueryBuilder builder = new SubCollectionFetchQueryBuilder( relationship, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); @@ -303,7 +311,8 @@ public void testDistinctSubCollectionFetchWithToManyJoinFilter() { SubCollectionFetchQueryBuilder builder = new SubCollectionFetchQueryBuilder( relationship, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); @@ -356,7 +365,8 @@ public void testDistinctSubCollectionFetchWithToManyJoinFilterAndSortOverRelatio SubCollectionFetchQueryBuilder builder = new SubCollectionFetchQueryBuilder( relationship, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); assertThrows(InvalidValueException.class, () -> { @@ -401,7 +411,8 @@ public void testSubCollectionFetchWithSortingAndFilters() { SubCollectionFetchQueryBuilder builder = new SubCollectionFetchQueryBuilder( relationship, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); @@ -441,7 +452,8 @@ public void testFetchJoinExcludesParent() { SubCollectionFetchQueryBuilder builder = new SubCollectionFetchQueryBuilder( relationship, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); @@ -482,7 +494,8 @@ public void testSubCollectionFetchWithRelationshipSorting() { SubCollectionFetchQueryBuilder builder = new SubCollectionFetchQueryBuilder( relationship, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); diff --git a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/SubCollectionPageTotalsQueryBuilderTest.java b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/SubCollectionPageTotalsQueryBuilderTest.java index 98e1a7eaa3..7a263c8b13 100644 --- a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/SubCollectionPageTotalsQueryBuilderTest.java +++ b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/SubCollectionPageTotalsQueryBuilderTest.java @@ -17,6 +17,8 @@ import com.yahoo.elide.core.request.Relationship; import com.yahoo.elide.core.request.Sorting; import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.datastores.jpql.query.CursorEncoder; +import com.yahoo.elide.datastores.jpql.query.JacksonCursorEncoder; import com.yahoo.elide.datastores.jpql.query.RelationshipImpl; import com.yahoo.elide.datastores.jpql.query.SubCollectionPageTotalsQueryBuilder; import example.Author; @@ -34,6 +36,7 @@ public class SubCollectionPageTotalsQueryBuilderTest { private EntityDictionary dictionary; + private CursorEncoder cursorEncoder = new JacksonCursorEncoder(); private static final String BOOKS = "books"; private static final String PUBLISHER = "publisher"; @@ -64,7 +67,7 @@ public void testSubCollectionPageTotals() { ); SubCollectionPageTotalsQueryBuilder builder = new SubCollectionPageTotalsQueryBuilder( - relationship, dictionary, new TestSessionWrapper() + relationship, dictionary, new TestSessionWrapper(), cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder @@ -108,7 +111,8 @@ public void testSubCollectionPageTotalsWithSorting() { TestQueryWrapper query = (TestQueryWrapper) new SubCollectionPageTotalsQueryBuilder( relationship, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ).build(); String expected = @@ -146,7 +150,8 @@ public void testSubCollectionPageTotalsWithPagination() { TestQueryWrapper query = (TestQueryWrapper) new SubCollectionPageTotalsQueryBuilder( relationship, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ).build(); String expected = @@ -194,7 +199,8 @@ public void testSubCollectionPageTotalsWithJoinFilter() { SubCollectionPageTotalsQueryBuilder builder = new SubCollectionPageTotalsQueryBuilder( relationship, dictionary, - new TestSessionWrapper() + new TestSessionWrapper(), + cursorEncoder ); TestQueryWrapper query = (TestQueryWrapper) builder.build(); diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/Environment.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/Environment.java index b93fb5a89b..b0f8b22a2f 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/Environment.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/Environment.java @@ -29,8 +29,10 @@ public class Environment { public final Optional sort; public final Optional>> data; public final Optional filters; - public final Optional offset; - public final Optional first; + public final Optional first; + public final Optional after; + public final Optional last; + public final Optional before; public final Object rawSource; public final GraphQLContainer container; @@ -48,8 +50,10 @@ public Environment(DataFetchingEnvironment environment, NonEntityDictionary nonE requestScope = environment.getLocalContext(); filters = Optional.ofNullable((String) args.get(ModelBuilder.ARGUMENT_FILTER)); - offset = Optional.ofNullable((String) args.get(ModelBuilder.ARGUMENT_AFTER)); - first = Optional.ofNullable((String) args.get(ModelBuilder.ARGUMENT_FIRST)); + first = Optional.ofNullable(args.get(ModelBuilder.ARGUMENT_FIRST)); + after = Optional.ofNullable(args.get(ModelBuilder.ARGUMENT_AFTER)); + last = Optional.ofNullable(args.get(ModelBuilder.ARGUMENT_FIRST)); + before = Optional.ofNullable(args.get(ModelBuilder.ARGUMENT_AFTER)); sort = Optional.ofNullable((String) args.get(ModelBuilder.ARGUMENT_SORT)); parentType = environment.getParentType(); diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLScalars.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLScalars.java index b1ba28fdb6..a3a5f38392 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLScalars.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLScalars.java @@ -88,4 +88,34 @@ public String parseLiteral(Object o) { } }) .build(); + + public static GraphQLScalarType GRAPHQL_STRING_OR_INT_TYPE = GraphQLScalarType.newScalar() + .name("StringOrInt") + .description("The `StringOrInt` scalar type represents a type that can accept either a `String` " + + "textual value or `Int` non-fractional signed whole numeric values.") + .coercing(new Coercing() { + @Override + public Object serialize(Object o) { + return o; + } + + @Override + public Object parseValue(Object o) { + return o; + } + + @Override + public Object parseLiteral(Object o) { + Object input; + if (o instanceof IntValue) { + input = ((IntValue) o).getValue().longValue(); + } else if (o instanceof StringValue) { + input = ((StringValue) o).getValue(); + } else { + throw new CoercingParseValueException(ERROR_BAD_EPOCH_TYPE); + } + return parseValue(input); + } + }) + .build(); } diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/KeyWord.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/KeyWord.java index 890580f576..c3d0ec2d27 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/KeyWord.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/KeyWord.java @@ -20,6 +20,7 @@ public enum KeyWord { NODE("node"), EDGES("edges"), PAGE_INFO("pageInfo"), + PAGE_INFO_HAS_PREVIOUS_PAGE("hasPreviousPage"), PAGE_INFO_HAS_NEXT_PAGE("hasNextPage"), PAGE_INFO_START_CURSOR("startCursor"), PAGE_INFO_END_CURSOR("endCursor"), diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java index f16e8704d4..6334923fac 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java @@ -14,6 +14,8 @@ import com.yahoo.elide.ElideSettings; import com.yahoo.elide.annotation.CreatePermission; import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.Paginate; +import com.yahoo.elide.annotation.PaginationMode; import com.yahoo.elide.annotation.ReadPermission; import com.yahoo.elide.annotation.UpdatePermission; import com.yahoo.elide.core.dictionary.EntityDictionary; @@ -50,6 +52,7 @@ import lombok.extern.slf4j.Slf4j; import java.lang.annotation.Annotation; +import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.Locale; @@ -69,6 +72,8 @@ public class ModelBuilder { public static final String ARGUMENT_SORT = "sort"; public static final String ARGUMENT_FIRST = "first"; public static final String ARGUMENT_AFTER = "after"; + public static final String ARGUMENT_LAST = "last"; // cursor + public static final String ARGUMENT_BEFORE = "before"; // cursor public static final String ARGUMENT_OPERATION = "op"; public static final String OBJECT_PAGE_INFO = "PageInfo"; public static final String OBJECT_MUTATION = "Mutation"; @@ -78,8 +83,10 @@ public class ModelBuilder { private DataFetcher dataFetcher; private GraphQLArgument idArgument; private GraphQLArgument filterArgument; - private GraphQLArgument pageOffsetArgument; + private GraphQLArgument pageAfterArgument; private GraphQLArgument pageFirstArgument; + private GraphQLArgument pageLastArgument; // cursor + private GraphQLArgument pageBeforeArgument; // cursor private GraphQLArgument sortArgument; private GraphQLConversionUtils generator; private GraphQLNameUtils nameUtils; @@ -152,18 +159,35 @@ public ModelBuilder(EntityDictionary entityDictionary, .type(Scalars.GraphQLString) .build(); - pageFirstArgument = newArgument() + // Offset and Cursor + pageFirstArgument = newArgument() // Should be Scalars.GraphQLInt but is a breaking change .name(ARGUMENT_FIRST) - .type(Scalars.GraphQLString) + .type(GraphQLScalars.GRAPHQL_STRING_OR_INT_TYPE) .build(); - pageOffsetArgument = newArgument() + // Offset and Cursor + pageAfterArgument = newArgument() .name(ARGUMENT_AFTER) - .type(Scalars.GraphQLString) + .type(GraphQLScalars.GRAPHQL_STRING_OR_INT_TYPE) + .build(); + + // Cursor + pageLastArgument = newArgument() + .name(ARGUMENT_LAST) + .type(Scalars.GraphQLInt) + .build(); + + // Cursor + pageBeforeArgument = newArgument() + .name(ARGUMENT_BEFORE) + .type(GraphQLScalars.GRAPHQL_STRING_OR_INT_TYPE) .build(); GraphQLObjectType.Builder pageInfoObjectBuilder = newObject() .name(OBJECT_PAGE_INFO) + .field(newFieldDefinition() + .name("hasPreviousPage") + .type(Scalars.GraphQLBoolean)) .field(newFieldDefinition() .name("hasNextPage") .type(Scalars.GraphQLBoolean)) @@ -327,18 +351,33 @@ public GraphQLSchema build() { GraphQLArgument relationshipOpArg = getRelationshipOp(clazz); if (relationshipOpArg != null) { - root.field(newFieldDefinition() - .name(entityName) + Paginate paginate = entityDictionary.getAnnotation(clazz, Paginate.class); + boolean offsetSupported = true; + boolean cursorSupported = false; + if (paginate != null) { + if (paginate.modes() != null) { + offsetSupported = Arrays.stream(paginate.modes()).anyMatch(PaginationMode.OFFSET::equals); + cursorSupported = Arrays.stream(paginate.modes()).anyMatch(PaginationMode.CURSOR::equals); + } + } + GraphQLFieldDefinition.Builder fieldDefinition = newFieldDefinition().name(entityName) .description(EntityDictionary.getEntityDescription(clazz)) .argument(relationshipOpArg) .argument(idArgument) .argument(filterArgument) - .argument(sortArgument) - .argument(pageFirstArgument) - .argument(pageOffsetArgument) - .argument(buildInputObjectArgument(clazz, true)) + .argument(sortArgument); + if (offsetSupported || cursorSupported) { + fieldDefinition.argument(pageFirstArgument); // cursor and offset + fieldDefinition.argument(pageAfterArgument); // cursor and offset + } + if (cursorSupported) { + fieldDefinition.argument(pageLastArgument); // cursor + fieldDefinition.argument(pageBeforeArgument); // cursor + } + fieldDefinition.argument(buildInputObjectArgument(clazz, true)) .arguments(generator.entityArgumentToQueryObject(clazz, entityDictionary)) - .type(buildConnectionObject(clazz))); + .type(buildConnectionObject(clazz)); + root.field(fieldDefinition); } } @@ -498,13 +537,28 @@ private GraphQLObjectType buildQueryObject(Type entityClass) { } builder.field(fieldDefinition); } else { + Paginate paginate = entityDictionary.getAnnotation(relationshipClass, Paginate.class); + boolean offsetSupported = true; + boolean cursorSupported = false; + if (paginate != null) { + if (paginate.modes() != null) { + offsetSupported = Arrays.stream(paginate.modes()).anyMatch(PaginationMode.OFFSET::equals); + cursorSupported = Arrays.stream(paginate.modes()).anyMatch(PaginationMode.CURSOR::equals); + } + } GraphQLFieldDefinition.Builder fieldDefinition = newFieldDefinition().name(relationship) .argument(relationshipOpArg) .argument(filterArgument) - .argument(sortArgument) - .argument(pageOffsetArgument) - .argument(pageFirstArgument) - .argument(idArgument) + .argument(sortArgument); + if (offsetSupported || cursorSupported) { + fieldDefinition.argument(pageAfterArgument); + fieldDefinition.argument(pageFirstArgument); + } + if (cursorSupported) { + fieldDefinition.argument(pageBeforeArgument); // cursor + fieldDefinition.argument(pageLastArgument); // cursor + } + fieldDefinition.argument(idArgument) .argument(buildInputObjectArgument(relationshipClass, true)) .arguments(generator.entityArgumentToQueryObject(relationshipClass, entityDictionary)) .type(new GraphQLTypeReference(relationshipEntityName)); diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/PersistentResourceFetcher.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/PersistentResourceFetcher.java index 02e7e7c04a..6fd9f516fb 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/PersistentResourceFetcher.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/PersistentResourceFetcher.java @@ -129,8 +129,8 @@ public Object get(DataFetchingEnvironment environment) { * @param environment Environment encapsulating graphQL's request environment */ private void filterSortPaginateSanityCheck(Environment environment) { - if (environment.filters.isPresent() || environment.sort.isPresent() || environment.offset.isPresent() - || environment.first.isPresent()) { + if (environment.filters.isPresent() || environment.sort.isPresent() || environment.after.isPresent() + || environment.first.isPresent() || environment.before.isPresent() || environment.last.isPresent()) { throw new BadRequestException("Pagination/Filtering/Sorting is only supported with FETCH operation"); } } diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/PageInfoContainer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/PageInfoContainer.java index 95438b2c28..64084c05fb 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/PageInfoContainer.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/PageInfoContainer.java @@ -20,7 +20,7 @@ /** * Container for nodes. */ -public class PageInfoContainer implements GraphQLContainer { +public class PageInfoContainer implements GraphQLContainer { @Getter private final ConnectionContainer connectionContainer; public PageInfoContainer(ConnectionContainer connectionContainer) { @@ -38,25 +38,54 @@ public Object processFetch(Environment context) { .sorted() .collect(Collectors.toList()); - return pagination.map(pageValue -> { - switch (KeyWord.byName(fieldName)) { - case PAGE_INFO_HAS_NEXT_PAGE: { - int numResults = ids.size(); - int nextOffset = numResults + pageValue.getOffset(); - return nextOffset < pageValue.getPageTotals(); - } - case PAGE_INFO_START_CURSOR: - return pageValue.getOffset(); - case PAGE_INFO_END_CURSOR: - return pageValue.getOffset() + ids.size(); - case PAGE_INFO_TOTAL_RECORDS: - return pageValue.getPageTotals(); - default: - break; - } - throw new BadRequestException("Invalid request. Looking for field: " - + fieldName + " in an pageInfo object."); - }).orElseThrow(() -> new BadRequestException("Could not generate pagination information for type: " - + connectionContainer.getTypeName())); + Pagination pageValue = pagination.orElseThrow(() -> new BadRequestException( + "Could not generate pagination information for type: " + connectionContainer.getTypeName())); + + switch (KeyWord.byName(fieldName)) { + case PAGE_INFO_HAS_PREVIOUS_PAGE: { + if (pageValue.getHasPreviousPage() != null) { + return pageValue.getHasPreviousPage(); + } + if (pageValue.getDirection() != null) { // cursor + return null; // if cannot efficiently determine return null + } + return pageValue.getOffset() > 0; // offset + } + case PAGE_INFO_HAS_NEXT_PAGE: { + if (pageValue.getHasNextPage() != null) { + return pageValue.getHasNextPage(); + } + if (pageValue.getDirection() != null) { // cursor + return null; // if cannot efficiently determine return null + } + int numResults = ids.size(); + int nextOffset = numResults + pageValue.getOffset(); + if (pageValue.getPageTotals() == null) { + throw new BadRequestException("Cannot determine hasNextPage without totalRecords."); + } + return nextOffset < pageValue.getPageTotals(); // offset + } + case PAGE_INFO_START_CURSOR: + if (pageValue.getStartCursor() != null) { + return pageValue.getStartCursor(); + } + if (pageValue.getDirection() != null) { // cursor + return null; // can be null if there are no results + } + return pageValue.getOffset(); // offset + case PAGE_INFO_END_CURSOR: + if (pageValue.getEndCursor() != null) { + return pageValue.getEndCursor(); + } + if (pageValue.getDirection() != null) { // cursor + return null; // can be null if there are no results + } + return pageValue.getOffset() + ids.size(); // offset + case PAGE_INFO_TOTAL_RECORDS: + return pageValue.getPageTotals(); + default: + break; + } + throw new BadRequestException("Invalid request. Looking for field: " + fieldName + " in an pageInfo object."); } } diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/GraphQLEntityProjectionMaker.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/GraphQLEntityProjectionMaker.java index bcf699e2e8..11171a934b 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/GraphQLEntityProjectionMaker.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/GraphQLEntityProjectionMaker.java @@ -32,6 +32,7 @@ import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.request.EntityProjection.EntityProjectionBuilder; import com.yahoo.elide.core.request.Pagination; +import com.yahoo.elide.core.request.Pagination.Direction; import com.yahoo.elide.core.request.Relationship; import com.yahoo.elide.core.request.Sorting; import com.yahoo.elide.core.sort.SortingImpl; @@ -454,10 +455,11 @@ private static boolean isEntityArgument(String argumentName, EntityDictionary di * @param argumentName Name key of the GraphQL argument * * @return {@code true} if the name equals to {@link ModelBuilder#ARGUMENT_FIRST} or - * {@link ModelBuilder#ARGUMENT_AFTER} + * {@link ModelBuilder#ARGUMENT_AFTER} or {@link ModelBuilder#ARGUMENT_LAST} or {@link ModelBuilder#ARGUMENT_BEFORE} */ private static boolean isPaginationArgument(String argumentName) { - return ModelBuilder.ARGUMENT_FIRST.equals(argumentName) || ModelBuilder.ARGUMENT_AFTER.equals(argumentName); + return ModelBuilder.ARGUMENT_FIRST.equals(argumentName) || ModelBuilder.ARGUMENT_AFTER.equals(argumentName) + || ModelBuilder.ARGUMENT_LAST.equals(argumentName) || ModelBuilder.ARGUMENT_BEFORE.equals(argumentName); } /** @@ -471,29 +473,91 @@ private void addPagination(Argument argument, EntityProjectionBuilder projection Pagination pagination = projectionBuilder.getPagination() == null ? PaginationImpl.getDefaultPagination(projectionBuilder.getType(), elideSettings) : projectionBuilder.getPagination(); - Object argumentValue = variableResolver.resolveValue(argument.getValue()); - int value = argumentValue instanceof BigInteger - ? ((BigInteger) argumentValue).intValue() - : Integer.parseInt((String) argumentValue); - if (ModelBuilder.ARGUMENT_FIRST.equals(argument.getName())) { - pagination = new PaginationImpl( - projectionBuilder.getType(), - pagination.getOffset(), - value, - elideSettings.getDefaultPageSize(), - elideSettings.getMaxPageSize(), - pagination.returnPageTotals(), - false); - } else if (ModelBuilder.ARGUMENT_AFTER.equals(argument.getName())) { - pagination = new PaginationImpl( - projectionBuilder.getType(), - value, - pagination.getLimit(), - elideSettings.getDefaultPageSize(), - elideSettings.getMaxPageSize(), - pagination.returnPageTotals(), - false); + try { + int value = argumentValue instanceof BigInteger + ? ((BigInteger) argumentValue).intValue() + : Integer.parseInt((String) argumentValue); + if (ModelBuilder.ARGUMENT_FIRST.equals(argument.getName())) { + // Offset or Cursor + pagination = new PaginationImpl( + projectionBuilder.getType(), + pagination.getOffset(), + value, + elideSettings.getDefaultPageSize(), + elideSettings.getMaxPageSize(), + pagination.returnPageTotals(), + false, + null, + null, + Direction.FORWARD); + } else if (ModelBuilder.ARGUMENT_LAST.equals(argument.getName())) { + // Cursor + pagination = new PaginationImpl( + projectionBuilder.getType(), + pagination.getOffset(), + value, + elideSettings.getDefaultPageSize(), + elideSettings.getMaxPageSize(), + pagination.returnPageTotals(), + false, + null, + null, + Direction.BACKWARD); + } else if (ModelBuilder.ARGUMENT_AFTER.equals(argument.getName())) { + // Offset + pagination = new PaginationImpl( + projectionBuilder.getType(), + value, + pagination.getLimit(), + elideSettings.getDefaultPageSize(), + elideSettings.getMaxPageSize(), + pagination.returnPageTotals(), + false, + null, + null, + null); // Offset set null direction + } else if (ModelBuilder.ARGUMENT_BEFORE.equals(argument.getName())) { + // Offset + pagination = new PaginationImpl( + projectionBuilder.getType(), + value, + pagination.getLimit(), + elideSettings.getDefaultPageSize(), + elideSettings.getMaxPageSize(), + pagination.returnPageTotals(), + false, + null, + null, + null); // Offset set null direction + } + } catch (NumberFormatException e) { + // Cursor + if (ModelBuilder.ARGUMENT_AFTER.equals(argument.getName())) { + pagination = new PaginationImpl( + projectionBuilder.getType(), + null, + pagination.getLimit(), + elideSettings.getDefaultPageSize(), + elideSettings.getMaxPageSize(), + pagination.returnPageTotals(), + false, + pagination.getBefore(), + argumentValue.toString(), + pagination.getBefore() == null ? Direction.FORWARD : Direction.BETWEEN); + } else if (ModelBuilder.ARGUMENT_BEFORE.equals(argument.getName())) { + pagination = new PaginationImpl( + projectionBuilder.getType(), + null, + pagination.getLimit(), + elideSettings.getDefaultPageSize(), + elideSettings.getMaxPageSize(), + pagination.returnPageTotals(), + false, + argumentValue.toString(), + pagination.getAfter(), + pagination.getAfter() == null ? Direction.BACKWARD : Direction.BETWEEN); + } } projectionBuilder.pagination(pagination); diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLEndpointTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLEndpointTest.java index a6485b9870..125ef1e3a7 100644 --- a/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLEndpointTest.java +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLEndpointTest.java @@ -1089,6 +1089,133 @@ public void testInvalidApiVersion() throws IOException { } } + @Test + public void testCursorLast() throws JSONException { + String graphQLRequest = document( + selections( + field( + "book", + arguments(argument("last", 1)), + selections( + field("id"), + field("title"), + field( + "authors", + selection( + field("name") + ) + ) + ) + ) + ) + ).toQuery(); + + String graphQLResponse = document( + selection( + field( + "book", + selections( + field("id", "1"), + field("title", "My first book"), + field( + "authors", + selection( + field("name", "Ricky Carmichael") + ) + ) + ) + ) + ) + ).toResponse(); + + Response response = endpoint.post("", uriInfo, requestHeaders, user1, graphQLRequestToJSON(graphQLRequest)); + assert200EqualBody(response, graphQLResponse); + } + + @Test + public void testCursorLastBefore() throws JSONException, IOException { + String graphQLRequest = document( + selections( + field( + "book", + arguments(argument("last", 1), argument("before", "MQ", true)), + selections( + field("id"), + field("title"), + field( + "authors", + selection( + field("name") + ) + ) + ) + ) + ) + ).toQuery(); + + Response response = endpoint.post("", uriInfo, requestHeaders, user1, graphQLRequestToJSON(graphQLRequest)); + JsonNode responseNode = extract200Response(response); + JsonNode resultNode = responseNode.at("/data/book/edges"); + assertTrue(resultNode.isArray()); + assertTrue(resultNode.isEmpty()); + } + + @Test + public void testCursorFirstAfter() throws JSONException, IOException { + String graphQLRequest = document( + selections( + field( + "book", + arguments(argument("first", 1), argument("after", "MQ", true)), + selections( + field("id"), + field("title"), + field( + "authors", + selection( + field("name") + ) + ) + ) + ) + ) + ).toQuery(); + + Response response = endpoint.post("", uriInfo, requestHeaders, user1, graphQLRequestToJSON(graphQLRequest)); + JsonNode responseNode = extract200Response(response); + JsonNode resultNode = responseNode.at("/data/book/edges"); + assertTrue(resultNode.isArray()); + assertTrue(resultNode.isEmpty()); + } + + @Test + public void testCursorAfterBefore() throws JSONException, IOException { + String graphQLRequest = document( + selections( + field( + "book", + arguments(argument("after", "MQ", true), argument("before", "MQ", true)), + selections( + field("id"), + field("title"), + field( + "authors", + selection( + field("name") + ) + ) + ) + ) + ) + ).toQuery(); + + Response response = endpoint.post("", uriInfo, requestHeaders, user1, graphQLRequestToJSON(graphQLRequest)); + JsonNode responseNode = extract200Response(response); + JsonNode resultNode = responseNode.at("/data/book/edges"); + assertTrue(resultNode.isArray()); + assertTrue(resultNode.isEmpty()); + } + private static String graphQLRequestToJSON(String request) { return graphQLRequestToJSON(request, new HashMap<>()); } diff --git a/elide-graphql/src/test/java/graphqlEndpointTestModels/Book.java b/elide-graphql/src/test/java/graphqlEndpointTestModels/Book.java index 304421a00b..d30bf5efd9 100644 --- a/elide-graphql/src/test/java/graphqlEndpointTestModels/Book.java +++ b/elide-graphql/src/test/java/graphqlEndpointTestModels/Book.java @@ -16,6 +16,8 @@ import com.yahoo.elide.annotation.DeletePermission; import com.yahoo.elide.annotation.Include; import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.annotation.Paginate; +import com.yahoo.elide.annotation.PaginationMode; import com.yahoo.elide.annotation.ReadPermission; import com.yahoo.elide.annotation.UpdatePermission; @@ -52,6 +54,7 @@ operation = 10, logStatement = "{0}", logExpressions = {"${book.title}"}) +@Paginate(modes = { PaginationMode.OFFSET, PaginationMode.CURSOR }) public class Book { long id; String title; diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/IdObfuscationIT.java b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/IdObfuscationIT.java index 734da4288f..4b81aa0a03 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/IdObfuscationIT.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/IdObfuscationIT.java @@ -11,10 +11,22 @@ import static com.yahoo.elide.test.jsonapi.JsonApiDSL.data; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.datum; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.linkage; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.patchOperation; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.patchSet; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.relation; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.relationships; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; +import static com.yahoo.elide.test.jsonapi.elements.PatchOperationType.add; +import static io.restassured.RestAssured.get; import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.when; +import static org.eclipse.jetty.http.HttpStatus.OK_200; +import static org.hamcrest.Matchers.emptyString; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; import static org.junit.jupiter.api.Assertions.assertTrue; import com.yahoo.elide.core.exceptions.HttpStatus; @@ -24,6 +36,7 @@ import com.yahoo.elide.jsonapi.resources.JsonApiEndpoint; import com.yahoo.elide.test.jsonapi.elements.Resource; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; @@ -37,6 +50,210 @@ public IdObfuscationIT() { super(IdObfuscationTestApplicationResourceConfig.class, JsonApiEndpoint.class.getPackage().getName()); } + @BeforeEach + void setup() { + createBookEntities(); + } + + private void createBookEntities() { + String tempAuthorId1 = "12345678-1234-1234-1234-1234567890ab"; + String tempAuthorId2 = "12345679-1234-1234-1234-1234567890ab"; + String tempAuthorId3 = "12345681-1234-1234-1234-1234567890ab"; + + String tempBookId1 = "12345678-1234-1234-1234-1234567890ac"; + String tempBookId2 = "12345678-1234-1234-1234-1234567890ad"; + String tempBookId3 = "12345679-1234-1234-1234-1234567890ac"; + String tempBookId4 = "23451234-1234-1234-1234-1234567890ac"; + String tempBookId5 = "12345680-1234-1234-1234-1234567890ac"; + String tempBookId6 = "12345680-1234-1234-1234-1234567890ad"; + String tempBookId7 = "12345681-1234-1234-1234-1234567890ac"; + String tempBookId8 = "12345681-1234-1234-1234-1234567890ad"; + + String tempPubId = "12345678-1234-1234-1234-1234567890ae"; + + given() + .contentType(JsonApi.JsonPatch.MEDIA_TYPE) + .accept(JsonApi.JsonPatch.MEDIA_TYPE) + .body( + patchSet( + patchOperation(add, "/author", resource( + type("author"), + id(tempAuthorId1), + attributes( + attr("name", "Ernest Hemingway") + ), + relationships( + relation("books", + linkage(type("book"), id(tempBookId1)), + linkage(type("book"), id(tempBookId2)) + ) + ) + )), + patchOperation(add, "/book/", resource( + type("book"), + id(tempBookId1), + attributes( + attr("title", "The Old Man and the Sea"), + attr("genre", "Literary Fiction"), + attr("language", "English") + ), + relationships( + relation("publisher", + linkage(type("publisher"), id(tempPubId)) + + ) + ) + )), + patchOperation(add, "/book/", resource( + type("book"), + id(tempBookId2), + attributes( + attr("title", "For Whom the Bell Tolls"), + attr("genre", "Literary Fiction"), + attr("language", "English") + ) + )), + patchOperation(add, "/book/" + tempBookId1 + "/publisher", resource( + type("publisher"), + id(tempPubId), + attributes( + attr("name", "Default publisher") + ) + )) + ).toJSON() + ) + .patch("/") + .then() + .statusCode(OK_200); + + given() + .contentType(JsonApi.JsonPatch.MEDIA_TYPE) + .accept(JsonApi.JsonPatch.MEDIA_TYPE) + .body( + patchSet( + patchOperation(add, "/author", resource( + type("author"), + id(tempAuthorId2), + attributes( + attr("name", "Orson Scott Card") + ), + relationships( + relation("books", + linkage(type("book"), id(tempBookId3)), + linkage(type("book"), id(tempBookId4)) + ) + ) + )), + patchOperation(add, "/book", resource( + type("book"), + id(tempBookId3), + attributes( + attr("title", "Enders Game"), + attr("genre", "Science Fiction"), + attr("language", "English"), + attr("publishDate", 1454638927412L) + ) + )), + patchOperation(add, "/book", resource( + type("book"), + id(tempBookId4), + attributes( + attr("title", "Enders Shadow"), + attr("genre", "Science Fiction"), + attr("language", "English"), + attr("publishDate", 1464638927412L) + ) + )) + ) + ) + .patch("/") + .then() + .statusCode(OK_200); + + given() + .contentType(JsonApi.JsonPatch.MEDIA_TYPE) + .accept(JsonApi.JsonPatch.MEDIA_TYPE) + .body( + patchSet( + patchOperation(add, "/author", resource( + type("author"), + id(tempAuthorId3), + attributes( + attr("name", "Isaac Asimov") + ), + relationships( + relation("books", + linkage(type("book"), id(tempBookId5)), + linkage(type("book"), id(tempBookId6)) + ) + ) + )), + patchOperation(add, "/book", resource( + type("book"), + id(tempBookId5), + attributes( + attr("title", "Foundation"), + attr("genre", "Science Fiction"), + attr("language", "English") + ) + )), + patchOperation(add, "/book", resource( + type("book"), + id(tempBookId6), + attributes( + attr("title", "The Roman Republic"), + //genre null + attr("language", "English") + ) + )) + ) + ) + .patch("/") + .then() + .statusCode(OK_200); + + given() + .contentType(JsonApi.JsonPatch.MEDIA_TYPE) + .accept(JsonApi.JsonPatch.MEDIA_TYPE) + .body( + patchSet( + patchOperation(add, "/author", resource( + type("author"), + id(tempAuthorId3), + attributes( + attr("name", "Null Ned") + ), + relationships( + relation("books", + linkage(type("book"), id(tempBookId7)), + linkage(type("book"), id(tempBookId8)) + ) + ) + )), + patchOperation(add, "/book", resource( + type("book"), + id(tempBookId7), + attributes( + attr("title", "Life with Null Ned"), + attr("language", "English") + ) + )), + patchOperation(add, "/book", resource( + type("book"), + id(tempBookId8), + attributes( + attr("title", "Life with Null Ned 2"), + attr("genre", "Not Null"), + attr("language", "English") + ) + )) + ).toJSON() + ) + .patch("/") + .then() + .statusCode(OK_200); + } + @Test void testIdObfuscation() { // Create @@ -123,4 +340,71 @@ void testIdObfuscation() { .then() .statusCode(HttpStatus.SC_NO_CONTENT); } + + @Test + void testPaginationCursorFirst() { + String url = "/book?page[first]=2"; + when() + .get(url) + .then() + .body("data", hasSize(2), + "meta.page.startCursor", not(emptyString()), + "meta.page.endCursor", not(emptyString()) + ); + } + + @Test + void testPaginationCursorLast() { + String url = "/book?page[last]=2"; + when() + .get(url) + .then() + .body("data", hasSize(2), + "meta.page.startCursor", not(emptyString()), + "meta.page.endCursor", not(emptyString()) + ); + } + + @Test + void testPaginationCursorAfter() { + String url = "/book?page[first]=2"; + String endCursor = get(url).path("meta.page.endCursor"); + String url2 = "/book?page[size]=2&page[after]=" + endCursor; + when() + .get(url2) + .then() + .body("data", hasSize(2), + "meta.page.startCursor", not(emptyString()), + "meta.page.endCursor", not(emptyString()) + ); + } + + @Test + void testPaginationCursorBefore() { + String url = "/book?page[last]=2"; + String startCursor = get(url).path("meta.page.startCursor"); + String url2 = "/book?page[size]=2&page[before]=" + startCursor; + when() + .get(url2) + .then() + .body("data", hasSize(2), + "meta.page.startCursor", not(emptyString()), + "meta.page.endCursor", not(emptyString()) + ); + } + + @Test + void testPaginationCursorAfterBefore() { + String url = "/book?page[last]=3"; + String startCursor = get(url).path("meta.page.startCursor"); + String endCursor = get(url).path("meta.page.endCursor"); + String url2 = "/book?page[size]=2&page[after]=" + startCursor + "&page[before]=" + endCursor; + when() + .get(url2) + .then() + .body("data", hasSize(1), + "meta.page.startCursor", not(emptyString()), + "meta.page.endCursor", not(emptyString()) + ); + } } diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/PaginateIT.java b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/PaginateIT.java index 665d4a6cdf..89adca92c5 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/PaginateIT.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/PaginateIT.java @@ -25,8 +25,10 @@ import static org.eclipse.jetty.http.HttpStatus.OK_200; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.emptyString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -643,4 +645,71 @@ void testPaginationTotalsOfEmptyCollection() { "meta.page.totalRecords", equalTo(0) ); } + + @Test + void testPaginationCursorFirst() { + String url = "/book?page[first]=2"; + when() + .get(url) + .then() + .body("data", hasSize(2), + "meta.page.startCursor", not(emptyString()), + "meta.page.endCursor", not(emptyString()) + ); + } + + @Test + void testPaginationCursorLast() { + String url = "/book?page[last]=2"; + when() + .get(url) + .then() + .body("data", hasSize(2), + "meta.page.startCursor", not(emptyString()), + "meta.page.endCursor", not(emptyString()) + ); + } + + @Test + void testPaginationCursorAfter() { + String url = "/book?page[first]=2"; + String endCursor = get(url).path("meta.page.endCursor"); + String url2 = "/book?page[size]=2&page[after]=" + endCursor; + when() + .get(url2) + .then() + .body("data", hasSize(2), + "meta.page.startCursor", not(emptyString()), + "meta.page.endCursor", not(emptyString()) + ); + } + + @Test + void testPaginationCursorBefore() { + String url = "/book?page[last]=2"; + String startCursor = get(url).path("meta.page.startCursor"); + String url2 = "/book?page[size]=2&page[before]=" + startCursor; + when() + .get(url2) + .then() + .body("data", hasSize(2), + "meta.page.startCursor", not(emptyString()), + "meta.page.endCursor", not(emptyString()) + ); + } + + @Test + void testPaginationCursorAfterBefore() { + String url = "/book?page[last]=3"; + String startCursor = get(url).path("meta.page.startCursor"); + String endCursor = get(url).path("meta.page.endCursor"); + String url2 = "/book?page[size]=2&page[after]=" + startCursor + "&page[before]=" + endCursor; + when() + .get(url2) + .then() + .body("data", hasSize(1), + "meta.page.startCursor", not(emptyString()), + "meta.page.endCursor", not(emptyString()) + ); + } } diff --git a/elide-swagger/src/main/java/com/yahoo/elide/swagger/OpenApiBuilder.java b/elide-swagger/src/main/java/com/yahoo/elide/swagger/OpenApiBuilder.java index 869d5faf24..c01eda1f5e 100644 --- a/elide-swagger/src/main/java/com/yahoo/elide/swagger/OpenApiBuilder.java +++ b/elide-swagger/src/main/java/com/yahoo/elide/swagger/OpenApiBuilder.java @@ -10,6 +10,8 @@ import com.yahoo.elide.annotation.CreatePermission; import com.yahoo.elide.annotation.DeletePermission; import com.yahoo.elide.annotation.Exclude; +import com.yahoo.elide.annotation.Paginate; +import com.yahoo.elide.annotation.PaginationMode; import com.yahoo.elide.annotation.ReadPermission; import com.yahoo.elide.annotation.UpdatePermission; @@ -308,7 +310,7 @@ public PathItem getRelationshipPath() { path.getGet().addParametersItem(param); } - for (Parameter param : getPageParameters()) { + for (Parameter param : getPageParameters(type)) { path.getGet().addParametersItem(param); } } @@ -375,8 +377,7 @@ public PathItem getCollectionPath() { for (Parameter param : getFilterParameters()) { path.getGet().addParametersItem(param); } - - for (Parameter param : getPageParameters()) { + for (Parameter param : getPageParameters(type)) { path.getGet().addParametersItem(param); } } @@ -519,35 +520,95 @@ private Optional getIncludeParameter() { /** * Returns the pagination parameter. * + * @param type the type + * @return the Elide 'page' query parameter for some GET operations. + */ + private List getPageParameters(Type type) { + Paginate paginate = dictionary.getAnnotation(type, Paginate.class); + boolean pageTotalsSupported = true; + boolean offsetSupported = true; + boolean cursorSupported = false; + if (paginate != null) { + pageTotalsSupported = paginate.countable(); + if (paginate.modes() != null) { + offsetSupported = Arrays.stream(paginate.modes()).anyMatch(PaginationMode.OFFSET::equals); + cursorSupported = Arrays.stream(paginate.modes()).anyMatch(PaginationMode.CURSOR::equals); + } + } + return getPageParameters(pageTotalsSupported, offsetSupported, cursorSupported); + } + + /** + * Returns the pagination parameter. + * + * @param pageTotalsSupported support page totals + * @offsetSupported support offset pagination + * @cursorSupported support cursor pagination * @return the Elide 'page' query parameter for some GET operations. */ - private List getPageParameters() { + private List getPageParameters(boolean pageTotalsSupported, boolean offsetSupported, + boolean cursorSupported) { List params = new ArrayList<>(); - params.add(new QueryParameter().name("page[number]") - .description("Number of pages to return. Can be used with page[size]") - .schema(new IntegerSchema())); + if (offsetSupported) { + params.add(new QueryParameter().name("page[number]") + .description("Number of pages to return. Can be used with page[size]") + .schema(new IntegerSchema())); + } - params.add(new QueryParameter().name("page[size]") - .description("Number of elements per page. Can be used with page[number]") - .schema(new IntegerSchema())); + if (offsetSupported || cursorSupported) { + List usedWith = new ArrayList<>(); + if (offsetSupported) { + usedWith.add("page[number]"); + } + if (cursorSupported) { + usedWith.add("page[after]"); + usedWith.add("page[before]"); + } + String usedWithString = usedWith.stream().collect(Collectors.joining(", ")); + params.add(new QueryParameter().name("page[size]") + .description( + "Number of elements per page. Can be used with " + usedWithString) + .schema(new IntegerSchema())); + } - params.add(new QueryParameter().name("page[offset]") - .description("Offset from 0 to start paginating. Can be used with page[limit]") - .schema(new IntegerSchema())); + if (offsetSupported) { + params.add(new QueryParameter().name("page[offset]") + .description("Offset from 0 to start paginating. Can be used with page[limit]") + .schema(new IntegerSchema())); + params.add(new QueryParameter().name("page[limit]") + .description("Maximum number of items to return. Can be used with page[offset]") + .schema(new IntegerSchema())); + } - params.add(new QueryParameter().name("page[limit]") - .description("Maximum number of items to return. Can be used with page[offset]") - .schema(new IntegerSchema())); + if (cursorSupported) { + // Cursor pagination + params.add(new QueryParameter().name("page[first]") + .description("Get first number of items and return the cursors ") + .schema(new IntegerSchema())); - params.add(new QueryParameter().name("page[totals]") - .description("For requesting total pages/records be included in the response page meta data") - /* - * Swagger UI doesn't support parameters that don't take args today. We'll just - * make this a string for now - */ - .schema(new StringSchema())); + params.add(new QueryParameter().name("page[after]") + .description("Get next items after the cursor. Can be used with page[size] ") + .schema(new StringSchema())); + params.add(new QueryParameter().name("page[last]") + .description("Get last number of items and return the cursors ") + .schema(new IntegerSchema())); + + params.add(new QueryParameter().name("page[before]") + .description("Get previous items before the cursor. Can be used with page[size] ") + .schema(new StringSchema())); + } + + if (pageTotalsSupported) { + params.add(new QueryParameter().name("page[totals]") + .description("For requesting total pages/records be included in the response page meta data") + /* + * Swagger UI doesn't support parameters that don't take args today. We'll just + * make this a string for now + */ + .schema(new StringSchema())); + } return params; }