feat(14.2.1-05): hydrate ML create/GET/save through TranslatedExact

Form schema meta lists enabled locales, Show/save expand mltext maps
without D-11 fallback, and the SPA seeds and adopt-merges every locale.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-06 15:37:00 +02:00
parent ed81f2a0c1
commit 0f8d13738d
11 changed files with 309 additions and 34 deletions

View File

@@ -441,6 +441,9 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id
if err != nil {
return err
}
if err := hydrateMLRecord(ctx, tx, cc, s.writer, target, result.Data, "update"); err != nil {
return err
}
result.Meta.Actions, err = s.offeredRecordActions(withTx(ctx, tx), cc, target)
return err
})
@@ -700,7 +703,10 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
return err
}
result, err = projectFullRecord(ctx, tx, cc, target)
return err
if err != nil {
return err
}
return hydrateMLRecord(ctx, tx, cc, s.writer, target, result.Data, op)
})
if err != nil {
return RecordResult{}, err

View File

@@ -12,11 +12,13 @@ const (
mlMarkdownFieldType = "mlmarkdown"
)
// TranslationWriter persists non-default locale values during a cabana save.
// The interface lives in the framework so cabana never imports a plugin; a
// host publishes an adapter at Boot. There is no standalone translate-write
// TranslationWriter persists non-default locale values during a cabana save
// and reads exact stored translations for Show/save hydration. The interface
// lives in the framework so cabana never imports a plugin; a host publishes
// an adapter at Boot. There is no standalone translate-read or translate-write
// HTTP endpoint: writes run only inside a permissioned, scoped host save
// transaction after the host row has a primary key.
// transaction after the host row has a primary key, and reads run on the
// same adapter after projectFullRecord.
type TranslationWriter interface {
// DefaultLocale is the host-column locale.
DefaultLocale(ctx context.Context, tx *gorm.DB) (string, error)
@@ -25,6 +27,11 @@ type TranslationWriter interface {
// WriteTranslated stores field in locale for model. locale is never the
// default; cabana already filled the host scalar.
WriteTranslated(ctx context.Context, tx *gorm.DB, model any, field, locale, value string) error
// TranslatedExact returns the stored value for field in locale. ok is
// false when the non-default key is missing. It must not apply D-11
// fallback: empty admin values stay empty. The default locale still
// reads the host column.
TranslatedExact(ctx context.Context, tx *gorm.DB, model any, field, locale string) (string, bool, error)
}
// mlValue is one multilingual field lifted from a save body.
@@ -158,3 +165,68 @@ func applyMLTranslations(ctx context.Context, tx *gorm.DB, writer TranslationWri
}
return nil
}
// hydrateMLRecord replaces host scalars of declared mltext/mlmarkdown fields
// in a Show/save RecordResult.Data map with a locale→text map for every
// enabled code. The default locale is the projected host column; other codes
// come from TranslatedExact; a missing non-default code is an empty string,
// never D-11 fallback. It is a no-op when writer is nil and is not used from
// list row projection.
func hydrateMLRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, writer TranslationWriter, model any, data map[string]any, op string) error {
if writer == nil || cc == nil || cc.Form == nil || data == nil {
return nil
}
var names []string
for _, field := range cc.Form.Fields {
if !mlFieldType(field.Type) || !contextAllows(cc, field.Name, op) {
continue
}
names = append(names, field.Name)
}
if len(names) == 0 {
return nil
}
defaultLocale, err := writer.DefaultLocale(ctx, tx)
if err != nil || defaultLocale == "" {
return fmt.Errorf("cabana: default locale: %w", err)
}
codes, err := writer.EnabledLocales(ctx, tx)
if err != nil {
return fmt.Errorf("cabana: enabled locales: %w", err)
}
for _, name := range names {
host := hostScalarString(data[name])
out := make(map[string]string, len(codes))
for _, code := range codes {
if code == defaultLocale {
out[code] = host
continue
}
text, ok, err := writer.TranslatedExact(ctx, tx, model, name, code)
if err != nil {
return err
}
if !ok {
out[code] = ""
continue
}
out[code] = text
}
data[name] = out
}
return nil
}
func hostScalarString(v any) string {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
case []byte:
return string(t)
default:
return fmt.Sprint(t)
}
}

View File

