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:
@@ -3,6 +3,7 @@ package cabana
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -217,16 +218,29 @@ func hydrateMLRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, w
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hostScalarString renders a projected host column as the default-locale text.
|
||||||
|
// Nullable pointer columns (*string, *int, **string, ...) are dereferenced at
|
||||||
|
// any depth and a nil pointer becomes the empty string, so neither "<nil>" nor
|
||||||
|
// a pointer address reaches the admin form. String kinds return their raw text
|
||||||
|
// (bypassing any String method), byte slices are decoded as text and every
|
||||||
|
// other kind is formatted from the dereferenced value.
|
||||||
func hostScalarString(v any) string {
|
func hostScalarString(v any) string {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
switch t := v.(type) {
|
rv := reflect.ValueOf(v)
|
||||||
case string:
|
for rv.Kind() == reflect.Pointer {
|
||||||
return t
|
if rv.IsNil() {
|
||||||
case []byte:
|
return ""
|
||||||
return string(t)
|
}
|
||||||
|
rv = rv.Elem()
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case rv.Kind() == reflect.String:
|
||||||
|
return rv.String()
|
||||||
|
case rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8:
|
||||||
|
return string(rv.Bytes())
|
||||||
default:
|
default:
|
||||||
return fmt.Sprint(t)
|
return fmt.Sprint(rv.Interface())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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