Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,16 @@ val seeker = users.toSeeker
.seek(_.name.asc)
.seek(_.id.asc)

// Paginate forward
// Paginate forward (includes total count)
val page1 = db.run(seeker.page(limit = 20, cursor = None))
// PaginatedResult(total=100, items=[...], nextCursor=Some("..."), prevCursor=None)

val page2 = db.run(seeker.page(limit = 20, cursor = page1.nextCursor))

// Paginate without total count (skips the COUNT(*) query)
val fast1 = db.run(seeker.pageWithoutCount(limit = 20, cursor = None))
// PaginatedResultWithoutCount(items=[...], nextCursor=Some("..."), prevCursor=None)

// Paginate backward
val page0 = db.run(seeker.page(limit = 20, cursor = page1.prevCursor))
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,39 @@ class PlayJsonPaginationIntegrationSpec extends AnyWordSpec with Matchers with B
result.nextCursor shouldBe defined
}

"paginate forward with pageWithoutCount" in {
val seeker = persons.toSeeker
.seek(_.firstName.asc)
.seek(_.id.asc)

val page1 = await(db.run(seeker.pageWithoutCount(limit = 3, cursor = None)))
page1.items should have size 3
page1.items.map(_.firstName) shouldBe Seq("Alice", "Bob", "Charlie")
page1.nextCursor shouldBe defined
page1.prevCursor shouldBe None

val page2 = await(db.run(seeker.pageWithoutCount(limit = 3, cursor = page1.nextCursor)))
page2.items.map(_.firstName) shouldBe Seq("Diana", "Eve", "Frank")
page2.prevCursor shouldBe defined
}

"produce cursors interoperable between page and pageWithoutCount" in {
val seeker = persons.toSeeker
.seek(_.firstName.asc)
.seek(_.id.asc)

// Cursor from pageWithoutCount works with page
val withoutCount1 = await(db.run(seeker.pageWithoutCount(limit = 3, cursor = None)))
val withCount2 = await(db.run(seeker.page(limit = 3, cursor = withoutCount1.nextCursor)))

withCount2.items.map(_.firstName) shouldBe Seq("Diana", "Eve", "Frank")
withCount2.total shouldBe 10

// Cursor from page works with pageWithoutCount
val withoutCount3 = await(db.run(seeker.pageWithoutCount(limit = 3, cursor = withCount2.nextCursor)))
withoutCount3.items.map(_.firstName) shouldBe Seq("Grace", "Henry", "Iris")
}

