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:
Jakub Zych
2026-10-06 20:35:10 +02:00
parent 8e22e5f25b
commit 90b87d6363
2 changed files with 202 additions and 6 deletions

View File

@@ -3,6 +3,7 @@ package cabana
import (
"context"
"fmt"
"reflect"
"gorm.io/gorm"
)
@@ -217,16 +218,29 @@ func hydrateMLRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, w
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 {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
case []byte:
return string(t)
rv := reflect.ValueOf(v)
for rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return ""
}
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:
return fmt.Sprint(t)
return fmt.Sprint(rv.Interface())
}
}

View 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)
}
})
}