kuysor

package module
v1.0.9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 19, 2026 License: MIT Imports: 8 Imported by: 2

README

Kuysor

Tired of wrestling with complex SQL for cursor pagination? Kuysor is here to help. This lightweight Go SDK makes cursor-based pagination straightforward, letting you focus on your application logic instead of database plumbing.

What is Cursor Pagination?

Cursor-based pagination (aka Keyset Pagination) is a more efficient and scalable alternative to offset-based pagination, particularly for large datasets. Instead of specifying an offset, it uses a cursor—a unique identifier from the last retrieved record—to determine the starting point for the next / previous set of results.

Why choose kuysor?

  • Designed for simplicity and ease of use.
  • The only Go SDK built for this purpose.
  • Compatible with most if not all SQL-based databases
  • Supports multiple sorting columns and ordering directions.
  • Handles nullable columns in sorting (maximum one nullable column).
  • Zero dependencies.

How kuysor works?

  1. Kuysor modifies your SQL query to support cursor-based pagination, appending the necessary ORDER BY, LIMIT, and WHERE clauses.
  2. You execute the modified query to fetch paginated data from your database.
  3. Kuysor then sanitizes the data and generates the next and previous cursors.
  4. When fetching the next or previous page, you pass the generated cursor back to Kuysor, which modifies the query accordingly, repeating the process from Step 1.

Installation

go get github.com/redhajuanda/kuysor

Quick Start

Retrieving the First Page

Suppose you have a query like this:

SELECT a.id, a.code, a.name 
FROM account a 
WHERE a.status = ? 
ORDER BY a.code ASC, a.id DESC
LIMIT ?

To implement Kuysor, first you need to remove the ORDER BY and LIMIT clauses from your original query. And set the ORDER BY and LIMIT clauses using Kuysor's methods WithOrderBy() and WithLimit().

So the query will look like this:

SELECT a.id, a.code, a.name
FROM account a 
WHERE a.status = ?

Then, you can use Kuysor to modify the query and add the ORDER BY and LIMIT clauses. Here's how to do it:

package main
import (
	"fmt"
	"github.com/redhajuanda/kuysor"
)

func main() {
	query := `
		SELECT a.id, a.code, a.name 
		FROM account a 
		WHERE a.status = ?
	`
	args := []interface{}{"active"}

	ks, err := kuysor.
		NewQuery(query, kuysor.Cursor).
		WithOrderBy("a.code", "-a.id"). // Required. Defines the order by. Prefix columns with `-` for descending order, `+` for ascending order. Default is ascending.
		WithLimit(10). // Override the default limit.
		WithArgs(args...). // Since Kuysor modifies the query by appending additional conditions and limit, it also adjusts the argument list accordingly, and ensuring the generated arguments are placed in the correct order.
		Build()
	if err != nil {
		panic(err)
	}

	fmt.Println(ks.Query) // Prints the modified query
	fmt.Println(ks.Args)  // Prints the modified arguments
}

Return query:

SELECT a.id, a.code, a.name FROM account a WHERE a.status = ? ORDER BY a.code ASC, a.id DESC LIMIT ? -- ORDER BY and LIMIT are automatically appended based on the options

Return args:

["active", 11] // 11 is automatically appended to the arguments based on the limit + 1, additional 1 is used to check if there are more data to fetch for the next page
Fetching The Data

Use the modified query and arguments from the previous step to fetch the data from your database like usual.

For example, using the database/sql package:

type Account struct {
	ID     int    	`kuysor:"a.id"`
	Code   string 	`kuysor:"a.code"`
	Name   string 	`kuysor:"a.name"`
	Status *string 	`kuysor:"a.status"`
}

// fetching the data
rows, err := db.Query(res.Query, res.Args...)
if err != nil {
	return Result{}, err
}
defer rows.Close()
var result = make([]Account, 0)
for rows.Next() {
	var row Account
	err = rows.Scan(&row.ID, &row.Code, &row.Name, &row.Status)
	if err != nil {
		return Result{}, err
	}
	result = append(result, row)
}
Sanitizing the Result

Cursor pagination can sometimes return extra data or even reverse the order of results. This is because the query may return more than the specified limit, and the order of the results may not match the expected order due to the cursor. To handle the extra data and correct the order, use the SanitizeStruct() or SanitizeMap() function. These functions will trim the extra data, correct the order when using a previous cursor, and generate the next and previous cursors properly.

// sanitize the data
next, prev, err := res.SanitizeStruct(&result)
if err != nil {
	return Result{}, err
}

