Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions mdl/executor/alter_page_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -517,3 +517,186 @@ func TestFindBsonWidget_DataViewFooter(t *testing.T) {
t.Fatal("Expected to find btnFooter in DataView FooterWidgets")
}
}

// ============================================================================
// Page context tree tests (#157)
// ============================================================================

func makeWidgetWithID(name string, typeName string, id primitive.Binary) bson.D {
return bson.D{
{Key: "$ID", Value: id},
{Key: "$Type", Value: typeName},
{Key: "Name", Value: name},
}
}

func makeBsonID(b byte) primitive.Binary {
data := make([]byte, 16)
data[0] = b
return primitive.Binary{Subtype: 0x04, Data: data}
}

func TestExtractPageParamsFromBSON_EntityParams(t *testing.T) {
rawData := bson.D{
{Key: "Parameters", Value: bson.A{
int32(2), // type marker
bson.D{
{Key: "$ID", Value: makeBsonID(0x01)},
{Key: "$Type", Value: "Forms$PageParameter"},
{Key: "Name", Value: "Customer"},
{Key: "ParameterType", Value: bson.D{
{Key: "$ID", Value: makeBsonID(0x02)},
{Key: "$Type", Value: "DataTypes$ObjectType"},
{Key: "Entity", Value: "MyModule.Customer"},
}},
},
bson.D{
{Key: "$ID", Value: makeBsonID(0x03)},
{Key: "$Type", Value: "Forms$PageParameter"},
{Key: "Name", Value: "Order"},
{Key: "ParameterType", Value: bson.D{
{Key: "$ID", Value: makeBsonID(0x04)},
{Key: "$Type", Value: "DataTypes$ObjectType"},
{Key: "Entity", Value: "MyModule.Order"},
}},
},
}},
}

paramScope, paramEntityNames := extractPageParamsFromBSON(rawData)

if len(paramScope) != 2 {
t.Fatalf("Expected 2 params, got %d", len(paramScope))
}
if paramEntityNames["Customer"] != "MyModule.Customer" {
t.Errorf("Expected Customer -> MyModule.Customer, got %q", paramEntityNames["Customer"])
}
if paramEntityNames["Order"] != "MyModule.Order" {
t.Errorf("Expected Order -> MyModule.Order, got %q", paramEntityNames["Order"])
}
if paramScope["Customer"] == "" {
t.Error("Expected non-empty ID for Customer param")
}
}

func TestExtractPageParamsFromBSON_SkipsPrimitiveParams(t *testing.T) {
rawData := bson.D{
{Key: "Parameters", Value: bson.A{
int32(2),
bson.D{
{Key: "$ID", Value: makeBsonID(0x01)},
{Key: "$Type", Value: "Forms$PageParameter"},
{Key: "Name", Value: "Title"},
{Key: "ParameterType", Value: bson.D{
{Key: "$ID", Value: makeBsonID(0x02)},
{Key: "$Type", Value: "DataTypes$StringType"},
}},
},
}},
}

paramScope, paramEntityNames := extractPageParamsFromBSON(rawData)

if len(paramScope) != 0 {
t.Errorf("Expected 0 entity params (String is primitive), got %d", len(paramScope))
}
if len(paramEntityNames) != 0 {
t.Errorf("Expected 0 entity param names, got %d", len(paramEntityNames))
}
}

func TestExtractPageParamsFromBSON_Nil(t *testing.T) {
paramScope, paramEntityNames := extractPageParamsFromBSON(nil)
if len(paramScope) != 0 || len(paramEntityNames) != 0 {
t.Error("Expected empty maps for nil input")
}
}

func TestExtractWidgetScopeFromBSON_PageFormat(t *testing.T) {
id1 := makeBsonID(0x10)
id2 := makeBsonID(0x20)
rawData := bson.D{
{Key: "FormCall", Value: bson.D{
{Key: "Arguments", Value: bson.A{
int32(2),
bson.D{
{Key: "Widgets", Value: bson.A{
int32(2),
makeWidgetWithID("dgOrders", "CustomWidgets$CustomWidget", id1),
makeWidgetWithID("txtName", "Pages$TextBox", id2),
}},
},
}},
}},
}

scope := extractWidgetScopeFromBSON(rawData)

if len(scope) != 2 {
t.Fatalf("Expected 2 widgets in scope, got %d", len(scope))
}
if scope["dgOrders"] == "" {
t.Error("Expected dgOrders in widget scope")
}
if scope["txtName"] == "" {
t.Error("Expected txtName in widget scope")
}
}

