Skip to content

Commit 6db0faa

Browse files
committed
chore: release v0.4.0
1 parent dae7a45 commit 6db0faa

File tree

11 files changed

+902
-9
lines changed

11 files changed

+902
-9
lines changed

build.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ ThisBuild / makePomConfiguration := makePomConfiguration.value.withConfiguration
3030
)
3131

3232
// Version and Scala settings
33-
val slickSeekerVersion = "0.3.3"
33+
val slickSeekerVersion = "0.4.0"
3434

3535
val scala3Version = "3.3.5"
3636
val scala213Version = "2.13.16"

docs/docs/cookbook.md

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,369 @@ val seeker = persons.toSeeker
183183
.seek(_.id.asc)
184184
```
185185

186+
## PostgreSQL Tuple Optimization
187+
188+
For PostgreSQL databases, use `SlickPgTupleSeeker` for type-safe, tuple-optimized pagination. This generates simpler SQL with compile-time safety guarantees.
189+
190+
### Standard Approach (Default)
191+
192+
```scala
193+
val seeker = users.toSeeker
194+
.seek(_.name.asc)
195+
.seek(_.id.asc)
196+
```
197+
198+
Generates SQL like:
199+
```sql
200+
WHERE (name > ?) OR (name = ? AND id > ?)
201+
ORDER BY name ASC, id ASC
202+
```
203+
204+
### PostgreSQL Tuple Approach (Type-Safe)
205+
206+
```scala
207+
val seeker = users.toPgTupleSeekerAsc // Direction enforced at creation
208+
.seek(_.name) // No .asc needed - enforced by type
209+
.seek(_.id)
210+
```
211+
212+
Generates SQL like:
213+
```sql
214+
WHERE (name, id) > (?, ?)
215+
ORDER BY name ASC, id ASC
216+
```
217+
218+
### When to Use
219+
220+
- **Use `toPgTupleSeekerAsc` / `toPgTupleSeekerDesc`** when:
221+
- Your database is PostgreSQL 8.2+ or H2 in PostgreSQL mode
222+
- You have multiple seek columns (2 or more)
223+
- **All seek columns are non-nullable** (compile-time enforced)
224+
- **All seek columns have the SAME sort direction** (compile-time enforced)
225+
- Query performance is critical
226+
- You want maximum type safety
227+
228+
- **Use standard `.toSeeker`** when:
229+
- You need database portability (H2, MySQL, SQLite)
230+
- You have only one seek column
231+
- Any of your seek columns are nullable (`Option[T]`)
232+
- You have **mixed sort directions** (e.g., `col1.asc, col2.desc`)
233+
- You're unsure about database compatibility
234+
235+
### Type Safety Guarantees
236+
237+
`SlickPgTupleSeeker` enforces constraints at **compile time**:
238+
239+
```scala
240+
// ✅ CORRECT: All non-nullable, uniform direction
241+
val ascSeeker = users.toPgTupleSeekerAsc
242+
.seek(_.name) // String - OK
243+
.seek(_.age) // Int - OK
244+
.seek(_.id) // Int - OK
245+
246+
// ✅ CORRECT: All DESC
247+
val descSeeker = users.toPgTupleSeekerDesc
248+
.seek(_.createdAt) // Timestamp - OK
249+
.seek(_.id) // Int - OK
250+
251+
// ❌ COMPILE ERROR: Nullable column
252+
val broken1 = users.toPgTupleSeekerAsc
253+
.seek(_.name)
254+
.seek(_.email) // Option[String] → COMPILE ERROR!
255+
.seek(_.id)
256+
// Error: No given instance of type slick.ast.BaseTypedType[Option[String]]
257+
258+
// ❌ IMPOSSIBLE: Mixed directions (type system prevents it)
259+
// Once you choose Asc or Desc, ALL columns must be that direction
260+
```
261+
262+
### Example with Multiple Columns
263+
264+
```scala
265+
// All columns ASC
266+
val seeker = orders.toPgTupleSeekerAsc
267+
.seek(_.status)
268+
.seek(_.priority)
269+
.seek(_.createdAt)
270+
.seek(_.id)
271+
272+
// Or all columns DESC
273+
val descSeeker = orders.toPgTupleSeekerDesc
274+
.seek(_.createdAt)
275+
.seek(_.priority)
276+
.seek(_.status)
277+
.seek(_.id)
278+
279+
val page = db.run(seeker.page(limit = 50, cursor = None))
280+
```
281+
282+
This generates:
283+
```sql
284+
WHERE (status, priority, created_at, id) > (?, ?, ?, ?)
285+
ORDER BY status ASC, priority ASC, created_at ASC, id ASC
286+
```
287+
288+
**Important:** All columns must have the same direction (all ASC or all DESC). For mixed directions, use the standard `SlickSeeker`.
289+
290+
### Performance Benefits
291+
292+
PostgreSQL tuple comparison offers measurable benefits:
293+
294+
**Query Complexity:**
295+
- Standard: `O(n)` comparisons where `n` = number of columns
296+
- Tuple: `O(1)` single tuple comparison
297+
298+
**Example with 4 columns:**
299+
300+
Standard approach:
301+
```sql
302+
WHERE (col1 > ?) OR
303+
(col1 = ? AND col2 > ?) OR
304+
(col1 = ? AND col2 = ? AND col3 > ?) OR
305+
(col1 = ? AND col2 = ? AND col3 = ? AND col4 > ?)
306+
-- 10 comparisons, 4 parameters repeated
307+
```
308+
309+
Tuple approach:
310+
```sql
311+
WHERE (col1, col2, col3, col4) > (?, ?, ?, ?)
312+
-- 1 comparison, 4 parameters
313+
```
314+
315+
**Benefits:**
316+
- Simpler query plans (easier for PostgreSQL optimizer)
317+
- Better index utilization (composite index scanned as single key)
318+
- Cleaner logs and explain plans
319+
- Reduced parsing overhead
320+
321+
### Complete Working Example
322+
323+
```scala
324+
import slick.jdbc.PostgresProfile
325+
import io.github.devnico.slickseeker._
326+
import io.github.devnico.slickseeker.playjson._
327+
328+
// 1. Setup profile
329+
trait MyPostgresProfile extends PostgresProfile
330+
with SlickSeekerSupport
331+
with PlayJsonSeekerSupport {
332+
333+
object MyApi extends API with SeekImplicits with JsonSeekerImplicits
334+
override val api: MyApi.type = MyApi
335+
}
336+
337+
object MyPostgresProfile extends MyPostgresProfile
338+
339+
// 2. Import API
340+
import MyPostgresProfile.api._
341+
import scala.concurrent.ExecutionContext.Implicits.global
342+
343+
// 3. Define schema
344+
case class Product(
345+
id: Int,
346+
name: String,
347+
category: String,
348+
price: BigDecimal,
349+
stock: Int
350+
)
351+
352+
class Products(tag: Tag) extends Table[Product](tag, "products") {
353+
def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
354+
def name = column[String]("name")
355+
def category = column[String]("category")
356+
def price = column[BigDecimal]("price")
357+
def stock = column[Int]("stock")
358+
def * = (id, name, category, price, stock).mapTo[Product]
359+
}
360+
361+
val products = TableQuery[Products]
362+
363+
// 4. Create type-safe seeker
364+
val seeker = products.toPgTupleSeekerAsc
365+
.seek(_.category) // Group by category
366+
.seek(_.price) // Then by price
367+
.seek(_.id) // Tiebreaker
368+
369+
// 5. Paginate
370+
val db = Database.forConfig("mydb")
371+
372+
val page1 = db.run(seeker.page(limit = 50, cursor = None))
373+
// PaginatedResult(total=1000, items=[...], nextCursor=Some("..."))
374+
375+
val page2 = page1.flatMap { p1 =>
376+
db.run(seeker.page(limit = 50, cursor = p1.nextCursor))
377+
}
378+
379+
// 6. Reverse direction for descending sort
380+
val descSeeker = products.toPgTupleSeekerDesc
381+
.seek(_.price) // Most expensive first
382+
.seek(_.category) // Then by category
383+
.seek(_.id) // Tiebreaker
384+
385+
val expensiveFirst = db.run(descSeeker.page(limit = 10, cursor = None))
386+
```
387+
388+
### Migration Guide
389+
390+
If you're currently using `SlickSeeker` with uniform non-nullable columns on PostgreSQL:
391+
392+
**Before:**
393+
```scala
394+
val seeker = users.toSeeker
395+
.seek(_.lastName.asc)
396+
.seek(_.firstName.asc)
397+
.seek(_.id.asc)
398+
```
399+
400+
**After (Type-Safe):**
401+
```scala
402+
val seeker = users.toPgTupleSeekerAsc
403+
.seek(_.lastName)
404+
.seek(_.firstName)
405+
.seek(_.id)
406+
```
407+
408+
**Migration checklist:**
409+
1. ✅ All columns non-nullable? (no `Option[T]`)
410+
2. ✅ All columns same direction? (all ASC or all DESC)
411+
3. ✅ Database is PostgreSQL 8.2+ or H2 in PostgreSQL mode?
412+
4. ✅ Want compile-time safety?
413+
414+
If all yes → migrate to `toPgTupleSeekerAsc` / `toPgTupleSeekerDesc`
415+
416+
### Common Pitfalls
417+
418+
#### ❌ Trying to Mix Directions
419+
420+
```scala
421+
// This won't compile - direction fixed at seeker level
422+
val seeker = products.toPgTupleSeekerAsc
423+
.seek(_.name)
424+
// No way to make price DESC - type system prevents it!
425+
```
426+
427+
**Solution:** Use standard `SlickSeeker` for mixed directions.
428+
429+
#### ❌ Using Nullable Columns
430+
431+
```scala
432+
case class User(id: Int, name: String, email: Option[String])
433+
434+
// This won't compile - email is Option[String]
435+
val seeker = users.toPgTupleSeekerAsc
436+
.seek(_.name)
437+
.seek(_.email) // ❌ Error: No given instance of BaseTypedType[Option[String]]
438+
```
439+
440+
**Solution:** Use standard `SlickSeeker` or filter out nulls beforehand:
441+
```scala
442+
val activeUsers = users.filter(_.email.isDefined)
443+
// Still can't use PgTupleSeeker because email is still Option[String] type
444+
445+
// Better: Use standard SlickSeeker with nulls handling
446+
val seeker = users.toSeeker
447+
.seek(_.name.asc)
448+
.seek(_.email.nullsLast.asc)
449+
.seek(_.id.asc)
450+
```
451+
452+
#### ❌ Database Not PostgreSQL
453+
454+
```scala
455+
// Using MySQL or SQLite?
456+
val seeker = users.toPgTupleSeekerAsc // ❌ Will fail at runtime!
457+
.seek(_.name)
458+
.seek(_.id)
459+
460+
// Runtime error: Syntax error in SQL
461+
// MySQL/SQLite don't support tuple comparison
462+
```
463+
464+
**Solution:** Use standard `SlickSeeker` for database portability.
465+
466+
### Best Practices
467+
468+
**1. Use PgTupleSeeker When:**
469+
```scala
470+
// ✅ PostgreSQL, non-nullable columns, uniform direction
471+
val fastSeeker = orders.toPgTupleSeekerDesc
472+
.seek(_.createdAt) // Latest first
473+
.seek(_.id) // Tiebreaker
474+
```
475+
476+
**2. Use Standard SlickSeeker When:**
477+
```scala
478+
// ✅ Need nullable handling
479+
val nullableSeeker = users.toSeeker
480+
.seek(_.email.nullsLast.asc)
481+
.seek(_.id.asc)
482+
483+
// ✅ Need mixed directions
484+
val mixedSeeker = products.toSeeker
485+
.seek(_.featured.desc) // Featured first
486+
.seek(_.price.asc) // Then cheapest
487+
.seek(_.id.asc) // Tiebreaker
488+
489+
// ✅ Need database portability
490+
val portableSeeker = items.toSeeker // Works on MySQL, SQLite, H2, etc.
491+
.seek(_.name.asc)
492+
.seek(_.id.asc)
493+
```
494+
495+
**3. Always Include a Unique Tiebreaker:**
496+
```scala
497+
// ✅ GOOD: id is unique
498+
val seeker = products.toPgTupleSeekerAsc
499+
.seek(_.category)
500+
.seek(_.price)
501+
.seek(_.id) // Ensures stable pagination
502+
503+
// ❌ BAD: price might have duplicates
504+
val badSeeker = products.toPgTupleSeekerAsc
505+
.seek(_.category)
506+
.seek(_.price) // No unique tiebreaker - unstable pagination!
507+
```
508+
509+
**4. Match Index Structure:**
510+
```sql
511+
-- If you have this index:
512+
CREATE INDEX idx_products_category_price_id ON products(category, price, id);
513+
514+
-- Use this seeker to leverage it:
515+
val seeker = products.toPgTupleSeekerAsc
516+
.seek(_.category) -- Matches index order
517+
.seek(_.price)
518+
.seek(_.id)
519+
```
520+
521+
### Troubleshooting
522+
523+
**Compile Error: "No given instance of type BaseTypedType[Option[String]]"**
524+
525+
```scala
526+
// You're trying to use a nullable column
527+
val seeker = users.toPgTupleSeekerAsc
528+
.seek(_.email) // email is Option[String]
529+
```
530+
531+
**Fix:** Use standard `SlickSeeker` or ensure column is non-nullable in schema.
532+
533+
**Compile Error: "value toPgTupleSeekerAsc is not a member"**
534+
535+
```scala
536+
// You haven't imported the profile API
537+
val seeker = users.toPgTupleSeekerAsc //
538+
```
539+
540+
**Fix:** Import your profile API:
541+
```scala
542+
import MyPostgresProfile.api._
543+
```
544+
545+
**Runtime Error: "Syntax error near '>'"**
546+
547+
Database doesn't support tuple comparison. Use standard `SlickSeeker`.
548+
186549
## Custom Cursor Environments
187550

188551
Following are only examples and not meant to copy as-is. Adjust for your use case.

docs/docs/index.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Type-safe, high-performance cursor-based pagination for Slick 3.5+.
77
- **Keyset Pagination** - O(1) performance regardless of page depth
88
- **Bidirectional** - Navigate forward and backward through result sets
99
- **Type-Safe** - Compile-time verification of cursor/column matching
10+
- **PostgreSQL Tuple Optimization** - Compile-time safe tuple comparisons for PostgreSQL (NEW!)
1011
- **Profile Agnostic** - Works with any Slick JDBC profile (PostgreSQL, MySQL, H2, SQLite, Oracle, etc.)
1112
- **Flexible Ordering** - Support for nulls first/last, custom enum orders
1213
- **Modular** - Core + optional Play JSON integration
@@ -49,8 +50,8 @@ Add to your `build.sbt`:
4950

5051
```scala
5152
libraryDependencies ++= Seq(
52-
"io.github.devnico" %% "slick-seeker" % "0.3.3",
53-
"io.github.devnico" %% "slick-seeker-play-json" % "0.3.3" // Optional, but you need some kind of cursor encoder
53+
"io.github.devnico" %% "slick-seeker" % "0.4.0",
54+
"io.github.devnico" %% "slick-seeker-play-json" % "0.4.0" // Optional, but you need some kind of cursor encoder
5455
)
5556
```
5657

0 commit comments

Comments
 (0)