Note: struct tags are required to match the column names in the query. Kuysor uses these tags to map the struct fields to the query columns, so it can generate the next and previous cursors correctly.

Retrieving The Next/Previous Page

To fetch the next or previous page, simply include the cursor from the previous query result.

package main
import (
	"fmt"
	"github.com/redhajuanda/kuysor"
)
func main() {
	query := `SELECT a.id, a.code, a.name FROM account a WHERE a.status = ?`
	args := []interface{}{"active"}

	ks, err := kuysor.
		NewQuery(query, kuysor.Cursor).
		WithLimit(10). 
		WithOrderBy("a.code", "-a.id"). 
		WithArgs(args...).
		WithCursor("xxx"). // the query will start from the cursor
		Build()
	if err != nil {
		panic(err)
	}

	fmt.Println(ks.Query) // Prints the modified query
	fmt.Println(ks.Args)  // Prints the modified arguments

	// >
	// execute the query and get the result
	// ...

	// >
	// sanitize the result and get the next and previous cursor
	// ...
}

Return query:

SELECT a.id, a.`code`, a.`name` FROM account as a 
WHERE a.`status` = ? AND 
(a.`code` > ? OR (a.`code` = ? AND a.id < ?))
ORDER BY a.`code` ASC, a.id DESC
LIMIT ?

Return args:

["active", "C", "C", 3, 11]
Converting to Count Query

Use NewCount to convert a SELECT query into a COUNT query for pagination metadata (total row count). Pass the same query you use for data fetching — Kuysor handles all the structural transformations needed to produce a correct scalar count.

Basic usage
query := "SELECT id, code, name FROM users WHERE status = ?"

// Default: COUNT(*)
countQuery, err := kuysor.NewCount(query).Build()
// → SELECT COUNT(*) FROM users WHERE status = ?

// COUNT(1)
countQuery, err := kuysor.NewCount(query).UseColumn("1").Build()
// → SELECT COUNT(1) FROM users WHERE status = ?

// COUNT(id) or COUNT(t.id)
countQuery, err := kuysor.NewCount(query).UseColumn("id").Build()
// → SELECT COUNT(id) FROM users WHERE status = ?

WHERE, JOIN, and CTE clauses are preserved. Only the main SELECT list is replaced.

GROUP BY and HAVING — subquery wrapping

A query with GROUP BY returns one row per group, so a naive SELECT COUNT(*) … GROUP BY … returns multiple rows instead of a single total. Kuysor detects this and wraps the original query in a subquery:

query := "SELECT department, COUNT(employee_id) FROM employees GROUP BY department"

countQuery, err := kuysor.NewCount(query).Build()
// → SELECT COUNT(*) FROM (
//       SELECT department, COUNT(employee_id) FROM employees GROUP BY department
//   ) kuysor_count

HAVING is preserved inside the subquery so group filters remain effective:

query := "SELECT department FROM employees GROUP BY department HAVING COUNT(*) > 5"

countQuery, err := kuysor.NewCount(query).Build()
// → SELECT COUNT(*) FROM (
//       SELECT department FROM employees GROUP BY department HAVING COUNT(*) > 5
//   ) kuysor_count
DISTINCT — subquery wrapping

SELECT DISTINCT must also be wrapped. Without wrapping, COUNT(*) counts all rows and ignores the DISTINCT, producing an incorrect (inflated) total:

query := "SELECT DISTINCT department FROM employees"

countQuery, err := kuysor.NewCount(query).Build()
// → SELECT COUNT(*) FROM (SELECT DISTINCT department FROM employees) kuysor_count
//
// Without wrapping this would be: SELECT COUNT(*) FROM employees
// which counts all rows, not distinct departments.
UNION / UNION ALL — subquery wrapping

A top-level UNION must be wrapped too. Without wrapping, SELECT COUNT(*) FROM t1 UNION SELECT … FROM t2 returns multiple rows (one per union branch) instead of a single total:

query := "SELECT id FROM employees UNION ALL SELECT id FROM contractors"

countQuery, err := kuysor.NewCount(query).Build()
// → SELECT COUNT(*) FROM (
//       SELECT id FROM employees UNION ALL SELECT id FROM contractors
//   ) kuysor_count
ORDER BY, LIMIT, and OFFSET — always stripped

These clauses are meaningless for a count and are always removed, whether or not wrapping is needed:

query := "SELECT id, name FROM users WHERE status = ? ORDER BY name LIMIT 10 OFFSET 20"