@@ -709,10 +709,16 @@ func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
}
view.Fields = kept
view.Assets = s.controllerAssets(cc)
if codes := s.enabledContentLocales(r.Context()); len(codes) > 0 {
view.Meta.EnabledLocales = append([]string(nil), codes...)
}
meta := map[string]any{}
if view.Meta.Locale != "" {
meta["locale"] = view.Meta.Locale
}
if len(view.Meta.EnabledLocales) > 0 {
meta["enabledLocales"] = view.Meta.EnabledLocales
}
WriteData(w, http.StatusOK, view, meta)
})
}
@@ -939,6 +945,29 @@ func (s *service) crud() (CRUDService, error) {
return svc, nil
}
// enabledContentLocales is TranslationWriter.EnabledLocales after the same
// Lookup crud() uses. Empty when the adapter is unpublished or the query
// fails; list schemas never call this.
func (s *service) enabledContentLocales(ctx context.Context) []string {
if s == nil || s.app == nil {
return nil
}
writer, ok := s.app.Lookup[TranslationWriter]()
if !ok || writer == nil {
return nil
}
db, err := s.db()
if err != nil {
return nil
}
codes, err := writer.EnabledLocales(ctx, db)
if err != nil {
slog.Error("cabana: enabled locales", "error", err)
return nil
}
return codes
}
func (s *service) list(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
db, err := s.db()

View File

@@ -95,6 +95,25 @@ func (w *recordingWriter) WriteTranslated(_ context.Context, _ *gorm.DB, model a
return nil
}
func (w *recordingWriter) TranslatedExact(_ context.Context, _ *gorm.DB, model any, field, locale string) (string, bool, error) {
if locale == w.defaultLocale {
post, _ := model.(*mlPost)
if post == nil {
return "", false, nil
}
return post.Title, true, nil
}
if w.attrs == nil {
return "", false, nil
}
vals := w.attrs[locale]
if vals == nil {
return "", false, nil
}
text, ok := vals[field]
return text, ok, nil
}
func mlFS() fstest.MapFS {
return fstest.MapFS{
"controllers/posts/config_list.yaml": &fstest.MapFile{Data: []byte(mlListConfig)},
@@ -181,8 +200,13 @@ func TestMLNestedSaveSmoke(t *testing.T) {
if err != nil {
t.Fatalf("create: %v", err)
}
if rec["title"] != "Hello" {
t.Fatalf("projected title = %#v, want Hello", rec["title"])
if rec["title"] != nil {
got, ok := rec["title"].(map[string]string)
if !ok || got["en"] != "Hello" || got["pl"] != "Witaj" {
t.Fatalf("projected title = %#v, want hydrated en+pl map", rec["title"])
}
} else {
t.Fatalf("projected title = %#v, want hydrated en+pl map", rec["title"])
}
var row mlPost
if err := db.First(&row).Error; err != nil {

View File

@@ -3,6 +3,7 @@ package cabana
import (
"context"
"errors"
"reflect"
"strings"
"testing"
@@ -66,7 +67,13 @@ func TestMLNestedSave(t *testing.T) {
if err != nil {
t.Fatalf("create: %v", err)
}
if rec["title"] != "Hello" {
if rec["title"] != nil {
want := map[string]string{"en": "Hello", "pl": "Witaj"}
got, ok := rec["title"].(map[string]string)
if !ok || !reflect.DeepEqual(got, want) {
t.Fatalf("projected title = %#v", rec["title"])
}
} else {
t.Fatalf("projected title = %#v", rec["title"])
}
var row mlPost
@@ -97,6 +104,55 @@ func TestMLNestedSave(t *testing.T) {
}
}
func TestMLHydration(t *testing.T) {
_, db := newListService(t)
if err := db.Migrator().DropTable(&mlPost{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&mlPost{}); err != nil {
t.Fatal(err)
}
writer := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}
svc := CRUDService{DB: db, writer: writer}
cc := mlCompiled(t)
ctx := context.Background()
created, err := svc.CreateRecord(ctx, cc, RecordInput{Body: map[string]any{
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
}})
if err != nil {
t.Fatalf("create: %v", err)
}
got, ok := created.Data["title"].(map[string]string)
if !ok || got["en"] != "Hello" || got["pl"] != "Witaj" {
t.Fatalf("create title = %#v", created.Data["title"])
}
shown, err := svc.ShowRecord(ctx, cc, created.Data["id"])
if err != nil {
t.Fatalf("show: %v", err)
}
got, ok = shown.Data["title"].(map[string]string)
if !ok || got["en"] != "Hello" || got["pl"] != "Witaj" {
t.Fatalf("show title = %#v", shown.Data["title"])
}
englishOnly := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}
svcOnly := CRUDService{DB: db, writer: englishOnly}
row := mlPost{Title: "Hello"}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
shown, err = svcOnly.ShowRecord(ctx, cc, row.ID)
if err != nil {
t.Fatalf("show english-only: %v", err)
}
got, ok = shown.Data["title"].(map[string]string)
if !ok || got["en"] != "Hello" || got["pl"] != "" {
t.Fatalf("english-only title = %#v", shown.Data["title"])
}
}
func TestML(t *testing.T) {
t.Run("writer failure rolls back host write", func(t *testing.T) {
_, db := newListService(t)

View File

@@ -227,8 +227,11 @@ type FormRedirect struct {
}
// FormMeta reports the locale selected for a schema response.
// EnabledLocales lists content locales for ML fields when a TranslationWriter
// is published; omitempty keeps list schemas without the key.
type FormMeta struct {
Locale string `json:"locale"`
Locale string `json:"locale"`
EnabledLocales []string `json:"enabledLocales,omitempty"`
}
// FormField is one Winter field in source order. JSON keys keep Winter spelling.