"encode and decode cursors correctly through multiple pages" in {
val seeker = persons.toSeeker
.seek(_.firstName.asc)
Expand Down
23 changes: 22 additions & 1 deletion docs/docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,30 @@ ORDER BY
END ASC
```

## Pagination Without Count

By default, `page()` runs a `COUNT(*)` query to include the total number of matching rows. Use `pageWithoutCount()` to skip it:

```scala
// With count (runs COUNT(*) + data query)
val result: PaginatedResult[User] =
db.run(seeker.page(limit = 20, cursor = None))

// Without count (runs data query only — faster)
val result: PaginatedResultWithoutCount[User] =
db.run(seeker.pageWithoutCount(limit = 20, cursor = None))
```

Both methods produce interoperable cursors. You can convert between result types:

```scala
val withoutCount: PaginatedResultWithoutCount[User] = result.withoutCount
val withCount: PaginatedResult[User] = withoutCountResult.withCount(total)
```

## Bidirectional Pagination

Slick Seeker supports both forward and backward navigation:
Slick Seeker supports both forward and backward navigation with both `page()` and `pageWithoutCount()`:

```scala
// Forward
Expand Down
20 changes: 15 additions & 5 deletions docs/docs/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ class UserController @Inject()(
case _ => users.toSeeker.seek(_.id.asc)
}

// Use pageWithoutCount to skip the COUNT(*) query for better performance
db.run(seeker.pageWithoutCount(limit.getOrElse(20), cursor, maxLimit = 100))
.map(result => Ok(Json.toJson(result)))

// Or use page to include the total count
db.run(seeker.page(limit.getOrElse(20), cursor, maxLimit = 100))
.map(result => Ok(Json.toJson(result)))
}
Expand All @@ -44,17 +49,18 @@ def searchUsers(
activeOnly: Boolean,
cursor: Option[String],
limit: Int
): Future[PaginatedResult[User]] = {
): Future[PaginatedResultWithoutCount[User]] = {

val baseQuery = users
.filterOpt(nameFilter)(_.name like _)
.filterIf(activeOnly)(_.active === true)

val seeker = baseQuery.toSeeker
.seek(_.name.asc)
.seek(_.id.asc)

db.run(seeker.page(limit, cursor))

// Use pageWithoutCount for filtered queries where the total is less useful
db.run(seeker.pageWithoutCount(limit, cursor))
}
```

Expand Down Expand Up @@ -372,6 +378,10 @@ val db = Database.forConfig("mydb")
val page1 = db.run(seeker.page(limit = 50, cursor = None))
// PaginatedResult(total=1000, items=[...], nextCursor=Some("..."))

// Or without count for better performance
val fast1 = db.run(seeker.pageWithoutCount(limit = 50, cursor = None))
// PaginatedResultWithoutCount(items=[...], nextCursor=Some("..."))

val page2 = page1.flatMap { p1 =>
db.run(seeker.page(limit = 50, cursor = p1.nextCursor))
}
Expand Down
9 changes: 7 additions & 2 deletions docs/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ Type-safe, high-performance cursor-based pagination for Slick 3.5+.
- **Keyset Pagination** - O(1) performance regardless of page depth
- **Bidirectional** - Navigate forward and backward through result sets
- **Type-Safe** - Compile-time verification of cursor/column matching
- **PostgreSQL Tuple Optimization** - Compile-time safe tuple comparisons for PostgreSQL (NEW!)
- **Count-Free Pagination** - Skip `COUNT(*)` queries with `pageWithoutCount`
- **PostgreSQL Tuple Optimization** - Compile-time safe tuple comparisons for PostgreSQL
- **Profile Agnostic** - Works with any Slick JDBC profile (PostgreSQL, MySQL, H2, SQLite, Oracle, etc.)
- **Flexible Ordering** - Support for nulls first/last, custom enum orders
- **Modular** - Core + optional Play JSON integration
Expand Down Expand Up @@ -93,10 +94,14 @@ val seeker = users.toSeeker
.seek(_.name.asc) // Primary sort
.seek(_.id.asc) // Tiebreaker

// Paginate!
// Paginate (with total count)
val page1 = db.run(seeker.page(limit = 20, cursor = None))
// PaginatedResult(total=100, items=[...], nextCursor=Some("..."), prevCursor=None)

// Paginate (without total count — skips the COUNT(*) query)
val fast1 = db.run(seeker.pageWithoutCount(limit = 20, cursor = None))
// PaginatedResultWithoutCount(items=[...], nextCursor=Some("..."), prevCursor=None)

val page2 = db.run(seeker.page(limit = 20, cursor = page1.nextCursor))
// Continue pagination...
```
Expand Down
33 changes: 30 additions & 3 deletions docs/docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,32 @@ case class PaginatedResult[T](
)
```

## PaginatedResultWithoutCount

The `pageWithoutCount()` method returns a `PaginatedResultWithoutCount[T]`, skipping the `COUNT(*)` query. This is useful when the total count is not needed (e.g., infinite scroll UIs).

```scala
case class PaginatedResultWithoutCount[T](
items: Seq[T], // Current page items
nextCursor: Option[String], // Cursor for next page (None if last page)
prevCursor: Option[String] // Cursor for previous page (None if first page)
)
```

Usage:

```scala
// Without count (faster — no COUNT(*) query)
val page1: Future[PaginatedResultWithoutCount[User]] =
db.run(seeker.pageWithoutCount(limit = 20, cursor = None))