countQuery, err := kuysor.NewCount(query).Build()
// → SELECT COUNT(*) FROM users WHERE status = ?
//   (ORDER BY, LIMIT, OFFSET removed)

When wrapping is needed (GROUP BY / DISTINCT / UNION), ORDER BY and LIMIT are stripped from the inner query before wrapping, so they don't accidentally limit the counted rows:

query := "SELECT department, COUNT(*) FROM employees GROUP BY department ORDER BY department LIMIT 10"

countQuery, err := kuysor.NewCount(query).Build()
// → SELECT COUNT(*) FROM (
//       SELECT department, COUNT(*) FROM employees GROUP BY department
//       -- ORDER BY and LIMIT stripped from inner query
//   ) kuysor_count
CTE queries

CTEs are always preserved at the statement level so the inner subquery can reference them:

query := `
    WITH dept AS (SELECT id, name FROM departments)
    SELECT d.name, COUNT(*) FROM employees e
    JOIN dept d ON e.dept_id = d.id
    GROUP BY d.name
`
countQuery, err := kuysor.NewCount(query).Build()
// → WITH dept AS (SELECT id, name FROM departments)
//   SELECT COUNT(*) FROM (
//       SELECT d.name, COUNT(*) FROM employees e
//       JOIN dept d ON e.dept_id = d.id GROUP BY d.name
//   ) kuysor_count
Database compatibility

The subquery alias kuysor_count is written without the AS keyword (FROM (...) kuysor_count), which is compatible with all major databases including Oracle (which does not support AS for table aliases in FROM).

Database Supported
MySQL
PostgreSQL
SQL Server
Snowflake
Oracle
Handling Nullable Columns

If sorting involves nullable columns, specify them explicitly by adding null after the column name. This is mandatory to handle null values correctly, as they can affect the order of results. To indicate a nullable column, append null after the column name, like so:

WithOrderBy("a.status null", "a.id")

This generates the following SQL query:

ORDER BY a.status IS NULL ASC, a.status ASC,  a.id ASC

You can also specify the sorting method for the nullable sorting column. Kuysor provides 3 methods to handle sorting of nullable columns:

  • BoolSort: ORDER BY a.status IS NULL ASC, a.status ASC // the default, supported by most sql databases
  • FirstLast: ORDER BY a.status ASC NULLS LAST // Only supported by few databases
  • CaseWhen: ORDER BY CASE WHEN a.status IS NULL THEN 1 ELSE 0 END ASC, a.status ASC // work around for databases that doesn't support both direct boolean in order by and aslo NULLS FIRST/NULLS LAST (like MySQL before 8.0)

To specify the method to use, you can set it in the global SetGlobalOptions, in the instance level, or in the query level.

Here's an example of how to use FirstLast at the query level:

ks, err := kuysor.
	NewQuery(query, kuysor.Cursor).
	WithOrderBy("a.status null", "a.id").
	WithNullSortMethod(kuysor.FirstLast). // set the null sort method to FirstLast
	WithLimit(10).
	WithArgs(args...).
	Build()

Note: You can only use one nullable column in the sort, due to complexity of the query, it will beat the purpose of using cursor pagination in the first place.

Ensuring Unique Ordering

Cursor pagination requires that the ordering is based on at least one unique column or a combination of columns that are unique. Kuysor does not validate the uniqueness of the order columns - it is the user's responsibility to ensure that the ordering criteria are unique.

To avoid issues, always include the primary key as the last ordering column when defining your pagination rules. This ensures that even if your main sorting column contains duplicate values (including NULL), pagination remains stable.

Paginating Inside a CTE (WithCTETarget)

Some queries use a CTE (Common Table Expression) to pre-filter rows, and the pagination clauses — cursor WHERE condition, ORDER BY, and LIMIT — must go inside the CTE body rather than the outer SELECT. This is common when:

  • The CTE performs expensive filtering and you want keyset pagination to operate on the pre-filtered set.
  • The main query joins the CTE to other tables, adds aggregations, or computes columns that should not interfere with the keyset WHERE condition.
The Problem

Without WithCTETarget, Kuysor places all modifications on the outermost SELECT. In a CTE-driven query this is wrong: the cursor WHERE clause refers to columns that only exist inside the CTE, and the LIMIT would apply after all joins/aggregations rather than before.

-- ❌ Without WithCTETarget: WHERE / ORDER BY / LIMIT land on the outer SELECT
WITH filtered_ticket AS (
    SELECT t.id
    FROM ticket t
    WHERE t.status = ?
    -- ORDER BY and LIMIT should go here, inside the CTE
)
SELECT t.id, t.code, ...
FROM filtered_ticket ft
JOIN ticket t ON t.id = ft.id
GROUP BY t.id
Basic Usage