func TestExtractWidgetScopeFromBSON_NestedWidgets(t *testing.T) {
idDv := makeBsonID(0x10)
idInner := makeBsonID(0x20)
rawData := bson.D{
{Key: "FormCall", Value: bson.D{
{Key: "Arguments", Value: bson.A{
int32(2),
bson.D{
{Key: "Widgets", Value: bson.A{
int32(2),
bson.D{
{Key: "$ID", Value: idDv},
{Key: "$Type", Value: "Pages$DataView"},
{Key: "Name", Value: "dvOrder"},
{Key: "Widgets", Value: bson.A{
int32(2),
makeWidgetWithID("txtOrderNum", "Pages$TextBox", idInner),
}},
},
}},
},
}},
}},
}

scope := extractWidgetScopeFromBSON(rawData)

if scope["dvOrder"] == "" {
t.Error("Expected dvOrder in widget scope")
}
if scope["txtOrderNum"] == "" {
t.Error("Expected txtOrderNum in widget scope (nested in DataView)")
}
}

func TestExtractWidgetScopeFromBSON_SnippetFormat(t *testing.T) {
idW := makeBsonID(0x10)
rawData := bson.D{
{Key: "Widgets", Value: bson.A{
int32(2),
makeWidgetWithID("txtSnippet", "Pages$TextBox", idW),
}},
}

scope := extractWidgetScopeFromBSON(rawData)

if scope["txtSnippet"] == "" {
t.Error("Expected txtSnippet in widget scope (snippet format)")
}
}

