Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
164 lines
4.3 KiB
Go
164 lines
4.3 KiB
Go
// Copyright © 2025 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package keysetpagination
|
|
|
|
import (
|
|
"database/sql/driver"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/ory/pop/v6"
|
|
)
|
|
|
|
type Order int
|
|
|
|
const (
|
|
OrderAscending Order = iota
|
|
OrderDescending
|
|
)
|
|
|
|
func (o Order) extract() (string, string) {
|
|
switch o {
|
|
case OrderAscending:
|
|
return ">", "ASC"
|
|
case OrderDescending:
|
|
return "<", "DESC"
|
|
default:
|
|
panic(fmt.Sprintf("keyset pagination: unknown order %d", o))
|
|
}
|
|
}
|
|
|
|
// Paginate returns a function that paginates a pop.Query.
|
|
// Usage:
|
|
//
|
|
// q := c.Where("foo = ?", foo).Scope(keysetpagination.Paginate[MyItemType](paginator))
|
|
func Paginate[I any](p *Paginator) pop.ScopeFunc {
|
|
model := pop.Model{Value: *new(I)}
|
|
tableName := model.Alias()
|
|
return func(q *pop.Query) *pop.Query {
|
|
quoteAndContextualize := func(name string) string {
|
|
quote := q.Connection.Dialect.Quote
|
|
return quote(tableName) + "." + quote(name)
|
|
}
|
|
dialect := q.Connection.Dialect.Name()
|
|
where, args, order := BuildWhereAndOrder(p.PageToken().Columns(), quoteAndContextualize, dialect)
|
|
// IMPORTANT: Ensures correct query logic by grouping conditions.
|
|
// Without parentheses, `WHERE otherCond AND pageCond1 OR pageCond2` would be
|
|
// evaluated as `(otherCond = ? AND pageCond1) OR pageCond2`, potentially returning
|
|
// rows that do not match `otherCond`.
|
|
// We fix it by forcing the query to be: `WHERE otherCond AND (paginationCond1 OR paginationCond2)`.
|
|
where = "(" + where + ")"
|
|
|
|
return q.
|
|
Where(where, args...).
|
|
Order(order).
|
|
Limit(p.Size() + 1)
|
|
}
|
|
}
|
|
|
|
func BuildWhereAndOrder(columns []Column, quote func(string) string, dialect string) (string, []any, string) {
|
|
var whereBuilder, orderByBuilder, prevEqual strings.Builder
|
|
|
|
keysetCols := make([]Column, 0, len(columns))
|
|
|
|
// ORDER BY includes all columns (even constrained ones)
|
|
for i, part := range columns {
|
|
column := quote(part.Name)
|
|
_, keyword := part.Order.extract()
|
|
|
|
if i > 0 {
|
|
orderByBuilder.WriteString(", ")
|
|
}
|
|
|
|
orderByBuilder.WriteString(column + " " + keyword)
|
|
|
|
// Postgres orders NULLs differently depending on sort direction;
|
|
// (ASC → NULLS LAST, DESC → NULLS FIRST), which does not match the
|
|
// assumptions of our keyset pagination logic and other supported DBs.
|
|
// We therefore make NULL ordering explicit on Postgres (and YugabyteDB,
|
|
// which shares Postgres NULL ordering) to keep pagination stable and
|
|
// consistent with sqlite/mysql/cockroachdb.
|
|
if (dialect == "postgres" || dialect == "yugabyte") && part.Nullable {
|
|
if part.Order == OrderAscending {
|
|
orderByBuilder.WriteString(" NULLS FIRST")
|
|
} else {
|
|
orderByBuilder.WriteString(" NULLS LAST")
|
|
}
|
|
}
|
|
|
|
// Build keyset WHERE only from unconstrained columns
|
|
if !part.HasConstraint {
|
|
keysetCols = append(keysetCols, part)
|
|
}
|
|
}
|
|
|
|
// If everything is constrained, no keyset predicate is needed.
|
|
if len(keysetCols) == 0 {
|
|
return "", nil, orderByBuilder.String()
|
|
}
|
|
|
|
args := make([]any, 0, len(keysetCols)*(len(keysetCols)+1)/2)
|
|
prevEqualArgs := make([]any, 0, len(keysetCols))
|
|
|
|
whereBuilder.WriteRune('(')
|
|
|
|
for i, part := range keysetCols {
|
|
column := quote(part.Name)
|
|
sign, _ := part.Order.extract()
|
|
|
|
if i > 0 {
|
|
whereBuilder.WriteString(") OR (")
|
|
}
|
|
|
|
whereBuilder.WriteString(prevEqual.String())
|
|
if prevEqual.Len() > 0 {
|
|
whereBuilder.WriteString(" AND ")
|
|
}
|
|
|
|
isNull := part.Nullable && isSQLNull(part.Value)
|
|
|
|
if !part.Nullable {
|
|
whereBuilder.WriteString(column + " " + sign + " ?")
|
|
} else if !isNull {
|
|
whereBuilder.WriteString(column + " IS NOT NULL AND " + column + " " + sign + " ?")
|
|
} else {
|
|
whereBuilder.WriteString(column + " IS NOT NULL")
|
|
}
|
|
|
|
if i > 0 {
|
|
prevEqual.WriteString(" AND ")
|
|
}
|
|
|
|
if !part.Nullable {
|
|
prevEqual.WriteString(column + " = ?")
|
|
prevEqualArgs = append(prevEqualArgs, part.Value)
|
|
} else if !isNull {
|
|
prevEqual.WriteString(column + " = ?")
|
|
prevEqualArgs = append(prevEqualArgs, part.Value)
|
|
} else {
|
|
prevEqual.WriteString(column + " IS NULL")
|
|
}
|
|
|
|
args = append(args, prevEqualArgs...)
|
|
}
|
|
|
|
whereBuilder.WriteRune(')')
|
|
|
|
return whereBuilder.String(), args, orderByBuilder.String()
|
|
}
|
|
|
|
// isSQLNull reports whether v represents a SQL NULL value.
|
|
func isSQLNull(v any) bool {
|
|
if v == nil {
|
|
return true
|
|
}
|
|
|
|
if valuer, ok := v.(driver.Valuer); ok {
|
|
val, err := valuer.Value()
|
|
return err == nil && val == nil
|
|
}
|
|
|
|
return false
|
|
}
|