- 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
82 lines
4.0 KiB
Markdown
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.
|