Skip to content

Commit 76b4342

Browse files
authored
Merge pull request #158 from engalar/fix/page-context-tree
fix: resolve page context tree for ALTER PAGE and check --references
2 parents cbb64d8 + 9428869 commit 76b4342

5 files changed

Lines changed: 587 additions & 8 deletions

File tree

mdl/executor/alter_page_test.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,3 +517,186 @@ func TestFindBsonWidget_DataViewFooter(t *testing.T) {
517517
t.Fatal("Expected to find btnFooter in DataView FooterWidgets")
518518
}
519519
}
520+
521+
// ============================================================================
522+
// Page context tree tests (#157)
523+
// ============================================================================
524+
525+
func makeWidgetWithID(name string, typeName string, id primitive.Binary) bson.D {
526+
return bson.D{
527+
{Key: "$ID", Value: id},
528+
{Key: "$Type", Value: typeName},
529+
{Key: "Name", Value: name},
530+
}
531+
}
532+
533+
func makeBsonID(b byte) primitive.Binary {
534+
data := make([]byte, 16)
535+
data[0] = b
536+
return primitive.Binary{Subtype: 0x04, Data: data}
537+
}
538+
539+
func TestExtractPageParamsFromBSON_EntityParams(t *testing.T) {
540+
rawData := bson.D{
541+
{Key: "Parameters", Value: bson.A{
542+
int32(2), // type marker
543+
bson.D{
544+
{Key: "$ID", Value: makeBsonID(0x01)},
545+
{Key: "$Type", Value: "Forms$PageParameter"},
546+
{Key: "Name", Value: "Customer"},
547+
{Key: "ParameterType", Value: bson.D{
548+
{Key: "$ID", Value: makeBsonID(0x02)},
549+
{Key: "$Type", Value: "DataTypes$ObjectType"},
550+
{Key: "Entity", Value: "MyModule.Customer"},
551+
}},
552+
},
553+
bson.D{
554+
{Key: "$ID", Value: makeBsonID(0x03)},
555+
{Key: "$Type", Value: "Forms$PageParameter"},
556+
{Key: "Name", Value: "Order"},
557+
{Key: "ParameterType", Value: bson.D{
558+
{Key: "$ID", Value: makeBsonID(0x04)},
559+
{Key: "$Type", Value: "DataTypes$ObjectType"},
560+
{Key: "Entity", Value: "MyModule.Order"},
561+
}},
562+
},
563+
}},
564+
}
565+
566+
paramScope, paramEntityNames := extractPageParamsFromBSON(rawData)
567+
568+
if len(paramScope) != 2 {
569+
t.Fatalf("Expected 2 params, got %d", len(paramScope))
570+
}
571+
if paramEntityNames["Customer"] != "MyModule.Customer" {
572+
t.Errorf("Expected Customer -> MyModule.Customer, got %q", paramEntityNames["Customer"])
573+
}
574+
if paramEntityNames["Order"] != "MyModule.Order" {
575+
t.Errorf("Expected Order -> MyModule.Order, got %q", paramEntityNames["Order"])
576+
}
577+
if paramScope["Customer"] == "" {
578+
t.Error("Expected non-empty ID for Customer param")
579+
}
580+
}
581+
582+
func TestExtractPageParamsFromBSON_SkipsPrimitiveParams(t *testing.T) {
583+
rawData := bson.D{
584+
{Key: "Parameters", Value: bson.A{
585+
int32(2),
586+
bson.D{
587+
{Key: "$ID", Value: makeBsonID(0x01)},
588+
{Key: "$Type", Value: "Forms$PageParameter"},
589+
{Key: "Name", Value: "Title"},
590+
{Key: "ParameterType", Value: bson.D{
591+
{Key: "$ID", Value: makeBsonID(0x02)},
592+
{Key: "$Type", Value: "DataTypes$StringType"},
593+
}},
594+
},
595+
}},
596+
}
597+
598+
paramScope, paramEntityNames := extractPageParamsFromBSON(rawData)
599+
600+
if len(paramScope) != 0 {
601+
t.Errorf("Expected 0 entity params (String is primitive), got %d", len(paramScope))
602+
}
603+
if len(paramEntityNames) != 0 {
604+
t.Errorf("Expected 0 entity param names, got %d", len(paramEntityNames))
605+
}
606+
}
607+
608+
func TestExtractPageParamsFromBSON_Nil(t *testing.T) {
609+
paramScope, paramEntityNames := extractPageParamsFromBSON(nil)
610+
if len(paramScope) != 0 || len(paramEntityNames) != 0 {
611+
t.Error("Expected empty maps for nil input")
612+
}
613+
}
614+
615+
func TestExtractWidgetScopeFromBSON_PageFormat(t *testing.T) {
616+
id1 := makeBsonID(0x10)
617+
id2 := makeBsonID(0x20)
618+
rawData := bson.D{
619+
{Key: "FormCall", Value: bson.D{
620+
{Key: "Arguments", Value: bson.A{
621+
int32(2),
622+
bson.D{
623+
{Key: "Widgets", Value: bson.A{
624+
int32(2),
625+
makeWidgetWithID("dgOrders", "CustomWidgets$CustomWidget", id1),
626+
makeWidgetWithID("txtName", "Pages$TextBox", id2),
627+
}},
628+
},
629+
}},
630+
}},
631+
}
632+
633+
scope := extractWidgetScopeFromBSON(rawData)
634+
635+
if len(scope) != 2 {
636+
t.Fatalf("Expected 2 widgets in scope, got %d", len(scope))
637+
}
638+
if scope["dgOrders"] == "" {
639+
t.Error("Expected dgOrders in widget scope")
640+
}
641+
if scope["txtName"] == "" {
642+
t.Error("Expected txtName in widget scope")
643+
}
644+
}
645+
646+
func TestExtractWidgetScopeFromBSON_NestedWidgets(t *testing.T) {
647+
idDv := makeBsonID(0x10)
648+
idInner := makeBsonID(0x20)
649+
rawData := bson.D{
650+
{Key: "FormCall", Value: bson.D{
651+
{Key: "Arguments", Value: bson.A{
652+
int32(2),
653+
bson.D{
654+
{Key: "Widgets", Value: bson.A{
655+
int32(2),
656+
bson.D{
657+
{Key: "$ID", Value: idDv},
658+
{Key: "$Type", Value: "Pages$DataView"},
659+
{Key: "Name", Value: "dvOrder"},
660+
{Key: "Widgets", Value: bson.A{
661+
int32(2),
662+
makeWidgetWithID("txtOrderNum", "Pages$TextBox", idInner),
663+
}},
664+
},
665+
}},
666+
},
667+
}},
668+
}},
669+
}
670+
671+
scope := extractWidgetScopeFromBSON(rawData)
672+
673+
if scope["dvOrder"] == "" {
674+
t.Error("Expected dvOrder in widget scope")
675+
}
676+
if scope["txtOrderNum"] == "" {
677+
t.Error("Expected txtOrderNum in widget scope (nested in DataView)")
678+
}
679+
}
680+
681+
func TestExtractWidgetScopeFromBSON_SnippetFormat(t *testing.T) {
682+
idW := makeBsonID(0x10)
683+
rawData := bson.D{
684+
{Key: "Widgets", Value: bson.A{
685+
int32(2),
686+
makeWidgetWithID("txtSnippet", "Pages$TextBox", idW),
687+
}},
688+
}
689+
690+
scope := extractWidgetScopeFromBSON(rawData)
691+
692+
if scope["txtSnippet"] == "" {
693+
t.Error("Expected txtSnippet in widget scope (snippet format)")
694+
}
695+
}
696+
697+
func TestExtractWidgetScopeFromBSON_Nil(t *testing.T) {
698+
scope := extractWidgetScopeFromBSON(nil)
699+
if len(scope) != 0 {
700+
t.Error("Expected empty scope for nil input")
701+
}
702+
}