// Convert between result types
val withCount: PaginatedResult[User] = page1WithoutCount.withCount(100)
val withoutCount: PaginatedResultWithoutCount[User] = fullResult.withoutCount
```

Cursors are fully interoperable — a cursor from `page()` works with `pageWithoutCount()` and vice versa.

## Using with Play JSON

For REST APIs, you can serialize `PaginatedResult` to JSON:
Expand Down Expand Up @@ -232,9 +258,10 @@ users.toPgTupleSeekerAsc // All columns ASC
users.toPgTupleSeekerDesc // All columns DESC
.seek(_.col)

// Pagination (same for both)
seeker.page(limit = 20, cursor = None) // First page
seeker.page(limit = 20, cursor = Some("...")) // Next/prev page
// Pagination (same for both seeker types)
seeker.page(limit = 20, cursor = None) // With total count
seeker.page(limit = 20, cursor = Some("...")) // Next/prev page
seeker.pageWithoutCount(limit = 20, cursor = None) // Without total count
```

## What's Next?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ package object slickseeker {
type PaginatedResult[T] = pagination.PaginatedResult[T]
val PaginatedResult: pagination.PaginatedResult.type = pagination.PaginatedResult

type PaginatedResultWithoutCount[T] = pagination.PaginatedResultWithoutCount[T]
val PaginatedResultWithoutCount: pagination.PaginatedResultWithoutCount.type = pagination.PaginatedResultWithoutCount

// Type aliases for cursor system
type CursorEnvironment[E] = cursor.CursorEnvironment[E]
val CursorEnvironment: cursor.CursorEnvironment.type = cursor.CursorEnvironment
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,8 @@ final case class PaginatedResult[T](
def mapItems[U](f: T => U): PaginatedResult[U] =
copy(items = items.map(f))

/** Convert to a result without total count */
def withoutCount: PaginatedResultWithoutCount[T] =
PaginatedResultWithoutCount(items, prevCursor, nextCursor)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package io.github.devnico.slickseeker.pagination

/** Result of a paginated query without total count.
*
* Use this when you don't need the total count, avoiding the extra COUNT(*) query.
*
* @param items
* Items in this page
* @param prevCursor
* Cursor for the previous page (backward pagination)
* @param nextCursor
* Cursor for the next page (forward pagination)
* @tparam T
* Type of items
*/
final case class PaginatedResultWithoutCount[T](
items: Seq[T],
prevCursor: Option[String],
nextCursor: Option[String]
) {

/** Map items to a different type while preserving pagination metadata */
def mapItems[U](f: T => U): PaginatedResultWithoutCount[U] =
copy(items = items.map(f))

/** Add a total count to produce a full PaginatedResult */
def withCount(total: Int): PaginatedResult[T] =
PaginatedResult(total, items, prevCursor, nextCursor)

}
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,33 @@ trait Seeker[U, CVE] {
cursorEnvironment: CursorEnvironment[CVE],
ec: ExecutionContext
): profile.api.DBIOAction[PaginatedResult[U], profile.api.NoStream, profile.api.Effect.Read]

/** Execute a paginated query without computing the total count.
*
* This avoids the extra COUNT(*) query.
*
* @param limit
* Maximum number of items to return
* @param cursor
* Optional cursor for pagination navigation
* @param maxLimit
* Maximum allowed limit
* @param profile
* JDBC profile for database operations
* @param cursorEnvironment
* Environment for encoding/decoding cursors
* @param ec
* Execution context for async operations
* @return
* Database action that returns a paginated result without total count
*/
def pageWithoutCount[Profile <: JdbcProfile](
limit: Int,
cursor: Option[String],
maxLimit: Int
)(implicit
profile: Profile,
cursorEnvironment: CursorEnvironment[CVE],
ec: ExecutionContext
): profile.api.DBIOAction[PaginatedResultWithoutCount[U], profile.api.NoStream, profile.api.Effect.Read]
}
Loading