Files
summercms/docs/database/queries-and-pagination.md
Jakub Zych efb35a2d35 feat(11.1-04): add the Database section and the core Services pages
- docs/database: models, migrations, queries and pagination, relations,
  casts and validation, attachments and transactions (lagoon.Transaction,
  lagoon.AfterCommit, nested savepoints, lagoon.OnDatabase)
- docs/services: configuration, events, routing with auth groups, rate
  limiting, authentication, the OAuth server, mail and localization
- runnable Examples for lagoon, attach, compass, surf, wire, bouncer,
  wristband, postcard, phrasebook and festival; lagoon TestDocs* regions
  run on the package's Postgres harness through DocsDB
- 15 new required pages
2026-09-30 22:59:25 +02:00

82 lines
4.0 KiB
Markdown

---
title: Queries and pagination
description: Query models with GORM, sort by a client-chosen column safely with lagoon.OrderBy, and return Laravel-shaped pages with lagoon.Paginate.
section: database
order: 30
---
# Queries and pagination
Queries are plain GORM: `Where`, `Joins`, `Preload`, `Count`, `Find` and the rest work as the [GORM documentation](https://gorm.io/docs/) describes. [lagoon](../../modules/lagoon/README.md) adds two helpers for list endpoints, which almost always let the client choose the sort order and ask for one page.
Pass the request context to every query with `WithContext`, so a cancelled request stops its query.
## Sorting by a column the client names
A list endpoint usually takes `?sort=title&order=desc`. Never pass those values to `Order` directly: a column name is SQL, not a bound parameter. `lagoon.OrderBy` appends the ORDER BY only when the column is in an allow-list you give it and the direction is `asc` or `desc`, and returns an error for anything else:
```go src=modules/lagoon/example_test.go#ExampleOrderBy
// A dry-run handle shows the SQL without a database.
db, _ := gorm.Open(postgres.New(postgres.Config{DSN: "host=127.0.0.1"}), &gorm.Config{DryRun: true, DisableAutomaticPing: true})
allowed := []string{"title", "views"}
q, err := lagoon.OrderBy(db.Model(&Post{}), "views", "desc", allowed)
if err != nil {
fmt.Println(err)
return
}
var posts []Post
fmt.Println(q.Find(&posts).Statement.SQL.String())
_, err = lagoon.OrderBy(db, "api_token", "asc", allowed)
fmt.Println(err)
_, err = lagoon.OrderBy(db, "title", "asc; DROP TABLE acme_blog_posts", allowed)
fmt.Println(err)
// Output:
// SELECT * FROM "acme_blog_posts" WHERE "acme_blog_posts"."deleted_at" IS NULL ORDER BY views DESC
// lagoon: order column "api_token" is not allow-listed
// lagoon: order direction "asc; DROP TABLE acme_blog_posts" is not allow-listed
```
Answer the error as a validation failure (422). The column must match an allow-list entry exactly, so list the qualified name (`acme_blog_posts.title`) when the query joins another table.
`lagoon.OrderBy` never adds a `COLLATE` clause. Text sorts by the database's ICU `pl-PL` default collation, which lagoon checks when it connects.
## Pagination
`lagoon.Paginate` wraps the rows of one page in the envelope Laravel's paginator produces for the API: `data` and a `meta` object with `current_page`, `last_page`, `per_page` and `total`. You run the count and the page query yourself, so the query stays under your control:
```go src=modules/lagoon/example_test.go#list-posts
q, err := lagoon.OrderBy(db.WithContext(ctx).Model(&Post{}), sort, dir, []string{"title", "views"})
if err != nil {
return lagoon.Page[Post]{}, err // answer 422: the client asked for a column it may not sort by
}
var total int64
if err := q.Count(&total).Error; err != nil {
return lagoon.Page[Post]{}, err
}
var posts []Post
if err := q.Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
return lagoon.Page[Post]{}, err
}
return lagoon.Paginate(posts, page, perPage, total), nil
```
The result is a `lagoon.Page` whose `lagoon.PageMeta` marshals to the Laravel field names:
```go src=modules/lagoon/example_test.go#ExamplePaginate
rows := []map[string]any{{"id": 3, "title": "Third"}}
page := lagoon.Paginate(rows, 2, 2, 3)
out, _ := json.Marshal(page)
fmt.Println(string(out))
empty, _ := json.Marshal(lagoon.Paginate[map[string]any](nil, 1, 15, 0))
fmt.Println(string(empty))
// Output:
// {"data":[{"id":3,"title":"Third"}],"meta":{"current_page":2,"last_page":2,"per_page":2,"total":3}}
// {"data":[],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":0}}
```
A nil slice becomes `[]`, and a zero or negative page size gives one page instead of dividing by zero. There is no `links` block. When a ported endpoint's response has a different shape, build that shape yourself: the existing clients define the contract.
Clamp `page` and `per_page` from the request before you use them, for example to at least 1 and at most 100, so a client cannot ask for the whole table in one page.