fix(cabana): dereference nullable pointer scalars in ML host hydration
- hostScalarString walks pointers via reflect; nil at any depth is "" - string kinds return raw text, byte slices decode, other kinds format the dereferenced value - Postgres round-trip regression for an mltext field on a *string column
This commit is contained in:
182
modules/cabana/ml_nullable_test.go
Normal file
182
modules/cabana/ml_nullable_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const mlNullableFields = `fields:
|
||||
title:
|
||||
label: Title
|
||||
type: mltext
|
||||
required: true
|
||||
description:
|
||||
label: Description
|
||||
type: mltext
|
||||
`
|
||||
|
||||
type mlNullablePost struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Title string `gorm:"column:title"`
|
||||
Description *string `gorm:"column:description"`
|
||||
}
|
||||
|
||||
func (mlNullablePost) TableName() string { return "cabana_ml_nullable_posts" }
|
||||
func (mlNullablePost) Fillable() []string { return []string{"title", "description"} }
|
||||
func (mlNullablePost) Rules() map[string]string { return map[string]string{"title": "required"} }
|
||||
|
||||
type mlNullableController struct{}
|
||||
|
||||
func (mlNullableController) ID() string { return "acme.demo.posts" }
|
||||
func (mlNullableController) ModelName() string { return "Post" }
|
||||
func (mlNullableController) ConfigDir() string { return "controllers/posts" }
|
||||
func (mlNullableController) NewRecord() any { return &mlNullablePost{} }
|
||||
|
||||
func (mlNullableController) FormExtendQuery(ctx context.Context, q *gorm.DB) *gorm.DB {
|
||||
return q
|
||||
}
|
||||
|
||||
var (
|
||||
_ pact.AdminController = mlNullableController{}
|
||||
_ pact.AdminRecordSource = mlNullableController{}
|
||||
_ pact.FormExtendQuery = mlNullableController{}
|
||||
)
|
||||
|
||||
func mlNullableFS() fstest.MapFS {
|
||||
fsys := mlFS()
|
||||
fsys["models/post/fields.yaml"] = &fstest.MapFile{Data: []byte(mlNullableFields)}
|
||||
return fsys
|
||||
}
|
||||
|
||||
func mlNullableCompiled(t *testing.T) *CompiledController {
|
||||
t.Helper()
|
||||
reg, err := compileRegistry([]controllerRef{{
|
||||
plugin: formPlugin{fsys: mlNullableFS()},
|
||||
ctl: mlNullableController{},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("registry: %v", err)
|
||||
}
|
||||
cc, ok := reg.Get("acme.demo.posts")
|
||||
if !ok || cc.Form == nil {
|
||||
t.Fatalf("compiled controller missing form: %+v", cc)
|
||||
}
|
||||
return cc
|
||||
}
|
||||
|
||||
// assertNoPointerLeak fails when a hydrated value carries the text of a Go
|
||||
// pointer instead of the value it points at.
|
||||
func assertNoPointerLeak(t *testing.T, where, got string) {
|
||||
t.Helper()
|
||||
if strings.Contains(got, "<nil>") || strings.HasPrefix(got, "0x") {
|
||||
t.Fatalf("%s = %q, leaked a pointer instead of its value", where, got)
|
||||
}
|
||||
}
|
||||
|
||||
// mlLocaleMap asserts data[field] is a hydrated locale map with no pointer
|
||||
// leak in any locale and returns it.
|
||||
func mlLocaleMap(t *testing.T, where string, data map[string]any, field string) map[string]string {
|
||||
t.Helper()
|
||||
got, ok := data[field].(map[string]string)
|
||||
if !ok {
|
||||
t.Fatalf("%s %s = %#v, want hydrated locale map", where, field, data[field])
|
||||
}
|
||||
for code, text := range got {
|
||||
assertNoPointerLeak(t, where+" "+field+"."+code, text)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestMLNullablePointerHost(t *testing.T) {
|
||||
_, db := newListService(t)
|
||||
if err := db.Migrator().DropTable(&mlNullablePost{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&mlNullablePost{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}
|
||||
svc := CRUDService{DB: db, writer: writer}
|
||||
cc := mlNullableCompiled(t)
|
||||
ctx := context.Background()
|
||||
|
||||
row := mlNullablePost{Title: "Hello"}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
title := map[string]any{"en": "Hello", "pl": "Witaj"}
|
||||
|
||||
t.Run("nil pointer host hydrates to empty strings", func(t *testing.T) {
|
||||
shown, err := svc.ShowRecord(ctx, cc, row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
got := mlLocaleMap(t, "show", shown.Data, "description")
|
||||
if got["en"] != "" || got["pl"] != "" || len(got) != 2 {
|
||||
t.Fatalf("nil description = %#v, want en and pl empty", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cleared value round-trips as empty string", func(t *testing.T) {
|
||||
saved, err := svc.UpdateRecord(ctx, cc, row.ID, RecordInput{Body: map[string]any{
|
||||
"title": title,
|
||||
"description": map[string]any{"en": "", "pl": ""},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if got := mlLocaleMap(t, "save", saved.Data, "description"); got["en"] != "" {
|
||||
t.Fatalf("saved description = %#v, want en empty", got)
|
||||
}
|
||||
shown, err := svc.ShowRecord(ctx, cc, row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
if got := mlLocaleMap(t, "show", shown.Data, "description"); got["en"] != "" || got["pl"] != "" {
|
||||
t.Fatalf("shown description = %#v, want en and pl empty", got)
|
||||
}
|
||||
var stored mlNullablePost
|
||||
if err := db.First(&stored, row.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Description == nil || *stored.Description != "" {
|
||||
t.Fatalf("stored description = %v, want pointer to empty string", stored.Description)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("filled value stores host text and writes translation", func(t *testing.T) {
|
||||
saved, err := svc.UpdateRecord(ctx, cc, row.ID, RecordInput{Body: map[string]any{
|
||||
"title": title,
|
||||
"description": map[string]any{"en": "Opis kategorii", "pl": "Opis"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if got := mlLocaleMap(t, "save", saved.Data, "description"); got["en"] != "Opis kategorii" {
|
||||
t.Fatalf("saved description = %#v", got)
|
||||
}
|
||||
shown, err := svc.ShowRecord(ctx, cc, row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
got := mlLocaleMap(t, "show", shown.Data, "description")
|
||||
if got["en"] != "Opis kategorii" || got["pl"] != "Opis" {
|
||||
t.Fatalf("shown description = %#v", got)
|
||||
}
|
||||
var stored mlNullablePost
|
||||
if err := db.First(&stored, row.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Description == nil || *stored.Description != "Opis kategorii" {
|
||||
t.Fatalf("stored description = %v, want Opis kategorii", stored.Description)
|
||||
}
|
||||
if writer.attrs["pl"]["description"] != "Opis" {
|
||||
t.Fatalf("Polish attributes = %#v", writer.attrs)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user