diff --git a/lagoon/paginate.go b/lagoon/paginate.go new file mode 100644 index 0000000..88634b4 --- /dev/null +++ b/lagoon/paginate.go @@ -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, + }, + } +} diff --git a/lagoon/paginate_test.go b/lagoon/paginate_test.go new file mode 100644 index 0000000..6d45e5f --- /dev/null +++ b/lagoon/paginate_test.go @@ -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) + } +} diff --git a/lagoon/relations.go b/lagoon/relations.go new file mode 100644 index 0000000..9cfee74 --- /dev/null +++ b/lagoon/relations.go @@ -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 +} diff --git a/lagoon/relations_test.go b/lagoon/relations_test.go new file mode 100644 index 0000000..cd320c0 --- /dev/null +++ b/lagoon/relations_test.go @@ -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) + } +}