pagination

djmil 2023-07-22 15:05:49 +02:00
parent d9941799d0
commit 0ef130cb3b
3 changed files with 276 additions and 31 deletions

@ -20,7 +20,7 @@
} }
}, },
{ {
"id": "7e5ec70badaa86f3", "id": "ca75671fa123bc88",
"type": "leaf", "type": "leaf",
"state": { "state": {
"type": "markdown", "type": "markdown",
@ -30,33 +30,9 @@
"source": false "source": false
} }
} }
},
{
"id": "92c92033d31ed01c",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "Post.md",
"mode": "source",
"source": false
}
}
},
{
"id": "af9b076d6f1ac377",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "Database.md",
"mode": "source",
"source": false
}
}
} }
], ],
"currentTab": 2 "currentTab": 1
} }
], ],
"direction": "vertical" "direction": "vertical"
@ -122,7 +98,7 @@
"state": { "state": {
"type": "backlink", "type": "backlink",
"state": { "state": {
"file": "Post.md", "file": "Home.md",
"collapseAll": false, "collapseAll": false,
"extraContext": false, "extraContext": false,
"sortOrder": "alphabetical", "sortOrder": "alphabetical",
@ -139,7 +115,7 @@
"state": { "state": {
"type": "outgoing-link", "type": "outgoing-link",
"state": { "state": {
"file": "Post.md", "file": "Home.md",
"linksCollapsed": false, "linksCollapsed": false,
"unlinkedCollapsed": true "unlinkedCollapsed": true
} }
@ -162,7 +138,7 @@
"state": { "state": {
"type": "outline", "type": "outline",
"state": { "state": {
"file": "Post.md" "file": "Home.md"
} }
} }
} }
@ -183,14 +159,15 @@
"command-palette:Open command palette": false "command-palette:Open command palette": false
} }
}, },
"active": "92c92033d31ed01c", "active": "ca75671fa123bc88",
"lastOpenFiles": [ "lastOpenFiles": [
"Pagination.md",
"Home.md", "Home.md",
"Database.md",
"Post.md", "Post.md",
"GET.md", "GET.md",
"Get.md", "Get.md",
"assets/Pasted image 20230721100304.png", "assets/Pasted image 20230721100304.png",
"Database.md",
"IntegrationTests.md", "IntegrationTests.md",
"UnitTests.md", "UnitTests.md",
"assets/Pasted image 20230719152007.png", "assets/Pasted image 20230719152007.png",

84
Home.md

@ -223,3 +223,87 @@ cashCard = cashCardRepository.findById(99);
You might immediately wonder: where is the implementation of the `CashCardRepository.findById()` method? `CrudRepository` and everything it inherits from is an Interface with no actual code! Well, based on the specific Spring Data framework used, which for us will be Spring Data JDBC, Spring Data takes care of this implementation for us during the IoC container startup time. The Spring runtime will then expose the repository as yet another bean that you can reference wherever needed in your application. You might immediately wonder: where is the implementation of the `CashCardRepository.findById()` method? `CrudRepository` and everything it inherits from is an Interface with no actual code! Well, based on the specific Spring Data framework used, which for us will be Spring Data JDBC, Spring Data takes care of this implementation for us during the IoC container startup time. The Spring runtime will then expose the repository as yet another bean that you can reference wherever needed in your application.
As weve learned, there are typically trade-offs. For example the `CrudRepository` generates SQL statements to read and write your data, which is useful for many cases, but sometimes you need to write your own custom SQL statements for specific use cases. But for now, were happy to take advantage of its convenient, out-of-the-box methods. As weve learned, there are typically trade-offs. For example the `CrudRepository` generates SQL statements to read and write your data, which is useful for many cases, but sometimes you need to write your own custom SQL statements for specific use cases. But for now, were happy to take advantage of its convenient, out-of-the-box methods.
# [[Pagination]]
We can expect each of our Family Cash Card users to have a few cards, thus we have to implement the “Read Many” endpoint.
```json
[
{
"id": 1,
"amount": 123.45
},
{
"id": 2,
"amount": 50.00
}
]
```
It turns out that `CrudRepository` has a `findAll` method that can be used to easily fetch all the Cash Cards in the database.
```java
@GetMapping()
public ResponseEntity<Iterable<CashCard>> findAll() {
return ResponseEntity.ok(cashCardRepository.findAll());
}
```
However, it turns out theres a lot more to this operation than just returning all the Cash Cards in the database. Some questions come to mind:
- How do I return only the Cash Cards that the user owns?
This question discussed in the [SpringSecurity] section.
- What if there are hundreds (or thousands?!) of Cash Cards? Should the API return an unlimited number of results or return them in “chunks”?
This is known as **Pagination**.
- Should the Cash Cards be returned in a particular order i.e. somehow sorted?
In order to ensure that an API response doesnt include an astronomically large number of Cash Cards, lets utilize Spring Datas pagination functionality. [[Pagination]] in Spring (and many other frameworks) is to specify the page length (e.g. 10 items), and the page index (starting with 0). For example, if a user has 25 Cash Cards, and you elect to request the second page where each page has 10 items, you would request a page of size 10, and page index of 1.
## Sorting
There are a few good reasons to opt for ordering (aka sorting paged response) by a specific field:
- Minimize **cognitive overhead**: Other developers (not to mention users) will probably appreciate a thoughtful ordering when developing it.
- Minimize future errors: What happens when a new version of Spring, or Java, or the database, suddenly causes the “random” order to change overnight?
Thankfully, Spring Data provides the `PageRequest` and `Sort` classes for pagination. Lets look at a query to get page 2 with page size 10, _sorting by amount in descending order_ (largest amounts first):
```java
Page<CashCard> page2 = cashCardRepository.findAll(
PageRequest.of(
1, // page index for the second page - indexing starts at 0
10, // page size (the last page might have fewer items)
Sort.by(new Sort.Order(Sort.Direction.DESC, "amount"))));
```
For this code to work, it is important for our repository implementation to support pagination. `src\main\java\djmil\cashcard\CashCardRepository.java`:
```java
import org.springframework.data.repository.PagingAndSortingRepository;
public interface
CashCardRepository
extends
CrudRepository<CashCard, Long>,
PagingAndSortingRepository<CashCard, Long> {
}
```
But how do we get query configuration data for sorting?
## The URI
The HTTP request parameters is used to transfer values that is used to configure pagination query: *(we've omitted the `https://domain` prefix in the following)*
1. Get the second page
/cashcards**?page=1**
2. …where a page has length of 3
/cashcards?page=1**&size=3**
3. …sorted by the current Cash Card balance
/cashcards?page=1&size=3**&sort=amount**
4. …in descending order (highest balance first)
/cashcards?page=1&size=3&sort=amount**,desc**

184
Pagination.md Normal file

@ -0,0 +1,184 @@
# Test for Data contract list
`src/test/resources/djmil/cashcard/list.json`
```json
[
{ "id": 99, "amount": 123.45 },
{ "id": 100, "amount": 1.0 },
{ "id": 101, "amount": 150.0 }
]
```
Now open the `CashCardsJsonTest.java` file. And add `cashCards` class-level variable to contain the following Java array:
```java
private CashCard[] cashCards;
@BeforeEach
void setUp() {
cashCards = Arrays.array(
new CashCard(99L, 123.45),
new CashCard(100L, 100.00),
new CashCard(101L, 150.00));
}
```
Than add the new test:
```java
@Test
void cashCardListSerializationTest() throws IOException {
assertThat(jsonList.write(cashCards)).isStrictlyEqualToJson("list.json");
}
@Test
void cashCardListDeserializationTest() throws IOException {
String expected="""
[
{ "id": 99, "amount": 123.45 },
{ "id": 100, "amount": 100.00 },
{ "id": 101, "amount": 150.00 }
]
""";
assertThat(jsonList.parse(expected)).isEqualTo(cashCards);
}
```
# GET list
## DB data
It is important to populate our test db with known data, that would reflect desired test case scenario. `src/test/resource/data.sql`
```sql
INSERT INTO CASH_CARD(ID, AMOUNT) VALUES (99, 123.45);
INSERT INTO CASH_CARD(ID, AMOUNT) VALUES (100, 1.00);
INSERT INTO CASH_CARD(ID, AMOUNT) VALUES (101, 150.00);
```
## Endpoint Test
In `src/test/java/djmil/cashcard/CashCardApplication.test`
```java
@Test
void shouldReturnAllCashCardsWhenListIsRequested() {
ResponseEntity<String> response = restTemplate.getForEntity("/cashcards", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
DocumentContext documentContext = JsonPath.parse(response.getBody());
int cashCardCount = documentContext.read("$.length()");
assertThat(cashCardCount).isEqualTo(3);
JSONArray ids = documentContext.read("$..id");
assertThat(ids).containsExactlyInAnyOrder(99, 100, 101);
JSONArray amounts = documentContext.read("$..amount");
assertThat(amounts).containsExactlyInAnyOrder(123.45, 100.0, 150.00);
}
```
Check out these new JsonPath expressions! A good place to learn more about JsonPath is [the JsonPath documentation](https://github.com/json-path/JsonPath):
- `documentContext.read("$.length()")` calculates the length of the array.
- `.read("$..id")` retrieves the list of all `id` values returned, while `.read("$..amount")` collects all `amounts` returned.
## Controller code
`src/main/java/djmil/cashcard/CashCardController.java`
```java
@GetMapping()
public ResponseEntity<Iterable<CashCard>> findAll() {
return ResponseEntity.ok(cashCardRepository.findAll());
}
```
# Test Interaction and @DirtiesContext
Depending on test execution order schedule, our new `shouldReturnAllCashCardsWhenListIsRequested` test might not pass! Why?
```bash
[~/exercises] $ ./gradlew test
...
org.opentest4j.AssertionFailedError:
expected: 3
but was: 4
...
at app//example.cashcard.CashCardApplicationTests.shouldReturnAllCashCardsWhenListIsRequested(CashCardApplicationTests.java:70)
```
The reason is that one of the other tests is interfering with our new test by creating a new Cash Card. `@DirtiesContext` fixes this problem by causing Spring to start with a clean slate, as if those other tests hadn't been run. Removing it (commenting it out) from the class caused our new test to fail.
Although you can use `@DirtiesContext` to work around inter-test interaction, you shouldn't use it indiscriminately; you should have a good reason. Our reason here is to clean up after creating a new Cash Card.
Leave `DirtiesContext` commented out at the class level, and _uncomment_ it on the method which creates a new Cash Card:
```java
//@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD)
class CashCardApplicationTests {
...
@Test
@DirtiesContext
void shouldCreateANewCashCard() {
...
```
# GET pagination
## Test
Since we have 3 `CashCards` in our [[Pagination#DB data|database]], Let's set up a test to fetch them one at a time (page size of 1), then have their amounts sorted from highest to lowest (descending).
```java
@Test
void shouldReturnASortedPageOfCashCards() {
ResponseEntity<String> response = restTemplate.getForEntity("/cashcards?page=0&size=1&sort=amount,desc", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
DocumentContext documentContext = JsonPath.parse(response.getBody());
JSONArray read = documentContext.read("$[*]");
assertThat(read.size()).isEqualTo(1);
double amount = documentContext.read("$[0].amount");
assertThat(amount).isEqualTo(150.00);
}
```
The URI we are requesting contains both pagination and sorting information: `/cashcards?page=0&size=1&sort=amount,desc`:
- `page=0`: Get the first page. Page indexes start at 0.
- `size=1`: Each page has size 1.
- `sort=amount,desc`
## Controller
```java
@GetMapping
public ResponseEntity<List<CashCard>> findAll(Pageable pageable) {
Page<CashCard> page = cashCardRepository.findAll(
PageRequest.of(
pageable.getPageNumber(),
pageable.getPageSize(),
pageable.getSort()
));
return ResponseEntity.ok(page.getContent());
}
```
## Pagination with defaults
Spring provides the default `page` and `size` values (they are 0 and 20, respectively). A default of 20 for page size explains why all three of our Cash Cards were returned. Again: we didn't need to explicitly define these defaults. Spring provides them "out of the box". So the test with
```java
ResponseEntity<String> response = restTemplate.getForEntity("/cashcards", String.class);
```
shall work. But there are no reasonable defaults for sorting order possible. So if the one was not provided, query would return items in order defined by DB (most likely in order of creation). To deal with this limitation, we have to define the default `sort` parameter in our own code, by passing a `Sort` object to `getSortOr()`:
```java
PageRequest.of(
pageable.getPageNumber(),
pageable.getPageSize(),
pageable.getSortOr(Sort.by(Sort.Direction.ASC, "amount"))
));
```