@@ -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
188551Following are only examples and not meant to copy as-is. Adjust for your use case.
0 commit comments