Use WithCTETarget("cte_name") to tell Kuysor which CTE body to modify.
Kuysor locates the named CTE (case-insensitive), routes the cursor WHERE, ORDER BY, and LIMIT inside it, and mirrors the same ORDER BY on the main query so the final result set is returned in a consistent order.

query := `
    WITH filtered_ticket AS (
        SELECT t.id
        FROM ticket t
        WHERE t.status = ?
    )
    SELECT t.id, t.code
    FROM filtered_ticket ft
    JOIN ticket t ON t.id = ft.id
    GROUP BY t.id
`

// First page
res, err := kuysor.
    NewQuery(query, kuysor.Cursor).
    WithCTETarget("filtered_ticket"). // ← route modifications into this CTE
    WithOrderBy("-t.id").             // descending by t.id
    WithLimit(10).
    WithArgs("active").               // argument for WHERE t.status = ?
    Build()

Generated SQL — first page:

WITH filtered_ticket AS (
    SELECT t.id FROM ticket t
    WHERE t.status = ?
    ORDER BY t.id DESC   -- ← appended inside CTE body
    LIMIT ?              -- ← appended inside CTE body (limit+1 = 11)
)
SELECT t.id, t.code
FROM filtered_ticket ft JOIN ticket t ON t.id = ft.id
GROUP BY t.id
ORDER BY t.id DESC       -- ← mirrored on main query (default behaviour)
Args: ["active", 11]

Generated SQL — next page (cursor passed via WithCursor):

WITH filtered_ticket AS (
    SELECT t.id FROM ticket t
    WHERE (t.status = ?) AND (t.id < ?)  -- ← cursor condition appended inside CTE
    ORDER BY t.id DESC
    LIMIT ?
)
SELECT t.id, t.code ...
GROUP BY t.id
ORDER BY t.id DESC
Args: ["active", "<last_id>", 11]

Generated SQL — previous page (prev cursor passed via WithCursor):

WITH filtered_ticket AS (
    SELECT t.id FROM ticket t
    WHERE (t.status = ?) AND (t.id > ?)  -- ← cursor condition appended inside CTE
    ORDER BY t.id ASC   -- ← direction reversed for prev page, inside CTE
    LIMIT ?
)
SELECT t.id, t.code ...
GROUP BY t.id
ORDER BY t.id ASC       -- ← reversed direction mirrored on main query
Args: ["active", "<first_id>", 11]

Note: For previous-page queries the result set is automatically re-reversed by SanitizeStruct / SanitizeMap, so the caller always receives rows in the original sort order.

Per-Clause Routing with CTEOptions

By default Kuysor uses sensible routing for each clause:

Clause Default routing
ORDER BY Both CTE body and main query (CTETargetModeBoth)
LIMIT / OFFSET CTE body only (CTETargetModeCTE)
Cursor WHERE CTE body only (CTETargetModeCTE)

You can override any of these by passing a CTEOptions value as the second argument to WithCTETarget:

res, err := kuysor.
    NewQuery(query, kuysor.Cursor).
    WithCTETarget("filtered_ticket", kuysor.CTEOptions{
        OrderBy:     kuysor.CTETargetModeBoth, // ORDER BY in CTE + mirrored on main (default)
        LimitOffset: kuysor.CTETargetModeCTE,  // LIMIT only inside CTE (default)
        Where:       kuysor.CTETargetModeCTE,  // cursor WHERE only inside CTE (default)
    }).
    WithOrderBy("-t.id").
    WithLimit(10).
    WithArgs("active").
    Build()

The three routing modes available for each clause are:

Mode Constant Description
Default CTETargetModeDefault (zero value) Uses the natural default for that clause (see table above).
CTE only CTETargetModeCTE Clause is injected inside the named CTE body only.
Main only CTETargetModeMain Clause is injected on the outer SELECT only; the CTE is not modified.
Both CTETargetModeBoth Clause is injected in both places. Arguments are duplicated when Both is used for LIMIT/OFFSET or WHERE.

Example — LIMIT on main query only (useful when the CTE already has its own LIMIT):

WithCTETarget("filtered_ticket", kuysor.CTEOptions{
    LimitOffset: kuysor.CTETargetModeMain,
})

Example — ORDER BY inside CTE only, no mirroring on main query:

WithCTETarget("filtered_ticket", kuysor.CTEOptions{
    OrderBy: kuysor.CTETargetModeCTE,
})

