feat(05-01): add Paginate envelope and RegisterJoinTable

- Page/PageMeta emit {data, meta} with no links key
- RegisterJoinTable fails loud on nil db; document pivot-write contract
This commit is contained in:
Jakub Zych
2026-09-18 18:47:42 +02:00
parent 969a5f522a
commit 52638fb4e3
4 changed files with 126 additions and 0 deletions

40
lagoon/paginate.go Normal file
View File

@@ -0,0 +1,40 @@
package lagoon
// PageMeta is the pagination block of the house REST envelope (DATA-10).
type PageMeta struct {
CurrentPage int `json:"current_page"`
LastPage int `json:"last_page"`
PerPage int `json:"per_page"`
Total int64 `json:"total"`
}
// Page wraps already-built row DTOs. There is no links field.
type Page[T any] struct {
Data []T `json:"data"`
Meta PageMeta `json:"meta"`
}
// Paginate builds {data, meta{current_page,last_page,per_page,total}} from
// a caller-sliced row set. LastPage is ceil(total/perPage); perPage<=0
// yields LastPage 1 instead of dividing by zero. Nil rows become [].
func Paginate[T any](rows []T, page, perPage int, total int64) Page[T] {
last := 1
if perPage > 0 {
last = int((total + int64(perPage) - 1) / int64(perPage))
if last < 1 {
last = 1
}
}
if rows == nil {
rows = []T{}
}
return Page[T]{
Data: rows,
Meta: PageMeta{
CurrentPage: page,
LastPage: last,
PerPage: perPage,
Total: total,
},
}
}

42
lagoon/paginate_test.go Normal file
View File

@@ -0,0 +1,42 @@
package lagoon
import (
"encoding/json"
"strings"
"testing"
)
func TestPaginateEnvelope(t *testing.T) {
got := Paginate([]string{"a", "b", "c"}, 1, 2, 3)
if got.Meta.CurrentPage != 1 || got.Meta.LastPage != 2 || got.Meta.PerPage != 2 || got.Meta.Total != 3 {
t.Fatalf("meta = %+v", got.Meta)
}
raw, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "links") {
t.Fatalf("envelope must not contain links: %s", raw)
}
if !strings.Contains(string(raw), `"data"`) || !strings.Contains(string(raw), `"meta"`) {
t.Fatalf("missing data/meta: %s", raw)
}
}
func TestPaginateZeroPerPage(t *testing.T) {
got := Paginate([]int{1}, 1, 0, 10)
if got.Meta.LastPage != 1 {
t.Fatalf("last_page = %d, want 1", got.Meta.LastPage)
}
}
func TestPaginateNilDataIsEmptyArray(t *testing.T) {
got := Paginate[string](nil, 1, 10, 0)
raw, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"data":[]`) {
t.Fatalf("nil rows must marshal as []: %s", raw)
}
}

28
lagoon/relations.go Normal file
View File

@@ -0,0 +1,28 @@
package lagoon
import (
"fmt"
"gorm.io/gorm"
)
// RegisterJoinTable wraps db.SetupJoinTable so a nil db returns an error
// instead of panicking.
//
// Pivot-write contract: pivot tables with business columns (sort_order,
// role, granted_at, granted_by) are never written through
// db.Model(&owner).Association(field).Append/Replace(...). GORM's
// Association Mode has no documented path to set those columns. Writes go
// through an explicit delete-then-bulk-insert (or ON CONFLICT DO UPDATE)
// against the join table directly, in the same transaction as the parent
// save. Reads use Preload(field) plus .Order(...) on the pivot's own
// columns once RegisterJoinTable has run.
func RegisterJoinTable(db *gorm.DB, owner any, field string, joinModel any) error {
if db == nil {
return fmt.Errorf("lagoon: register join table: db is nil")
}
if err := db.SetupJoinTable(owner, field, joinModel); err != nil {
return fmt.Errorf("lagoon: register join table: %w", err)
}
return nil
}

16
lagoon/relations_test.go Normal file
View File

@@ -0,0 +1,16 @@
package lagoon
import (
"strings"
"testing"
)
func TestRegisterJoinTableNilDB(t *testing.T) {
err := RegisterJoinTable(nil, struct{}{}, "Artists", struct{}{})
if err == nil {
t.Fatal("want non-nil error")
}
if !strings.Contains(err.Error(), "nil") {
t.Fatalf("got %v", err)
}
}