Skip to content

Commit 7ebc4e0

Browse files
committed
fix: address all PR #28 code review issues (critical, moderate, minor)
Critical: fix ComboBox association mapping order (datasource before association), align gallery.def.json embedded/user definitions. Moderate: cache engine init errors, surface LoadUserDefinitions errors, fix fallback "first wins" semantics. Minor: validate operation names at load time, fix test condition string, extract TEMPLATE constant, add MPK zip-bomb protection. Update design doc and templates README to reflect changes.
1 parent 22b440e commit 7ebc4e0

File tree

10 files changed

+252
-67
lines changed

10 files changed

+252
-67
lines changed

docs/plans/2026-03-25-pluggable-widget-engine-design.md

Lines changed: 53 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Pluggable Widget Engine: 声明式 Widget 构建系统
22

33
**Date**: 2026-03-25
4-
**Status**: Design (research only)
4+
**Status**: Implemented
55

66
## Problem
77

@@ -56,18 +56,9 @@
5656
"templateFile": "combobox.json",
5757
"defaultEditable": "Always",
5858

59-
"modes": {
60-
"default": {
61-
"description": "Enumeration mode",
62-
"propertyMappings": [
63-
{
64-
"propertyKey": "attributeEnumeration",
65-
"source": "Attribute",
66-
"operation": "attribute"
67-
}
68-
]
69-
},
70-
"association": {
59+
"modes": [
60+
{
61+
"name": "association",
7162
"condition": "hasDataSource",
7263
"description": "Association mode with DataSource",
7364
"propertyMappings": [
@@ -76,24 +67,35 @@
7667
"value": "association",
7768
"operation": "primitive"
7869
},
79-
{
80-
"propertyKey": "attributeAssociation",
81-
"source": "Attribute",
82-
"operation": "association"
83-
},
8470
{
8571
"propertyKey": "optionsSourceAssociationDataSource",
8672
"source": "DataSource",
8773
"operation": "datasource"
8874
},
75+
{
76+
"propertyKey": "attributeAssociation",
77+
"source": "Attribute",
78+
"operation": "association"
79+
},
8980
{
9081
"propertyKey": "optionsSourceAssociationCaptionAttribute",
9182
"source": "CaptionAttribute",
9283
"operation": "attribute"
9384
}
9485
]
86+
},
87+
{
88+
"name": "default",
89+
"description": "Enumeration mode",
90+
"propertyMappings": [
91+
{
92+
"propertyKey": "attributeEnumeration",
93+
"source": "Attribute",
94+
"operation": "attribute"
95+
}
96+
]
9597
}
96-
}
98+
]
9799
}
98100
```
99101

@@ -105,33 +107,26 @@ Gallery (with child slots):
105107
"mdlName": "GALLERY",
106108
"templateFile": "gallery.json",
107109
"defaultEditable": "Always",
108-
"defaultSelection": "Single",
109110

110111
"propertyMappings": [
111-
{
112-
"propertyKey": "datasource",
113-
"source": "DataSource",
114-
"operation": "datasource"
115-
},
116-
{
117-
"propertyKey": "itemSelection",
118-
"source": "Selection",
119-
"operation": "primitive",
120-
"default": "Single"
121-
}
112+
{"propertyKey": "advanced", "value": "false", "operation": "primitive"},
113+
{"propertyKey": "datasource", "source": "DataSource", "operation": "datasource"},
114+
{"propertyKey": "itemSelection", "source": "Selection", "operation": "selection"},
115+
{"propertyKey": "itemSelectionMode", "value": "clear", "operation": "primitive"},
116+
{"propertyKey": "desktopItems", "value": "1", "operation": "primitive"},
117+
{"propertyKey": "tabletItems", "value": "1", "operation": "primitive"},
118+
{"propertyKey": "phoneItems", "value": "1", "operation": "primitive"},
119+
{"propertyKey": "pageSize", "value": "20", "operation": "primitive"},
120+
{"propertyKey": "pagination", "value": "buttons", "operation": "primitive"},
121+
{"propertyKey": "pagingPosition", "value": "below", "operation": "primitive"},
122+
{"propertyKey": "showEmptyPlaceholder", "value": "none", "operation": "primitive"},
123+
{"propertyKey": "onClickTrigger", "value": "single", "operation": "primitive"}
122124
],
123125

124126
"childSlots": [
125-
{
126-
"propertyKey": "content",
127-
"mdlContainer": "TEMPLATE",
128-
"operation": "widgets"
129-
},
130-
{
131-
"propertyKey": "filtersPlaceholder",
132-
"mdlContainer": "FILTER",
133-
"operation": "widgets"
134-
}
127+
{"propertyKey": "content", "mdlContainer": "TEMPLATE", "operation": "widgets"},
128+
{"propertyKey": "emptyPlaceholder", "mdlContainer": "EMPTYPLACEHOLDER", "operation": "widgets"},
129+
{"propertyKey": "filtersPlaceholder", "mdlContainer": "FILTERSPLACEHOLDER", "operation": "widgets"}
135130
]
136131
}
137132
```
@@ -192,6 +187,19 @@ type ChildSlotMapping struct {
192187
}
193188
```
194189

190+
### Critical: Property Mapping Order Dependency
191+
192+
**The engine processes `propertyMappings` in array order.** Some operations depend on side effects of earlier ones:
193+
194+
- `datasource` sets `pageBuilder.entityContext` as a side effect
195+
- `association` reads `pageBuilder.entityContext` to resolve the target entity
196+
197+
Therefore, in any mode that uses both, **`datasource` must come before `association`** in the mappings array. Getting this wrong produces silently incorrect BSON (wrong entity reference).
198+
199+
### Operation Validation
200+
201+
Operation names in `.def.json` files are validated at load time against the 6 known operations: `attribute`, `association`, `primitive`, `selection`, `datasource`, `widgets`. Invalid operation names produce an error when `NewWidgetRegistry()` or `LoadUserDefinitions()` runs, rather than failing silently at build time.
202+
195203
### Mode Selection Conditions
196204

197205
Built-in conditions (extensible):
@@ -201,7 +209,7 @@ Built-in conditions (extensible):
201209
| `hasDataSource` | `w.GetDataSource() != nil` |
202210
| `hasAttribute` | `w.GetAttribute() != ""` |
203211
| `hasProp:X` | `w.GetStringProp("X") != ""` |
204-
| (none) | `"default"` mode always selected |
212+
| (none) | Fallback — first no-condition mode wins if multiple exist |
205213

206214
### Engine Flow
207215

@@ -317,3 +325,6 @@ mxcli widget extract --mpk path/to/widget.mpk
317325
| Template version drift | Existing `augment.go` handles .mpk sync, works unchanged |
318326
| Performance regression | Template loading is already cached; engine adds minimal overhead |
319327
| User-provided templates may be invalid | Validate on load: check type+object sections exist, PropertyKey coverage |
328+
| MPK zip-bomb attack | `ParseMPK` enforces per-file (50MB) and total (200MB) extraction limits |
329+
| Invalid operation names in .def.json | Validated at load time, not build time — immediate feedback |
330+
| Engine init failure retried on every widget | Init error cached; subsequent widgets skip immediately |

mdl/executor/cmd_pages_builder.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ type pageBuilder struct {
5050

5151
// initPluggableEngine lazily initializes the pluggable widget engine.
5252
func (pb *pageBuilder) initPluggableEngine() {
53-
if pb.pluggableEngine != nil {
53+
if pb.pluggableEngine != nil || pb.pluggableEngineErr != nil {
5454
return
5555
}
5656
registry, err := NewWidgetRegistry()
@@ -60,7 +60,9 @@ func (pb *pageBuilder) initPluggableEngine() {
6060
return
6161
}
6262
if pb.reader != nil {
63-
_ = registry.LoadUserDefinitions(pb.reader.Path())
63+
if loadErr := registry.LoadUserDefinitions(pb.reader.Path()); loadErr != nil {
64+
fmt.Fprintf(os.Stderr, "warning: loading user widget definitions: %v\n", loadErr)
65+
}
6466
}
6567
pb.widgetRegistry = registry
6668
pb.pluggableEngine = NewPluggableWidgetEngine(NewOperationRegistry(), pb)

mdl/executor/widget_engine.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import (
1414
"go.mongodb.org/mongo-driver/bson"
1515
)
1616

17+
// defaultSlotContainer is the MDLContainer name that receives default (non-containerized) child widgets.
18+
const defaultSlotContainer = "TEMPLATE"
19+
1720
// =============================================================================
1821
// Pluggable Widget Engine — Core Types and Operation Registry
1922
// =============================================================================
@@ -297,8 +300,10 @@ func (e *PluggableWidgetEngine) selectMappings(def *WidgetDefinition, w *ast.Wid
297300
for i := range def.Modes {
298301
mode := &def.Modes[i]
299302
if mode.Condition == "" {
300-
// No condition = default fallback (use last one if multiple)
301-
fallback = mode
303+
// No condition = default fallback (use first one if multiple)
304+
if fallback == nil {
305+
fallback = mode
306+
}
302307
continue
303308
}
304309
if e.evaluateCondition(mode.Condition, w) {
@@ -447,7 +452,7 @@ func (e *PluggableWidgetEngine) applyChildSlots(slots []ChildSlotMapping, w *ast
447452
for _, slot := range slots {
448453
childBSONs := slotWidgets[slot.PropertyKey]
449454
// If no explicit container children, use default widgets for the first slot
450-
if len(childBSONs) == 0 && len(defaultWidgets) > 0 && slot.MDLContainer == "TEMPLATE" {
455+
if len(childBSONs) == 0 && len(defaultWidgets) > 0 && slot.MDLContainer == defaultSlotContainer {
451456
childBSONs = defaultWidgets
452457
defaultWidgets = nil // consume once
453458
}

mdl/executor/widget_engine_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func TestWidgetDefinitionJSONRoundTrip(t *testing.T) {
2929
Modes: []WidgetMode{
3030
{
3131
Name: "association",
32-
Condition: "DataSource != nil",
32+
Condition: "hasDataSource",
3333
Description: "Association-based ComboBox with datasource",
3434
PropertyMappings: []PropertyMapping{
3535
{PropertyKey: "attributeAssociation", Source: "Attribute", Operation: "association"},
@@ -87,8 +87,8 @@ func TestWidgetDefinitionJSONRoundTrip(t *testing.T) {
8787
if assocMode.Name != "association" {
8888
t.Errorf("Mode name: got %q, want %q", assocMode.Name, "association")
8989
}
90-
if assocMode.Condition != "DataSource != nil" {
91-
t.Errorf("Mode condition: got %q, want %q", assocMode.Condition, "DataSource != nil")
90+
if assocMode.Condition != "hasDataSource" {
91+
t.Errorf("Mode condition: got %q, want %q", assocMode.Condition, "hasDataSource")
9292
}
9393
if len(assocMode.PropertyMappings) != 2 {
9494
t.Errorf("Mode PropertyMappings count: got %d, want 2", len(assocMode.PropertyMappings))

mdl/executor/widget_registry.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ func NewWidgetRegistry() (*WidgetRegistry, error) {
4545
return nil, fmt.Errorf("parse definition %s: %w", entry.Name(), err)
4646
}
4747

48+
if err := validateDefinitionOperations(&def, entry.Name()); err != nil {
49+
return nil, err
50+
}
51+
4852
reg.byMDLName[strings.ToUpper(def.MDLName)] = &def
4953
reg.byWidgetID[def.WidgetID] = &def
5054
}
@@ -136,8 +140,49 @@ func (r *WidgetRegistry) loadDefinitionsFromDir(dir string) error {
136140
return fmt.Errorf("invalid definition %s: widgetId and mdlName are required", entry.Name())
137141
}
138142

143+
if err := validateDefinitionOperations(&def, entry.Name()); err != nil {
144+
return err
145+
}
146+
139147
r.byMDLName[strings.ToUpper(def.MDLName)] = &def
140148
r.byWidgetID[def.WidgetID] = &def
141149
}
142150
return nil
143151
}
152+
153+
// validOperations is the set of recognized operation names for property and child-slot mappings.
154+
var validOperations = map[string]bool{
155+
"attribute": true,
156+
"association": true,
157+
"primitive": true,
158+
"selection": true,
159+
"datasource": true,
160+
"widgets": true,
161+
}
162+
163+
// validateDefinitionOperations checks that all operation names in a definition are recognized.
164+
func validateDefinitionOperations(def *WidgetDefinition, source string) error {
165+
for _, m := range def.PropertyMappings {
166+
if !validOperations[m.Operation] {
167+
return fmt.Errorf("%s: unknown operation %q in propertyMappings for key %q", source, m.Operation, m.PropertyKey)
168+
}
169+
}
170+
for _, s := range def.ChildSlots {
171+
if !validOperations[s.Operation] {
172+
return fmt.Errorf("%s: unknown operation %q in childSlots for key %q", source, s.Operation, s.PropertyKey)
173+
}
174+
}
175+
for _, mode := range def.Modes {
176+
for _, m := range mode.PropertyMappings {
177+
if !validOperations[m.Operation] {
178+
return fmt.Errorf("%s: unknown operation %q in mode %q propertyMappings for key %q", source, m.Operation, mode.Name, m.PropertyKey)
179+
}
180+
}
181+
for _, s := range mode.ChildSlots {
182+
if !validOperations[s.Operation] {
183+
return fmt.Errorf("%s: unknown operation %q in mode %q childSlots for key %q", source, s.Operation, mode.Name, s.PropertyKey)
184+
}
185+
}
186+
}
187+
return nil
188+
}

mdl/executor/widget_registry_test.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,8 @@ func TestRegistryGalleryChildSlots(t *testing.T) {
239239
t.Fatal("GALLERY not found")
240240
}
241241

242-
if len(def.ChildSlots) != 2 {
243-
t.Fatalf("childSlots count = %d, want 2", len(def.ChildSlots))
242+
if len(def.ChildSlots) != 3 {
243+
t.Fatalf("childSlots count = %d, want 3", len(def.ChildSlots))
244244
}
245245

246246
// Verify slot mappings
@@ -257,11 +257,19 @@ func TestRegistryGalleryChildSlots(t *testing.T) {
257257
t.Errorf("TEMPLATE slot propertyKey = %q, want content", contentSlot.PropertyKey)
258258
}
259259

260-
filterSlot, ok := slotsByContainer["FILTER"]
260+
emptySlot, ok := slotsByContainer["EMPTYPLACEHOLDER"]
261261
if !ok {
262-
t.Fatal("FILTER slot not found")
262+
t.Fatal("EMPTYPLACEHOLDER slot not found")
263+
}
264+
if emptySlot.PropertyKey != "emptyPlaceholder" {
265+
t.Errorf("EMPTYPLACEHOLDER slot propertyKey = %q, want emptyPlaceholder", emptySlot.PropertyKey)
266+
}
267+
268+
filterSlot, ok := slotsByContainer["FILTERSPLACEHOLDER"]
269+
if !ok {
270+
t.Fatal("FILTERSPLACEHOLDER slot not found")
263271
}
264272
if filterSlot.PropertyKey != "filtersPlaceholder" {
265-
t.Errorf("FILTER slot propertyKey = %q, want filtersPlaceholder", filterSlot.PropertyKey)
273+
t.Errorf("FILTERSPLACEHOLDER slot propertyKey = %q, want filtersPlaceholder", filterSlot.PropertyKey)
266274
}
267275
}

sdk/widgets/definitions/combobox.def.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
"description": "Association mode",
1111
"propertyMappings": [
1212
{"propertyKey": "optionsSourceType", "value": "association", "operation": "primitive"},
13-
{"propertyKey": "attributeAssociation", "source": "Attribute", "operation": "association"},
1413
{"propertyKey": "optionsSourceAssociationDataSource", "source": "DataSource", "operation": "datasource"},
14+
{"propertyKey": "attributeAssociation", "source": "Attribute", "operation": "association"},
1515
{"propertyKey": "optionsSourceAssociationCaptionAttribute", "source": "CaptionAttribute", "operation": "attribute"}
1616
]
1717
},

0 commit comments

Comments
 (0)