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
This commit is contained in:
81
modules/lagoon/attach/example_test.go
Normal file
81
modules/lagoon/attach/example_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package attach_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
)
|
||||
|
||||
// Post owns attachments. MorphName is the attachment_type value its rows
|
||||
// carry: the PHP class name, so rows copied from WinterCMS keep matching.
|
||||
type Post struct {
|
||||
ID uint
|
||||
}
|
||||
|
||||
func (Post) MorphName() string { return `Acme\Blog\Models\Post` }
|
||||
|
||||
func ExampleFile_Thumb() {
|
||||
ctx := context.Background()
|
||||
dir, err := os.MkdirTemp("", "acme-config")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
// storage.uploads.bucket_url is a file:// URL in production; mem:// keeps
|
||||
// the example in memory.
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{}})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
_ = cfg.Set("storage.uploads.bucket_url", "mem://")
|
||||
bucket, err := attach.OpenBucket(ctx, cfg)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer bucket.Close()
|
||||
|
||||
// The system_files row of a post's cover image.
|
||||
var owner attach.Owner = Post{ID: 1}
|
||||
f := attach.File{
|
||||
ID: 7,
|
||||
DiskName: "5f1d0c2e9a7b4c3d8e6f.jpg",
|
||||
FileName: "cover.jpg",
|
||||
ContentType: "image/jpeg",
|
||||
Field: "cover",
|
||||
AttachmentType: owner.MorphName(),
|
||||
AttachmentID: "1",
|
||||
}
|
||||
|
||||
// The original is stored under its partitioned WinterCMS key.
|
||||
var img bytes.Buffer
|
||||
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 640, 480)), nil); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
if err := bucket.WriteAll(ctx, attach.BlobKey(f.DiskName), img.Bytes(), nil); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(attach.BlobKey(f.DiskName))
|
||||
|
||||
// A thumbnail is generated on first use and reused afterwards.
|
||||
url, err := f.Thumb(ctx, bucket, 200, 200, "crop")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(url)
|
||||
// Output:
|
||||
// 5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.jpg
|
||||
// /storage/uploads/5f1/d0c/2e9/thumb_7_200_200_0_0_crop.jpg
|
||||
}
|
||||
626
modules/lagoon/example_test.go
Normal file
626
modules/lagoon/example_test.go
Normal file
@@ -0,0 +1,626 @@
|
||||
package lagoon_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Post is the acme.blog post model: a plain GORM struct with lagoon column
|
||||
// types.
|
||||
type Post struct {
|
||||
ID uint `gorm:"column:id;primaryKey" json:"id"`
|
||||
Title string `gorm:"column:title" json:"title"`
|
||||
Slug string `gorm:"column:slug" json:"slug"`
|
||||
Views int `gorm:"column:views" json:"views"`
|
||||
Tags lagoon.Jsonable[[]string] `gorm:"column:tags" json:"-"`
|
||||
APIToken lagoon.Encrypted `gorm:"column:api_token" json:"-"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"-"`
|
||||
|
||||
Categories []Category `gorm:"many2many:acme_blog_post_categories" json:"-"`
|
||||
}
|
||||
|
||||
// TableName keeps the WinterCMS table name.
|
||||
func (Post) TableName() string { return "acme_blog_posts" }
|
||||
|
||||
// Fillable is the Go form of $fillable: the keys mass assignment may set.
|
||||
func (Post) Fillable() []string { return []string{"title", "views"} }
|
||||
|
||||
// Hidden is the Go form of $hidden. The json:"-" tags are what keep these
|
||||
// columns out of JSON; the list documents them for tooling.
|
||||
func (Post) Hidden() []string { return []string{"tags", "api_token", "deleted_at"} }
|
||||
|
||||
// BeforeCreate fills the slug from the title. A hook that only touches the
|
||||
// model stays on the model.
|
||||
func (p *Post) BeforeCreate(tx *gorm.DB) error {
|
||||
if p.Slug == "" {
|
||||
p.Slug = strings.ReplaceAll(strings.ToLower(strings.TrimSpace(p.Title)), " ", "-")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeDelete soft-deletes the post's comments in the transaction of the
|
||||
// post's own delete; an error aborts that delete.
|
||||
func (p *Post) BeforeDelete(tx *gorm.DB) error {
|
||||
return lagoon.WithSoftDeleteCascade(tx, func(tx *gorm.DB) error {
|
||||
return tx.Where("post_id = ?", p.ID).Delete(&Comment{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Comment belongs to a post and is soft-deleted with it.
|
||||
type Comment struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
PostID uint `gorm:"column:post_id"`
|
||||
Body string `gorm:"column:body"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
}
|
||||
|
||||
func (Comment) TableName() string { return "acme_blog_comments" }
|
||||
|
||||
// Category is linked to posts through a pivot with its own sort order.
|
||||
type Category struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
func (Category) TableName() string { return "acme_blog_categories" }
|
||||
|
||||
// PostCategory is the pivot model: the join table has a business column.
|
||||
type PostCategory struct {
|
||||
PostID uint `gorm:"column:post_id;primaryKey"`
|
||||
CategoryID uint `gorm:"column:category_id;primaryKey"`
|
||||
SortOrder int `gorm:"column:sort_order"`
|
||||
}
|
||||
|
||||
func (PostCategory) TableName() string { return "acme_blog_post_categories" }
|
||||
|
||||
// BlogPlugin is the acme.blog plugin; only its migrations are shown here.
|
||||
type BlogPlugin struct{}
|
||||
|
||||
var _ pact.HasMigrations = (*BlogPlugin)(nil)
|
||||
|
||||
func (p *BlogPlugin) ID() string { return "acme.blog" }
|
||||
func (p *BlogPlugin) Requires() []string { return nil }
|
||||
func (p *BlogPlugin) Register(app *backpack.App) error { return nil }
|
||||
func (p *BlogPlugin) Boot(app *backpack.App) error { return nil }
|
||||
|
||||
// Migrations returns the plugin's schema as an ordered gormigrate set. In a
|
||||
// scaffolded plugin each migration is a file in updates/ and this list is
|
||||
// generated in file name order.
|
||||
func (p *BlogPlugin) Migrations() []*gormigrate.Migration {
|
||||
return []*gormigrate.Migration{
|
||||
{
|
||||
ID: "20260101000100_create_posts",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`CREATE TABLE acme_blog_posts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
tags TEXT,
|
||||
api_token TEXT,
|
||||
deleted_at TIMESTAMPTZ
|
||||
)`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`DROP TABLE IF EXISTS acme_blog_posts`).Error
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "20260101000200_create_comments_and_categories",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
for _, stmt := range []string{
|
||||
`CREATE TABLE acme_blog_comments (id SERIAL PRIMARY KEY, post_id INTEGER NOT NULL, body TEXT NOT NULL, deleted_at TIMESTAMPTZ)`,
|
||||
`CREATE TABLE acme_blog_categories (id SERIAL PRIMARY KEY, name TEXT NOT NULL)`,
|
||||
`CREATE TABLE acme_blog_post_categories (post_id INTEGER NOT NULL, category_id INTEGER NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (post_id, category_id))`,
|
||||
} {
|
||||
if err := tx.Exec(stmt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`DROP TABLE IF EXISTS acme_blog_post_categories, acme_blog_categories, acme_blog_comments`).Error
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ExampleFill() {
|
||||
input := map[string]any{"title": "Hello", "views": 3, "slug": "forged"}
|
||||
var post Post
|
||||
// production=false logs each dropped key once, to catch typos in development.
|
||||
if err := lagoon.Fill(&post, post.Fillable(), input, true); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
fmt.Printf("%q %q %d\n", post.Title, post.Slug, post.Views)
|
||||
|
||||
err := lagoon.Fill(&post, post.Fillable(), map[string]any{"views": "many"}, true)
|
||||
var typeErr *lagoon.FillTypeError
|
||||
if errors.As(err, &typeErr) {
|
||||
fmt.Println("invalid value for", typeErr.Key)
|
||||
}
|
||||
// Output:
|
||||
// "Hello" "" 3
|
||||
// invalid value for views
|
||||
}
|
||||
|
||||
func ExampleValidate() {
|
||||
rules := map[string]string{
|
||||
"title": "required|max:10",
|
||||
"views": "nullable|integer|max:1000",
|
||||
}
|
||||
input := map[string]any{"title": "", "views": 5000}
|
||||
// A nil translator gives the built-in English messages; unique: rules
|
||||
// need a database handle instead of nil.
|
||||
errs, err := lagoon.Validate(context.Background(), nil, &Post{}, rules, input, nil)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
out, _ := json.Marshal(errs)
|
||||
fmt.Println(string(out))
|
||||
// Output:
|
||||
// {"title":["The title field is required."],"views":["The views may not be greater than 1000."]}
|
||||
}
|
||||
|
||||
func ExampleHasHidden() {
|
||||
post := Post{ID: 1, Title: "Hello", APIToken: lagoon.NewEncrypted("s3cret")}
|
||||
out, _ := json.Marshal(post)
|
||||
fmt.Println(string(out))
|
||||
var _ lagoon.HasHidden = post
|
||||
// Output:
|
||||
// {"id":1,"title":"Hello","slug":"","views":0}
|
||||
}
|
||||
|
||||
func 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
|
||||
}
|
||||
|
||||
func 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}}
|
||||
}
|
||||
|
||||
func ExampleJsonable() {
|
||||
tags := lagoon.Jsonable[[]string]{Data: []string{"go", "cms"}, Valid: true}
|
||||
v, _ := tags.Value()
|
||||
fmt.Println(v)
|
||||
|
||||
var none lagoon.Jsonable[[]string] // Valid false stores SQL NULL
|
||||
v, _ = none.Value()
|
||||
fmt.Println(v)
|
||||
|
||||
var read lagoon.Jsonable[[]string]
|
||||
_ = read.Scan(`["winter"]`)
|
||||
fmt.Println(read.Get(), read.Valid)
|
||||
// Output:
|
||||
// ["go","cms"]
|
||||
// <nil>
|
||||
// [winter] true
|
||||
}
|
||||
|
||||
func ExampleEncrypted() {
|
||||
// The application publishes the keys from app.key at boot; a test can
|
||||
// install a key directly.
|
||||
key := []byte("0123456789abcdef0123456789abcdef")
|
||||
if err := lagoon.PublishEncryptionKeys(nil, key, nil); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
token := lagoon.NewEncrypted("s3cret")
|
||||
stored, _ := token.Value() // what the column holds
|
||||
fmt.Println(strings.Contains(fmt.Sprint(stored), "s3cret"))
|
||||
|
||||
var read lagoon.Encrypted
|
||||
if err := read.Scan(stored); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
out, _ := json.Marshal(map[string]any{"api_token": read})
|
||||
fmt.Println(read, string(out))
|
||||
fmt.Println(read.Reveal())
|
||||
// Output:
|
||||
// false
|
||||
// [redacted] {"api_token":"[redacted]"}
|
||||
// s3cret
|
||||
}
|
||||
|
||||
// createPost validates input, mass-assigns the fillable keys and inserts
|
||||
// the post. It returns the validation errors, if any.
|
||||
func createPost(ctx context.Context, db *gorm.DB, input map[string]any) (*Post, map[string][]string, error) {
|
||||
// docs:start create-post
|
||||
rules := map[string]string{
|
||||
"title": "required|max:255|unique:acme_blog_posts",
|
||||
"views": "nullable|integer|min:0",
|
||||
}
|
||||
var post Post
|
||||
errs, err := lagoon.Validate(ctx, db, &post, rules, input, nil)
|
||||
if err != nil || errs != nil {
|
||||
return nil, errs, err
|
||||
}
|
||||
if err := lagoon.Fill(&post, post.Fillable(), input, false); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := db.WithContext(ctx).Create(&post).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &post, nil, nil
|
||||
// docs:end create-post
|
||||
}
|
||||
|
||||
// TestDocsModels runs the create-post region of the Models page.
|
||||
func TestDocsModels(t *testing.T) {
|
||||
db := lagoon.DocsDB(t, "docs_models")
|
||||
plugins := []party.Plugin{&BlogPlugin{}}
|
||||
if err := lagoon.Migrate(db, plugins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post, errs, err := createPost(t.Context(), db, map[string]any{"title": "Hello World", "views": 2, "slug": "forged"})
|
||||
if err != nil || errs != nil {
|
||||
t.Fatalf("createPost: %v %v", errs, err)
|
||||
}
|
||||
if post.ID == 0 || post.Slug != "hello-world" || post.Views != 2 {
|
||||
t.Fatalf("post = %+v", post)
|
||||
}
|
||||
_, errs, err = createPost(t.Context(), db, map[string]any{"title": "Hello World"})
|
||||
if err != nil || len(errs["title"]) != 1 {
|
||||
t.Fatalf("duplicate title: %v %v", errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// migrateBlog runs the framework and plugin migrations, prints the status
|
||||
// and rolls back the plugin's last migration.
|
||||
func migrateBlog(db *gorm.DB) ([]string, error) {
|
||||
var lines []string
|
||||
// docs:start migrate
|
||||
plugins := []party.Plugin{&BlogPlugin{}}
|
||||
if err := lagoon.Migrate(db, plugins); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := lagoon.Status(db, plugins)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
lines = append(lines, fmt.Sprintf("%s %s %v", row.Plugin, row.Table, row.IDs))
|
||||
}
|
||||
if err := lagoon.RollbackLast(db, plugins, "acme.blog"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// docs:end migrate
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// TestDocsMigrate runs the migrate region of the Migrations page.
|
||||
func TestDocsMigrate(t *testing.T) {
|
||||
db := lagoon.DocsDB(t, "docs_migrate")
|
||||
lines, err := migrateBlog(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "acme.blog summer_migrations_acme_blog [20260101000100_create_posts 20260101000200_create_comments_and_categories]"
|
||||
if len(lines) != 1 || lines[0] != want {
|
||||
t.Fatalf("status = %q, want %q", lines, want)
|
||||
}
|
||||
if db.Migrator().HasTable("acme_blog_comments") {
|
||||
t.Fatal("rollback left acme_blog_comments")
|
||||
}
|
||||
if !db.Migrator().HasTable("acme_blog_posts") {
|
||||
t.Fatal("rollback removed acme_blog_posts")
|
||||
}
|
||||
}
|
||||
|
||||
// listPosts returns one page of posts sorted by a column the client names.
|
||||
func listPosts(ctx context.Context, db *gorm.DB, sort, dir string, page, perPage int) (lagoon.Page[Post], error) {
|
||||
// docs:start 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
|
||||
// docs:end list-posts
|
||||
}
|
||||
|
||||
// TestDocsQueries runs the list-posts region of the Queries page.
|
||||
func TestDocsQueries(t *testing.T) {
|
||||
db := lagoon.DocsDB(t, "docs_queries")
|
||||
if err := lagoon.Migrate(db, []party.Plugin{&BlogPlugin{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, title := range []string{"Alpha", "Beta", "Gamma"} {
|
||||
if err := db.Create(&Post{Title: title, Views: i * 10}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
page, err := listPosts(t.Context(), db, "views", "desc", 2, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Data) != 1 || page.Data[0].Title != "Alpha" || page.Meta.LastPage != 2 || page.Meta.Total != 3 {
|
||||
t.Fatalf("page = %+v", page)
|
||||
}
|
||||
if _, err := listPosts(t.Context(), db, "api_token", "asc", 1, 2); err == nil {
|
||||
t.Fatal("sorting by api_token succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// setCategories replaces a post's categories in the given order and reads
|
||||
// them back through the pivot.
|
||||
func setCategories(ctx context.Context, db *gorm.DB, post *Post, categoryIDs []uint) ([]Category, error) {
|
||||
// docs:start pivot
|
||||
if err := lagoon.RegisterJoinTable(db, &Post{}, "Categories", &PostCategory{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
if err := tx.Where("post_id = ?", post.ID).Delete(&PostCategory{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]PostCategory, len(categoryIDs))
|
||||
for i, id := range categoryIDs {
|
||||
rows[i] = PostCategory{PostID: post.ID, CategoryID: id, SortOrder: i}
|
||||
}
|
||||
return tx.Create(&rows).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var categories []Category
|
||||
err = db.WithContext(ctx).
|
||||
Joins("JOIN acme_blog_post_categories pc ON pc.category_id = acme_blog_categories.id").
|
||||
Where("pc.post_id = ?", post.ID).
|
||||
Order("pc.sort_order").
|
||||
Find(&categories).Error
|
||||
return categories, err
|
||||
// docs:end pivot
|
||||
}
|
||||
|
||||
// TestDocsRelations runs the pivot region of the Relations page and the
|
||||
// soft-delete cascade of Post.BeforeDelete.
|
||||
func TestDocsRelations(t *testing.T) {
|
||||
db := lagoon.DocsDB(t, "docs_relations")
|
||||
if err := lagoon.Migrate(db, []party.Plugin{&BlogPlugin{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post := Post{Title: "Hello"}
|
||||
news, events := Category{Name: "News"}, Category{Name: "Events"}
|
||||
for _, v := range []any{&post, &news, &events} {
|
||||
if err := db.Create(v).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := setCategories(t.Context(), db, &post, []uint{events.ID, news.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Name != "Events" || got[1].Name != "News" {
|
||||
t.Fatalf("categories = %+v", got)
|
||||
}
|
||||
var loaded Post
|
||||
if err := db.Preload("Categories").First(&loaded, post.ID).Error; err != nil || len(loaded.Categories) != 2 {
|
||||
t.Fatalf("Preload(Categories) = %+v, %v", loaded.Categories, err)
|
||||
}
|
||||
|
||||
if err := db.Create(&Comment{PostID: post.ID, Body: "First"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Delete(&post).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var left int64
|
||||
if err := db.Model(&Comment{}).Where("post_id = ?", post.ID).Count(&left).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if left != 0 {
|
||||
t.Fatalf("%d comments left after the post was deleted", left)
|
||||
}
|
||||
}
|
||||
|
||||
// publishPost renames a post in a transaction and notifies after commit.
|
||||
// fail makes the transaction roll back.
|
||||
func publishPost(ctx context.Context, db *gorm.DB, id uint, fail bool, log *[]string) error {
|
||||
// docs:start publish
|
||||
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
if err := tx.Model(&Post{}).Where("id = ?", id).Update("title", "Published").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, db *gorm.DB) {
|
||||
*log = append(*log, fmt.Sprintf("post %d published", id)) // broadcast, index, send mail...
|
||||
})
|
||||
if fail {
|
||||
return errors.New("rolled back") // the AfterCommit work never runs
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// docs:end publish
|
||||
}
|
||||
|
||||
// nestedPublish runs a savepoint inside a transaction; a failing savepoint
|
||||
// drops its own AfterCommit work only.
|
||||
func nestedPublish(ctx context.Context, db *gorm.DB, log *[]string) error {
|
||||
// docs:start nested
|
||||
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "outer") })
|
||||
|
||||
// A nested Transaction is a savepoint. Pass it the outer tx: given
|
||||
// the root db handle it returns an error instead.
|
||||
_ = lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error {
|
||||
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "dropped") })
|
||||
return errors.New("savepoint rolled back")
|
||||
})
|
||||
return lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error {
|
||||
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "inner") })
|
||||
return nil
|
||||
})
|
||||
})
|
||||
// docs:end nested
|
||||
}
|
||||
|
||||
// TestDocsTransactions runs the publish and nested regions of the
|
||||
// Transactions page, and checks the rules the page states.
|
||||
func TestDocsTransactions(t *testing.T) {
|
||||
db := lagoon.DocsDB(t, "docs_transactions")
|
||||
if err := lagoon.Migrate(db, []party.Plugin{&BlogPlugin{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post := Post{Title: "Draft"}
|
||||
if err := db.Create(&post).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
var log []string
|
||||
if err := publishPost(ctx, db, post.ID, true, &log); err == nil || len(log) != 0 {
|
||||
t.Fatalf("rolled-back publish: err %v, log %q", err, log)
|
||||
}
|
||||
if err := publishPost(ctx, db, post.ID, false, &log); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := fmt.Sprintf("post %d published", post.ID); len(log) != 1 || log[0] != want {
|
||||
t.Fatalf("log = %q, want %q", log, want)
|
||||
}
|
||||
|
||||
log = nil
|
||||
if err := nestedPublish(ctx, db, &log); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(log, ",") != "outer,inner" {
|
||||
t.Fatalf("nested log = %q, want outer,inner", log)
|
||||
}
|
||||
|
||||
// A nested Transaction given the root handle fails without running.
|
||||
ran := false
|
||||
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
return lagoon.Transaction(ctx, db, func(context.Context, *gorm.DB) error {
|
||||
ran = true
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err == nil || ran {
|
||||
t.Fatalf("nested Transaction on the root handle: err %v, ran %v", err, ran)
|
||||
}
|
||||
|
||||
// Inside a plain GORM transaction AfterCommit skips the work.
|
||||
log = nil
|
||||
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { log = append(log, "foreign") })
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(log) != 0 {
|
||||
t.Fatalf("AfterCommit ran inside a plain GORM transaction: %q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// installCallbacks registers a GORM callback from a plugin's Boot through
|
||||
// OnDatabase; its after-commit work runs once the insert commits.
|
||||
func installCallbacks(app *backpack.App, log *[]string) error {
|
||||
// docs:start on-database
|
||||
return lagoon.OnDatabase(app, func(_ *sql.DB, gdb *gorm.DB) error {
|
||||
return gdb.Callback().Create().After("gorm:create").Before("gorm:commit_or_rollback_transaction").Register("acme:post_created", func(db *gorm.DB) {
|
||||
post, ok := db.Statement.Dest.(*Post)
|
||||
if db.Error != nil || !ok {
|
||||
return
|
||||
}
|
||||
lagoon.AfterCommit(db.Statement.Context, db, func(ctx context.Context, db *gorm.DB) {
|
||||
*log = append(*log, "created "+post.Slug) // runs only once the insert is committed
|
||||
})
|
||||
})
|
||||
})
|
||||
// docs:end on-database
|
||||
}
|
||||
|
||||
// TestDocsOnDatabase runs the on-database region of the Transactions page.
|
||||
func TestDocsOnDatabase(t *testing.T) {
|
||||
db := lagoon.DocsDB(t, "docs_on_database")
|
||||
if err := lagoon.Migrate(db, []party.Plugin{&BlogPlugin{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := backpack.New(nil)
|
||||
var log []string
|
||||
if err := installCallbacks(app, &log); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := lagoon.Publish(app, sqlDB, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&Post{Title: "Hello"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(log) != 1 || log[0] != "created hello" {
|
||||
t.Fatalf("log = %q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocsModelDeclarations checks the model and plugin declarations the
|
||||
// Database pages show, without a database.
|
||||
func TestDocsModelDeclarations(t *testing.T) {
|
||||
if got := (Post{}).TableName(); got != "acme_blog_posts" {
|
||||
t.Fatalf("TableName = %q", got)
|
||||
}
|
||||
if got := (Post{}).Hidden(); len(got) != 3 {
|
||||
t.Fatalf("Hidden = %q", got)
|
||||
}
|
||||
post := &Post{Title: " Hello World "}
|
||||
if err := post.BeforeCreate(nil); err != nil || post.Slug != "hello-world" {
|
||||
t.Fatalf("BeforeCreate: slug %q, err %v", post.Slug, err)
|
||||
}
|
||||
if err := post.BeforeDelete(nil); err == nil {
|
||||
t.Fatal("BeforeDelete accepted a nil transaction")
|
||||
}
|
||||
if n := len((&BlogPlugin{}).Migrations()); n != 2 {
|
||||
t.Fatalf("Migrations() = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
21
modules/lagoon/export_docs_test.go
Normal file
21
modules/lagoon/export_docs_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DocsDB returns a GORM handle on a fresh ICU pl-PL database named name in
|
||||
// this package's Postgres harness, for the database-backed docs examples in
|
||||
// example_test.go. It skips under -short and fails when the harness has no
|
||||
// database, like the package's other database tests.
|
||||
func DocsDB(t *testing.T, name string) *gorm.DB {
|
||||
t.Helper()
|
||||
db, _ := dedicatedDB(t, name)
|
||||
gdb, err := Use(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return gdb
|
||||
}
|
||||
Reference in New Issue
Block a user