Skip to content

Commit a8d755e

Browse files
authored
Let window and scrollable return next/previous scrollable even if hasNext/hasPrevious is false. (#94) (#98)
1 parent cfb1af6 commit a8d755e

File tree

12 files changed

+162
-160
lines changed

12 files changed

+162
-160
lines changed

docs/common-patterns.md

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -454,15 +454,13 @@ Use the `scroll()` method on any entity repository with a `Scrollable` that capt
454454
// First page of 20 users ordered by ID
455455
val window: Window<User> = userRepository.scroll(Scrollable.of(User_.id, 20))
456456

457-
// Next page
458-
if (window.hasNext()) {
459-
val next: Window<User> = userRepository.scroll(window.nextScrollable())
460-
}
457+
// Navigate forward: nextScrollable() is non-null whenever the window has content.
458+
// hasNext() is an informational flag indicating whether more rows existed at
459+
// query time, but the developer decides whether to follow the cursor.
460+
val next: Window<User> = userRepository.scroll(window.nextScrollable())
461461

462-
// Previous page
463-
if (window.hasPrevious()) {
464-
val previous: Window<User> = userRepository.scroll(window.previousScrollable())
465-
}
462+
// Navigate backward
463+
val previous: Window<User> = userRepository.scroll(window.previousScrollable())
466464
```
467465

468466
</TabItem>
@@ -472,21 +470,19 @@ if (window.hasPrevious()) {
472470
// First page of 20 users ordered by ID
473471
Window<User> window = userRepository.scroll(Scrollable.of(User_.id, 20));
474472

475-
// Next page
476-
if (window.hasNext()) {
477-
Window<User> next = userRepository.scroll(window.nextScrollable());
478-
}
473+
// Navigate forward: nextScrollable() is non-null whenever the window has content.
474+
// hasNext() is an informational flag indicating whether more rows existed at
475+
// query time, but the developer decides whether to follow the cursor.
476+
Window<User> next = userRepository.scroll(window.nextScrollable());
479477

480-
// Previous page
481-
if (window.hasPrevious()) {
482-
Window<User> previous = userRepository.scroll(window.previousScrollable());
483-
}
478+
// Navigate backward
479+
Window<User> previous = userRepository.scroll(window.previousScrollable());
484480
```
485481

486482
</TabItem>
487483
</Tabs>
488484

489-
Each method returns a `Window` containing the page content, a `hasNext` flag, and navigation tokens (`nextScrollable()`, `previousScrollable()`) for sequential traversal. For REST APIs, `Window` also provides `nextCursor()` and `previousCursor()` to serialize the scroll position as an opaque string, and `Scrollable.fromCursor(key, cursor)` to reconstruct a `Scrollable` from a cursor string. See [Repositories: Scrolling](repositories.md#scrolling) for the full API, including sort overloads, filtering, and Ref variants.
485+
Each method returns a `Window` containing the page content and navigation cursors for sequential traversal. The `hasNext()` and `hasPrevious()` flags reflect whether additional rows existed at query time, but they are not prerequisites for calling `nextScrollable()` or `previousScrollable()`. Both methods return a non-null `Scrollable` whenever the window contains at least one element, and return `null` only when the window is empty. This means you can always follow the cursor if you choose to; for example, new rows may have been inserted after the original query. For REST APIs, `Window` also provides `nextCursor()` and `previousCursor()` to serialize the scroll position as an opaque string, and `Scrollable.fromCursor(key, cursor)` to reconstruct a `Scrollable` from a cursor string. See [Repositories: Scrolling](repositories.md#scrolling) for the full API, including sort overloads, filtering, and Ref variants.
490486

491487
### Choosing Between the Two
492488

docs/faq.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -286,19 +286,19 @@ For large tables where users scroll through results sequentially, prefer **scrol
286286

287287
```kotlin
288288
val window = userRepository.scroll(Scrollable.of(User_.id, 20))
289-
if (window.hasNext()) {
290-
val next = userRepository.scroll(window.nextScrollable())
291-
}
289+
// nextScrollable() is non-null when the window has content.
290+
// hasNext() is informational; the developer decides whether to follow the cursor.
291+
val next = userRepository.scroll(window.nextScrollable())
292292
```
293293

294294
</TabItem>
295295
<TabItem value="java" label="Java">
296296

297297
```java
298298
Window<User> window = userRepository.scroll(Scrollable.of(User_.id, 20));
299-
if (window.hasNext()) {
300-
Window<User> next = userRepository.scroll(window.nextScrollable());
301-
}
299+
// nextScrollable() is non-null when the window has content.
300+
// hasNext() is informational; the developer decides whether to follow the cursor.
301+
Window<User> next = userRepository.scroll(window.nextScrollable());
302302
```
303303

304304
</TabItem>

docs/glossary.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ A lightweight identifier (`Ref<T>`) that carries only the record type and primar
4141
An interface that provides database access methods for an entity or projection type. `EntityRepository<E, ID>` offers built-in CRUD operations; `ProjectionRepository<P, ID>` offers read-only operations. Custom repositories extend these interfaces with domain-specific query methods. See [Repositories](repositories.md).
4242

4343
**Scrollable**
44-
A scroll request that captures cursor state for fetching a window of results. The scrolling counterpart of `Pageable`. Created via `Scrollable.of(key, size)` or obtained from `Window.nextScrollable()` / `Window.previousScrollable()`. Supports cursor serialization for REST APIs via `toCursor()` / `Scrollable.fromCursor(key, cursor)`. See [Pagination and Scrolling: Scrolling](pagination-and-scrolling.md#scrolling).
44+
A scroll request that captures cursor state for fetching a window of results. The scrolling counterpart of `Pageable`. Created via `Scrollable.of(key, size)` or obtained from `Window.nextScrollable()` / `Window.previousScrollable()`, which are always non-null when the window has content. Supports cursor serialization for REST APIs via `toCursor()` / `Scrollable.fromCursor(key, cursor)`. See [Pagination and Scrolling: Scrolling](pagination-and-scrolling.md#scrolling).
4545

4646
**SQL Template**
4747
Storm's template engine that uses string interpolation to embed entity types, metamodel fields, and parameter values into SQL text. Types expand to column lists, metamodel fields to column names, and values to parameterized placeholders. SQL Templates are the foundation of all Storm queries, including those generated by repositories. See [SQL Templates](sql-templates.md).
@@ -53,4 +53,4 @@ See [Metamodel](#metamodel) above.
5353
A configuration object (`StormConfig`) that controls runtime behavior for features like dirty checking mode, entity cache retention, and template cache size. All settings have sensible defaults, so configuration is optional. See [Configuration](configuration.md).
5454

5555
**Window**
56-
A window of query results from a scrolling operation. A `Window<R>` contains the result list (`content`), `hasNext` and `hasPrevious` flags, and navigation tokens (`nextScrollable()`, `previousScrollable()`) for sequential traversal. Also provides `nextCursor()` / `previousCursor()` for REST API cursor strings. See [Pagination and Scrolling: Scrolling](pagination-and-scrolling.md#scrolling).
56+
A window of query results from a scrolling operation. A `Window<R>` contains the result list (`content`), informational `hasNext` and `hasPrevious` flags (a snapshot at query time), and navigation tokens (`nextScrollable()`, `previousScrollable()`) for sequential traversal. The navigation tokens are always non-null when the window has content; `hasNext` and `hasPrevious` are not prerequisites for accessing them, since new data may appear after the query. Also provides `nextCursor()` / `previousCursor()` for REST API cursor strings. See [Pagination and Scrolling: Scrolling](pagination-and-scrolling.md#scrolling).

docs/metamodel.md

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -457,37 +457,33 @@ User user = userRepository.getBy(User_.email, "alice@example.com"); // throws i
457457

458458
```kotlin
459459
val window: Window<User> = userRepository.scroll(Scrollable.of(User_.id, 20))
460-
if (window.hasNext()) {
461-
val nextWindow: Window<User> = userRepository.scroll(window.nextScrollable())
462-
}
460+
// nextScrollable() is non-null when the window has content.
461+
// hasNext() is informational; the developer decides whether to follow the cursor.
462+
val nextWindow: Window<User> = userRepository.scroll(window.nextScrollable())
463463
```
464464

465465
Compound unique keys work the same way. The inline record is used as the cursor value:
466466

467467
```kotlin
468468
val window: Window<SomeEntity> = repository.scroll(Scrollable.of(SomeEntity_.uniqueKey, 20))
469-
if (window.hasNext()) {
470-
val nextWindow: Window<SomeEntity> = repository.scroll(window.nextScrollable())
471-
}
469+
val nextWindow: Window<SomeEntity> = repository.scroll(window.nextScrollable())
472470
```
473471

474472
</TabItem>
475473
<TabItem value="java" label="Java">
476474

477475
```java
478476
Window<User> window = userRepository.scroll(Scrollable.of(User_.id, 20));
479-
if (window.hasNext()) {
480-
Window<User> next = userRepository.scroll(window.nextScrollable());
481-
}
477+
// nextScrollable() is non-null when the window has content.
478+
// hasNext() is informational; the developer decides whether to follow the cursor.
479+
Window<User> next = userRepository.scroll(window.nextScrollable());
482480
```
483481

484482
Compound unique keys work the same way:
485483

486484
```java
487485
Window<SomeEntity> window = repository.scroll(Scrollable.of(SomeEntity_.uniqueKey, 20));
488-
if (window.hasNext()) {
489-
Window<SomeEntity> next = repository.scroll(window.nextScrollable());
490-
}
486+
Window<SomeEntity> next = repository.scroll(window.nextScrollable());
491487
```
492488

493489
</TabItem>

docs/pagination-and-scrolling.md

Lines changed: 37 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ For the full `Page` and `Pageable` API reference, see [Repositories: Offset-Base
159159

160160
## Scrolling
161161

162-
Scrolling navigates sequentially using a cursor and returns a `Window<T>`. A `Window` represents a portion of the result set: it contains the data, a flag indicating whether more results exist, and `Scrollable<T>` navigation tokens for sequential traversal, but no total count or page number.
162+
Scrolling navigates sequentially using a cursor and returns a `Window<T>`. A `Window` represents a portion of the result set: it contains the data, informational flags (`hasNext`, `hasPrevious`) that indicate whether adjacent results existed at query time, and `Scrollable<T>` navigation tokens for sequential traversal, but no total count or page number. The navigation tokens `nextScrollable()` and `previousScrollable()` are always available when the window has content, regardless of whether `hasNext` or `hasPrevious` is `true`. This allows the developer to decide whether to follow a cursor, since new data may appear after the query was executed.
163163

164164
Under the hood, scrolling uses keyset pagination: it remembers the last value seen on the current page and asks the database for rows after (or before) that value. This avoids the performance cliff of `OFFSET` on large tables, because the database can seek directly to the cursor position using an index.
165165

@@ -172,10 +172,10 @@ The `scroll` method is available directly on repositories and on the query build
172172
| Field / Method | Description |
173173
|-------|-------------|
174174
| `content()` | The list of results for this window. |
175-
| `hasNext()` | `true` if more results exist beyond this window in the scroll direction. |
176-
| `hasPrevious()` | `true` if a previous window exists before this one. |
177-
| `nextScrollable()` | Returns a `Scrollable<T>` for the next window, or `null` if there is no next window. |
178-
| `previousScrollable()` | Returns a `Scrollable<T>` for the previous window, or `null` if this is the first window. |
175+
| `hasNext()` | `true` if more results existed beyond this window at query time. |
176+
| `hasPrevious()` | `true` if this window was fetched with a cursor position (i.e., not the first page). |
177+
| `nextScrollable()` | Returns a `Scrollable<T>` for the next window, or `null` if the window is empty. |
178+
| `previousScrollable()` | Returns a `Scrollable<T>` for the previous window, or `null` if the window is empty. |
179179

180180
Create a `Scrollable` using the factory methods, or obtain one from a `Window`:
181181

@@ -192,7 +192,7 @@ The extra row (`size+1`) is used internally to determine the value of `hasNext`,
192192

193193
**No total count.** Unlike pagination, scrolling does not include a total element count. A separate `COUNT(*)` query must execute the same joins, filters, and conditions as the main query, which can be expensive on large or complex result sets. Total counts are also inherently unstable: rows may be inserted or deleted while a user navigates through pages, so the count can become stale between requests. Scrolling is designed for sequential "load more" or infinite-scroll patterns where a total is rarely needed. If you do need a total count (for example, for a UI label like "showing 10 of 4,827 results"), call the `count` (Kotlin) or `getCount()` (Java) method on the query builder separately, keeping in mind that the value is a snapshot that may drift as the underlying data changes.
194194

195-
**REST cursor support.** For REST APIs that need to pass scroll state as an opaque string (for example, as a query parameter), `Window` provides `nextCursor()` and `previousCursor()` methods that serialize the scroll position to a cursor string. To reconstruct a `Scrollable` from a cursor string, use `Scrollable.fromCursor(key, cursor)`. For details on supported cursor types, security considerations, and custom codec registration, see [Cursor Serialization](cursors.md).
195+
**REST cursor support.** For REST APIs that need to pass scroll state as an opaque string (for example, as a query parameter), `Window` provides `nextCursor()` and `previousCursor()` methods that serialize the scroll position to a cursor string. These convenience methods are gated by the informational flags: `nextCursor()` returns `null` when `hasNext()` is `false`, and `previousCursor()` returns `null` when `hasPrevious()` is `false`. This makes them safe to use directly in REST responses without additional checks. The underlying `nextScrollable()` and `previousScrollable()` methods remain available whenever the window has content, so server-side code can still follow a cursor even when the flags indicate no more results were seen at query time. To reconstruct a `Scrollable` from a cursor string, use `Scrollable.fromCursor(key, cursor)`. For details on supported cursor types, security considerations, and custom codec registration, see [Cursor Serialization](cursors.md).
196196

197197
<Tabs groupId="language">
198198
<TabItem value="kotlin" label="Kotlin" default>
@@ -297,21 +297,19 @@ The single-key `Scrollable.of(key, size)` uses the cursor column as both the sor
297297
val window = postRepository.select()
298298
.scroll(Scrollable.of(Post_.id, Post_.createdAt, 20))
299299

300-
// Next page (cursor values are captured in the Scrollable automatically)
301-
window.nextScrollable()?.let { scrollable ->
302-
val next = postRepository.select()
303-
.scroll(scrollable)
304-
}
300+
// Next page (cursor values are captured in the Scrollable automatically).
301+
// nextScrollable() is non-null whenever the window has content.
302+
// hasNext() is informational; the developer decides whether to follow the cursor.
303+
val next = postRepository.select()
304+
.scroll(window.nextScrollable())
305305

306306
// First page sorted by creation date descending (most recent first)
307307
val latest = postRepository.select()
308308
.scroll(Scrollable.of(Post_.id, Post_.createdAt, 20).backward())
309309

310310
// Previous page
311-
window.previousScrollable()?.let { scrollable ->
312-
val prev = postRepository.select()
313-
.scroll(scrollable)
314-
}
311+
val prev = postRepository.select()
312+
.scroll(window.previousScrollable())
315313
```
316314

317315
</TabItem>
@@ -322,16 +320,22 @@ window.previousScrollable()?.let { scrollable ->
322320
var window = postRepository.select()
323321
.scroll(Scrollable.of(Post_.id, Post_.createdAt, 20));
324322

325-
// Next page (cursor values are captured in the Scrollable automatically)
326-
if (window.hasNext()) {
323+
// Next page (cursor values are captured in the Scrollable automatically).
324+
// nextScrollable() is non-null whenever the window has content.
325+
// You can check hasNext() if you only want to proceed when more results
326+
// were known to exist at query time, or follow the cursor unconditionally
327+
// to pick up data that may have arrived after the query.
328+
var nextScrollable = window.nextScrollable();
329+
if (nextScrollable != null) {
327330
var next = postRepository.select()
328-
.scroll(window.nextScrollable());
331+
.scroll(nextScrollable);
329332
}
330333

331334
// Previous page
332-
if (window.hasPrevious()) {
335+
var previousScrollable = window.previousScrollable();
336+
if (previousScrollable != null) {
333337
var prev = postRepository.select()
334-
.scroll(window.previousScrollable());
338+
.scroll(previousScrollable);
335339
}
336340
```
337341

@@ -391,7 +395,7 @@ See [Manual Key Wrapping](metamodel.md#manual-key-wrapping) for more details.
391395

392396
When calling `scroll` on the query builder directly (rather than through a repository), the return type is `MappedWindow<R, T>` where `R` is the result type and `T` is the entity type from the FROM clause. For entity queries where `R` and `T` are the same type, `MappedWindow` carries `Scrollable<T>` navigation tokens and works the same as `Window<T>`. Repository convenience methods return `Window<T>` directly.
393397

394-
For queries where the result type differs from the entity type (for example, selecting into a data class that combines columns from multiple sources), `MappedWindow` does not carry navigation tokens because Storm cannot extract cursor values from a result type it does not know how to navigate. In this case, `nextScrollable()` and `previousScrollable()` return `null`, but `hasNext()` still works correctly. To continue scrolling, construct the next `Scrollable` manually using cursor values from your result:
398+
For queries where the result type differs from the entity type (for example, selecting into a data class that combines columns from multiple sources), `MappedWindow` does not carry navigation tokens because Storm cannot extract cursor values from a result type it does not know how to navigate. In this case, `nextScrollable()` and `previousScrollable()` return `null` (even when the window has content), and `hasNext()` still works correctly as an informational flag. To continue scrolling, check `hasNext()` and construct the next `Scrollable` manually using cursor values from your result:
395399

396400
<Tabs groupId="language">
397401
<TabItem value="kotlin" label="Kotlin" default>
@@ -406,13 +410,12 @@ val window: MappedWindow<OrderSummary, Order> = orm.selectFrom(Order::class, Ord
406410
.scroll(Scrollable.of(Order_.city.key(), 20))
407411

408412
// Navigation tokens are null because OrderSummary != Order.
409-
// Construct the next scrollable manually from the last result:
410-
if (window.hasNext()) {
411-
val lastCity = window.content.last().city.id()
412-
val next: MappedWindow<OrderSummary, Order> = orm.selectFrom(Order::class, OrderSummary::class) { ... }
413-
.groupBy(Order_.city)
414-
.scroll(Scrollable.of(Order_.city.key(), lastCity, 20))
415-
}
413+
// Construct the next scrollable manually from the last result.
414+
// hasNext() is informational; the developer decides whether to follow the cursor.
415+
val lastCity = window.content.last().city.id()
416+
val next: MappedWindow<OrderSummary, Order> = orm.selectFrom(Order::class, OrderSummary::class) { ... }
417+
.groupBy(Order_.city)
418+
.scroll(Scrollable.of(Order_.city.key(), lastCity, 20))
416419
```
417420

418421
</TabItem>
@@ -427,13 +430,12 @@ MappedWindow<OrderSummary, Order> window = orm.selectFrom(Order.class, OrderSumm
427430
.scroll(Scrollable.of(Metamodel.key(Order_.city), 20));
428431

429432
// Navigation tokens are null because OrderSummary != Order.
430-
// Construct the next scrollable manually from the last result:
431-
if (window.hasNext()) {
432-
var lastCity = window.content().getLast().city().id();
433-
MappedWindow<OrderSummary, Order> next = orm.selectFrom(Order.class, OrderSummary.class, ...)
434-
.groupBy(Order_.city)
435-
.scroll(Scrollable.of(Metamodel.key(Order_.city), lastCity, 20));
436-
}
433+
// Construct the next scrollable manually from the last result.
434+
// hasNext() is informational; the developer decides whether to follow the cursor.
435+
var lastCity = window.content().getLast().city().id();
436+
MappedWindow<OrderSummary, Order> next = orm.selectFrom(Order.class, OrderSummary.class, ...)
437+
.groupBy(Order_.city)
438+
.scroll(Scrollable.of(Metamodel.key(Order_.city), lastCity, 20));
437439
```
438440

439441
</TabItem>

0 commit comments

Comments
 (0)