mdl/executor/cmd_alter_page.go

Lines changed: 157 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,8 +1193,8 @@ func (e *Executor) applyInsertWidgetWith(rawData bson.D, op *ast.InsertWidgetOp,
11931193
// Find entity context from enclosing DataView/DataGrid/ListView
11941194
entityCtx := findEnclosingEntityContext(rawData, op.Target.Widget)
11951195

1196-
// Build new widget BSON from AST
1197-
newBsonWidgets, err := e.buildWidgetsBson(op.Widgets, moduleName, moduleID, entityCtx)
1196+
// Build new widget BSON from AST (pass rawData for page param + widget scope resolution)
1197+
newBsonWidgets, err := e.buildWidgetsBson(op.Widgets, moduleName, moduleID, entityCtx, rawData)
11981198
if err != nil {
11991199
return fmt.Errorf("failed to build widgets: %w", err)
12001200
}
@@ -1281,8 +1281,8 @@ func (e *Executor) applyReplaceWidgetWith(rawData bson.D, op *ast.ReplaceWidgetO
12811281
// Find entity context from enclosing DataView/DataGrid/ListView
12821282
entityCtx := findEnclosingEntityContext(rawData, op.Target.Widget)
12831283

1284-
// Build new widget BSON from AST
1285-
newBsonWidgets, err := e.buildWidgetsBson(op.NewWidgets, moduleName, moduleID, entityCtx)
1284+
// Build new widget BSON from AST (pass rawData for page param + widget scope resolution)
1285+
newBsonWidgets, err := e.buildWidgetsBson(op.NewWidgets, moduleName, moduleID, entityCtx, rawData)
12861286
if err != nil {
12871287
return fmt.Errorf("failed to build replacement widgets: %w", err)
12881288
}
@@ -1529,16 +1529,21 @@ func applyDropVariable(rawData bson.D, op *ast.DropVariableOp) error {
15291529

15301530
// buildWidgetsBson converts AST widgets to ordered BSON documents.
15311531
// Returns bson.D elements (not map[string]any) to preserve field ordering.
1532-
func (e *Executor) buildWidgetsBson(widgets []*ast.WidgetV3, moduleName string, moduleID model.ID, entityContext string) ([]any, error) {
1532+
// rawPageData is the full page/snippet BSON, used to extract page parameters
1533+
// and existing widget IDs for PARAMETER/SELECTION DataSource resolution.
1534+
func (e *Executor) buildWidgetsBson(widgets []*ast.WidgetV3, moduleName string, moduleID model.ID, entityContext string, rawPageData bson.D) ([]any, error) {
1535+
paramScope, paramEntityNames := extractPageParamsFromBSON(rawPageData)
1536+
widgetScope := extractWidgetScopeFromBSON(rawPageData)
1537+
15331538
pb := &pageBuilder{
15341539
writer: e.writer,
15351540
reader: e.reader,
15361541
moduleID: moduleID,
15371542
moduleName: moduleName,
15381543
entityContext: entityContext,
1539-
widgetScope: make(map[string]model.ID),
1540-
paramScope: make(map[string]model.ID),
1541-
paramEntityNames: make(map[string]string),
1544+
widgetScope: widgetScope,
1545+
paramScope: paramScope,
1546+
paramEntityNames: paramEntityNames,
15421547
execCache: e.cache,
15431548
fragments: e.fragments,
15441549
themeRegistry: e.getThemeRegistry(),
@@ -1561,6 +1566,150 @@ func (e *Executor) buildWidgetsBson(widgets []*ast.WidgetV3, moduleName string,
15611566
return result, nil
15621567
}
15631568

1569+
// extractPageParamsFromBSON extracts page/snippet parameter names and entity
1570+
// IDs from the raw BSON document. This enables ALTER PAGE REPLACE/INSERT
1571+
// operations to resolve PARAMETER DataSource references (e.g., DataSource: $Customer).
1572+
func extractPageParamsFromBSON(rawData bson.D) (map[string]model.ID, map[string]string) {
1573+
paramScope := make(map[string]model.ID)
1574+
paramEntityNames := make(map[string]string)
1575+
if rawData == nil {
1576+
return paramScope, paramEntityNames
1577+
}
1578+
1579+
params := dGetArrayElements(dGet(rawData, "Parameters"))
1580+
for _, p := range params {
1581+
pDoc, ok := p.(bson.D)
1582+
if !ok {
1583+
continue
1584+
}
1585+
name := dGetString(pDoc, "Name")
1586+
if name == "" {
1587+
continue
1588+
}
1589+
paramType := dGetDoc(pDoc, "ParameterType")
1590+
if paramType == nil {
1591+
continue
1592+
}
1593+
typeName := dGetString(paramType, "$Type")
1594+
if typeName != "DataTypes$ObjectType" {
1595+
continue
1596+
}
1597+
entityName := dGetString(paramType, "Entity")
1598+
if entityName == "" {
1599+
continue
1600+
}
1601+
idVal := dGet(pDoc, "$ID")
1602+
paramID := model.ID(extractBinaryIDFromDoc(idVal))
1603+
paramScope[name] = paramID
1604+
paramEntityNames[name] = entityName
1605+
}
1606+
return paramScope, paramEntityNames
1607+
}
1608+
1609+
// extractWidgetScopeFromBSON walks the entire raw BSON widget tree and
1610+
// collects a map of widget name → widget ID. This enables ALTER PAGE INSERT
1611+
// operations to resolve SELECTION DataSource references to existing sibling widgets.
1612+
func extractWidgetScopeFromBSON(rawData bson.D) map[string]model.ID {
1613+
scope := make(map[string]model.ID)
1614+
if rawData == nil {
1615+
return scope
1616+
}
1617+
// Page format: FormCall.Arguments[].Widgets[]
1618+
if formCall := dGetDoc(rawData, "FormCall"); formCall != nil {
1619+
args := dGetArrayElements(dGet(formCall, "Arguments"))
1620+
for _, arg := range args {
1621+
argDoc, ok := arg.(bson.D)
1622+
if !ok {
1623+
continue
1624+
}
1625+
collectWidgetScope(argDoc, "Widgets", scope)
1626+
}
1627+
}
1628+
// Snippet format: Widgets[] or Widget.Widgets[]
1629+
collectWidgetScope(rawData, "Widgets", scope)
1630+
if widgetContainer := dGetDoc(rawData, "Widget"); widgetContainer != nil {
1631+
collectWidgetScope(widgetContainer, "Widgets", scope)
1632+
}
1633+
return scope
1634+
}
1635+
1636+
// collectWidgetScope recursively walks a widget array and collects name→ID mappings.
1637+
func collectWidgetScope(parentDoc bson.D, key string, scope map[string]model.ID) {
1638+
elements := dGetArrayElements(dGet(parentDoc, key))
1639+
for _, elem := range elements {
1640+
wDoc, ok := elem.(bson.D)
1641+
if !ok {
1642+
continue
1643+
}
1644+
name := dGetString(wDoc, "Name")
1645+
if name != "" {
1646+
idVal := dGet(wDoc, "$ID")
1647+
if wID := extractBinaryIDFromDoc(idVal); wID != "" {
1648+
scope[name] = model.ID(wID)
1649+
}
1650+
}
1651+
// Also register entity context for widgets with DataSource
1652+
// so SELECTION can resolve the entity type
1653+
collectWidgetScopeInChildren(wDoc, scope)
1654+
}
1655+
}
1656+
1657+
// collectWidgetScopeInChildren recursively walks widget children to collect scope.
1658+
func collectWidgetScopeInChildren(wDoc bson.D, scope map[string]model.ID) {
1659+
typeName := dGetString(wDoc, "$Type")
1660+
1661+
// Direct Widgets[]
1662+
collectWidgetScope(wDoc, "Widgets", scope)
1663+
// FooterWidgets[]
1664+
collectWidgetScope(wDoc, "FooterWidgets", scope)
1665+
// LayoutGrid: Rows[].Columns[].Widgets[]
1666+
if strings.Contains(typeName, "LayoutGrid") {
1667+
rows := dGetArrayElements(dGet(wDoc, "Rows"))
1668+
for _, row := range rows {
1669+
rowDoc, ok := row.(bson.D)
1670+
if !ok {
1671+
continue
1672+
}
1673+
cols := dGetArrayElements(dGet(rowDoc, "Columns"))
1674+
for _, col := range cols {
1675+
colDoc, ok := col.(bson.D)
1676+
if !ok {
1677+
continue
1678+
}
1679+
collectWidgetScope(colDoc, "Widgets", scope)
1680+
}
1681+
}
1682+
}
1683+
// TabContainer: TabPages[].Widgets[]
1684+
tabPages := dGetArrayElements(dGet(wDoc, "TabPages"))
1685+
for _, tp := range tabPages {
1686+
tpDoc, ok := tp.(bson.D)
1687+
if !ok {
1688+
continue
1689+
}
1690+
collectWidgetScope(tpDoc, "Widgets", scope)
1691+
}
1692+
// ControlBar
1693+
if controlBar := dGetDoc(wDoc, "ControlBar"); controlBar != nil {
1694+
collectWidgetScope(controlBar, "Items", scope)
1695+
}
1696+
// CustomWidget (pluggable): Object.Properties[].Value.Widgets[]
1697+
if strings.Contains(typeName, "CustomWidget") {
1698+
if obj := dGetDoc(wDoc, "Object"); obj != nil {
1699+
props := dGetArrayElements(dGet(obj, "Properties"))
1700+
for _, prop := range props {
1701+
propDoc, ok := prop.(bson.D)
1702+
if !ok {
1703+
continue
1704+
}
1705+
if valDoc := dGetDoc(propDoc, "Value"); valDoc != nil {
1706+
collectWidgetScope(valDoc, "Widgets", scope)
1707+
}
1708+
}
1709+
}
1710+
}
1711+
}
1712+
15641713
// ============================================================================
15651714
// Helper: SerializeWidget is already available via mpr package
15661715
// ============================================================================

0 commit comments

Comments
 (0)