feat(14.2.1-06): persist nested ML maps on relation-child create

RelationService looks up TranslationWriter like CRUDService so CreateChild can lift locale maps before fillChild, write Polish after the child PK, and return a hydrated title map.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-06 15:51:38 +02:00
parent f4bea33faf
commit 88a3a88876
5 changed files with 225 additions and 6 deletions

View File

@@ -656,7 +656,13 @@ func (s *service) relations() (RelationService, error) {
if err != nil {
return RelationService{}, err
}
return RelationService{DB: db, bucket: s.bucket(), tr: s.translator()}, nil
svc := RelationService{DB: db, bucket: s.bucket(), tr: s.translator()}
if s.app != nil {
if w, ok := s.app.Lookup[TranslationWriter](); ok {
svc.writer = w
}
}
return svc, nil
}
// relationsFor is the relation service of one request, carrying its

View File

@@ -80,11 +80,11 @@ func (w *recordingWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, e
}
func (w *recordingWriter) WriteTranslated(_ context.Context, _ *gorm.DB, model any, field, locale, value string) error {
post, _ := model.(*mlPost)
if post == nil || post.ID == 0 {
id := pkUint(model)
if id == 0 {
return &ValidationError{Details: map[string]any{field: []string{"translation writer ran before the host row had an id"}}}
}
w.ids = append(w.ids, post.ID)
w.ids = append(w.ids, id)
if w.attrs == nil {
w.attrs = map[string]map[string]string{}
}

View File

@@ -186,9 +186,11 @@ type RelationService struct {
SessionKey string
// bucket deletes the blobs of child files a save removes, after commit;
// tr localizes date bound messages. Both may be nil.
// tr localizes date bound messages. Both may be nil. writer is the same
// optional plugin adapter CRUDService uses for mltext/mlmarkdown.
bucket *blob.Bucket
tr *phrasebook.Translator
writer TranslationWriter
}
func (s RelationSchema) MarshalJSON() ([]byte, error) {

View File

@@ -165,6 +165,10 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
return lifecycleFailure(cc, err)
}
}
translations, err := liftMLValues(ctx, cr.child, in.Body, "create", s.writer, tx)
if err != nil {
return err
}
if err := s.fillChild(ctx, tx, cr.child, child, in.Body, "create"); err != nil {
return err
}
@@ -176,6 +180,9 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
if err := tx.WithContext(ctx).Create(child).Error; err != nil {
return lifecycleFailure(cc, err)
}
if err := applyMLTranslations(ctx, tx, s.writer, child, translations); err != nil {
return err
}
switch {
case parent.unsaved():
// The child exists now, unattached; the parent's first save
@@ -198,7 +205,10 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
}
}
result, err = projectFullRecord(ctx, tx, cr.child, child)
return err
if err != nil {
return err
}
return hydrateMLRecord(ctx, tx, cr.child, s.writer, child, result.Data, "create")
})
if err != nil {
return RecordResult{}, err

View File

@@ -0,0 +1,201 @@
package cabana
import (
"context"
"errors"
"reflect"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
const mlParentFormConfig = `name: journals
form: ~/plugins/acme/demo/models/journal/fields.yaml
modelClass: Journal
`
const mlParentListConfig = `modelClass: Journal
list: ~/plugins/acme/demo/models/journal/columns.yaml
recordsPerPage: 20
toolbar:
buttons: [create]
`
const mlParentFields = `fields:
entries:
type: relation-manager
relation: entries
`
const mlParentColumns = `columns:
id:
label: ID
`
const mlParentRelation = `entries:
label: Entries
view:
list:
columns:
title:
label: Title
toolbarButtons: create|update
manage:
form: $/acme/demo/models/entry/fields.yaml
`
const mlChildFields = `fields:
title:
label: Title
type: mltext
required: true
`
type mlJournal struct {
ID uint `gorm:"column:id;primaryKey"`
Entries []mlEntry `gorm:"-"`
}
func (mlJournal) TableName() string { return "cabana_ml_journals" }
func (mlJournal) Fillable() []string { return []string{} }
func (mlJournal) Rules() map[string]string { return map[string]string{} }
type mlEntry struct {
ID uint `gorm:"column:id;primaryKey"`
JournalID *uint `gorm:"column:journal_id"`
Title string `gorm:"column:title"`
}
func (mlEntry) TableName() string { return "cabana_ml_entries" }
func (mlEntry) Fillable() []string { return []string{"title"} }
func (mlEntry) Rules() map[string]string { return map[string]string{"title": "required"} }
type mlJournalController struct{}
func (mlJournalController) ID() string { return "acme.demo.journals" }
func (mlJournalController) ModelName() string { return "Journal" }
func (mlJournalController) ConfigDir() string { return "controllers/journals" }
func (mlJournalController) NewRecord() any { return &mlJournal{} }
func (mlJournalController) FormExtendQuery(_ context.Context, q *gorm.DB) *gorm.DB {
return q
}
func (mlJournalController) AdminRelationContracts() []RelationContract {
return []RelationContract{{
Name: "entries",
Kind: RelationHasMany,
NewRelated: func() any { return &mlEntry{} },
ForeignKey: "journal_id",
Columns: map[string]string{"title": "title"},
}}
}
var (
_ pact.AdminController = mlJournalController{}
_ pact.AdminRecordSource = mlJournalController{}
_ pact.FormExtendQuery = mlJournalController{}
_ AdminRelationContractProvider = mlJournalController{}
)
func mlRelationFS() fstest.MapFS {
return fstest.MapFS{
"controllers/journals/config_list.yaml": &fstest.MapFile{Data: []byte(mlParentListConfig)},
"controllers/journals/config_form.yaml": &fstest.MapFile{Data: []byte(mlParentFormConfig)},
"controllers/journals/config_relation.yaml": &fstest.MapFile{Data: []byte(mlParentRelation)},
"models/journal/columns.yaml": &fstest.MapFile{Data: []byte(mlParentColumns)},
"models/journal/fields.yaml": &fstest.MapFile{Data: []byte(mlParentFields)},
"models/entry/fields.yaml": &fstest.MapFile{Data: []byte(mlChildFields)},
}
}
func mlRelationCompiled(t *testing.T) *CompiledController {
t.Helper()
reg, err := compileRegistry([]controllerRef{{
plugin: formPlugin{fsys: mlRelationFS()},
ctl: mlJournalController{},
}})
if err != nil {
t.Fatalf("registry: %v", err)
}
cc, ok := reg.Get("acme.demo.journals")
if !ok || cc.Form == nil || cc.Relations["entries"] == nil || cc.Relations["entries"].child == nil {
t.Fatalf("compiled journal missing entries form: %+v", cc)
}
return cc
}
func TestRelationChildMLNestedSave(t *testing.T) {
_, db := newListService(t)
if err := db.Migrator().DropTable(&mlEntry{}, &mlJournal{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&mlJournal{}, &mlEntry{}); err != nil {
t.Fatal(err)
}
parent := mlJournal{}
if err := db.Create(&parent).Error; err != nil {
t.Fatal(err)
}
writer := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}
svc := RelationService{DB: db, writer: writer}
cc := mlRelationCompiled(t)
childForm := cc.Relations["entries"].child
projected := ProjectWritableFields(childForm, map[string]any{
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
"extra": map[string]any{"nested": true},
})
if len(projected) != 0 {
t.Fatalf("unlifted nested maps reached projection: %#v", projected)
}
created, err := svc.CreateChild(context.Background(), cc, "entries", parent.ID, RecordInput{Body: map[string]any{
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
"extra": map[string]any{"nested": true},
}})
if err != nil {
t.Fatalf("create: %v", err)
}
got, ok := created.Data["title"].(map[string]string)
wantTitle := map[string]string{"en": "Hello", "pl": "Witaj"}
if !ok || !reflect.DeepEqual(got, wantTitle) {
t.Fatalf("create title = %#v", created.Data["title"])
}
var row mlEntry
if err := db.First(&row).Error; err != nil {
t.Fatal(err)
}
if row.Title != "Hello" {
t.Fatalf("host title = %q", row.Title)
}
if writer.attrs["en"] != nil {
t.Fatalf("default locale duplicated: %#v", writer.attrs["en"])
}
if writer.attrs["pl"]["title"] != "Witaj" {
t.Fatalf("Polish attributes = %#v", writer.attrs)
}
before := int64(0)
if err := db.Model(&mlEntry{}).Count(&before).Error; err != nil {
t.Fatal(err)
}
_, err = svc.CreateChild(context.Background(), cc, "entries", parent.ID, RecordInput{Body: map[string]any{
"title": map[string]any{"en": "Hello", "de": "Hallo"},
}})
var ve *ValidationError
if err == nil || !errors.As(err, &ve) {
t.Fatalf("undeclared locale err = %v", err)
}
after := int64(0)
if err := db.Model(&mlEntry{}).Count(&after).Error; err != nil {
t.Fatal(err)
}
if after != before {
t.Fatalf("undeclared locale inserted a child: before=%d after=%d", before, after)
}
}