diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f8694a1..a6f8f50 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,15 +3,24 @@ # # Each demo is an independent Gradle project (its own settings.gradle.kts and # wrapper), so Dependabot needs to be told about each directory explicitly. -# We use `directories:` (plural) so one ecosystem entry covers them all -# with a shared group/schedule/label policy — much less duplication than -# nine near-identical entries. +# Demos are split into two gradle entries based on which starter release line +# they showcase — they get different Spring-major policies. version: 2 updates: # --------------------------------------------------------------------- - # Gradle: every demo + the starter ecosystems they depend on. + # Spring Boot 3 demos — pinned to the SB3 starter line. + # + # easy-paging 0.4.x and ssrf-guard 3.x are certified against + # Spring Boot 3.3-3.5. Letting Dependabot land a Spring Boot major + # (3.x → 4.x) here would silently advertise an incompatible + # combination to anyone copying one of these demos. + # + # When a given starter publishes its own SB4-compatible line (as + # easy-paging did with 0.5.x), the SB4 demos live under the second + # gradle entry below; the SB3 demos here keep getting patches on + # the 3.5.x line. # --------------------------------------------------------------------- - package-ecosystem: "gradle" directories: @@ -32,65 +41,90 @@ updates: open-pull-requests-limit: 10 labels: - "dependencies" + - "sb3" commit-message: prefix: "build(deps)" include: "scope" - # ---------------------------------------------------------------- - # Hold majors at the starter's tested baseline. - # - # The demos exist to showcase the starters at the Spring Boot - # version each starter is *certified against* (per its README). - # Letting Dependabot land a Spring Boot major (3.x → 4.x) ahead of - # the starter's own SB4 release would silently advertise - # incompatible combinations to anyone copying a demo. - # - # When a starter publishes a SB4-compatible release line (e.g. - # easy-paging 0.5.x), this ignore block gets relaxed for that - # specific starter family, and the demos are upgraded together in - # a single PR — not piecemeal by a robot. - # ---------------------------------------------------------------- ignore: - # Spring Boot + dependency-management major bumps - dependency-name: "org.springframework.boot:*" update-types: ["version-update:semver-major"] - dependency-name: "io.spring.dependency-management" update-types: ["version-update:semver-major"] - # Spring Framework / Spring Cloud — pulled in transitively by SB, - # but their majors land outside the SB BOM cadence sometimes - dependency-name: "org.springframework:*" update-types: ["version-update:semver-major"] - dependency-name: "org.springframework.cloud:*" update-types: ["version-update:semver-major"] - # Gradle wrapper — each new major (8 → 9, etc.) needs hand - # verification (deprecations may have become errors). Dependabot - # bumps the *wrapper version*, which doesn't run any code; the - # CI catches breakage on next build, but a major bump deserves - # a dedicated PR with eyes on it, not 9 silent ones. - dependency-name: "gradle" update-types: ["version-update:semver-major"] - # Group related bumps into single PRs to keep the queue manageable. groups: - # devslab-kr starters this repo exists to showcase — every new release - # should produce one tight PR per demo, not N PRs across submodules. easy-paging: patterns: - "kr.devslab:easy-paging*" ssrf-guard: patterns: - "kr.devslab:ssrf-guard*" - # Spring Boot moves as a unit; bump everything together or nothing. spring-boot: patterns: - "org.springframework.boot:*" - "io.spring.dependency-management" - # Test deps — none of these affect runtime behaviour, batch them. test-tooling: patterns: - "org.testcontainers:*" - "org.springframework.boot:spring-boot-testcontainers" - "io.projectreactor:reactor-test" - "org.junit*:*" - # Database drivers — usually bumped together when Spring Boot moves. + database-drivers: + patterns: + - "org.postgresql:*" + - "com.h2database:h2" + - "io.r2dbc:*" + + # --------------------------------------------------------------------- + # Spring Boot 4 demos — easy-paging 0.5.x active line. + # + # These showcase the SB4-certified starter line, so Spring majors + # are NOT held — Dependabot is free to roll the BOM through patch / + # minor / major within the SB4 family. A future SB5 jump would + # come back through here as a major bump on a dedicated PR (the + # group keeps everything atomic). + # --------------------------------------------------------------------- + - package-ecosystem: "gradle" + directories: + - "/easy-paging-sb4-demo" + - "/easy-paging-sb4-keyset-demo" + - "/easy-paging-sb4-postgres-demo" + - "/easy-paging-sb4-reactive-demo" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "Asia/Seoul" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "sb4" + commit-message: + prefix: "build(deps)" + include: "scope" + # Still hold the Gradle wrapper major — has nothing to do with SB, + # and a wrapper major needs hand verification regardless of line. + ignore: + - dependency-name: "gradle" + update-types: ["version-update:semver-major"] + groups: + easy-paging: + patterns: + - "kr.devslab:easy-paging*" + spring-boot: + patterns: + - "org.springframework.boot:*" + - "io.spring.dependency-management" + test-tooling: + patterns: + - "org.testcontainers:*" + - "org.springframework.boot:spring-boot-testcontainers" + - "io.projectreactor:reactor-test" + - "org.junit*:*" database-drivers: patterns: - "org.postgresql:*" diff --git a/README.ko.md b/README.ko.md index a98b8a6..0a6d2bf 100644 --- a/README.ko.md +++ b/README.ko.md @@ -10,12 +10,32 @@ ## 예제 +### easy-paging — Spring Boot 4 (`0.5.x` 라인) + +최신 active 라인. Spring Boot 4 이상 사용 중이면 여기. + +| 데모 | 보여주는 것 | Maven Central 좌표 | +| --- | --- | --- | +| [`easy-paging-sb4-demo`](easy-paging-sb4-demo/) | `@AutoPaginate` 어노테이션 기반 offset 페이지네이션 (Spring Boot 4 + MyBatis + H2) | [`kr.devslab:easy-paging-spring-boot-starter:0.5.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | +| [`easy-paging-sb4-keyset-demo`](easy-paging-sb4-keyset-demo/) | `@KeysetPaginate` 커서(keyset) 페이지네이션 — composite `(time, id)` 키, 쓰기 중에도 안정, `OFFSET`/`COUNT(*)` 없음 | [`kr.devslab:easy-paging-spring-boot-starter:0.5.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | +| [`easy-paging-sb4-postgres-demo`](easy-paging-sb4-postgres-demo/) | 동일 스타터를 **실제 PostgreSQL**과 — `bootRun`은 Docker Compose, 테스트는 Testcontainers + `@ServiceConnection`, 로컬 DB 설치 불필요 | [`kr.devslab:easy-paging-spring-boot-starter:0.5.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | +| [`easy-paging-sb4-reactive-demo`](easy-paging-sb4-reactive-demo/) | Reactive 스택 — **WebFlux + R2DBC**, `R2dbcOffsetPagingSupport` 사용. MVC 데모와 동일한 JSON 봉투를 `Mono>`로 서빙 | [`kr.devslab:easy-paging-spring-boot-starter-reactive:0.5.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter-reactive) | + +### easy-paging — Spring Boot 3 maintenance (`0.4.x` 라인) + +Spring Boot 3.3–3.5 사용 중인 앱용. 스타터의 [`0.4.x` 브랜치](https://github.com/devslab-kr/easy-paging-spring-boot-starter/tree/0.4.x)가 SB3 보안 패치를 계속 받고, 이 데모들은 그 라인에 pin 됨. + +| 데모 | 보여주는 것 | Maven Central 좌표 | +| --- | --- | --- | +| [`easy-paging-demo`](easy-paging-demo/) | `@AutoPaginate` 어노테이션 기반 offset 페이지네이션 (Spring Boot 3 + MyBatis + H2) | [`kr.devslab:easy-paging-spring-boot-starter:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | +| [`easy-paging-keyset-demo`](easy-paging-keyset-demo/) | `@KeysetPaginate` 커서 페이지네이션 | [`kr.devslab:easy-paging-spring-boot-starter:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | +| [`easy-paging-postgres-demo`](easy-paging-postgres-demo/) | 실제 PostgreSQL 사용 | [`kr.devslab:easy-paging-spring-boot-starter:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | +| [`easy-paging-reactive-demo`](easy-paging-reactive-demo/) | Reactive 스택 — WebFlux + R2DBC | [`kr.devslab:easy-paging-spring-boot-starter-reactive:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter-reactive) | + +### ssrf-guard + | 데모 | 보여주는 것 | Maven Central 좌표 | | --- | --- | --- | -| [`easy-paging-demo`](easy-paging-demo/) | `@AutoPaginate` 어노테이션 기반 offset 페이지네이션 (Spring Boot + MyBatis + H2) | [`kr.devslab:easy-paging-spring-boot-starter:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | -| [`easy-paging-keyset-demo`](easy-paging-keyset-demo/) | `@KeysetPaginate` 커서(keyset) 페이지네이션 — composite `(time, id)` 키, 쓰기 중에도 안정, `OFFSET`/`COUNT(*)` 없음 | [`kr.devslab:easy-paging-spring-boot-starter:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | -| [`easy-paging-postgres-demo`](easy-paging-postgres-demo/) | 동일 스타터를 **실제 PostgreSQL**과 — `bootRun`은 Docker Compose, 테스트는 Testcontainers + `@ServiceConnection`, 로컬 DB 설치 불필요 | [`kr.devslab:easy-paging-spring-boot-starter:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | -| [`easy-paging-reactive-demo`](easy-paging-reactive-demo/) | Reactive 스택 — **WebFlux + R2DBC**, `R2dbcOffsetPagingSupport` 사용. MVC 데모와 동일한 JSON 봉투를 `Mono>`로 서빙 | [`kr.devslab:easy-paging-spring-boot-starter-reactive:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter-reactive) | | [`ssrf-guard-demo`](ssrf-guard-demo/) | SSRF(Server-Side Request Forgery) 방어를 3종 Spring HTTP 클라이언트(RestClient, RestTemplate, WebClient)에 동시 적용 — 모두 같은 `UrlPolicy`. 15가지 공격 매트릭스 엔드포인트, Micrometer 메트릭 포함 | [`kr.devslab:ssrf-guard:3.0.1`](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard) | | [`ssrf-guard-springai-demo`](ssrf-guard-springai-demo/) | ⭐ **LLM 에이전트 SSRF 방어.** 모든 Spring AI `ToolCallback`을 자동으로 wrap해서 LLM이 `fetch_url`을 호출하기 전에 URL 인자를 검증. 가짜 LLM 드라이버로 API 키 없이 오프라인 실행 가능 | [`kr.devslab:ssrf-guard-springai:3.0.1`](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-springai) | | [`ssrf-guard-feign-demo`](ssrf-guard-feign-demo/) | Spring Cloud OpenFeign `RequestInterceptor` — `@FeignClient` 호출에 동일 `UrlPolicy` 적용. 화이트리스트 / 비화이트리스트 `@FeignClient` 2개로 차단 경로 시연 | [`kr.devslab:ssrf-guard-feign:3.0.1`](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-feign) | diff --git a/README.md b/README.md index d091e97..caa0ccd 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,32 @@ Each subdirectory is an **independent** Spring Boot application with its own Gra ## Examples +### easy-paging — Spring Boot 4 (`0.5.x` line) + +Latest active line. Use these if your app is on Spring Boot 4+. + +| Demo | Showcases | Maven Central coordinates | +| --- | --- | --- | +| [`easy-paging-sb4-demo`](easy-paging-sb4-demo/) | Annotation-driven offset pagination with `@AutoPaginate` (Spring Boot 4 + MyBatis + H2) | [`kr.devslab:easy-paging-spring-boot-starter:0.5.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | +| [`easy-paging-sb4-keyset-demo`](easy-paging-sb4-keyset-demo/) | Cursor (keyset) pagination with `@KeysetPaginate` — composite `(time, id)` key, stable under writes, no `OFFSET`/`COUNT(*)` | [`kr.devslab:easy-paging-spring-boot-starter:0.5.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | +| [`easy-paging-sb4-postgres-demo`](easy-paging-sb4-postgres-demo/) | Same starter against **real PostgreSQL** — Docker Compose for `bootRun`, Testcontainers + `@ServiceConnection` for tests, no local DB install | [`kr.devslab:easy-paging-spring-boot-starter:0.5.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | +| [`easy-paging-sb4-reactive-demo`](easy-paging-sb4-reactive-demo/) | Reactive stack — **WebFlux + R2DBC** via `R2dbcOffsetPagingSupport`. Same JSON envelope as the MVC demos, served as `Mono>` | [`kr.devslab:easy-paging-spring-boot-starter-reactive:0.5.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter-reactive) | + +### easy-paging — Spring Boot 3 maintenance (`0.4.x` line) + +For apps still on Spring Boot 3.3–3.5. The starter's [`0.4.x` branch](https://github.com/devslab-kr/easy-paging-spring-boot-starter/tree/0.4.x) continues to receive SB3 security patches; these demos pin against that line. + +| Demo | Showcases | Maven Central coordinates | +| --- | --- | --- | +| [`easy-paging-demo`](easy-paging-demo/) | Annotation-driven offset pagination with `@AutoPaginate` (Spring Boot 3 + MyBatis + H2) | [`kr.devslab:easy-paging-spring-boot-starter:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | +| [`easy-paging-keyset-demo`](easy-paging-keyset-demo/) | Cursor (keyset) pagination with `@KeysetPaginate` | [`kr.devslab:easy-paging-spring-boot-starter:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | +| [`easy-paging-postgres-demo`](easy-paging-postgres-demo/) | Same starter against real PostgreSQL | [`kr.devslab:easy-paging-spring-boot-starter:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | +| [`easy-paging-reactive-demo`](easy-paging-reactive-demo/) | Reactive stack — WebFlux + R2DBC | [`kr.devslab:easy-paging-spring-boot-starter-reactive:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter-reactive) | + +### ssrf-guard + | Demo | Showcases | Maven Central coordinates | | --- | --- | --- | -| [`easy-paging-demo`](easy-paging-demo/) | Annotation-driven offset pagination with `@AutoPaginate` (Spring Boot + MyBatis + H2) | [`kr.devslab:easy-paging-spring-boot-starter:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | -| [`easy-paging-keyset-demo`](easy-paging-keyset-demo/) | Cursor (keyset) pagination with `@KeysetPaginate` — composite `(time, id)` key, stable under writes, no `OFFSET`/`COUNT(*)` | [`kr.devslab:easy-paging-spring-boot-starter:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | -| [`easy-paging-postgres-demo`](easy-paging-postgres-demo/) | Same starter against **real PostgreSQL** — Docker Compose for `bootRun`, Testcontainers + `@ServiceConnection` for tests, no local DB install | [`kr.devslab:easy-paging-spring-boot-starter:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | -| [`easy-paging-reactive-demo`](easy-paging-reactive-demo/) | Reactive stack — **WebFlux + R2DBC** via `R2dbcOffsetPagingSupport`. Same JSON envelope as the MVC demos, served as `Mono>` | [`kr.devslab:easy-paging-spring-boot-starter-reactive:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter-reactive) | | [`ssrf-guard-demo`](ssrf-guard-demo/) | SSRF (Server-Side Request Forgery) protection across three Spring HTTP clients (RestClient, RestTemplate, WebClient) — same `UrlPolicy` for all. 15-pattern attack matrix endpoint, Micrometer metrics. | [`kr.devslab:ssrf-guard:3.0.1`](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard) | | [`ssrf-guard-springai-demo`](ssrf-guard-springai-demo/) | ⭐ **LLM agent SSRF defense.** Wraps every Spring AI `ToolCallback` so URL-shaped tool arguments are validated before the LLM-driven `fetch_url` runs. Fake-LLM driver makes the demo runnable offline (no API key). | [`kr.devslab:ssrf-guard-springai:3.0.1`](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-springai) | | [`ssrf-guard-feign-demo`](ssrf-guard-feign-demo/) | Spring Cloud OpenFeign `RequestInterceptor` — same `UrlPolicy` applied to `@FeignClient` calls. Two `@FeignClient` interfaces (one whitelisted, one not) to show the block path. | [`kr.devslab:ssrf-guard-feign:3.0.1`](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-feign) | diff --git a/easy-paging-sb4-demo/README.ko.md b/easy-paging-sb4-demo/README.ko.md new file mode 100644 index 0000000..24d2e04 --- /dev/null +++ b/easy-paging-sb4-demo/README.ko.md @@ -0,0 +1,137 @@ +# easy-paging-sb4-demo + +[English](README.md) · **한국어** + +> ✨ **Spring Boot 4 라인.** 이 데모는 `easy-paging-spring-boot-starter`의 활성 `0.5.x` 라인(Spring Boot 4 / Spring Framework 7 / Jackson 3)을 사용. Spring Boot 3.3–3.5 maintenance 버전은 [`easy-paging-demo`](../easy-paging-demo/) 참조. + +[`easy-paging-spring-boot-starter`](https://github.com/devslab-kr/easy-paging-spring-boot-starter) — Spring Boot + MyBatis용 어노테이션 기반 페이지네이션의 실행 가능한 예제. + +이 데모는 스타터를 최소 Spring Boot 앱에 연결한 형태로, 인메모리 H2 데이터베이스, 137개의 시드 report 행, 그리고 단 하나의 `@AutoPaginate` 어노테이션이 붙은 컨트롤러로 구성. 외부 서비스도, DB 설정도 없음 — clone, run, curl 끝. + +## 전제조건 + +- JDK 21+ +- 그 외 없음. H2는 인메모리, 의존성은 최초 빌드 시 다운로드됨. + +## 실행 + +```bash +cd easy-paging-demo +./gradlew bootRun +``` + +앱은 `http://localhost:8080`에 뜸. 시작할 때마다 H2 DB가 인메모리로 생성되고 `reports` 테이블에 137행이 시드됨. + +## 시험해보기 + +### 첫 페이지 (Spring Data 기본 0-based) +```bash +curl 'http://localhost:8080/reports?page=0&size=5' +``` +```json +{ + "content": [ { "id": 1, "title": "Report #1", "createdAt": "..." }, ... ], + "page": 0, + "size": 5, + "totalElements": 137, + "totalPages": 28, + "first": true, + "last": false, + "empty": false +} +``` + +### 정렬 (SQL 인젝션 방어 검증됨) +```bash +# 다중 컬럼 정렬, createdAt 내림차순 후 id 오름차순 +curl 'http://localhost:8080/reports?page=0&size=5&sort=createdAt,desc&sort=id,asc' + +# 인젝션 시도는 HTTP 400으로 거절됨 +curl -i 'http://localhost:8080/reports?sort=id;DROP%20TABLE%20reports' +``` + +### 페이지 크기 clamping +```bash +# 컨트롤러가 @AutoPaginate(maxSize = 50)을 선언 +# 9999 요청 → 조용히 50으로 clamping +curl 'http://localhost:8080/reports?page=0&size=9999' | jq '.size, .content | length' +# → 50 +# → 50 +``` + +### 범위 초과 페이지 +```bash +# Page 999는 존재 안 함. reasonable=true (기본값)에서 스타터가 마지막 페이지로 clamping +curl 'http://localhost:8080/reports?page=999&size=20' | jq '.page, .empty' +# → 6 (137/20의 마지막 페이지 인덱스) +# → false +``` + +## 읽을 만한 파일 + +흥미로운 부분, 순서대로: + +| 파일 | 왜 | +| --- | --- | +| `build.gradle.kts` | `spring-boot-starter-web` 외 추가하는 의존성은 `kr.devslab:easy-paging-spring-boot-starter:0.4.0` 하나뿐 | +| `report/ReportController.java` | 전체 페이지네이션 계약은 `@AutoPaginate(maxSize = 50)` 어노테이션 + `PageResponse` 반환 타입 | +| `report/ReportMapper.java` + `resources/mapper/ReportMapper.xml` | 평범한 `SELECT` — `LIMIT`, `OFFSET`, `COUNT` 모두 없음. 런타임에 aspect가 주입 | +| `resources/application.yml` | `easy-paging` 전역 cap + 기본값 | + +## 고급: 응답 봉투 교체 + +많은 팀이 전사 표준 봉투 형식 — `{ ok, data, meta: { page, size, total, pages } }` 같은 — 을 가지고 모든 paginated 엔드포인트가 그 형식을 반환하도록 합니다. 스타터는 기본 `PageResponse`를 자기 형식으로 교체할 2가지 방법을 지원. 이 데모는 둘 다 나란히 포함하므로 wire JSON을 비교해서 자기 코드베이스에 맞는 패턴을 고를 수 있어요. + +여기서 사용하는 커스텀 봉투는 [`CompanyPage`](src/main/java/kr/devslab/examples/easypaging/envelope/CompanyPage.java) record: + +```json +{ + "ok": true, + "data": [ { "id": 1, "title": "Report #1", "createdAt": "..." }, ... ], + "meta": { "page": 0, "size": 5, "total": 137, "pages": 28 } +} +``` + +### 패턴 1 — 커스텀 반환 타입 + 정적 팩토리 (권장) + +컨트롤러가 반환 타입을 `CompanyPage`로 선언하고 `CompanyPage.from(...)`을 명시적으로 호출: + +```bash +curl 'http://localhost:8080/reports/company?page=0&size=5' +``` + +[`CompanyPageReportController`](src/main/java/kr/devslab/examples/easypaging/report/CompanyPageReportController.java) 참고 — 메서드 본문은 `CompanyPage.from(reports.findAll(), pageable)` 한 줄. `@AutoPaginate` aspect는 여전히 PageHelper 셋업, 정렬 검증, 크기 clamping 담당; 봉투 생성만 안 함. + +### 패턴 2 — `Object` 반환 + `PageResponseFactory` 빈 + +컨트롤러가 반환 타입을 `Object`로 선언하고 raw `List`를 돌려줌. [`PageResponseFactory`](src/main/java/kr/devslab/examples/easypaging/envelope/CompanyEnvelopeConfig.java) 빈이 aspect에게 그 리스트를 회사 봉투로 wrap하는 방법을 알려줌: + +```bash +curl 'http://localhost:8080/reports/auto-envelope?page=0&size=5' +``` + +`/reports/company`와 동일한 JSON 출력 — wire 형식은 같고 코드 경로만 다름. [`AutoEnvelopeReportController`](src/main/java/kr/devslab/examples/easypaging/report/AutoEnvelopeReportController.java) 참고. + +### 하나 고르기 — 트레이드오프 + +| | 패턴 1 (커스텀 타입 + `.from()`) | 패턴 2 (`Object` + 팩토리 빈) | +| --- | --- | --- | +| 타입 안전성 | 완전함 — 반환 타입이 `CompanyPage` | 없음 — 반환 타입이 `Object` | +| 엔드포인트별 wiring | 컨트롤러마다 `CompanyPage.from(...)` 호출 | 없음 — 팩토리는 한 번만 정의 | +| 같은 앱에서 봉투 섞기 | 쉬움 — 엔드포인트별 opt-in | 어려움 — 팩토리가 모든 `Object`/`List` 반환에 영향 | +| 테스트 | 정적 메서드, Spring 불필요 | `@SpringBootTest` 필요 (빈이 컨텍스트에 있어야) | +| 적합한 경우 | 한두 개 엔드포인트만 커스텀 형식 | 모든 paginated 엔드포인트가 같은 형식 사용 | + +기본 `/reports` 엔드포인트는 여전히 스타터의 `PageResponse`를 반환 — 명시적 `PageResponse` (그리고 `CompanyPage`) 반환 타입은 aspect를 통과하지 않으므로, 팩토리 빈 등록이 영향 미치지 않음. [`CustomEnvelopeTest`](src/test/java/kr/devslab/examples/easypaging/CustomEnvelopeTest.java)가 이 속성을 end-to-end로 검증. + +스타터의 전체 기능 (keyset 페이지네이션, WebFlux/R2DBC 지원)은 이 repo의 자매 데모들이 다룸 — 인덱스는 [최상위 README](../README.md) 참고. + +## 빌드 검증 + +```bash +./gradlew build +``` + +두 테스트 클래스 실행: +- `ReportControllerTest` — 앱 부팅 후 `/reports` 호출, 기본 페이지네이션 봉투 형식 + `maxSize` clamping 검증 +- `CustomEnvelopeTest` — `/reports/company`와 `/reports/auto-envelope` 호출해서 둘 다 같은 `CompanyPage` JSON 형식을 생성하는지, `PageResponseFactory` 빈이 `/reports` (여전히 기본 `PageResponse` 반환)에 새지 않는지 검증 diff --git a/easy-paging-sb4-demo/README.md b/easy-paging-sb4-demo/README.md new file mode 100644 index 0000000..c37183a --- /dev/null +++ b/easy-paging-sb4-demo/README.md @@ -0,0 +1,137 @@ +# easy-paging-sb4-demo + +**English** · [한국어](README.ko.md) + +> ✨ **Spring Boot 4 line.** This demo runs against the active `0.5.x` line of `easy-paging-spring-boot-starter` (Spring Boot 4 / Spring Framework 7 / Jackson 3). For the Spring Boot 3.3–3.5 maintenance equivalent, see [`easy-paging-demo`](../easy-paging-demo/). + +Runnable example for [`easy-paging-spring-boot-starter`](https://github.com/devslab-kr/easy-paging-spring-boot-starter) — annotation-driven pagination for Spring Boot + MyBatis. + +This demo wires the starter into a minimal Spring Boot app with an in-memory H2 database, 137 seeded report rows, and a single `@AutoPaginate`-annotated controller. No external services, no database setup — clone, run, curl. + +## Prerequisites + +- JDK 21+ +- Nothing else. H2 runs in-memory, dependencies download on first build. + +## Run + +```bash +cd easy-paging-demo +./gradlew bootRun +``` + +The app comes up on `http://localhost:8080`. The H2 database is created in-memory and pre-seeded with 137 `reports` rows on every startup. + +## Try it + +### First page (default Spring Data 0-based numbering) +```bash +curl 'http://localhost:8080/reports?page=0&size=5' +``` +```json +{ + "content": [ { "id": 1, "title": "Report #1", "createdAt": "..." }, ... ], + "page": 0, + "size": 5, + "totalElements": 137, + "totalPages": 28, + "first": true, + "last": false, + "empty": false +} +``` + +### Sorting (validated against SQL injection) +```bash +# Multi-column sort, descending by createdAt then ascending by id +curl 'http://localhost:8080/reports?page=0&size=5&sort=createdAt,desc&sort=id,asc' + +# Injection attempts are rejected with HTTP 400 +curl -i 'http://localhost:8080/reports?sort=id;DROP%20TABLE%20reports' +``` + +### Page-size clamping +```bash +# Controller declares @AutoPaginate(maxSize = 50) +# Asking for 9999 is clamped silently to 50 +curl 'http://localhost:8080/reports?page=0&size=9999' | jq '.size, .content | length' +# → 50 +# → 50 +``` + +### Out-of-range pages +```bash +# Page 999 doesn't exist. With reasonable=true (default), the starter clamps to the last page +curl 'http://localhost:8080/reports?page=999&size=20' | jq '.page, .empty' +# → 6 (the last page index for 137/20) +# → false +``` + +## What to read + +The interesting parts, in order: + +| File | Why | +| --- | --- | +| `build.gradle.kts` | the only dependency the demo adds beyond `spring-boot-starter-web` is `kr.devslab:easy-paging-spring-boot-starter:0.4.0` | +| `report/ReportController.java` | the entire pagination contract is the `@AutoPaginate(maxSize = 50)` annotation and the `PageResponse` return type | +| `report/ReportMapper.java` + `resources/mapper/ReportMapper.xml` | plain `SELECT` — no `LIMIT`, no `OFFSET`, no `COUNT`. The aspect injects all of that at runtime | +| `resources/application.yml` | `easy-paging` global caps and defaults | + +## Advanced: replacing the response envelope + +Many teams have a company-wide envelope shape — something like `{ ok, data, meta: { page, size, total, pages } }` — that every paginated endpoint is supposed to return. The starter supports two ways to swap the default `PageResponse` for your own shape. This demo includes both, side by side, so you can compare the JSON on the wire and pick the pattern that fits your codebase. + +The custom envelope used here is the [`CompanyPage`](src/main/java/kr/devslab/examples/easypaging/envelope/CompanyPage.java) record: + +```json +{ + "ok": true, + "data": [ { "id": 1, "title": "Report #1", "createdAt": "..." }, ... ], + "meta": { "page": 0, "size": 5, "total": 137, "pages": 28 } +} +``` + +### Pattern 1 — custom return type + static factory (recommended) + +The controller declares its return type as `CompanyPage` and calls `CompanyPage.from(...)` explicitly: + +```bash +curl 'http://localhost:8080/reports/company?page=0&size=5' +``` + +See [`CompanyPageReportController`](src/main/java/kr/devslab/examples/easypaging/report/CompanyPageReportController.java) — the method body is a single `CompanyPage.from(reports.findAll(), pageable)` call. The `@AutoPaginate` aspect still handles PageHelper setup, sort validation, and size clamping; it just doesn't construct the envelope. + +### Pattern 2 — `Object` return + `PageResponseFactory` bean + +The controller declares its return type as `Object` and hands back a raw `List`. A [`PageResponseFactory`](src/main/java/kr/devslab/examples/easypaging/envelope/CompanyEnvelopeConfig.java) bean tells the aspect how to wrap that list into the company envelope: + +```bash +curl 'http://localhost:8080/reports/auto-envelope?page=0&size=5' +``` + +Same JSON output as the `/reports/company` endpoint — the wire shape is identical, the code path is different. See [`AutoEnvelopeReportController`](src/main/java/kr/devslab/examples/easypaging/report/AutoEnvelopeReportController.java). + +### Pick one — trade-offs + +| | Pattern 1 (custom type + `.from()`) | Pattern 2 (`Object` + factory bean) | +| --- | --- | --- | +| Type safety | full — return type is `CompanyPage` | none — return type is `Object` | +| Wiring per endpoint | each controller calls `CompanyPage.from(...)` | none — factory defined once | +| Mixing envelopes in the same app | trivial — opt in per endpoint | hard — factory affects every `Object`/`List` return | +| Testing | static method, no Spring needed | requires `@SpringBootTest` (bean must be in context) | +| Best when | one or two endpoints want a custom shape | every paginated endpoint must use the same shape | + +The default `/reports` endpoint still returns the starter's `PageResponse` — explicit `PageResponse` (and `CompanyPage`) return types pass through the aspect untouched, so registering the factory bean doesn't affect them. [`CustomEnvelopeTest`](src/test/java/kr/devslab/examples/easypaging/CustomEnvelopeTest.java) asserts that property end-to-end. + +The starter's full feature set (keyset pagination, WebFlux/R2DBC support) is covered by sibling demos in this repo — see the [top-level README](../README.md) for the index. + +## Verify the build + +```bash +./gradlew build +``` + +Runs two test classes: +- `ReportControllerTest` — boots the app, hits `/reports`, asserts the default pagination envelope shape and `maxSize` clamping. +- `CustomEnvelopeTest` — exercises `/reports/company` and `/reports/auto-envelope`, asserts both produce the same `CompanyPage` JSON shape, and verifies that the `PageResponseFactory` bean does not bleed into `/reports` (which still returns the default `PageResponse`). diff --git a/easy-paging-sb4-demo/build.gradle.kts b/easy-paging-sb4-demo/build.gradle.kts new file mode 100644 index 0000000..506e5d3 --- /dev/null +++ b/easy-paging-sb4-demo/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + java + id("org.springframework.boot") version "4.0.6" + id("io.spring.dependency-management") version "1.1.7" +} + +group = "kr.devslab.examples" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-web") + + // The library this demo showcases. Bumped by Dependabot on new releases. + implementation("kr.devslab:easy-paging-spring-boot-starter:0.5.0") + + // H2 in-memory database — keeps the demo self-contained (no external DB). + runtimeOnly("com.h2database:h2") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.named("test") { + useJUnitPlatform() +} diff --git a/easy-paging-sb4-demo/gradle.properties b/easy-paging-sb4-demo/gradle.properties new file mode 100644 index 0000000..a31b0e0 --- /dev/null +++ b/easy-paging-sb4-demo/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true diff --git a/easy-paging-sb4-demo/gradle/wrapper/gradle-wrapper.jar b/easy-paging-sb4-demo/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/easy-paging-sb4-demo/gradle/wrapper/gradle-wrapper.jar differ diff --git a/easy-paging-sb4-demo/gradle/wrapper/gradle-wrapper.properties b/easy-paging-sb4-demo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/easy-paging-sb4-demo/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/easy-paging-sb4-demo/gradlew b/easy-paging-sb4-demo/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/easy-paging-sb4-demo/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/easy-paging-sb4-demo/gradlew.bat b/easy-paging-sb4-demo/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/easy-paging-sb4-demo/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/easy-paging-sb4-demo/settings.gradle.kts b/easy-paging-sb4-demo/settings.gradle.kts new file mode 100644 index 0000000..7f9f54c --- /dev/null +++ b/easy-paging-sb4-demo/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "easy-paging-sb4-demo" diff --git a/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/EasyPagingDemoApplication.java b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/EasyPagingDemoApplication.java new file mode 100644 index 0000000..8953c46 --- /dev/null +++ b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/EasyPagingDemoApplication.java @@ -0,0 +1,14 @@ +package kr.devslab.examples.easypaging; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@MapperScan("kr.devslab.examples.easypaging") +public class EasyPagingDemoApplication { + + public static void main(String[] args) { + SpringApplication.run(EasyPagingDemoApplication.class, args); + } +} diff --git a/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/envelope/CompanyEnvelopeConfig.java b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/envelope/CompanyEnvelopeConfig.java new file mode 100644 index 0000000..56928a4 --- /dev/null +++ b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/envelope/CompanyEnvelopeConfig.java @@ -0,0 +1,40 @@ +package kr.devslab.examples.easypaging.envelope; + +import java.util.List; +import kr.devslab.easypaging.spi.PageResponseFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Registers a {@link PageResponseFactory} so that any controller + * method annotated with {@code @AutoPaginate} that returns {@code Object} (or + * a raw {@code List}) gets its result wrapped in a {@link CompanyPage} + * instead of the default {@code PageResponse}. + * + *

