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:
@@ -656,7 +656,13 @@ func (s *service) relations() (RelationService, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return RelationService{}, err
|
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
|
// relationsFor is the relation service of one request, carrying its
|
||||||
|
|||||||
@@ -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 {
|
func (w *recordingWriter) WriteTranslated(_ context.Context, _ *gorm.DB, model any, field, locale, value string) error {
|
||||||
post, _ := model.(*mlPost)
|
id := pkUint(model)
|
||||||
if post == nil || post.ID == 0 {
|
if id == 0 {
|
||||||
return &ValidationError{Details: map[string]any{field: []string{"translation writer ran before the host row had an id"}}}
|
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 {
|
if w.attrs == nil {
|
||||||
w.attrs = map[string]map[string]string{}
|
w.attrs = map[string]map[string]string{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,9 +186,11 @@ type RelationService struct {
|
|||||||
SessionKey string
|
SessionKey string
|
||||||
|
|
||||||
// bucket deletes the blobs of child files a save removes, after commit;
|
// 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
|
bucket *blob.Bucket
|
||||||
tr *phrasebook.Translator
|
tr *phrasebook.Translator
|
||||||
|
writer TranslationWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s RelationSchema) MarshalJSON() ([]byte, error) {
|
func (s RelationSchema) MarshalJSON() ([]byte, error) {
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
|
|||||||
return lifecycleFailure(cc, err)
|
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 {
|
if err := s.fillChild(ctx, tx, cr.child, child, in.Body, "create"); err != nil {
|
||||||
return err
|
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 {
|
if err := tx.WithContext(ctx).Create(child).Error; err != nil {
|
||||||
return lifecycleFailure(cc, err)
|
return lifecycleFailure(cc, err)
|
||||||
}
|
}
|
||||||
|
if err := applyMLTranslations(ctx, tx, s.writer, child, translations); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case parent.unsaved():
|
case parent.unsaved():
|
||||||
// The child exists now, unattached; the parent's first save
|
// 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)
|
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 {
|
if err != nil {
|
||||||
return RecordResult{}, err
|
return RecordResult{}, err
|
||||||
|
|||||||
201
modules/cabana/relation_child_ml_test.go
Normal file
201
modules/cabana/relation_child_ml_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user