Example — cursor WHERE on both CTE and main query (e.g. to apply double filtering):

WithCTETarget("filtered_ticket", kuysor.CTEOptions{
    Where: kuysor.CTETargetModeBoth,
})
// Note: cursor arg is duplicated → Args: ["active", "<cursor_id>", "<cursor_id>", 11]
Multiple CTEs

If your query has several CTEs, WithCTETarget modifies only the named one; all other CTEs are left unchanged:

query := `
    WITH stats AS (
        SELECT user_id, COUNT(*) AS total FROM orders GROUP BY user_id
    ),
    active_users AS (
        SELECT u.id FROM users u WHERE u.status = ?
    )
    SELECT u.id, u.name, s.total
    FROM active_users au
    JOIN users u ON u.id = au.id
    LEFT JOIN stats s ON s.user_id = u.id
`

res, err := kuysor.
    NewQuery(query, kuysor.Cursor).
    WithCTETarget("active_users"). // only active_users is modified; stats is untouched
    WithOrderBy("u.id").
    WithLimit(20).
    WithArgs("active").
    Build()
Constraints
Constraint Behaviour
Query must contain a WITH clause Build() returns an error if no CTE is present.
CTE name must exist in the query Build() returns an error if the name is not found (case-insensitive match).
Sort columns must appear in the CTE's SELECT Required for cursor generation — the same rule as standard cursor pagination.
One nullable sort column maximum Same limitation as standard cursor pagination.
Configuring Options 

Kuysor provides flexible configuration / options to customize default behaviors, such as query placeholder types, default limits, struct tags, and null sorting methods. These settings can be applied globally, per instance, or at the query level to accommodate various needs.

Configuring Global Options

You can set global options that apply to all Kuysor instances within your application. This is useful for standardizing behavior across queries.

package main

import (
    "fmt"
    "github.com/redhajuanda/kuysor"
)

func main() {
    // (Optional) Configure Kuysor with custom settings, only call once at the beginning of your program
    kuysor.SetGlobalOptions(kuysor.Options{
        PlaceHolderType: kuysor.Question,  // Default: `Question`. Options: `Question`, `Dollar`, `At`.
        DefaultLimit:    10,               // Default: 10. Specifies the default query limit.
        StructTag:       "kuysor",         // Default: `kuysor`. Defines the struct tag used for field mapping.
        NullSortMethod:  kuysor.BoolSort,  // Default: `BoolSort`. Options: `BoolSort`, `FirstLast`, `CaseWhen`.
    })
}

Note: Global settings affect all queries unless overridden at the instance or query level.

Creating Instances with Custom Options

In applications that interact with multiple databases, you may need different Kuysor configurations for each database connection. Instead of modifying global settings, you can create custom instances with NewInstance().

Example: Different Placeholder Formats for PostgreSQL & MySQL

package main

import (
 "github.com/redhajuanda/kuysor"
)

func main() {
 // PostgreSQL instance using `$` placeholders
 ksPostgres := kuysor.NewInstance(kuysor.Options{
  PlaceHolderType: kuysor.Dollar, // Use `$` for parameter substitution
  DefaultLimit:    5,
 })

 // MySQL instance using `?` placeholders
 ksMysql := kuysor.NewInstance(kuysor.Options{
  PlaceHolderType: kuysor.Question, // Use `?` for parameter substitution
  DefaultLimit:    10,
 })
}
Overriding Options at the Query Level

Some options can be specified directly when building a query. These query-level settings take precedence over both global and instance options.

These options are:

  • PlaceHolderType: Use Method WithPlaceHolderType to set the placeholder type for the query.
  • Limit: Use Method WithLimit to set the limit for the query.
  • NullSortMethod: Use Method WithNullSortMethod to set the null sort method for the query.

Example:

package main

import (
 "github.com/redhajuanda/kuysor"
)

func main() {
  ks, err := kuysor.
    NewQuery("SELECT * FROM account", kuysor.Cursor).
    WithPlaceHolderType(kuysor.Dollar). // use $ placeholder
    WithLimit(5). // set the limit to 5
    WithNullSortMethod(kuysor.FirstLast) // set the null sort method to FirstLast
}
Processing the Query Result With SanitizeMap()
package main

import (
	"database/sql"
	"fmt"

	_ "github.com/go-sql-driver/mysql"
	"github.com/redhajuanda/kuysor"
)