Crucially, this only affects controller methods whose declared return + * type is {@code Object} or {@code List}. Methods that explicitly return + * {@code PageResponse} (like the main {@code /reports} endpoint) or + * {@code CompanyPage} (like {@code /reports/company}) pass through + * untouched — the aspect only routes through this factory when it's + * the one constructing the response envelope itself. + * + *

So registering this bean is safe even in an app that mixes the two + * patterns: explicit-return endpoints keep their declared shape, and only the + * "let the aspect build it" endpoints share the company-wide format. + */ +@Configuration +class CompanyEnvelopeConfig { + + @Bean + PageResponseFactory companyEnvelope() { + return (content, pageable, totalElements, totalPages) -> + new CompanyPage<>( + true, + List.copyOf(content), + new CompanyPage.PageMeta( + pageable.getPageNumber(), + pageable.getPageSize(), + totalElements, + totalPages)); + } +} diff --git a/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/envelope/CompanyPage.java b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/envelope/CompanyPage.java new file mode 100644 index 0000000..1bc0b35 --- /dev/null +++ b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/envelope/CompanyPage.java @@ -0,0 +1,45 @@ +package kr.devslab.examples.easypaging.envelope; + +import java.util.List; +import kr.devslab.easypaging.core.PageResponse; +import org.springframework.data.domain.Pageable; + +/** + * Example of a company-defined paginated response envelope, used by the two + * "custom response" controllers in this demo. + * + *

The shape is distinct from the starter's default + * {@link PageResponse} on purpose so a client hitting + * {@code /reports/company} or {@code /reports/auto-envelope} sees a JSON body + * that's visibly different from the one returned by {@code /reports}: + * + *

{@code
+ * {
+ *   "ok": true,
+ *   "data": [ ... ],
+ *   "meta": { "page": 0, "size": 5, "total": 137, "pages": 28 }
+ * }
+ * }
+ * + *

{@link CompanyPage#from(List, Pageable)} mirrors {@code PageResponse.from} + * — it borrows the starter's metadata extraction (the "right" PageHelper + * unwrapping, the 0-vs-1 indexing dance) and just remaps the fields into the + * company shape. That keeps the controllers using this type as thin as the + * default ones. + */ +public record CompanyPage( + boolean ok, + List data, + PageMeta meta) { + + public record PageMeta(int page, int size, long total, int pages) {} + + /** Build from a mapper result + the request {@link Pageable}. */ + public static CompanyPage from(List list, Pageable pageable) { + PageResponse p = PageResponse.from(list, pageable); + return new CompanyPage<>( + true, + p.content(), + new PageMeta(p.page(), p.size(), p.totalElements(), p.totalPages())); + } +} diff --git a/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/AutoEnvelopeReportController.java b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/AutoEnvelopeReportController.java new file mode 100644 index 0000000..453c836 --- /dev/null +++ b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/AutoEnvelopeReportController.java @@ -0,0 +1,61 @@ +package kr.devslab.examples.easypaging.report; + +import kr.devslab.easypaging.annotation.AutoPaginate; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * Pattern 2 of two for the custom-envelope advanced section. + * + *

