diff --git a/modules/cabana/field_ml.go b/modules/cabana/field_ml.go index 14e4419..2f3ba7e 100644 --- a/modules/cabana/field_ml.go +++ b/modules/cabana/field_ml.go @@ -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 "" 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()) } } diff --git a/modules/cabana/ml_nullable_test.go b/modules/cabana/ml_nullable_test.go new file mode 100644 index 0000000..401bc99 --- /dev/null +++ b/modules/cabana/ml_nullable_test.go @@ -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, "") || 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) + } + }) +}