func main() {

	// connect mysql
	db, err := sql.Open("mysql", "xxx:xxx@tcp(localhost:3300)/xxx")
	if err != nil {
		panic(err)
	}

	query := `SELECT a.id as id, a.code as code, a.name as name FROM account a WHERE a.status = ?`
	args := []any{"active"}

	ks, err := kuysor.
		NewQuery(query, kuysor.Cursor).
		WithLimit(10).
		WithOrderBy("code", "-id").
		WithArgs(args...).
		Build()
	if err != nil {
		panic(err)
	}

	rows, err := db.Query(ks.Query, ks.Args...)
	if err != nil {
		panic(err)
	}
	defer rows.Close()

	var result []map[string]interface{}

	for rows.Next() {
		var (
			id   int
			code string
			name *string
			row  = make(map[string]interface{})
		)

		err = rows.Scan(&id, &code, &name)
		if err != nil {
			panic(err)
		}

		row["id"] = id
		row["code"] = code
		row["name"] = name

		result = append(result, row)
	}

	nextCursor, prevCursor, err := ks.SanitizeMap(&result)
	if err != nil {
		panic(err)
	}

	fmt.Println(result)
	fmt.Println(nextCursor)
	fmt.Println(prevCursor)
}

Advance Usage

I wrote a brief article about how to use Kuysor in more detail, you can read it here.

Performance

Kuysor is designed for performance and efficiency. Benchmark results on Apple M1 show excellent performance characteristics for production use.

🚀 Core Performance Metrics
Operation Time (μs) Memory (KB) Allocations Performance
Basic Query Build 64.1μs 46.8 KB 473 ⭐⭐⭐⭐⭐ Excellent
Query with Cursor 131.3μs 83.2 KB 809 ⭐⭐⭐⭐ Very Good
Complex Query 160.2μs 53.2 KB 488 ⭐⭐⭐⭐ Very Good
Cursor Parsing 1.65μs 0.9 KB 20 ⭐⭐⭐⭐⭐ Blazing Fast
Result Sanitization 1.45μs 1.4 KB 22 ⭐⭐⭐⭐⭐ Blazing Fast
📈 Scalability

Sort Performance by Column Count:

  • 1 column: 113ns (3 allocs)
  • 2 columns: 206ns (5 allocs)
  • 4 columns: 352ns (8 allocs)
  • 8 columns: 688ns (13 allocs)

Linear scaling - Performance degrades gracefully with complexity.

Nullable Sort Method Comparison:

  • FirstLast: 70.3μs (fastest, PostgreSQL/Oracle)
  • CaseWhen: 72.4μs (MySQL < 8.0)
  • BoolSort: 83.4μs (most compatible)
Throughput Estimates
Operation Ops/sec (single core) Daily Capacity
Basic Query Build ~15,600 ~1.3 billion
With Cursor ~7,600 ~656 million
Cursor Parsing ~606,000 ~52 billion
🔧 Performance Tips
  1. Use Question mark placeholders (fastest: 909ns vs 1316ns for Dollar)
  2. Keep sort columns under 4 for optimal performance
  3. Use BoolSort for maximum database compatibility
Benchmarking

Run performance benchmarks on your system:

# Run all benchmarks
make bench

# Run specific benchmark categories
go test -bench=BenchmarkQueryBuild -benchmem ./...
go test -bench=BenchmarkCursor -benchmem ./...
go test -bench=BenchmarkSort -benchmem ./...

# Generate performance comparison
go test -bench=. -count=5 -benchmem ./... > baseline.txt

Benchmark Categories:

  • Query building performance across complexity levels
  • Cursor parsing and generation efficiency
  • Sort parsing with 1-8 columns
  • Memory allocation patterns and optimization
  • Concurrent access and thread safety
  • SQL modification operations (WHERE, ORDER BY, LIMIT)
  • Count query conversion performance

📋 For detailed performance analysis and optimization guides, see PERFORMANCE.md

Contributing

We welcome contributions! Please see our Contributing Guide for details on:

  • Setting up your development environment
  • Code standards and best practices
  • Testing guidelines
  • How to submit pull requests
  • Reporting issues

Support

Limitation

  • It requires that the ordering is based on at least one unique column or a combination of columns that are unique.
  • Each column in the sort must be included in the SELECT statement, and the column names must match exactly. This is because Kuysor uses the column values to generate the next and previous cursors.
  • Only one nullable column is allowed in the sort, due to complexity of the query, it will beat the purpose of using cursor pagination in the first place.
  • You need to handle indexing properly to make the query efficient.

License

This project is licensed under LICENSE - see the LICENSE file for details.

Documentation

Index

Constants