The controller method declares its return type as {@code Object} and + * hands the aspect a raw {@code List} from the service. Because there + * is a {@code PageResponseFactory} bean in the application context + * (see {@code envelope/CompanyEnvelopeConfig}), the aspect routes the result + * through it — and the wire output is the same {@code CompanyPage} shape that + * {@code /reports/company} produces. + * + *

This is the pattern to reach for when every paginated + * endpoint in an app should use the same envelope and you don't want each + * controller to repeat the {@code Envelope.from(...)} call. + * + *

Trade-offs vs. the explicit-return approach + * ({@link CompanyPageReportController}): + * + *

    + *
  • No type safety on the return: it's {@code Object}, so the IDE can't help
  • + *
  • One factory bean, many controllers — DRY for company-wide standardization
  • + *
  • Testing requires the factory bean to be in the context (so {@code @SpringBootTest}, not pure unit tests of the controller)
  • + *
+ * + *

Try it: + *

+ *   curl 'http://localhost:8080/reports/auto-envelope?page=0&size=5'
+ * 
+ * + *

The JSON returned by this endpoint is byte-for-byte the same shape as + * {@code /reports/company} — that's the point of having both: the two + * patterns produce the same wire output via different code paths. + */ +@RestController +@RequestMapping("/reports/auto-envelope") +public class AutoEnvelopeReportController { + + private final ReportService reports; + + public AutoEnvelopeReportController(ReportService reports) { + this.reports = reports; + } + + @GetMapping + @AutoPaginate(maxSize = 50) + public Object list( + Pageable pageable, + // Demonstrates that ordinary query params still work in the Object-return + // pattern — the aspect only cares about the pagination contract. + @RequestParam(required = false) String ignoredFilter) { + return reports.findAll(); + } +} diff --git a/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/CompanyPageReportController.java b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/CompanyPageReportController.java new file mode 100644 index 0000000..22d4cd1 --- /dev/null +++ b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/CompanyPageReportController.java @@ -0,0 +1,48 @@ +package kr.devslab.examples.easypaging.report; + +import kr.devslab.easypaging.annotation.AutoPaginate; +import kr.devslab.examples.easypaging.envelope.CompanyPage; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Pattern 1 of two for the custom-envelope advanced section. + * + *