func TestExtractWidgetScopeFromBSON_Nil(t *testing.T) {
scope := extractWidgetScopeFromBSON(nil)
if len(scope) != 0 {
t.Error("Expected empty scope for nil input")
}
}
165 changes: 157 additions & 8 deletions mdl/executor/cmd_alter_page.go
Original file line number Diff line number Diff line change
Expand Up @@ -1193,8 +1193,8 @@ func (e *Executor) applyInsertWidgetWith(rawData bson.D, op *ast.InsertWidgetOp,
// Find entity context from enclosing DataView/DataGrid/ListView
entityCtx := findEnclosingEntityContext(rawData, op.Target.Widget)

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

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

// buildWidgetsBson converts AST widgets to ordered BSON documents.
// Returns bson.D elements (not map[string]any) to preserve field ordering.
func (e *Executor) buildWidgetsBson(widgets []*ast.WidgetV3, moduleName string, moduleID model.ID, entityContext string) ([]any, error) {
// rawPageData is the full page/snippet BSON, used to extract page parameters
// and existing widget IDs for PARAMETER/SELECTION DataSource resolution.
func (e *Executor) buildWidgetsBson(widgets []*ast.WidgetV3, moduleName string, moduleID model.ID, entityContext string, rawPageData bson.D) ([]any, error) {
paramScope, paramEntityNames := extractPageParamsFromBSON(rawPageData)
widgetScope := extractWidgetScopeFromBSON(rawPageData)

pb := &pageBuilder{
writer: e.writer,
reader: e.reader,
moduleID: moduleID,
moduleName: moduleName,
entityContext: entityContext,
widgetScope: make(map[string]model.ID),
paramScope: make(map[string]model.ID),
paramEntityNames: make(map[string]string),
widgetScope: widgetScope,
paramScope: paramScope,
paramEntityNames: paramEntityNames,
execCache: e.cache,
fragments: e.fragments,
themeRegistry: e.getThemeRegistry(),
Expand All @@ -1561,6 +1566,150 @@ func (e *Executor) buildWidgetsBson(widgets []*ast.WidgetV3, moduleName string,
return result, nil
}

// extractPageParamsFromBSON extracts page/snippet parameter names and entity
// IDs from the raw BSON document. This enables ALTER PAGE REPLACE/INSERT
// operations to resolve PARAMETER DataSource references (e.g., DataSource: $Customer).
func extractPageParamsFromBSON(rawData bson.D) (map[string]model.ID, map[string]string) {
paramScope := make(map[string]model.ID)
paramEntityNames := make(map[string]string)
if rawData == nil {
return paramScope, paramEntityNames
}

params := dGetArrayElements(dGet(rawData, "Parameters"))
for _, p := range params {
pDoc, ok := p.(bson.D)
if !ok {
continue
}
name := dGetString(pDoc, "Name")
if name == "" {
continue
}
paramType := dGetDoc(pDoc, "ParameterType")
if paramType == nil {
continue
}
typeName := dGetString(paramType, "$Type")
if typeName != "DataTypes$ObjectType" {
continue
}
entityName := dGetString(paramType, "Entity")
if entityName == "" {
continue
}
idVal := dGet(pDoc, "$ID")
paramID := model.ID(extractBinaryIDFromDoc(idVal))
paramScope[name] = paramID
paramEntityNames[name] = entityName
}
return paramScope, paramEntityNames
}

// extractWidgetScopeFromBSON walks the entire raw BSON widget tree and
// collects a map of widget name → widget ID. This enables ALTER PAGE INSERT
// operations to resolve SELECTION DataSource references to existing sibling widgets.
func extractWidgetScopeFromBSON(rawData bson.D) map[string]model.ID {
scope := make(map[string]model.ID)
if rawData == nil {
return scope
}
// Page format: FormCall.Arguments[].Widgets[]
if formCall := dGetDoc(rawData, "FormCall"); formCall != nil {
args := dGetArrayElements(dGet(formCall, "Arguments"))
for _, arg := range args {
argDoc, ok := arg.(bson.D)
if !ok {
continue
}
collectWidgetScope(argDoc, "Widgets", scope)
}
}
// Snippet format: Widgets[] or Widget.Widgets[]
collectWidgetScope(rawData, "Widgets", scope)
if widgetContainer := dGetDoc(rawData, "Widget"); widgetContainer != nil {
collectWidgetScope(widgetContainer, "Widgets", scope)
}
return scope
}

// collectWidgetScope recursively walks a widget array and collects name→ID mappings.
func collectWidgetScope(parentDoc bson.D, key string, scope map[string]model.ID) {
elements := dGetArrayElements(dGet(parentDoc, key))
for _, elem := range elements {
wDoc, ok := elem.(bson.D)
if !ok {
continue
}
name := dGetString(wDoc, "Name")
if name != "" {
idVal := dGet(wDoc, "$ID")
if wID := extractBinaryIDFromDoc(idVal); wID != "" {
scope[name] = model.ID(wID)
}
}
// Also register entity context for widgets with DataSource
// so SELECTION can resolve the entity type
collectWidgetScopeInChildren(wDoc, scope)
}
}

// collectWidgetScopeInChildren recursively walks widget children to collect scope.
func collectWidgetScopeInChildren(wDoc bson.D, scope map[string]model.ID) {
typeName := dGetString(wDoc, "$Type")

// Direct Widgets[]
collectWidgetScope(wDoc, "Widgets", scope)
// FooterWidgets[]
collectWidgetScope(wDoc, "FooterWidgets", scope)
// LayoutGrid: Rows[].Columns[].Widgets[]
if strings.Contains(typeName, "LayoutGrid") {
rows := dGetArrayElements(dGet(wDoc, "Rows"))
for _, row := range rows {
rowDoc, ok := row.(bson.D)
if !ok {
continue
}
cols := dGetArrayElements(dGet(rowDoc, "Columns"))
for _, col := range cols {
colDoc, ok := col.(bson.D)
if !ok {
continue
}
collectWidgetScope(colDoc, "Widgets", scope)
}
}
}
// TabContainer: TabPages[].Widgets[]
tabPages := dGetArrayElements(dGet(wDoc, "TabPages"))
for _, tp := range tabPages {
tpDoc, ok := tp.(bson.D)
if !ok {
continue
}
collectWidgetScope(tpDoc, "Widgets", scope)
}
// ControlBar
if controlBar := dGetDoc(wDoc, "ControlBar"); controlBar != nil {
collectWidgetScope(controlBar, "Items", scope)
}
// CustomWidget (pluggable): Object.Properties[].Value.Widgets[]
if strings.Contains(typeName, "CustomWidget") {
if obj := dGetDoc(wDoc, "Object"); obj != nil {
props := dGetArrayElements(dGet(obj, "Properties"))
for _, prop := range props {
propDoc, ok := prop.(bson.D)
if !ok {
continue
}
if valDoc := dGetDoc(propDoc, "Value"); valDoc != nil {
collectWidgetScope(valDoc, "Widgets", scope)
}
}
}
}
}

// ============================================================================
// Helper: SerializeWidget is already available via mpr package
// ============================================================================
Expand Down
Loading
Loading