View Source
const (
	CountStar = "*"
	CountOne  = "1"
)

CountExpr constants for UseColumn. Use "*" for count(*), "1" for count(1), or a column name like "id" or "t.id" for count(id).

View Source
const (
	BoolSort  NullSortMethod = iota
	FirstLast                = iota
	CaseWhen                 = iota
)

Variables

This section is empty.

Functions

func SetGlobalOptions

func SetGlobalOptions(opt Options)

SetGlobalOptions sets the global options, which will be used by all kuysor instances. This should be called at the beginning of the application.

Types

type CTEOptions added in v1.0.4

type CTEOptions struct {
	// OrderBy controls where ORDER BY is injected.
	OrderBy CTETargetMode
	// LimitOffset controls where LIMIT and OFFSET are injected.
	LimitOffset CTETargetMode
	// Where controls where the cursor WHERE clause is injected.
	Where CTETargetMode
	// ColumnMap remaps the order-by column used inside the CTE body only, for
	// both the ORDER BY and the cursor WHERE clause. It is keyed by the original
	// order-by column (as passed to WithOrderBy, without the +/- prefix) and maps
	// to the column/expression to emit inside the CTE. The main query always keeps
	// the original column. Sort direction, LIMIT/OFFSET, and cursor values are
	// unaffected — only the rendered column text changes inside the CTE.
	//
	// Example: WithOrderBy("-t.id") + ColumnMap{"t.id": "id"} renders
	// "ORDER BY id DESC" / "id < ?" inside the CTE while the main query keeps
	// "t.id". A nil map leaves all behavior unchanged.
	ColumnMap map[string]string
}

CTEOptions provides per-clause control over where each SQL modification is applied when WithCTETarget is active. Zero value (CTETargetModeDefault) uses natural defaults:

  • OrderBy → CTETargetModeBoth (CTE body + mirrored on main)
  • LimitOffset → CTETargetModeCTE (CTE body only)
  • Where → CTETargetModeCTE (CTE body only)

type CTETargetMode added in v1.0.4

type CTETargetMode int

CTETargetMode controls where a specific SQL clause is injected when WithCTETarget is active.

const (
	// CTETargetModeDefault uses the natural default for each clause:
	// ORDER BY defaults to Both, LIMIT/OFFSET and WHERE default to CTE.
	// This is the zero value so an empty CTEOptions uses all defaults.
	CTETargetModeDefault CTETargetMode = iota
	// CTETargetModeCTE injects the clause inside the named CTE body only.
	CTETargetModeCTE
	// CTETargetModeMain injects the clause on the outer/main query only.
	CTETargetModeMain
	// CTETargetModeBoth injects the clause in both the CTE body and the main query.
	CTETargetModeBoth
)

type Count added in v1.0.4

type Count struct {
	// contains filtered or unexported fields
}

Count converts a SELECT query into a COUNT query. It replaces only the main query's SELECT clause (not in subqueries or CTEs) with count(*), count(1), or count(column). Unused LEFT JOINs (not referenced in WHERE, GROUP BY, or HAVING) are removed automatically.

func NewCount added in v1.0.4

func NewCount(query string) *Count

NewCount creates a new Count instance for converting a query to a count query. By default uses count(*). Use UseColumn to customize.

func (*Count) Build added in v1.0.4

func (c *Count) Build() (string, error)

Build converts the query to a count query and returns the result. Unused LEFT JOINs are automatically removed.

func (*Count) UseColumn added in v1.0.4

func (c *Count) UseColumn(expr string) *Count

UseColumn sets the expression to use inside count(). Use "*" for count(*), "1" for count(1), or a column name like "id" or "t.id" for count(id).

type Instance

type Instance struct {
	// contains filtered or unexported fields
}

func NewInstance

func NewInstance(opt ...Options) *Instance

NewInstance creates a new Kuysor instance with the given options. It is useful for grouping multiple queries with the same options. For example, if you manage multiple databases with different options, let's say you have Postgres and MySQL databases in the same application, you can create an instance for each database with different options, one with PlaceHolderType=Dollar and the other with PlaceHolderType=Question.

func (*Instance) NewQuery

func (i *Instance) NewQuery(query string, paginationType PaginationType) *Kuysor

NewQuery creates a new Kuysor instance. It accepts the SQL query.

type Kuysor

type Kuysor struct {
	// contains filtered or unexported fields
}

func NewQuery

func NewQuery(query string, paginationType PaginationType) *Kuysor

NewQuery creates a new Kuysor instance. It accepts the SQL query.

func (*Kuysor) Build