The controller's declared return type is the company's own + * {@link CompanyPage} record, and the method body explicitly calls + * {@link CompanyPage#from} to produce it. The {@code @AutoPaginate} aspect + * still handles the PageHelper lifecycle and the sort/size validation; it + * just doesn't construct the envelope itself. + * + *

Trade-offs vs. the factory-bean approach + * ({@link AutoEnvelopeReportController}): + * + *

    + *
  • Full type safety — return type is {@code CompanyPage}, not {@code Object}
  • + *
  • Each controller method opts in explicitly, so endpoints can mix and match envelope shapes
  • + *
  • Trivially testable — {@code CompanyPage.from} is a pure static method
  • + *
+ * + *

Try it: + *

+ *   curl 'http://localhost:8080/reports/company?page=0&size=5'
+ * 
+ */ +@RestController +@RequestMapping("/reports/company") +public class CompanyPageReportController { + + private final ReportService reports; + + public CompanyPageReportController(ReportService reports) { + this.reports = reports; + } + + @GetMapping + @AutoPaginate(maxSize = 50) + public CompanyPage list(Pageable pageable) { + return CompanyPage.from(reports.findAll(), pageable); + } +} diff --git a/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/Report.java b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/Report.java new file mode 100644 index 0000000..7c3426f --- /dev/null +++ b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/Report.java @@ -0,0 +1,41 @@ +package kr.devslab.examples.easypaging.report; + +import java.time.Instant; + +/** + * Simple report row stored in the in-memory H2 database. + * + *

MyBatis hydrates instances of this class from the {@code reports} table; the + * fields here line up with the column names in {@code schema.sql} via MyBatis's + * {@code map-underscore-to-camel-case} setting (so {@code created_at} → {@link #createdAt}). + */ +public class Report { + + private Long id; + private String title; + private Instant createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/ReportController.java b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/ReportController.java new file mode 100644 index 0000000..1eaeaad --- /dev/null +++ b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/ReportController.java @@ -0,0 +1,39 @@ +package kr.devslab.examples.easypaging.report; + +import kr.devslab.easypaging.annotation.AutoPaginate; +import kr.devslab.easypaging.core.PageResponse; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * The whole point of the demo is right here: one annotation, one mapper call, + * one wrapped response. Page-size clamping, 0-based pagination, safe sort, + * total counts, and the Spring Data-shaped JSON envelope all come from + * {@link AutoPaginate}. + * + *

Try it (once the app is running): + *

+ *   curl 'http://localhost:8080/reports?page=0&size=5'
+ *   curl 'http://localhost:8080/reports?page=2&size=5&sort=createdAt,desc'
+ *   curl 'http://localhost:8080/reports?size=9999'       # clamped to maxSize=50
+ *   curl 'http://localhost:8080/reports?sort=id;DROP'    # rejected (HTTP 400)
+ * 
+ */ +@RestController +@RequestMapping("/reports") +public class ReportController { + + private final ReportService reports; + + public ReportController(ReportService reports) { + this.reports = reports; + } + + @GetMapping + @AutoPaginate(maxSize = 50) + public PageResponse list(Pageable pageable) { + return PageResponse.from(reports.findAll(), pageable); + } +} diff --git a/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/ReportMapper.java b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/ReportMapper.java new file mode 100644 index 0000000..4df59d0 --- /dev/null +++ b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/ReportMapper.java @@ -0,0 +1,16 @@ +package kr.devslab.examples.easypaging.report; + +import java.util.List; +import org.apache.ibatis.annotations.Mapper; + +/** + * Notice this mapper has no {@code LIMIT} / {@code OFFSET} / {@code COUNT(*)} — + * {@link kr.devslab.easypaging.annotation.AutoPaginate} on the controller method + * sets up PageHelper's per-thread state before this query runs, and PageHelper + * rewrites the SQL underneath. + */ +@Mapper +public interface ReportMapper { + + List findAll(); +} diff --git a/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/ReportService.java b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/ReportService.java new file mode 100644 index 0000000..9c22cfe --- /dev/null +++ b/easy-paging-sb4-demo/src/main/java/kr/devslab/examples/easypaging/report/ReportService.java @@ -0,0 +1,24 @@ +package kr.devslab.examples.easypaging.report; + +import java.util.List; +import org.springframework.stereotype.Service; + +/** + * Plain service layer. In a real application, this is where authorization, + * tenant filtering, and domain rules would live. The pagination concern is + * intentionally absent here — it's handled by the aspect at the controller + * boundary. + */ +@Service +public class ReportService { + + private final ReportMapper mapper; + + public ReportService(ReportMapper mapper) { + this.mapper = mapper; + } + + public List findAll() { + return mapper.findAll(); + } +} diff --git a/easy-paging-sb4-demo/src/main/resources/application.yml b/easy-paging-sb4-demo/src/main/resources/application.yml new file mode 100644 index 0000000..af1555f --- /dev/null +++ b/easy-paging-sb4-demo/src/main/resources/application.yml @@ -0,0 +1,34 @@ +# Self-contained demo config: H2 in-memory + MyBatis + easy-paging defaults. + +spring: + application: + name: easy-paging-demo + + datasource: + url: jdbc:h2:mem:easypagingdemo;DB_CLOSE_DELAY=-1;MODE=PostgreSQL + driver-class-name: org.h2.Driver + username: sa + password: + + # schema.sql + data.sql are auto-applied on startup for embedded databases, + # but we make it explicit so the behavior survives a switch to a non-embedded DB. + sql: + init: + mode: always + +mybatis: + mapper-locations: classpath:mapper/**/*.xml + configuration: + map-underscore-to-camel-case: true + +# Demo only — adjust caps for your own app. +easy-paging: + enabled: true + default-page-size: 10 + max-page-size: 100 + +# Tighten startup logging so the curl examples in the README stand out. +logging: + level: + root: WARN + kr.devslab.examples: INFO diff --git a/easy-paging-sb4-demo/src/main/resources/data.sql b/easy-paging-sb4-demo/src/main/resources/data.sql new file mode 100644 index 0000000..27fe354 --- /dev/null +++ b/easy-paging-sb4-demo/src/main/resources/data.sql @@ -0,0 +1,10 @@ +-- Seed 137 rows so the pagination examples in the README produce realistic +-- multi-page output (137 rows / size=20 = 7 pages, matching the README sample). +DELETE FROM reports; + +INSERT INTO reports (id, title, created_at) +SELECT + X AS id, + 'Report #' || X AS title, + DATEADD('DAY', -1 * (138 - X), CURRENT_TIMESTAMP) AS created_at +FROM SYSTEM_RANGE(1, 137); diff --git a/easy-paging-sb4-demo/src/main/resources/mapper/ReportMapper.xml b/easy-paging-sb4-demo/src/main/resources/mapper/ReportMapper.xml new file mode 100644 index 0000000..bbaca50 --- /dev/null +++ b/easy-paging-sb4-demo/src/main/resources/mapper/ReportMapper.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/easy-paging-sb4-demo/src/main/resources/schema.sql b/easy-paging-sb4-demo/src/main/resources/schema.sql new file mode 100644 index 0000000..e146374 --- /dev/null +++ b/easy-paging-sb4-demo/src/main/resources/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS reports ( + id BIGINT PRIMARY KEY, + title VARCHAR(200) NOT NULL, + created_at TIMESTAMP NOT NULL +); diff --git a/easy-paging-sb4-demo/src/test/java/kr/devslab/examples/easypaging/CustomEnvelopeTest.java b/easy-paging-sb4-demo/src/test/java/kr/devslab/examples/easypaging/CustomEnvelopeTest.java new file mode 100644 index 0000000..0a46249 --- /dev/null +++ b/easy-paging-sb4-demo/src/test/java/kr/devslab/examples/easypaging/CustomEnvelopeTest.java @@ -0,0 +1,98 @@ +package kr.devslab.examples.easypaging; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +/** + * Verifies the two "custom response envelope" patterns documented in the + * demo's README: + * + *
    + *
  1. {@code CompanyPage} as the controller's explicit return type + * ({@code CompanyPageReportController}, mapped at {@code /reports/company}).
  2. + *
  3. {@code Object} return + a {@code PageResponseFactory} bean + * ({@code AutoEnvelopeReportController} + {@code CompanyEnvelopeConfig}, + * mapped at {@code /reports/auto-envelope}).
  4. + *
+ * + *

The interesting property the second test pair asserts is that + * both endpoints produce identical JSON — the + * {@code PageResponseFactory} bean and the static {@code CompanyPage.from} + * factory must agree on shape, since the whole reason to register the bean + * is to avoid every controller repeating the {@code .from(...)} call by hand. + * + *

The third test asserts the factory bean does not bleed into + * controllers that already return an explicit {@code PageResponse} — the + * main {@code /reports} endpoint keeps its default envelope shape regardless + * of the bean being registered. + */ +@SpringBootTest +@AutoConfigureMockMvc +class CustomEnvelopeTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void companyPageEndpointReturnsCompanyShape() throws Exception { + mockMvc.perform(get("/reports/company").param("page", "0").param("size", "5")) + .andExpect(status().isOk()) + // CompanyPage shape: { ok, data: [...], meta: { page, size, total, pages } } + .andExpect(jsonPath("$.ok").value(true)) + .andExpect(jsonPath("$.data.length()").value(5)) + .andExpect(jsonPath("$.meta.page").value(0)) + .andExpect(jsonPath("$.meta.size").value(5)) + .andExpect(jsonPath("$.meta.total").value(137)) + .andExpect(jsonPath("$.meta.pages").value(28)) + // None of the default PageResponse keys should be present + .andExpect(jsonPath("$.content").doesNotExist()) + .andExpect(jsonPath("$.totalElements").doesNotExist()); + } + + @Test + void autoEnvelopeEndpointReturnsSameShapeAsCompanyEndpoint() throws Exception { + // Same query, two different code paths — must produce byte-equal JSON. + MvcResult viaStaticFactory = mockMvc.perform( + get("/reports/company").param("page", "1").param("size", "10")) + .andExpect(status().isOk()).andReturn(); + + MvcResult viaFactoryBean = mockMvc.perform( + get("/reports/auto-envelope").param("page", "1").param("size", "10")) + .andExpect(status().isOk()) + .andExpect(content().json(viaStaticFactory.getResponse().getContentAsString(), true)) + .andReturn(); + + // Sanity-check the body itself has the company shape (not just that the two strings match). + // If both endpoints regressed to producing the same wrong thing, the equality above + // alone wouldn't catch it. + mockMvc.perform(get("/reports/auto-envelope").param("page", "0").param("size", "3")) + .andExpect(jsonPath("$.ok").value(true)) + .andExpect(jsonPath("$.data.length()").value(3)) + .andExpect(jsonPath("$.meta.total").value(137)); + } + + @Test + void defaultEndpointKeepsPageResponseShapeDespiteFactoryBean() throws Exception { + // /reports returns PageResponse explicitly. Even with the + // PageResponseFactory bean registered, it should pass through unchanged. + mockMvc.perform(get("/reports").param("page", "0").param("size", "5")) + .andExpect(status().isOk()) + // Default PageResponse keys — must be present + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.totalElements").value(137)) + .andExpect(jsonPath("$.totalPages").value(28)) + // CompanyPage keys — must NOT be present here + .andExpect(jsonPath("$.ok").doesNotExist()) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.meta").doesNotExist()); + } +} diff --git a/easy-paging-sb4-demo/src/test/java/kr/devslab/examples/easypaging/ReportControllerTest.java b/easy-paging-sb4-demo/src/test/java/kr/devslab/examples/easypaging/ReportControllerTest.java new file mode 100644 index 0000000..01f205f --- /dev/null +++ b/easy-paging-sb4-demo/src/test/java/kr/devslab/examples/easypaging/ReportControllerTest.java @@ -0,0 +1,48 @@ +package kr.devslab.examples.easypaging; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Smoke test for the demo. Verifies that: + * 1. the application context boots (auto-config from easy-paging works), + * 2. the seeded H2 data is queryable through the @AutoPaginate-annotated endpoint, and + * 3. the response carries the Spring Data-shaped pagination envelope. + */ +@SpringBootTest +@AutoConfigureMockMvc +class ReportControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void firstPageReturnsExpectedPaginationMetadata() throws Exception { + // 137 seeded rows / size=5 = 28 pages (27 full + 1 partial of 2). + mockMvc.perform(get("/reports").param("page", "0").param("size", "5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(5)) + .andExpect(jsonPath("$.totalElements").value(137)) + .andExpect(jsonPath("$.totalPages").value(28)) + .andExpect(jsonPath("$.first").value(true)) + .andExpect(jsonPath("$.last").value(false)); + } + + @Test + void oversizedPageSizeIsClampedByMaxSize() throws Exception { + // Controller has @AutoPaginate(maxSize = 50); ?size=9999 must be clamped. + mockMvc.perform(get("/reports").param("page", "0").param("size", "9999")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.size").value(50)) + .andExpect(jsonPath("$.content.length()").value(50)); + } +} diff --git a/easy-paging-sb4-keyset-demo/README.ko.md b/easy-paging-sb4-keyset-demo/README.ko.md new file mode 100644 index 0000000..367e0f8 --- /dev/null +++ b/easy-paging-sb4-keyset-demo/README.ko.md @@ -0,0 +1,115 @@ +# easy-paging-sb4-keyset-demo + +[English](README.md) · **한국어** + +> ✨ **Spring Boot 4 라인.** 이 데모는 `easy-paging-spring-boot-starter`의 활성 `0.5.x` 라인(Spring Boot 4 / Spring Framework 7 / Jackson 3)을 사용. Spring Boot 3.3–3.5 maintenance 버전은 [`easy-paging-keyset-demo`](../easy-paging-keyset-demo/) 참조. + +[`easy-paging-spring-boot-starter`](https://github.com/devslab-kr/easy-paging-spring-boot-starter)의 커서(keyset) 페이지네이션 예제 — `OFFSET`이 깊이에 따라 느려지고 `COUNT(*)`도 낭비인 무한 시계열 스트림 (로그, audit 이벤트, location ping)에 쓰는 전략. + +전통적인 offset 페이지네이션을 다루는 [`easy-paging-demo`](../easy-paging-demo/)의 자매 데모 — 어노테이션, 반환 타입, `WHERE` 절 패턴이 다름. 동일한 H2 인메모리 DB, 외부 서비스 없음. + +## 전제조건 + +- JDK 21+ +- 그 외 없음. + +## 실행 + +```bash +cd easy-paging-keyset-demo +./gradlew bootRun +``` + +앱은 `http://localhost:8080`에 뜸. H2는 고정 worker UUID (`00000000-0000-0000-0000-000000000001`)에 대한 **300개 location ping**으로 시드됨. + +## 시험해보기 + +### 첫 페이지 (커서 없음) + +```bash +curl 'http://localhost:8080/locations?workerId=00000000-0000-0000-0000-000000000001&size=10' +``` + +```json +{ + "content": [ + { "id": 300, "workerId": "00000000-...", "time": "2026-05-23T05:00:00Z", "lat": 37.5965, "lng": 127.0080 }, + { "id": 299, ... }, + ... + ], + "size": 10, + "nextCursor": "eyJrIjp7InRpbWUiOiIyMDI2LTA1LTIzVDA0OjUxOjAwWiIsImlkIjoyOTF9LCJkIjoiRk9SV0FSRCJ9", + "prevCursor": null, + "hasNext": true, + "hasPrev": false +} +``` + +`content`는 `(time DESC, id DESC)` 순서 — 가장 최근 행이 맨 위. `nextCursor`는 Base64 인코딩된 JSON payload — 서명 시크릿이 없으면 디코딩해서 내용 확인 가능 (아래 참고). + +### 스트림 walking + +`nextCursor`를 `?cursor=…`로 넘기기: + +```bash +curl 'http://localhost:8080/locations?workerId=00000000-0000-0000-0000-000000000001&size=10&cursor=<이전 응답의 nextCursor>' +``` + +`hasNext`가 `false`가 될 때까지 반복. 300행 / `size=10`이면 정확히 30페이지 — walk 동안 1~300 모든 `id`가 정확히 한 번씩 등장. (`LocationControllerTest`가 이 property를 프로그래매틱하게 검증.) + +### 크기 clamping + +```bash +# 컨트롤러가 @KeysetPaginate(maxSize = 200) — ?size=9999 → 200으로 clamping +curl 'http://localhost:8080/locations?workerId=00000000-0000-0000-0000-000000000001&size=9999' | jq '.size' +# → 200 +``` + +## 커서 서명 (프로덕션 가기 전에 읽기) + +데모는 기본적으로 **커서 서명 없이** 실행: + +```yaml +easy-paging: + keyset: + cursor-secret: ${EASY_PAGING_CURSOR_SECRET:} +``` + +로컬 탐색에는 OK — `base64 -d`로 커서 내용을 그대로 볼 수 있어서 동작 원리가 명확해짐. **하지만 프로덕션에서 시크릿 누락은 취약점**: 악의적 클라이언트가 커서를 조작해서 (커서에 박힌 tenant key 등을 변조해서) 보면 안 되는 행을 seek 가능. + +서명된 커서로 실행: + +```bash +EASY_PAGING_CURSOR_SECRET='a-long-random-string-from-your-secrets-manager' ./gradlew bootRun +``` + +정상 사용에선 동일해 보이지만 서명된 커서는 `.` 형태가 되고 변조된 커서는 거부됨. + +## 읽을 만한 파일 + +| 파일 | 왜 | +| --- | --- | +| `build.gradle.kts` | `spring-boot-starter-web` 외 추가 의존성은 `kr.devslab:easy-paging-spring-boot-starter:0.4.0` (offset 데모와 동일) | +| `location/LocationController.java` | 계약: `@KeysetPaginate(keys = {"time", "id"}, ...)` + 스타터가 자동 resolve하는 `KeysetRequest` 파라미터 | +| `location/LocationService.java` | **size + 1** 트릭 (매퍼가 한 행 더 가져와서 `KeysetPage.build`가 `hasNext`를 정확히 설정) + 다음 커서가 되는 `keyExtractor` 람다 | +| `location/LocationMapper.java` + `resources/mapper/LocationMapper.xml` | 복합 키 seek predicate — `time < ? OR (time = ? AND id < ?)`. 타임스탬프가 겹쳐도 walk를 안정적으로 만드는 핵심 | +| `resources/schema.sql` | 쿼리의 ORDER BY와 일치하는 covering 인덱스 `(worker_id, time DESC, id DESC)`. 실제 DB에서 페이지당 O(log N) 유지 | + +## 빌드 검증 + +```bash +./gradlew build +``` + +스모크 테스트가 앱 부팅 후 전체 커서 체인을 4페이지 walk하면서 **중복/누락 없음** 불변식 — keyset 페이지네이션이 실제로 보장해야 하는 property — 검증. + +## `easy-paging-demo`와의 차이 + +| | `easy-paging-demo` (offset) | `easy-paging-keyset-demo` (이거) | +|---|---|---| +| 어노테이션 | `@AutoPaginate` | `@KeysetPaginate(keys = {...})` | +| 반환 타입 | `PageResponse` | `KeysetPage` | +| 매퍼 SQL | 평범한 `SELECT` (LIMIT/OFFSET 없음 — aspect가 주입) | 명시적 `WHERE` seek 절 + `LIMIT size+1` | +| `totalElements` | 있음 | 없음 (`COUNT(*)` 피하는 게 keyset의 핵심) | +| 적합한 경우 | totals가 있는 유한 paginated 리스트 | 무한 스트림, append-only 테이블 | +| 깊이별 성능 | 페이지 번호에 따라 느려짐 | 페이지마다 일정 | diff --git a/easy-paging-sb4-keyset-demo/README.md b/easy-paging-sb4-keyset-demo/README.md new file mode 100644 index 0000000..70b779e --- /dev/null +++ b/easy-paging-sb4-keyset-demo/README.md @@ -0,0 +1,115 @@ +# easy-paging-sb4-keyset-demo + +**English** · [한국어](README.ko.md) + +> ✨ **Spring Boot 4 line.** This demo runs against the active `0.5.x` line of `easy-paging-spring-boot-starter` (Spring Boot 4 / Spring Framework 7 / Jackson 3). For the Spring Boot 3.3–3.5 maintenance equivalent, see [`easy-paging-keyset-demo`](../easy-paging-keyset-demo/). + +Cursor (keyset) pagination example for [`easy-paging-spring-boot-starter`](https://github.com/devslab-kr/easy-paging-spring-boot-starter) — the strategy you want for unbounded time-series streams (logs, audit events, location pings) where `OFFSET` would slow down with depth and `COUNT(*)` is wasted work. + +Companion to [`easy-paging-demo`](../easy-paging-demo/) — which covers traditional offset pagination — but with a different annotation, return type, and `WHERE`-clause pattern. Same H2 in-memory database, no external services. + +## Prerequisites + +- JDK 21+ +- Nothing else. + +## Run + +```bash +cd easy-paging-keyset-demo +./gradlew bootRun +``` + +The app comes up on `http://localhost:8080`. H2 is seeded with **300 location pings** for one fixed worker UUID (`00000000-0000-0000-0000-000000000001`). + +## Try it + +### First page (no cursor) + +```bash +curl 'http://localhost:8080/locations?workerId=00000000-0000-0000-0000-000000000001&size=10' +``` + +```json +{ + "content": [ + { "id": 300, "workerId": "00000000-...", "time": "2026-05-23T05:00:00Z", "lat": 37.5965, "lng": 127.0080 }, + { "id": 299, ... }, + ... + ], + "size": 10, + "nextCursor": "eyJrIjp7InRpbWUiOiIyMDI2LTA1LTIzVDA0OjUxOjAwWiIsImlkIjoyOTF9LCJkIjoiRk9SV0FSRCJ9", + "prevCursor": null, + "hasNext": true, + "hasPrev": false +} +``` + +The `content` is ordered by `(time DESC, id DESC)` so the newest row appears first. The `nextCursor` is a Base64-encoded JSON payload — it's safe to inspect when no signing secret is set (see below). + +### Walk the stream + +Pass `nextCursor` back as `?cursor=…`: + +```bash +curl 'http://localhost:8080/locations?workerId=00000000-0000-0000-0000-000000000001&size=10&cursor=' +``` + +Keep going until `hasNext` is `false`. With 300 seeded rows and `size=10`, that's exactly 30 pages — and every `id` from 1 to 300 will appear exactly once across the walk. (The `LocationControllerTest` asserts this property programmatically.) + +### Size clamping + +```bash +# Controller has @KeysetPaginate(maxSize = 200) — ?size=9999 clamps to 200 +curl 'http://localhost:8080/locations?workerId=00000000-0000-0000-0000-000000000001&size=9999' | jq '.size' +# → 200 +``` + +## Cursor signing (read this before production) + +By default the demo runs with **no cursor signing**: + +```yaml +easy-paging: + keyset: + cursor-secret: ${EASY_PAGING_CURSOR_SECRET:} +``` + +This is fine for local exploration — you can decode the cursor with `base64 -d` and see exactly what's in it, which makes the moving parts obvious. **But in production a missing secret is a vulnerability**: a malicious client can forge a cursor to seek past rows that they shouldn't see (e.g. by tampering with a tenant key embedded in the cursor). + +Run the demo with a secret to see signed cursors: + +```bash +EASY_PAGING_CURSOR_SECRET='a-long-random-string-from-your-secrets-manager' ./gradlew bootRun +``` + +Signed cursors look the same in normal use, but become `.` and any tampered cursor is rejected. + +## What to read + +| File | Why | +| --- | --- | +| `build.gradle.kts` | the only dependency added beyond `spring-boot-starter-web` is `kr.devslab:easy-paging-spring-boot-starter:0.4.0` (same as the offset demo) | +| `location/LocationController.java` | the contract: `@KeysetPaginate(keys = {"time", "id"}, ...)` plus a `KeysetRequest` parameter resolved automatically by the starter | +| `location/LocationService.java` | shows the **size + 1** trick (mapper fetches one extra row so `KeysetPage.build` can set `hasNext` correctly) and the `keyExtractor` lambda that becomes the next cursor | +| `location/LocationMapper.java` + `resources/mapper/LocationMapper.xml` | the composite-key seek predicate — `time < ? OR (time = ? AND id < ?)` is what makes the walk stable when timestamps collide | +| `resources/schema.sql` | covering index `(worker_id, time DESC, id DESC)` matches the query's ORDER BY so the page lookup stays O(log N) per page on a real DB | + +## Verify the build + +```bash +./gradlew build +``` + +The smoke test boots the app, walks the entire cursor chain across 4 pages, and asserts the **no-overlap / no-gaps** invariant — the property keyset pagination actually has to guarantee. + +## How this differs from `easy-paging-demo` + +| | `easy-paging-demo` (offset) | `easy-paging-keyset-demo` (this) | +|---|---|---| +| Annotation | `@AutoPaginate` | `@KeysetPaginate(keys = {...})` | +| Return type | `PageResponse` | `KeysetPage` | +| Mapper SQL | plain `SELECT` (no LIMIT/OFFSET — aspect injects) | explicit `WHERE` seek clause + `LIMIT size+1` | +| `totalElements` | yes | no (avoiding `COUNT(*)` is half the point) | +| Best for | finite paginated lists with totals | unbounded streams, append-only tables | +| Performance at depth | slows with page number | constant per page | diff --git a/easy-paging-sb4-keyset-demo/build.gradle.kts b/easy-paging-sb4-keyset-demo/build.gradle.kts new file mode 100644 index 0000000..088d5d7 --- /dev/null +++ b/easy-paging-sb4-keyset-demo/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + java + id("org.springframework.boot") version "4.0.6" + id("io.spring.dependency-management") version "1.1.7" +} + +group = "kr.devslab.examples" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-web") + + // The library this demo showcases. Bumped by Dependabot on new releases. + implementation("kr.devslab:easy-paging-spring-boot-starter:0.5.0") + + // H2 in-memory database — keeps the demo self-contained (no external DB). + // Keyset pagination doesn't need a "real" DB to demonstrate; the SQL pattern + // is portable to PostgreSQL/MySQL/etc. unchanged. + runtimeOnly("com.h2database:h2") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.named("test") { + useJUnitPlatform() +} diff --git a/easy-paging-sb4-keyset-demo/gradle.properties b/easy-paging-sb4-keyset-demo/gradle.properties new file mode 100644 index 0000000..a31b0e0 --- /dev/null +++ b/easy-paging-sb4-keyset-demo/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true diff --git a/easy-paging-sb4-keyset-demo/gradle/wrapper/gradle-wrapper.jar b/easy-paging-sb4-keyset-demo/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/easy-paging-sb4-keyset-demo/gradle/wrapper/gradle-wrapper.jar differ diff --git a/easy-paging-sb4-keyset-demo/gradle/wrapper/gradle-wrapper.properties b/easy-paging-sb4-keyset-demo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/easy-paging-sb4-keyset-demo/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/easy-paging-sb4-keyset-demo/gradlew b/easy-paging-sb4-keyset-demo/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/easy-paging-sb4-keyset-demo/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/easy-paging-sb4-keyset-demo/gradlew.bat b/easy-paging-sb4-keyset-demo/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/easy-paging-sb4-keyset-demo/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/easy-paging-sb4-keyset-demo/settings.gradle.kts b/easy-paging-sb4-keyset-demo/settings.gradle.kts new file mode 100644 index 0000000..4633c0c --- /dev/null +++ b/easy-paging-sb4-keyset-demo/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "easy-paging-sb4-keyset-demo" diff --git a/easy-paging-sb4-keyset-demo/src/main/java/kr/devslab/examples/easypagingkeyset/EasyPagingKeysetDemoApplication.java b/easy-paging-sb4-keyset-demo/src/main/java/kr/devslab/examples/easypagingkeyset/EasyPagingKeysetDemoApplication.java new file mode 100644 index 0000000..a050ceb --- /dev/null +++ b/easy-paging-sb4-keyset-demo/src/main/java/kr/devslab/examples/easypagingkeyset/EasyPagingKeysetDemoApplication.java @@ -0,0 +1,14 @@ +package kr.devslab.examples.easypagingkeyset; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@MapperScan("kr.devslab.examples.easypagingkeyset") +public class EasyPagingKeysetDemoApplication { + + public static void main(String[] args) { + SpringApplication.run(EasyPagingKeysetDemoApplication.class, args); + } +} diff --git a/easy-paging-sb4-keyset-demo/src/main/java/kr/devslab/examples/easypagingkeyset/location/Location.java b/easy-paging-sb4-keyset-demo/src/main/java/kr/devslab/examples/easypagingkeyset/location/Location.java new file mode 100644 index 0000000..77a21d7 --- /dev/null +++ b/easy-paging-sb4-keyset-demo/src/main/java/kr/devslab/examples/easypagingkeyset/location/Location.java @@ -0,0 +1,63 @@ +package kr.devslab.examples.easypagingkeyset.location; + +import java.time.Instant; +import java.util.UUID; + +/** + * A single GPS ping for a field worker — the kind of unbounded time-series + * row where keyset (cursor) pagination earns its keep. With millions of rows + * per worker, traditional {@code OFFSET}-based paging gets slower the deeper + * the client scrolls, and {@code COUNT(*)} is wasted work for an + * append-only stream. + * + *

The composite keyset {@code (time DESC, id DESC)} guarantees a stable + * ordering even when two pings share the same timestamp. + */ +public class Location { + + private Long id; + private UUID workerId; + private Instant time; + private Double lat; + private Double lng; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public UUID getWorkerId() { + return workerId; + } + + public void setWorkerId(UUID workerId) { + this.workerId = workerId; + } + + public Instant getTime() { + return time; + } + + public void setTime(Instant time) { + this.time = time; + } + + public Double getLat() { + return lat; + } + + public void setLat(Double lat) { + this.lat = lat; + } + + public Double getLng() { + return lng; + } + + public void setLng(Double lng) { + this.lng = lng; + } +} diff --git a/easy-paging-sb4-keyset-demo/src/main/java/kr/devslab/examples/easypagingkeyset/location/LocationController.java b/easy-paging-sb4-keyset-demo/src/main/java/kr/devslab/examples/easypagingkeyset/location/LocationController.java new file mode 100644 index 0000000..039acab --- /dev/null +++ b/easy-paging-sb4-keyset-demo/src/main/java/kr/devslab/examples/easypagingkeyset/location/LocationController.java @@ -0,0 +1,51 @@ +package kr.devslab.examples.easypagingkeyset.location; + +import java.util.UUID; +import kr.devslab.easypaging.annotation.KeysetPaginate; +import kr.devslab.easypaging.core.KeysetPage; +import kr.devslab.easypaging.core.KeysetRequest; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Composite-key cursor pagination: {@code (time DESC, id DESC)}. + * + *

The {@link KeysetRequest} parameter is filled by the starter's argument + * resolver from the {@code cursor} / {@code size} / {@code direction} query + * params, falling back to the defaults declared in the annotation. + * + *

Sample flow (once the app is running): + *

+ *   # First page — no cursor.
+ *   curl 'http://localhost:8080/locations?workerId=00000000-0000-0000-0000-000000000001&size=10'
+ *
+ *   # Pass back the response's nextCursor as ?cursor=... for the next page.
+ *   curl 'http://localhost:8080/locations?workerId=...&size=10&cursor=eyJrIjp7InRpbWUiOi...'
+ *
+ *   # Walking nextCursor until hasNext=false yields the whole stream — no OFFSET,
+ *   # no COUNT(*), and the result set is stable even if rows are inserted while
+ *   # the client is paging.
+ * 
+ */ +@RestController +@RequestMapping("/locations") +public class LocationController { + + private final LocationService locations; + + public LocationController(LocationService locations) { + this.locations = locations; + } + + @GetMapping + @KeysetPaginate( + keys = {"time", "id"}, + direction = "DESC", + defaultSize = 50, + maxSize = 200) + public KeysetPage stream(KeysetRequest req, @RequestParam UUID workerId) { + return locations.stream(workerId, req); + } +} diff --git a/easy-paging-sb4-keyset-demo/src/main/java/kr/devslab/examples/easypagingkeyset/location/LocationMapper.java b/easy-paging-sb4-keyset-demo/src/main/java/kr/devslab/examples/easypagingkeyset/location/LocationMapper.java new file mode 100644 index 0000000..f3a0f75 --- /dev/null +++ b/easy-paging-sb4-keyset-demo/src/main/java/kr/devslab/examples/easypagingkeyset/location/LocationMapper.java @@ -0,0 +1,28 @@ +package kr.devslab.examples.easypagingkeyset.location; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * Unlike the offset demo (where the mapper has no pagination logic), keyset + * SQL is explicit: the {@code WHERE} clause uses the cursor values to + * "seek past" the previously-returned rows. The service fetches {@code size+1} + * to detect whether a next page exists. + */ +@Mapper +public interface LocationMapper { + + /** + * @param time cursor timestamp; {@code null} on the first page + * @param id cursor id (tiebreaker for equal timestamps); {@code null} on the first page + * @param limit page size + 1 (the extra row tells us whether more pages exist) + */ + List findAfter( + @Param("workerId") UUID workerId, + @Param("time") Instant time, + @Param("id") Long id, + @Param("limit") int limit); +} diff --git a/easy-paging-sb4-keyset-demo/src/main/java/kr/devslab/examples/easypagingkeyset/location/LocationService.java b/easy-paging-sb4-keyset-demo/src/main/java/kr/devslab/examples/easypagingkeyset/location/LocationService.java new file mode 100644 index 0000000..da1f8b5 --- /dev/null +++ b/easy-paging-sb4-keyset-demo/src/main/java/kr/devslab/examples/easypagingkeyset/location/LocationService.java @@ -0,0 +1,40 @@ +package kr.devslab.examples.easypagingkeyset.location; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import kr.devslab.easypaging.core.CursorCodec; +import kr.devslab.easypaging.core.KeysetPage; +import kr.devslab.easypaging.core.KeysetRequest; +import org.springframework.stereotype.Service; + +/** + * The service owns the "size + 1" fetch and the {@code keyExtractor} that + * tells {@link KeysetPage#build} which fields of the last visible row become + * the {@code nextCursor}. The {@link CursorCodec} bean is registered by the + * starter's auto-configuration. + */ +@Service +public class LocationService { + + private final LocationMapper mapper; + private final CursorCodec codec; + + public LocationService(LocationMapper mapper, CursorCodec codec) { + this.mapper = mapper; + this.codec = codec; + } + + public KeysetPage stream(UUID workerId, KeysetRequest req) { + List rows = mapper.findAfter( + workerId, + req.keyAsInstant("time"), // null on the first page + req.keyAsLong("id"), // null on the first page + req.size() + 1); // +1 lets KeysetPage.build set hasNext correctly + + return KeysetPage.build(rows, req, row -> Map.of( + "time", row.getTime(), + "id", row.getId() + ), codec); + } +} diff --git a/easy-paging-sb4-keyset-demo/src/main/resources/application.yml b/easy-paging-sb4-keyset-demo/src/main/resources/application.yml new file mode 100644 index 0000000..adf2fa5 --- /dev/null +++ b/easy-paging-sb4-keyset-demo/src/main/resources/application.yml @@ -0,0 +1,38 @@ +# Self-contained keyset demo: H2 in-memory + MyBatis + easy-paging keyset config. + +spring: + application: + name: easy-paging-keyset-demo + + datasource: + url: jdbc:h2:mem:easypagingkeysetdemo;DB_CLOSE_DELAY=-1;MODE=PostgreSQL + driver-class-name: org.h2.Driver + username: sa + password: + + sql: + init: + mode: always + + +mybatis: + mapper-locations: classpath:mapper/**/*.xml + configuration: + map-underscore-to-camel-case: true + +easy-paging: + enabled: true + keyset: + # IMPORTANT in production. With an empty secret, cursors are Base64-encoded + # but NOT authenticated — a malicious client could forge a cursor that + # targets rows they shouldn't see (e.g. by tampering with a tenant key). + # The demo intentionally leaves it blank so cursors are inspectable; + # override via env var when trying a "production-like" run: + # EASY_PAGING_CURSOR_SECRET='some-long-random-string' ./gradlew bootRun + cursor-secret: ${EASY_PAGING_CURSOR_SECRET:} + max-cursor-bytes: 2048 + +logging: + level: + root: WARN + kr.devslab.examples: INFO diff --git a/easy-paging-sb4-keyset-demo/src/main/resources/data.sql b/easy-paging-sb4-keyset-demo/src/main/resources/data.sql new file mode 100644 index 0000000..8258f74 --- /dev/null +++ b/easy-paging-sb4-keyset-demo/src/main/resources/data.sql @@ -0,0 +1,20 @@ +-- Seed 300 location pings for one fixed worker UUID so the demo's curl examples +-- always have something to scroll through. Timestamps step back 1 minute per row, +-- so id=1 is the oldest and id=300 is the most recent (ORDER BY time DESC starts +-- with id=300). +-- +-- A real worker stream would be millions of rows — 300 is enough to walk the +-- cursor flow a few times and see hasNext flip from true to false on the last page. + +DELETE FROM locations; + +INSERT INTO locations (id, worker_id, time, lat, lng) +SELECT + X AS id, + '00000000-0000-0000-0000-000000000001' AS worker_id, + DATEADD('MINUTE', -1 * (301 - X), CURRENT_TIMESTAMP) AS time, + -- A drifting walk roughly around Seoul (37.5665, 126.9780), nothing + -- meaningful — just so the lat/lng aren't all identical. + 37.5665 + (X * 0.0001) AS lat, + 126.9780 + (X * 0.0001) AS lng +FROM SYSTEM_RANGE(1, 300); diff --git a/easy-paging-sb4-keyset-demo/src/main/resources/mapper/LocationMapper.xml b/easy-paging-sb4-keyset-demo/src/main/resources/mapper/LocationMapper.xml new file mode 100644 index 0000000..632406d --- /dev/null +++ b/easy-paging-sb4-keyset-demo/src/main/resources/mapper/LocationMapper.xml @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/easy-paging-sb4-keyset-demo/src/main/resources/schema.sql b/easy-paging-sb4-keyset-demo/src/main/resources/schema.sql new file mode 100644 index 0000000..d9ec481 --- /dev/null +++ b/easy-paging-sb4-keyset-demo/src/main/resources/schema.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS locations ( + id BIGINT PRIMARY KEY, + worker_id UUID NOT NULL, + time TIMESTAMP NOT NULL, + lat DOUBLE PRECISION NOT NULL, + lng DOUBLE PRECISION NOT NULL +); + +-- Covering index for the keyset query: WHERE worker_id = ? ORDER BY time DESC, id DESC. +-- A real PostgreSQL/MySQL deployment wants this for cursor pagination to stay O(log N) +-- per page instead of degrading like OFFSET would. +CREATE INDEX IF NOT EXISTS idx_locations_worker_time_id + ON locations(worker_id, time DESC, id DESC); diff --git a/easy-paging-sb4-keyset-demo/src/test/java/kr/devslab/examples/easypagingkeyset/LocationControllerTest.java b/easy-paging-sb4-keyset-demo/src/test/java/kr/devslab/examples/easypagingkeyset/LocationControllerTest.java new file mode 100644 index 0000000..c0ae134 --- /dev/null +++ b/easy-paging-sb4-keyset-demo/src/test/java/kr/devslab/examples/easypagingkeyset/LocationControllerTest.java @@ -0,0 +1,100 @@ +package kr.devslab.examples.easypagingkeyset; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +/** + * End-to-end smoke test for the keyset demo. Verifies the full cursor walk: + * first page produces a {@code nextCursor}, that cursor advances to a new set + * of rows, the walk continues until {@code hasNext=false}, and the entire + * seeded data set is returned exactly once with no overlap between pages. + * + *

This is the property keyset pagination cares most about — every row + * served exactly once, no gaps, no duplicates — and it's the property that + * tends to break first when the {@code WHERE} clause's tiebreaker logic is + * wrong. + */ +@SpringBootTest +@AutoConfigureMockMvc +class LocationControllerTest { + + private static final String WORKER_ID = "00000000-0000-0000-0000-000000000001"; + private static final int TOTAL_SEEDED_ROWS = 300; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper json; + + @Test + void firstPageReturnsRequestedSizeWithNextCursor() throws Exception { + mockMvc.perform(get("/locations").param("workerId", WORKER_ID).param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(10)) + .andExpect(jsonPath("$.size").value(10)) + .andExpect(jsonPath("$.hasNext").value(true)) + .andExpect(jsonPath("$.nextCursor").isNotEmpty()); + } + + @Test + void walkingNextCursorCoversEveryRowExactlyOnce() throws Exception { + // size=80 means the 300-row data set fits in 4 pages (80, 80, 80, 60). + int pageSize = 80; + Set seenIds = new HashSet<>(); + String cursor = null; + int pagesWalked = 0; + + while (pagesWalked < 10) { // safety bound: way more than the expected 4 pages + MvcResult result = mockMvc.perform( + cursor == null + ? get("/locations").param("workerId", WORKER_ID).param("size", String.valueOf(pageSize)) + : get("/locations").param("workerId", WORKER_ID).param("size", String.valueOf(pageSize)).param("cursor", cursor)) + .andExpect(status().isOk()) + .andReturn(); + + JsonNode body = json.readTree(result.getResponse().getContentAsByteArray()); + JsonNode content = body.get("content"); + for (JsonNode row : content) { + long id = row.get("id").asLong(); + // The fundamental keyset invariant: each row must appear at most once. + if (!seenIds.add(id)) { + throw new AssertionError("Row id=" + id + " appeared on more than one page"); + } + } + pagesWalked++; + + if (!body.get("hasNext").asBoolean()) { + break; + } + cursor = body.get("nextCursor").asText(); + } + + if (seenIds.size() != TOTAL_SEEDED_ROWS) { + throw new AssertionError( + "Expected " + TOTAL_SEEDED_ROWS + " distinct rows over the cursor walk, got " + + seenIds.size() + " in " + pagesWalked + " pages"); + } + } + + @Test + void oversizedSizeIsClampedByAnnotationMaxSize() throws Exception { + // @KeysetPaginate(maxSize = 200) on the controller — ?size=9999 must clamp to 200. + mockMvc.perform(get("/locations").param("workerId", WORKER_ID).param("size", "9999")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.size").value(200)) + .andExpect(jsonPath("$.content.length()").value(200)); + } +} diff --git a/easy-paging-sb4-postgres-demo/README.ko.md b/easy-paging-sb4-postgres-demo/README.ko.md new file mode 100644 index 0000000..92e61dc --- /dev/null +++ b/easy-paging-sb4-postgres-demo/README.ko.md @@ -0,0 +1,106 @@ +# easy-paging-sb4-postgres-demo + +[English](README.md) · **한국어** + +> ✨ **Spring Boot 4 라인.** 이 데모는 `easy-paging-spring-boot-starter`의 활성 `0.5.x` 라인(Spring Boot 4 / Spring Framework 7 / Jackson 3)을 사용. Spring Boot 3.3–3.5 maintenance 버전은 [`easy-paging-postgres-demo`](../easy-paging-postgres-demo/) 참조. + +[`easy-paging-spring-boot-starter`](https://github.com/devslab-kr/easy-paging-spring-boot-starter)의 프로덕션 풍 실행 가능한 예제 — H2 대신 **실제 PostgreSQL** 상대. + +스타터는 PageHelper가 지원하는 어떤 JDBC DB든 동작 (Postgres, MySQL, MariaDB, Oracle, ...) — 이 데모는 그걸 실제 팀이 가장 많이 쓰는 DB로 end-to-end 검증하고, 이 repo가 앞으로 모든 "외부 DB" 데모에 쓸 Docker Compose + Testcontainers 패턴을 보여줌. + +## 전제조건 + +- JDK 21+ +- **Docker** (Docker Desktop 또는 호환 런타임) +- 그 외 없음. 로컬 Postgres 설치 불필요. psql 클라이언트 불필요. + +## 실행 + +```bash +cd easy-paging-postgres-demo + +# Postgres 백그라운드로 시작. compose 파일이 localhost:5432에 바인드 +docker compose up -d db + +# 앱 부팅. spring.sql.init가 매 시작마다 스키마 재생성 + 500개 product 시드 +# → 항상 알려진 상태로 부팅 +./gradlew bootRun +``` + +앱은 `http://localhost:8080`에 뜸. 끝나면: + +```bash +docker compose down # 컨테이너 중지 +docker compose down -v # ... 그리고 볼륨 삭제 (다음에 clean slate) +``` + +## 시험해보기 + +```bash +# 기본 offset 페이지네이션 +curl 'http://localhost:8080/products?page=0&size=10' + +# 정렬 +curl 'http://localhost:8080/products?page=0&size=10&sort=price,desc' +curl 'http://localhost:8080/products?page=0&size=10&sort=createdAt,desc&sort=id,asc' + +# 카테고리 필터 — 데이터는 5개 카테고리에 100개씩 시드됨 +curl 'http://localhost:8080/products?category=books&page=0&size=20' | jq '.totalElements' +# → 100 + +# 페이지 크기 clamping (컨트롤러가 @AutoPaginate(maxSize = 100) 선언) +curl 'http://localhost:8080/products?size=9999' | jq '.size, (.content | length)' +# → 100 +# → 100 + +# 정렬 인젝션 시도는 DB에 닿기 전에 HTTP 400 +curl -i 'http://localhost:8080/products?sort=name;DROP%20TABLE%20products' +``` + +## 테스트 동작 방식 (Docker Compose vs Testcontainers) + +이 데모엔 두 Docker 경로가 의도적으로 분리되어 있음: + +| 경로 | 언제 실행 | 무엇 사용 | +| --- | --- | --- | +| `docker compose up -d db` | **사람**이 장수명 DB 상대 `bootRun` 할 때 | 이 디렉토리의 `docker-compose.yml` — 5432 포트 publish, named volume `pgdata` | +| `ProductControllerIT`의 Testcontainers | `./gradlew test` 실행 시 (로컬/CI) | **단명** `postgres:16-alpine` 컨테이너, 임의 포트, 테스트 클래스마다 시작/종료 | + +통합 테스트는 Spring Boot 3.1+의 [`@ServiceConnection`](https://docs.spring.io/spring-boot/reference/testing/testcontainers.html#testing.testcontainers.service-connections)을 사용 — Testcontainers 인스턴스로 `spring.datasource.url`을 자동 재배선. `application-test.yml` 없음, `@DynamicPropertySource` 없음. 테스트가 `docker-compose.yml`을 읽지 않으므로 compose의 포트와 테스트의 포트가 충돌 안 함. + +즉 CI는 Postgres 사전 설치 없이 깨끗한 Ubuntu 러너에서 `./gradlew build` 실행 가능. 러너엔 이미 Docker가 있고, 나머지는 Testcontainers가 처리. + +## 읽을 만한 파일 + +흥미로운 부분, 순서대로: + +| 파일 | 왜 | +| --- | --- | +| `docker-compose.yml` | healthcheck 포함된 최소 PG 서비스 — `docker compose up -d`가 한 줄짜리 | +| `build.gradle.kts` | `org.postgresql:postgresql` (드라이버) + `spring-boot-testcontainers` / `org.testcontainers:postgresql` 테스트 의존성 추가. 그 외는 H2 데모와 동일 | +| `src/test/.../ProductControllerIT.java` | `@Testcontainers` + `@ServiceConnection` 두 줄로 wiring 완성. 정적 초기화 블록 없음, `@DynamicPropertySource` 없음 | +| `src/main/resources/schema.sql` | `BIGSERIAL`과 복합 인덱스 — PG 네이티브, H2 비호환 — 사용해서 스타터가 실제 PG 스키마 기능과 잘 동작함을 보여줌 | +| `src/main/resources/data.sql` | 결정론적 시딩을 위한 `generate_series(1, 500)` (테스트가 검증하는 필드엔 `random()` 안 씀) | +| `product/ProductController.java` | 계약은 어노테이션 하나 + `Pageable` — H2 데모와 동일. 스타터는 밑이 어떤 DB인지 신경 안 씀 | + +## 마이그레이션 (실제 앱으로 복붙할 거면 읽기) + +이 데모는 매 부팅마다 `DROP TABLE IF EXISTS` + `CREATE TABLE` 스크립트를 실행하는 `spring.sql.init`을 사용. **프로덕션에선 그러지 마세요.** [Flyway](https://flywaydb.org/)나 [Liquibase](https://www.liquibase.org/) 사용 — 둘 다 Spring Boot 스타터 있고 `easy-paging-spring-boot-starter`와 별도 설정 없이 공존. `spring.sql.init`은 "셋업 단계 없이 항상 알려진 상태로 부팅"이 목적인 학습용 데모에서만 OK. + +## 빌드 검증 + +```bash +./gradlew build +``` + +`ProductControllerIT`가 단명 Testcontainers Postgres 상대로 실행됨. 첫 실행은 `postgres:16-alpine` 이미지 (~80MB) 풀; 이후 실행은 캐시된 이미지로 몇 초 안에 완료. + +## `easy-paging-demo`와의 차이 + +| | `easy-paging-demo` | `easy-paging-postgres-demo` (이거) | +|---|---|---| +| DB | H2 인메모리 (외부 런타임 없음) | 실제 PostgreSQL (`bootRun`은 Docker Compose, 테스트는 Testcontainers) | +| 스키마 기능 | 기본 테이블 | `BIGSERIAL`, `NUMERIC(10,2)`, 복합 인덱스, `generate_series` | +| 매퍼 로직 | 항상 `SELECT *` | `` 통해 옵셔널 `WHERE category = ?` | +| 테스트 인프라 | MockMvc만 | Testcontainers + `@ServiceConnection` | +| 적합한 경우 | "30초 안에 스타터 보여주기" | "프로덕션 DB 상대로 동작하고 테스트가 깔끔히 통합되는지 보여주기" | diff --git a/easy-paging-sb4-postgres-demo/README.md b/easy-paging-sb4-postgres-demo/README.md new file mode 100644 index 0000000..9d33ad6 --- /dev/null +++ b/easy-paging-sb4-postgres-demo/README.md @@ -0,0 +1,106 @@ +# easy-paging-sb4-postgres-demo + +**English** · [한국어](README.ko.md) + +> ✨ **Spring Boot 4 line.** This demo runs against the active `0.5.x` line of `easy-paging-spring-boot-starter` (Spring Boot 4 / Spring Framework 7 / Jackson 3). For the Spring Boot 3.3–3.5 maintenance equivalent, see [`easy-paging-postgres-demo`](../easy-paging-postgres-demo/). + +Production-flavoured runnable example for [`easy-paging-spring-boot-starter`](https://github.com/devslab-kr/easy-paging-spring-boot-starter) against a **real PostgreSQL** instead of H2. + +The starter works on whatever JDBC database PageHelper supports (Postgres, MySQL, MariaDB, Oracle, …) — this demo proves it end-to-end on the database most teams actually ship with, and shows the Docker Compose + Testcontainers pattern this repo will use for every "external DB" demo from here on. + +## Prerequisites + +- JDK 21+ +- **Docker** (Docker Desktop or any Docker-compatible runtime) +- That's it. No local Postgres install. No psql client needed. + +## Run + +```bash +cd easy-paging-postgres-demo + +# Start PostgreSQL in the background. The compose file binds 5432 on localhost. +docker compose up -d db + +# Boot the app. spring.sql.init recreates the schema and seeds 500 products +# on every start, so you always boot into a known state. +./gradlew bootRun +``` + +The app comes up on `http://localhost:8080`. When you're done: + +```bash +docker compose down # stop the container +docker compose down -v # ...and drop the volume (clean slate next time) +``` + +## Try it + +```bash +# Default offset pagination +curl 'http://localhost:8080/products?page=0&size=10' + +# Sort +curl 'http://localhost:8080/products?page=0&size=10&sort=price,desc' +curl 'http://localhost:8080/products?page=0&size=10&sort=createdAt,desc&sort=id,asc' + +# Filter by category — data is seeded into 5 categories, 100 each +curl 'http://localhost:8080/products?category=books&page=0&size=20' | jq '.totalElements' +# → 100 + +# Page-size clamping (controller declares @AutoPaginate(maxSize = 100)) +curl 'http://localhost:8080/products?size=9999' | jq '.size, (.content | length)' +# → 100 +# → 100 + +# Sort-injection attempts get HTTP 400 before they ever reach the database +curl -i 'http://localhost:8080/products?sort=name;DROP%20TABLE%20products' +``` + +## How testing works (Docker Compose vs Testcontainers) + +There are two Docker paths in this demo, deliberately separate: + +| Path | When it runs | What it uses | +| --- | --- | --- | +| `docker compose up -d db` | When a **human** wants to `bootRun` against a long-lived DB | `docker-compose.yml` in this directory — published port 5432, named volume `pgdata` | +| Testcontainers in `ProductControllerIT` | When `./gradlew test` runs (locally or in CI) | An **ephemeral** `postgres:16-alpine` container on a random port, started + torn down per test class | + +The integration test uses Spring Boot 3.1+'s [`@ServiceConnection`](https://docs.spring.io/spring-boot/reference/testing/testcontainers.html#testing.testcontainers.service-connections), which auto-rewires `spring.datasource.url` to the Testcontainers instance — no `application-test.yml`, no `@DynamicPropertySource`. The compose file's port and the test's port don't conflict because the test never reads `docker-compose.yml`. + +This means CI can run `./gradlew build` on a clean Ubuntu runner with no Postgres pre-installed and the integration tests still hit a real Postgres. The runner already has Docker; Testcontainers handles the rest. + +## What to read + +The interesting parts, in order: + +| File | Why | +| --- | --- | +| `docker-compose.yml` | minimal PG service with a healthcheck, so `docker compose up -d` is a one-liner | +| `build.gradle.kts` | adds `org.postgresql:postgresql` (driver) and the `spring-boot-testcontainers` / `org.testcontainers:postgresql` test deps; everything else is identical to the H2 demo | +| `src/test/.../ProductControllerIT.java` | `@Testcontainers` + `@ServiceConnection` is all the wiring needed; no static initializer block, no `@DynamicPropertySource` | +| `src/main/resources/schema.sql` | uses `BIGSERIAL` and a composite index — PG-native, not H2-portable — to show the starter is happy with real PG schema features | +| `src/main/resources/data.sql` | `generate_series(1, 500)` for deterministic seeding (no `random()` for fields tests assert against) | +| `product/ProductController.java` | the contract is one annotation + `Pageable` — same as the H2 demo. The starter doesn't care which database is underneath | + +## Migrations (read this if you're copy-pasting into a real app) + +This demo uses `spring.sql.init` with a `DROP TABLE IF EXISTS` + `CREATE TABLE` script that runs on every boot. **Don't do that in production.** Use [Flyway](https://flywaydb.org/) or [Liquibase](https://www.liquibase.org/) — both have Spring Boot starters and both coexist with `easy-paging-spring-boot-starter` without any special configuration. `spring.sql.init` is fine for a learning demo where the goal is "always boot into a known state with no setup steps". + +## Verify the build + +```bash +./gradlew build +``` + +This runs `ProductControllerIT` against an ephemeral Testcontainers Postgres. First run pulls the `postgres:16-alpine` image (~80MB); subsequent runs use the cached image and complete in seconds. + +## How this differs from `easy-paging-demo` + +| | `easy-paging-demo` | `easy-paging-postgres-demo` (this) | +|---|---|---| +| Database | H2 in-memory (no external runtime) | Real PostgreSQL (Docker Compose for `bootRun`, Testcontainers for tests) | +| Schema features used | basic table | `BIGSERIAL`, `NUMERIC(10,2)`, composite index, `generate_series` | +| Mapper logic | always `SELECT *` | optional `WHERE category = ?` via `` | +| Test infra | none beyond MockMvc | Testcontainers + `@ServiceConnection` | +| Best for | "show me the starter in 30 seconds" | "show me it works against a production DB and that tests can integrate cleanly" | diff --git a/easy-paging-sb4-postgres-demo/build.gradle.kts b/easy-paging-sb4-postgres-demo/build.gradle.kts new file mode 100644 index 0000000..840781e --- /dev/null +++ b/easy-paging-sb4-postgres-demo/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + java + id("org.springframework.boot") version "4.0.6" + id("io.spring.dependency-management") version "1.1.7" +} + +group = "kr.devslab.examples" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-web") + + // The library this demo showcases. Bumped by Dependabot on new releases. + implementation("kr.devslab:easy-paging-spring-boot-starter:0.5.0") + + // Real PostgreSQL JDBC driver — the whole point of this demo over the H2 one + // is "does the starter actually work against a production DB?". (Spoiler: yes, + // because PageHelper handles the dialect; the demo proves it.) + runtimeOnly("org.postgresql:postgresql") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + // Testcontainers — tests spin up a real PostgreSQL container instead of + // relying on a developer/CI-side install. @ServiceConnection (added in + // Spring Boot 3.1) auto-rewires the datasource to the container, so no + // application-test.yml shenanigans needed. + testImplementation(platform("org.testcontainers:testcontainers-bom:2.0.5")) + testImplementation("org.springframework.boot:spring-boot-testcontainers") + testImplementation("org.testcontainers:testcontainers-postgresql") + testImplementation("org.testcontainers:testcontainers-junit-jupiter") +} + +tasks.named("test") { + useJUnitPlatform() +} diff --git a/easy-paging-sb4-postgres-demo/docker-compose.yml b/easy-paging-sb4-postgres-demo/docker-compose.yml new file mode 100644 index 0000000..1910bb9 --- /dev/null +++ b/easy-paging-sb4-postgres-demo/docker-compose.yml @@ -0,0 +1,32 @@ +# Single-service compose file for the local "run it yourself" path. +# +# The CI / `./gradlew test` path does NOT use this file — Testcontainers spins +# up its own ephemeral PostgreSQL on a random port. This compose file exists +# so a human can do: +# +# docker compose up -d db +# ./gradlew bootRun +# +# ...without installing Postgres on their laptop. The credentials and port +# below match what application.yml reads. + +services: + db: + image: postgres:16-alpine + container_name: easy-paging-postgres-demo-db + environment: + POSTGRES_DB: easypaging + POSTGRES_USER: easypaging + POSTGRES_PASSWORD: easypaging + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U easypaging -d easypaging"] + interval: 5s + timeout: 3s + retries: 10 + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/easy-paging-sb4-postgres-demo/gradle.properties b/easy-paging-sb4-postgres-demo/gradle.properties new file mode 100644 index 0000000..a31b0e0 --- /dev/null +++ b/easy-paging-sb4-postgres-demo/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true diff --git a/easy-paging-sb4-postgres-demo/gradle/wrapper/gradle-wrapper.jar b/easy-paging-sb4-postgres-demo/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/easy-paging-sb4-postgres-demo/gradle/wrapper/gradle-wrapper.jar differ diff --git a/easy-paging-sb4-postgres-demo/gradle/wrapper/gradle-wrapper.properties b/easy-paging-sb4-postgres-demo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/easy-paging-sb4-postgres-demo/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/easy-paging-sb4-postgres-demo/gradlew b/easy-paging-sb4-postgres-demo/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/easy-paging-sb4-postgres-demo/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/easy-paging-sb4-postgres-demo/gradlew.bat b/easy-paging-sb4-postgres-demo/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/easy-paging-sb4-postgres-demo/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/easy-paging-sb4-postgres-demo/settings.gradle.kts b/easy-paging-sb4-postgres-demo/settings.gradle.kts new file mode 100644 index 0000000..6eb24f6 --- /dev/null +++ b/easy-paging-sb4-postgres-demo/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "easy-paging-sb4-postgres-demo" diff --git a/easy-paging-sb4-postgres-demo/src/main/java/kr/devslab/examples/easypagingpostgres/EasyPagingPostgresDemoApplication.java b/easy-paging-sb4-postgres-demo/src/main/java/kr/devslab/examples/easypagingpostgres/EasyPagingPostgresDemoApplication.java new file mode 100644 index 0000000..73fe63a --- /dev/null +++ b/easy-paging-sb4-postgres-demo/src/main/java/kr/devslab/examples/easypagingpostgres/EasyPagingPostgresDemoApplication.java @@ -0,0 +1,14 @@ +package kr.devslab.examples.easypagingpostgres; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@MapperScan("kr.devslab.examples.easypagingpostgres") +public class EasyPagingPostgresDemoApplication { + + public static void main(String[] args) { + SpringApplication.run(EasyPagingPostgresDemoApplication.class, args); + } +} diff --git a/easy-paging-sb4-postgres-demo/src/main/java/kr/devslab/examples/easypagingpostgres/product/Product.java b/easy-paging-sb4-postgres-demo/src/main/java/kr/devslab/examples/easypagingpostgres/product/Product.java new file mode 100644 index 0000000..badbaf4 --- /dev/null +++ b/easy-paging-sb4-postgres-demo/src/main/java/kr/devslab/examples/easypagingpostgres/product/Product.java @@ -0,0 +1,60 @@ +package kr.devslab.examples.easypagingpostgres.product; + +import java.math.BigDecimal; +import java.time.Instant; + +/** + * Catalog product — the classic "give me paginated listings, optionally + * filtered by category" use case that paginated REST APIs are usually written + * for. The schema and seed data live in + * {@code resources/{schema,data}.sql}; both are PostgreSQL-flavoured + * (BIGSERIAL, NUMERIC, generate_series). + */ +public class Product { + + private Long id; + private String name; + private BigDecimal price; + private String category; + private Instant createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public BigDecimal getPrice() { + return price; + } + + public void setPrice(BigDecimal price) { + this.price = price; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/easy-paging-sb4-postgres-demo/src/main/java/kr/devslab/examples/easypagingpostgres/product/ProductController.java b/easy-paging-sb4-postgres-demo/src/main/java/kr/devslab/examples/easypagingpostgres/product/ProductController.java new file mode 100644 index 0000000..1a14dee --- /dev/null +++ b/easy-paging-sb4-postgres-demo/src/main/java/kr/devslab/examples/easypagingpostgres/product/ProductController.java @@ -0,0 +1,42 @@ +package kr.devslab.examples.easypagingpostgres.product; + +import kr.devslab.easypaging.annotation.AutoPaginate; +import kr.devslab.easypaging.core.PageResponse; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Standard paginated catalog listing with an optional category filter — the + * shape most product/search APIs end up in. The {@code @AutoPaginate} aspect + * handles size clamping, sort validation, and the Spring Data-shaped JSON + * envelope; the controller method body stays one line. + * + *

Sample requests (once the DB is up and the app is running): + *

+ *   curl 'http://localhost:8080/products?page=0&size=10'
+ *   curl 'http://localhost:8080/products?page=0&size=10&sort=price,desc'
+ *   curl 'http://localhost:8080/products?category=books&page=0&size=20'
+ *   curl 'http://localhost:8080/products?sort=name;DROP%20TABLE%20products'  # rejected
+ * 
+ */ +@RestController +@RequestMapping("/products") +public class ProductController { + + private final ProductService products; + + public ProductController(ProductService products) { + this.products = products; + } + + @GetMapping + @AutoPaginate(maxSize = 100) + public PageResponse list( + Pageable pageable, + @RequestParam(required = false) String category) { + return PageResponse.from(products.findAll(category), pageable); + } +} diff --git a/easy-paging-sb4-postgres-demo/src/main/java/kr/devslab/examples/easypagingpostgres/product/ProductMapper.java b/easy-paging-sb4-postgres-demo/src/main/java/kr/devslab/examples/easypagingpostgres/product/ProductMapper.java new file mode 100644 index 0000000..384fb03 --- /dev/null +++ b/easy-paging-sb4-postgres-demo/src/main/java/kr/devslab/examples/easypagingpostgres/product/ProductMapper.java @@ -0,0 +1,20 @@ +package kr.devslab.examples.easypagingpostgres.product; + +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * Note this mapper takes a {@code category} parameter but does no pagination + * itself — same pattern as the H2 offset demo. The starter's aspect adds + * {@code LIMIT/OFFSET} and {@code ORDER BY} at runtime; PageHelper translates + * those into PostgreSQL-flavoured SQL underneath. + */ +@Mapper +public interface ProductMapper { + + /** + * @param category optional category filter; pass {@code null} to return everything + */ + List findAll(@Param("category") String category); +} diff --git a/easy-paging-sb4-postgres-demo/src/main/java/kr/devslab/examples/easypagingpostgres/product/ProductService.java b/easy-paging-sb4-postgres-demo/src/main/java/kr/devslab/examples/easypagingpostgres/product/ProductService.java new file mode 100644 index 0000000..0423e12 --- /dev/null +++ b/easy-paging-sb4-postgres-demo/src/main/java/kr/devslab/examples/easypagingpostgres/product/ProductService.java @@ -0,0 +1,18 @@ +package kr.devslab.examples.easypagingpostgres.product; + +import java.util.List; +import org.springframework.stereotype.Service; + +@Service +public class ProductService { + + private final ProductMapper mapper; + + public ProductService(ProductMapper mapper) { + this.mapper = mapper; + } + + public List findAll(String category) { + return mapper.findAll(category); + } +} diff --git a/easy-paging-sb4-postgres-demo/src/main/resources/application.yml b/easy-paging-sb4-postgres-demo/src/main/resources/application.yml new file mode 100644 index 0000000..1e33fba --- /dev/null +++ b/easy-paging-sb4-postgres-demo/src/main/resources/application.yml @@ -0,0 +1,43 @@ +# Self-contained PostgreSQL demo config. +# +# Local "run it yourself" flow: +# docker compose up -d db +# ./gradlew bootRun +# +# The defaults below line up with docker-compose.yml in this directory. The +# integration tests don't read this file's datasource — Testcontainers + +# @ServiceConnection rewires the datasource to the ephemeral container at +# test time. + +spring: + application: + name: easy-paging-postgres-demo + + datasource: + url: jdbc:postgresql://localhost:5432/easypaging + username: easypaging + password: easypaging + driver-class-name: org.postgresql.Driver + + sql: + init: + # Re-applied on every startup. schema.sql does DROP+CREATE so we always + # boot into a known state. For a real app you'd want Flyway/Liquibase + # instead; the README has a note pointing at that. + mode: always + continue-on-error: false + +mybatis: + mapper-locations: classpath:mapper/**/*.xml + configuration: + map-underscore-to-camel-case: true + +easy-paging: + enabled: true + default-page-size: 20 + max-page-size: 200 + +logging: + level: + root: WARN + kr.devslab.examples: INFO diff --git a/easy-paging-sb4-postgres-demo/src/main/resources/data.sql b/easy-paging-sb4-postgres-demo/src/main/resources/data.sql new file mode 100644 index 0000000..d409921 --- /dev/null +++ b/easy-paging-sb4-postgres-demo/src/main/resources/data.sql @@ -0,0 +1,14 @@ +-- Seed 500 products distributed evenly across 5 categories (100 each), with +-- timestamps stepping back 1 hour per row so sort=createdAt,desc produces a +-- realistic stream. Prices are deterministic (id-based) so tests can assert +-- against them without flakiness — random() would be more realistic but would +-- make snapshot-style assertions impossible. + +INSERT INTO products (name, price, category, created_at) +SELECT + 'Product #' || i, + -- 10.00 .. 999.99, varies smoothly with id so the listing isn't monotone + ROUND((10 + ((i * 7) % 990) + 0.99)::numeric, 2), + (ARRAY['electronics', 'books', 'clothing', 'home', 'sports'])[1 + ((i - 1) % 5)], + NOW() - (i || ' hours')::interval +FROM generate_series(1, 500) AS i; diff --git a/easy-paging-sb4-postgres-demo/src/main/resources/mapper/ProductMapper.xml b/easy-paging-sb4-postgres-demo/src/main/resources/mapper/ProductMapper.xml new file mode 100644 index 0000000..6d017e5 --- /dev/null +++ b/easy-paging-sb4-postgres-demo/src/main/resources/mapper/ProductMapper.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/easy-paging-sb4-postgres-demo/src/main/resources/schema.sql b/easy-paging-sb4-postgres-demo/src/main/resources/schema.sql new file mode 100644 index 0000000..9e97eac --- /dev/null +++ b/easy-paging-sb4-postgres-demo/src/main/resources/schema.sql @@ -0,0 +1,18 @@ +-- DROP-then-CREATE so spring.sql.init re-applying on every startup leaves us +-- in a known state without needing migration tooling for the demo. A real app +-- would use Flyway or Liquibase here — see the README. +DROP TABLE IF EXISTS products; + +CREATE TABLE products ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + price NUMERIC(10, 2) NOT NULL, + category VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- A composite index that matches the most common filter+sort combo. Not +-- required for correctness, but lets curious readers EXPLAIN ANALYZE the +-- paginated queries and see the planner pick this index. +CREATE INDEX idx_products_category_created_at + ON products(category, created_at DESC); diff --git a/easy-paging-sb4-postgres-demo/src/test/java/kr/devslab/examples/easypagingpostgres/ProductControllerIT.java b/easy-paging-sb4-postgres-demo/src/test/java/kr/devslab/examples/easypagingpostgres/ProductControllerIT.java new file mode 100644 index 0000000..0aa8208 --- /dev/null +++ b/easy-paging-sb4-postgres-demo/src/test/java/kr/devslab/examples/easypagingpostgres/ProductControllerIT.java @@ -0,0 +1,93 @@ +package kr.devslab.examples.easypagingpostgres; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Integration test against a real PostgreSQL container — not H2. The whole + * point of this demo is to prove the starter works end-to-end on the database + * a production deployment would actually use, so the test does too. + * + *

{@link ServiceConnection} (Spring Boot 3.1+) auto-rewires the + * application's datasource to the container's random port, so no + * {@code application-test.yml} or {@code DynamicPropertySource} boilerplate is + * needed — the {@code spring.datasource.url} in {@code application.yml} is + * silently overridden at test bootstrap. + * + *

CI runs this exactly the same way as a developer laptop would: the + * GitHub-hosted Ubuntu runner already has Docker, and Testcontainers pulls + * {@code postgres:16-alpine} on demand. No \"is Postgres installed?\" + * branching anywhere. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Testcontainers +class ProductControllerIT { + + @Container + @ServiceConnection + static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:16-alpine"); + + @Autowired + private MockMvc mockMvc; + + @Test + void firstPageReturnsExpectedPaginationMetadata() throws Exception { + // 500 seeded rows / size=10 = 50 pages + mockMvc.perform(get("/products").param("page", "0").param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(10)) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(10)) + .andExpect(jsonPath("$.totalElements").value(500)) + .andExpect(jsonPath("$.totalPages").value(50)) + .andExpect(jsonPath("$.first").value(true)) + .andExpect(jsonPath("$.last").value(false)); + } + + @Test + void categoryFilterReducesTotalElements() throws Exception { + // data.sql distributes 500 rows evenly across 5 categories → 100 each. + mockMvc.perform(get("/products") + .param("category", "books") + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(100)) + .andExpect(jsonPath("$.totalPages").value(5)) + .andExpect(jsonPath("$.content.length()").value(20)); + } + + @Test + void sortIsValidatedAgainstSqlInjection() throws Exception { + // The aspect rejects sort properties that don't match + // [A-Za-z_][A-Za-z0-9_.]* before they ever reach the DB. + mockMvc.perform(get("/products").param("sort", "name;DROP TABLE products")) + .andExpect(status().isBadRequest()); + + // And the table is, of course, still there. + mockMvc.perform(get("/products").param("page", "0").param("size", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(500)); + } + + @Test + void oversizedPageSizeIsClampedByMaxSize() throws Exception { + // @AutoPaginate(maxSize = 100) on the controller + mockMvc.perform(get("/products").param("page", "0").param("size", "9999")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.size").value(100)) + .andExpect(jsonPath("$.content.length()").value(100)); + } +} diff --git a/easy-paging-sb4-reactive-demo/README.ko.md b/easy-paging-sb4-reactive-demo/README.ko.md new file mode 100644 index 0000000..5ebb26c --- /dev/null +++ b/easy-paging-sb4-reactive-demo/README.ko.md @@ -0,0 +1,88 @@ +# easy-paging-sb4-reactive-demo + +[English](README.md) · **한국어** + +> ✨ **Spring Boot 4 라인.** 이 데모는 `easy-paging-spring-boot-starter`의 활성 `0.5.x` 라인(Spring Boot 4 / Spring Framework 7 / Jackson 3)을 사용. Spring Boot 3.3–3.5 maintenance 버전은 [`easy-paging-reactive-demo`](../easy-paging-reactive-demo/) 참조. + +[`easy-paging-spring-boot-starter`](https://github.com/devslab-kr/easy-paging-spring-boot-starter)의 Reactive (WebFlux + R2DBC) 실행 가능한 예제. 자매 아티팩트 [`easy-paging-spring-boot-starter-reactive`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter-reactive) 사용. + +MVC + MyBatis 데모들 (`easy-paging-demo`, `easy-paging-postgres-demo`)은 AOP aspect로 페이지네이션을 wiring. 그런데 reactive 스택엔 hook할 thread-per-request 컨텍스트가 없어서 reactive 스타터는 다른 형태를 취함: 서비스가 명시적으로 호출하는 헬퍼 (`R2dbcOffsetPagingSupport`). 하지만 **wire 계약은 동일** — 동일한 `PageResponse` JSON 봉투, 동일한 `?page=`/`?size=`/`?sort=` 시맨틱 — 그래서 클라이언트는 엔드포인트 뒤에 어떤 스택이 있는지 알 수 없음. + +## 전제조건 + +- JDK 21+ +- **Docker** (`bootRun`과 `./gradlew test` 둘 다) +- 로컬 Postgres 설치 불필요. + +## 실행 + +```bash +cd easy-paging-reactive-demo + +# PostgreSQL을 호스트 5433 포트에 — easy-paging-postgres-demo(5432)와 +# 동시에 실행 가능하도록 일부러 다른 포트. +docker compose up -d db + +# 앱 부팅. spring.sql.init가 매 시작마다 스키마 재생성 + 500개 article 시드, +# postgres 데모와 동일. +./gradlew bootRun +``` + +앱은 `http://localhost:8080`에 뜸. `docker compose down` (또는 `docker compose down -v`로 볼륨 삭제)으로 정리. + +## 시험해보기 + +```bash +# 기본 offset 페이지네이션 — totalElements=500, totalPages=50 +curl 'http://localhost:8080/articles?page=0&size=10' + +# 정렬 +curl 'http://localhost:8080/articles?page=0&size=10&sort=publishedAt,desc' +curl 'http://localhost:8080/articles?page=0&size=5&sort=viewCount,desc&sort=id,asc' + +# author 필터 — 5명 author에 각 100개씩 +curl 'http://localhost:8080/articles?author=alice&page=0&size=20' | jq '.totalElements' +# → 100 + +# 정렬 인젝션 시도는 여전히 HTTP 400 (검증 로직은 core 스타터에 있고 reactive 쪽이 재사용) +curl -i 'http://localhost:8080/articles?sort=title;DROP%20TABLE%20articles' +``` + +## postgres 데모와의 차이 + +wire 동작은 동일. 코드 차이는 모두 reactive 스택과 관련된 것: + +| 레이어 | Postgres 데모 | Reactive 데모 (이거) | +| --- | --- | --- | +| Web | `spring-boot-starter-web` (서블릿) | `spring-boot-starter-webflux` | +| DB 접근 | MyBatis + JDBC | Spring Data R2DBC (`R2dbcEntityTemplate`) | +| 페이지네이션 wiring | 컨트롤러의 `@AutoPaginate` aspect | 서비스에서 `R2dbcOffsetPagingSupport.paginate(...)` 명시적 호출 | +| 컨트롤러 반환 타입 | `PageResponse` | `Mono>` | +| 엔티티 매핑 | 수동 `Product` POJO + MyBatis XML resultType | Spring Data Relational `@Table` / `@Column` / `@Id` | +| 매퍼 SQL | XML의 손으로 쓴 `` in XML | `Query.query(criteria).with(pageable)` generated by the helper | +| Test infra | MockMvc + Testcontainers JDBC | WebTestClient + Testcontainers R2DBC | +| Companion artifact | none (`easy-paging-spring-boot-starter` only) | also `easy-paging-spring-boot-starter-reactive` | + +## What to read + +| File | Why | +| --- | --- | +| `build.gradle.kts` | adds **both** `easy-paging-spring-boot-starter-reactive` and `spring-boot-starter-{webflux,data-r2dbc}`. R2DBC PG driver is `org.postgresql:r2dbc-postgresql`, not the JDBC one | +| `article/Article.java` | Spring Data Relational entity; `@Column` only on fields where camelCase differs from snake_case | +| `article/ArticleService.java` | the headline line is `R2dbcOffsetPagingSupport.paginate(template, Article.class, criteria, pageable)` — that's the whole reactive paging primitive | +| `article/ArticleController.java` | `Mono>` return type. **No `@AutoPaginate`** — see the comment in the file for why | +| `src/test/.../ArticleControllerIT.java` | `@ServiceConnection` on a `PostgreSQLContainer` auto-wires the R2DBC `ConnectionFactory` (works because both `spring-boot-starter-data-r2dbc` and `testcontainers:r2dbc` are on the classpath) | + +## Migrations in real apps + +`spring.sql.init` for R2DBC works the same as for JDBC and is fine for a learning demo, but in a real app: +- **Flyway** has [`flyway-database-postgresql`](https://documentation.red-gate.com/fd/postgresql-184127604.html) and supports R2DBC since v10. +- **Liquibase** works through JDBC even in R2DBC apps — typically you wire a separate `DataSource` purely for migrations, then let your app use R2DBC at runtime. + +## Verify the build + +```bash +./gradlew build +``` + +Runs `ArticleControllerIT` against an ephemeral PostgreSQL container via Testcontainers + `@ServiceConnection`. First run pulls `postgres:16-alpine` (~80MB); subsequent runs hit the cache. diff --git a/easy-paging-sb4-reactive-demo/build.gradle.kts b/easy-paging-sb4-reactive-demo/build.gradle.kts new file mode 100644 index 0000000..5f76247 --- /dev/null +++ b/easy-paging-sb4-reactive-demo/build.gradle.kts @@ -0,0 +1,62 @@ +plugins { + java + id("org.springframework.boot") version "4.0.6" + id("io.spring.dependency-management") version "1.1.7" +} + +group = "kr.devslab.examples" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + // WebFlux instead of MVC — the whole point of this demo over the postgres one. + implementation("org.springframework.boot:spring-boot-starter-webflux") + + // R2DBC instead of JDBC. Spring Boot wires up R2dbcEntityTemplate and the + // R2DBC ConnectionFactory automatically when this is on the classpath. + implementation("org.springframework.boot:spring-boot-starter-data-r2dbc") + + // The reactive companion artifact — provides R2dbcOffsetPagingSupport, + // R2dbcKeysetSupport, and ReactiveKeysetRequestArgumentResolver. Pulls in + // the core starter (kr.devslab:easy-paging-spring-boot-starter) transitively + // via `api(project(":core"))` so PageResponse/Pageable/etc. are available + // without an explicit dependency on it. + implementation("kr.devslab:easy-paging-spring-boot-starter-reactive:0.5.0") + + // Real PostgreSQL R2DBC driver. + runtimeOnly("org.postgresql:r2dbc-postgresql") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-starter-webflux-test") + testImplementation("io.projectreactor:reactor-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + // Testcontainers. spring-boot-testcontainers provides @ServiceConnection, + // which auto-rewires r2dbc:postgresql:... to the container at test time. + // testcontainers:r2dbc is the bridge that exposes R2dbcConnectionDetails. + testImplementation(platform("org.testcontainers:testcontainers-bom:2.0.5")) + testImplementation("org.springframework.boot:spring-boot-testcontainers") + testImplementation("org.testcontainers:testcontainers-postgresql") + testImplementation("org.testcontainers:testcontainers-r2dbc") + testImplementation("org.testcontainers:testcontainers-junit-jupiter") +} + +tasks.named("test") { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + showExceptions = true + showCauses = true + showStackTraces = true + } +} diff --git a/easy-paging-sb4-reactive-demo/docker-compose.yml b/easy-paging-sb4-reactive-demo/docker-compose.yml new file mode 100644 index 0000000..69a58ee --- /dev/null +++ b/easy-paging-sb4-reactive-demo/docker-compose.yml @@ -0,0 +1,27 @@ +# Same shape as easy-paging-postgres-demo/docker-compose.yml — local "run it +# yourself" path; the test suite uses Testcontainers, not this compose file. +# +# docker compose up -d db +# ./gradlew bootRun + +services: + db: + image: postgres:16-alpine + container_name: easy-paging-reactive-demo-db + environment: + POSTGRES_DB: easypaging + POSTGRES_USER: easypaging + POSTGRES_PASSWORD: easypaging + ports: + # Different host port than the postgres demo so both demos can run side by side. + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U easypaging -d easypaging"] + interval: 5s + timeout: 3s + retries: 10 + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/easy-paging-sb4-reactive-demo/gradle.properties b/easy-paging-sb4-reactive-demo/gradle.properties new file mode 100644 index 0000000..a31b0e0 --- /dev/null +++ b/easy-paging-sb4-reactive-demo/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true diff --git a/easy-paging-sb4-reactive-demo/gradle/wrapper/gradle-wrapper.jar b/easy-paging-sb4-reactive-demo/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/easy-paging-sb4-reactive-demo/gradle/wrapper/gradle-wrapper.jar differ diff --git a/easy-paging-sb4-reactive-demo/gradle/wrapper/gradle-wrapper.properties b/easy-paging-sb4-reactive-demo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/easy-paging-sb4-reactive-demo/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/easy-paging-sb4-reactive-demo/gradlew b/easy-paging-sb4-reactive-demo/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/easy-paging-sb4-reactive-demo/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/easy-paging-sb4-reactive-demo/gradlew.bat b/easy-paging-sb4-reactive-demo/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/easy-paging-sb4-reactive-demo/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/easy-paging-sb4-reactive-demo/settings.gradle.kts b/easy-paging-sb4-reactive-demo/settings.gradle.kts new file mode 100644 index 0000000..10cdd4b --- /dev/null +++ b/easy-paging-sb4-reactive-demo/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "easy-paging-sb4-reactive-demo" diff --git a/easy-paging-sb4-reactive-demo/src/main/java/kr/devslab/examples/easypagingreactive/EasyPagingReactiveDemoApplication.java b/easy-paging-sb4-reactive-demo/src/main/java/kr/devslab/examples/easypagingreactive/EasyPagingReactiveDemoApplication.java new file mode 100644 index 0000000..35489eb --- /dev/null +++ b/easy-paging-sb4-reactive-demo/src/main/java/kr/devslab/examples/easypagingreactive/EasyPagingReactiveDemoApplication.java @@ -0,0 +1,12 @@ +package kr.devslab.examples.easypagingreactive; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class EasyPagingReactiveDemoApplication { + + public static void main(String[] args) { + SpringApplication.run(EasyPagingReactiveDemoApplication.class, args); + } +} diff --git a/easy-paging-sb4-reactive-demo/src/main/java/kr/devslab/examples/easypagingreactive/article/Article.java b/easy-paging-sb4-reactive-demo/src/main/java/kr/devslab/examples/easypagingreactive/article/Article.java new file mode 100644 index 0000000..c58e8ac --- /dev/null +++ b/easy-paging-sb4-reactive-demo/src/main/java/kr/devslab/examples/easypagingreactive/article/Article.java @@ -0,0 +1,68 @@ +package kr.devslab.examples.easypagingreactive.article; + +import java.time.Instant; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +/** + * Spring Data Relational entity (used by R2DBC under the hood). The + * {@code @Column("published_at")} mapping is needed because Spring Data + * Relational's default {@code NamingStrategy} doesn't convert camelCase → + * snake_case automatically — fields that already match the column name + * (id, title, author) can stay un-annotated. + */ +@Table("articles") +public class Article { + + @Id + private Long id; + private String title; + private String author; + + @Column("published_at") + private Instant publishedAt; + + @Column("view_count") + private Long viewCount; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAuthor() { + return author; + } + + public void setAuthor(String author) { + this.author = author; + } + + public Instant getPublishedAt() { + return publishedAt; + } + + public void setPublishedAt(Instant publishedAt) { + this.publishedAt = publishedAt; + } + + public Long getViewCount() { + return viewCount; + } + + public void setViewCount(Long viewCount) { + this.viewCount = viewCount; + } +} diff --git a/easy-paging-sb4-reactive-demo/src/main/java/kr/devslab/examples/easypagingreactive/article/ArticleController.java b/easy-paging-sb4-reactive-demo/src/main/java/kr/devslab/examples/easypagingreactive/article/ArticleController.java new file mode 100644 index 0000000..c9bd622 --- /dev/null +++ b/easy-paging-sb4-reactive-demo/src/main/java/kr/devslab/examples/easypagingreactive/article/ArticleController.java @@ -0,0 +1,47 @@ +package kr.devslab.examples.easypagingreactive.article; + +import kr.devslab.easypaging.core.PageResponse; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +/** + * Reactive (WebFlux) controller. The wire-level contract — JSON envelope shape, + * page-size handling, sort syntax — is identical to the {@code easy-paging-demo} + * and {@code easy-paging-postgres-demo} controllers. The only difference here + * is the return type ({@code Mono>}) and the fact that no + * thread is blocked while the DB round-trips run. + * + *

Notice: no {@code @AutoPaginate} annotation. On the reactive path, + * pagination is wired in the service via + * {@link kr.devslab.easypaging.r2dbc.R2dbcOffsetPagingSupport} rather than by + * an AOP aspect. The aspect machinery is MyBatis-specific; R2DBC has its own + * Query/Criteria API and the helper integrates with that directly. + * + *

Sample requests (once the DB is up and the app is running): + *

+ *   curl 'http://localhost:8080/articles?page=0&size=10'
+ *   curl 'http://localhost:8080/articles?page=0&size=10&sort=publishedAt,desc'
+ *   curl 'http://localhost:8080/articles?author=alice&page=0&size=20'
+ * 
+ */ +@RestController +@RequestMapping("/articles") +public class ArticleController { + + private final ArticleService articles; + + public ArticleController(ArticleService articles) { + this.articles = articles; + } + + @GetMapping + public Mono> list( + Pageable pageable, + @RequestParam(required = false) String author) { + return articles.list(author, pageable); + } +} diff --git a/easy-paging-sb4-reactive-demo/src/main/java/kr/devslab/examples/easypagingreactive/article/ArticleService.java b/easy-paging-sb4-reactive-demo/src/main/java/kr/devslab/examples/easypagingreactive/article/ArticleService.java new file mode 100644 index 0000000..559941c --- /dev/null +++ b/easy-paging-sb4-reactive-demo/src/main/java/kr/devslab/examples/easypagingreactive/article/ArticleService.java @@ -0,0 +1,38 @@ +package kr.devslab.examples.easypagingreactive.article; + +import kr.devslab.easypaging.core.PageResponse; +import kr.devslab.easypaging.r2dbc.R2dbcOffsetPagingSupport; +import org.springframework.data.domain.Pageable; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.data.relational.core.query.Criteria; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +/** + * The reactive equivalent of the postgres demo's {@code ProductService}. + * Instead of MyBatis + PageHelper, this uses Spring Data R2DBC's + * {@code R2dbcEntityTemplate} and the reactive starter's + * {@link R2dbcOffsetPagingSupport} helper to produce the same + * {@link PageResponse} envelope inside a {@link Mono}. + * + *

{@code R2dbcOffsetPagingSupport.paginate(...)} runs the rows query and + * the count query in parallel via {@code Mono.zip}, so the paginated endpoint + * pays one round-trip of latency for both. + */ +@Service +public class ArticleService { + + private final R2dbcEntityTemplate template; + + public ArticleService(R2dbcEntityTemplate template) { + this.template = template; + } + + public Mono> list(String author, Pageable pageable) { + Criteria criteria = (author == null) + ? Criteria.empty() + : Criteria.where("author").is(author); + + return R2dbcOffsetPagingSupport.paginate(template, Article.class, criteria, pageable); + } +} diff --git a/easy-paging-sb4-reactive-demo/src/main/resources/application.yml b/easy-paging-sb4-reactive-demo/src/main/resources/application.yml new file mode 100644 index 0000000..10dde39 --- /dev/null +++ b/easy-paging-sb4-reactive-demo/src/main/resources/application.yml @@ -0,0 +1,38 @@ +# Reactive (R2DBC + WebFlux) demo config. +# +# Local "run it yourself" flow: +# docker compose up -d db +# ./gradlew bootRun +# +# Note the port is 5433, not 5432, so this demo can run side by side with +# easy-paging-postgres-demo. The integration tests don't read this datasource — +# Testcontainers + @ServiceConnection wires R2dbcConnectionDetails to the +# ephemeral container at test time. + +spring: + application: + name: easy-paging-reactive-demo + + r2dbc: + url: r2dbc:postgresql://localhost:5433/easypaging + username: easypaging + password: easypaging + + sql: + init: + # Spring Boot 3.x auto-runs classpath:schema.sql and classpath:data.sql + # for R2DBC connection factories, same as for JDBC DataSources. The + # schema does DROP+CREATE so every restart boots into a known state. + # Production should use Flyway with the R2DBC migration runner instead. + mode: always + continue-on-error: false + +easy-paging: + enabled: true + default-page-size: 20 + max-page-size: 200 + +logging: + level: + root: WARN + kr.devslab.examples: INFO diff --git a/easy-paging-sb4-reactive-demo/src/main/resources/data.sql b/easy-paging-sb4-reactive-demo/src/main/resources/data.sql new file mode 100644 index 0000000..2ed2e14 --- /dev/null +++ b/easy-paging-sb4-reactive-demo/src/main/resources/data.sql @@ -0,0 +1,12 @@ +-- 500 articles distributed evenly across 5 authors (100 each). Timestamps step +-- back 1 hour per row so sort=publishedAt,desc produces a realistic stream. +-- view_count is deterministic so tests can assert against it without +-- flakiness (random() would be more realistic but unstable). + +INSERT INTO articles (title, author, published_at, view_count) +SELECT + 'Article #' || i, + (ARRAY['alice', 'bob', 'charlie', 'dana', 'eve'])[1 + ((i - 1) % 5)], + NOW() - (i || ' hours')::interval, + ((i * 13) % 1000)::bigint +FROM generate_series(1, 500) AS i; diff --git a/easy-paging-sb4-reactive-demo/src/main/resources/schema.sql b/easy-paging-sb4-reactive-demo/src/main/resources/schema.sql new file mode 100644 index 0000000..427a730 --- /dev/null +++ b/easy-paging-sb4-reactive-demo/src/main/resources/schema.sql @@ -0,0 +1,16 @@ +-- DROP-then-CREATE so every restart leaves us in a known state. Real apps +-- should use Flyway (it supports R2DBC migrations via flyway-r2dbc) or +-- Liquibase instead — see the README. +DROP TABLE IF EXISTS articles; + +CREATE TABLE articles ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(200) NOT NULL, + author VARCHAR(100) NOT NULL, + published_at TIMESTAMP NOT NULL, + view_count BIGINT NOT NULL +); + +-- Indexes that match the most common filter/sort combos. +CREATE INDEX idx_articles_published_at ON articles(published_at DESC); +CREATE INDEX idx_articles_author ON articles(author); diff --git a/easy-paging-sb4-reactive-demo/src/test/java/kr/devslab/examples/easypagingreactive/ArticleControllerIT.java b/easy-paging-sb4-reactive-demo/src/test/java/kr/devslab/examples/easypagingreactive/ArticleControllerIT.java new file mode 100644 index 0000000..3d9aec9 --- /dev/null +++ b/easy-paging-sb4-reactive-demo/src/test/java/kr/devslab/examples/easypagingreactive/ArticleControllerIT.java @@ -0,0 +1,87 @@ +package kr.devslab.examples.easypagingreactive; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Integration test against a real PostgreSQL R2DBC container. Mirrors the + * postgres demo's {@code ProductControllerIT} but uses {@link WebTestClient} + * (WebFlux) instead of MockMvc (servlet), and runs against R2DBC instead + * of JDBC. + * + *

{@link ServiceConnection} on a {@link PostgreSQLContainer} produces an + * {@code R2dbcConnectionDetails} automatically when both + * {@code spring-boot-starter-data-r2dbc} and {@code testcontainers:r2dbc} + * are on the classpath — no JDBC URL rewriting, no manual + * {@code @DynamicPropertySource}, no JDBC fallback. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureWebTestClient +@Testcontainers +class ArticleControllerIT { + + @Container + @ServiceConnection + static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:16-alpine"); + + @Autowired + private WebTestClient webTestClient; + + @Test + void firstPageReturnsExpectedPaginationMetadata() { + // 500 seeded rows / size=10 = 50 pages + webTestClient.get().uri("/articles?page=0&size=10") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.content.length()").isEqualTo(10) + .jsonPath("$.page").isEqualTo(0) + .jsonPath("$.size").isEqualTo(10) + .jsonPath("$.totalElements").isEqualTo(500) + .jsonPath("$.totalPages").isEqualTo(50) + .jsonPath("$.first").isEqualTo(true) + .jsonPath("$.last").isEqualTo(false); + } + + @Test + void authorFilterReducesTotalElements() { + // data.sql distributes 500 rows across 5 authors → 100 each. + webTestClient.get().uri("/articles?author=alice&page=0&size=20") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.totalElements").isEqualTo(100) + .jsonPath("$.totalPages").isEqualTo(5) + .jsonPath("$.content.length()").isEqualTo(20); + } + + @Test + void sortPushesNewestFirstAndContentMatchesId() { + // With size=1 and sort=id,asc the very first article is id=1. + webTestClient.get().uri("/articles?page=0&size=1&sort=id,asc") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.content[0].id").isEqualTo(1) + .jsonPath("$.content[0].title").isEqualTo("Article #1"); + } + + @Test + void lastPageReportsLastFlag() { + // 500 rows, size=100 → pages 0..4. page=4 is the last. + webTestClient.get().uri("/articles?page=4&size=100") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.content.length()").isEqualTo(100) + .jsonPath("$.first").isEqualTo(false) + .jsonPath("$.last").isEqualTo(true); + } +}