func (p *Kuysor) Build() (*Result, error)

Build builds the paginated / sorted query.

func (*Kuysor) WithArgs

func (p *Kuysor) WithArgs(args ...any) *Kuysor

WithArgs sets the arguments for the query.

func (*Kuysor) WithCTESecondaryTarget added in v1.0.9

func (p *Kuysor) WithCTESecondaryTarget(cteName string, opts ...CTEOptions) *Kuysor

WithCTESecondaryTarget registers an ADDITIONAL CTE whose body also receives the cursor WHERE, ORDER BY, and LIMIT — on top of the primary WithCTETarget. Use it for stacked CTEs where an upstream id-gathering CTE (e.g. a UNION of ownership lookups) should be capped early as well as the primary filtering CTE.

Constraints:

  • A primary WithCTETarget must also be set.
  • The secondary CTE must be defined BEFORE the primary CTE in the WITH clause (its injected placeholders must appear earlier in the SQL string).
  • Only opts.ColumnMap is honored; the clauses are always injected into the CTE body (never mirrored on the main query). For a UNION CTE body the union is auto-wrapped in a derived table, so the column must reference the union's output column (use ColumnMap, e.g. {"t.id": "id"}).

May be called multiple times; register secondaries in WITH-clause order.

func (*Kuysor) WithCTETarget added in v1.0.3

func (p *Kuysor) WithCTETarget(cteName string, opts ...CTEOptions) *Kuysor

WithCTETarget sets the name of the CTE whose body should receive the WHERE, ORDER BY, and LIMIT modifications instead of the main query. This is useful when pagination is intentionally performed inside a CTE for performance (e.g. to limit rows before expensive JOINs in the main query).

An optional CTEOptions argument controls per-clause routing:

  • OrderBy: where ORDER BY is injected (default: CTETargetModeBoth)
  • LimitOffset: where LIMIT/OFFSET is injected (default: CTETargetModeCTE)
  • Where: where the cursor WHERE clause is injected (default: CTETargetModeCTE)

func (*Kuysor) WithCursor

func (p *Kuysor) WithCursor(cursor string) *Kuysor

WithCursor sets the cursor for the query.

func (*Kuysor) WithLimit

func (p *Kuysor) WithLimit(limit int) *Kuysor

WithLimit sets the limit for the query.

func (*Kuysor) WithNullSortMethod

func (p *Kuysor) WithNullSortMethod(method NullSortMethod) *Kuysor

WithNullSortMethod sets the null sort method for the query. It is useful when you want to override the instance options or the global options.

func (*Kuysor) WithOffset

func (p *Kuysor) WithOffset(offset int) *Kuysor

WithOffset sets the offset for the query.

func (*Kuysor) WithOrderBy

func (p *Kuysor) WithOrderBy(orderBy ...string) *Kuysor

WithOrderBy sets the sorting / order for the query. Prefix the column name with "-" to sort in descending order, and "+" to sort in ascending order. If no prefix is provided, it will default to ascending order. The order of the strings in the slice determines the order of sorting. For example, if you want to sort by "name" in ascending order and "age" in descending order, you can call WithOrderBy("name", "-age").

func (*Kuysor) WithPlaceHolderType

func (p *Kuysor) WithPlaceHolderType(placeHolderType PlaceHolderType) *Kuysor

WithPlaceHolderType sets the placeholder type for the query. It is useful when you want to override the instance options or the global options.

type NullSortMethod

type NullSortMethod uint8

type Options

type Options struct {
	PlaceHolderType PlaceHolderType
	DefaultLimit    int
	StructTag       string
	NullSortMethod  NullSortMethod
}

type PaginationType

type PaginationType string
const (
	Cursor PaginationType = "cursor"
	Offset PaginationType = "offset"
)

type PlaceHolderType

type PlaceHolderType uint8
const (
	Question PlaceHolderType = iota
	Dollar
	At
	Colon
)

type Result

type Result struct {
	Query string
	Args  []any
	// contains filtered or unexported fields
}

Result represents the result of a query.

func (*Result) SanitizeMap

func (r *Result) SanitizeMap(data *[]map[string]any) (next string, prev string, err error)

SanitizeMap handles the map data for the cursor pagination. It returns the next and previous cursor.

func (*Result) SanitizeStruct

func (r *Result) SanitizeStruct(data any) (next string, prev string, err error)

SanitizeStruct handles struct data for the cursor pagination. It returns the next and previous cursor.

type Token

type Token struct {
	// contains filtered or unexported fields
}

Token represents a token in the SQL query

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL