Skip to content
Open
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
72 changes: 72 additions & 0 deletions mdl-examples/bug-tests/116-datagrid2-column-name-mismatch.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
-- ============================================================================
-- Bug test: issue #116 — DESCRIBE PAGE / ALTER PAGE DataGrid2 column name mismatch
-- ============================================================================
--
-- Verifies that ALTER PAGE can address DataGrid2 columns using the names that
-- DESCRIBE PAGE produces — for both attribute-bound columns and caption-only
-- columns (the previously broken case).
--
-- Affected functions (fixed in PR #298):
-- deriveColumnNameBson — now traverses TextTemplate → Template → Items
-- sanitizeColumnName — now matches deriveColumnName exactly
--
-- To verify in Studio Pro:
-- 1. Run this script against a project.
-- 2. DESCRIBE PAGE Bug116.OrderList — column names in output should match
-- what ALTER PAGE uses below (Name, Order_Status, col3).
-- 3. Confirm no errors.
-- ============================================================================

-- ############################################################################
-- PREREQUISITES
-- ############################################################################

CREATE MODULE Bug116;

CREATE PERSISTENT ENTITY Bug116.Order (
Name: String(200),
Status: String(50)
);

-- ############################################################################
-- PAGE WITH THREE COLUMN TYPES
--
-- colAttr — attribute-bound: DESCRIBE yields "Name"
-- colCaption — caption-only (the previously broken case): DESCRIBE yields "Order_Status"
-- colNoName — no attribute, special-char caption only: DESCRIBE yields "col3"
-- ############################################################################

CREATE PAGE Bug116.OrderList (
Title: 'Order List',
Layout: Atlas_Core.Atlas_Default
) {
DATAGRID dgOrders (DataSource: DATABASE Bug116.Order) {
COLUMN colAttr (Attribute: Name, Caption: 'Name')
COLUMN colCaption ( Caption: 'Order Status')
COLUMN colNoName ( Caption: '---')
}
};

-- ############################################################################
-- ALTER PAGE USING DESCRIBE-DERIVED COLUMN NAMES
--
-- H1 fix: colCaption uses TextTemplate → Template → Items path;
-- previously always fell back to col2 because Template was skipped.
-- H2 fix: sanitizeColumnName now trims underscores and falls through to col{N}
-- for all-special-char captions like '---'.
-- ############################################################################

-- Attribute-bound column — always worked; regression guard
ALTER PAGE Bug116.OrderList {
SET WIDGET dgOrders.Name (Visible: true)
};

-- Caption-only column — previously broken (derived col2 instead of Order_Status)
ALTER PAGE Bug116.OrderList {
SET WIDGET dgOrders.Order_Status (Visible: true)
};

-- All-special-char caption → falls back to col3 on both sides
ALTER PAGE Bug116.OrderList {
SET WIDGET dgOrders.col3 (Visible: true)
};
135 changes: 135 additions & 0 deletions mdl/executor/alter_page_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -700,3 +700,138 @@ func TestExtractWidgetScopeFromBSON_Nil(t *testing.T) {
t.Error("Expected empty scope for nil input")
}
}

// ============================================================================
// deriveColumnNameBson regression tests (issue #116)
// ============================================================================

// makePropTypeID builds a primitive.Binary suitable for use as a TypePointer.
func makePropTypeID(b byte) primitive.Binary {
data := make([]byte, 16)
data[0] = b
return primitive.Binary{Subtype: 0x04, Data: data}
}

// propKey builds the map key used by deriveColumnNameBson — the UUID string
// that extractBinaryIDFromDoc produces from a primitive.Binary TypePointer.
func propKey(id primitive.Binary) string {
return extractBinaryIDFromDoc(id)
}

// TestDeriveColumnNameBson_AttributeBinding verifies attribute-bound columns
// produce the short attribute name (last segment after dot).
func TestDeriveColumnNameBson_AttributeBinding(t *testing.T) {
typeID := makePropTypeID(0x01)
propKeyMap := map[string]string{propKey(typeID): "attribute"}

colDoc := bson.D{
{Key: "Properties", Value: bson.A{
int32(2),
bson.D{
{Key: "TypePointer", Value: typeID},
{Key: "Value", Value: bson.D{
{Key: "AttributeRef", Value: "MyModule.Customer.Description"},
}},
},
}},
}

got := deriveColumnNameBson(colDoc, propKeyMap, 0)
if got != "Description" {
t.Errorf("expected 'Description', got %q", got)
}
}

// TestDeriveColumnNameBson_CaptionFallback verifies caption-only columns
// produce the sanitized caption. This exercises the TextTemplate → Template →
// Items[] path that was broken in issue #116.
func TestDeriveColumnNameBson_CaptionFallback(t *testing.T) {
typeID := makePropTypeID(0x02)
propKeyMap := map[string]string{propKey(typeID): "header"}

colDoc := bson.D{
{Key: "Properties", Value: bson.A{
int32(2),
bson.D{
{Key: "TypePointer", Value: typeID},
{Key: "Value", Value: bson.D{
{Key: "TextTemplate", Value: bson.D{
{Key: "Template", Value: bson.D{
{Key: "Items", Value: bson.A{
int32(2),
bson.D{{Key: "Text", Value: "Order Status"}},
}},
}},
}},
}},
},
}},
}

got := deriveColumnNameBson(colDoc, propKeyMap, 0)
if got != "Order_Status" {
t.Errorf("expected 'Order_Status', got %q", got)
}
}

// TestDeriveColumnNameBson_IndexFallback verifies that a column with neither
// attribute nor caption falls back to "col{N}" (1-based).
func TestDeriveColumnNameBson_IndexFallback(t *testing.T) {
colDoc := bson.D{{Key: "Properties", Value: bson.A{int32(2)}}}
got := deriveColumnNameBson(colDoc, map[string]string{}, 2)
if got != "col3" {
t.Errorf("expected 'col3', got %q", got)
}
}

// TestSanitizeColumnName_TrimUnderscores verifies that leading/trailing
// underscores are trimmed to match deriveColumnName() on the DESCRIBE side.
func TestSanitizeColumnName_TrimUnderscores(t *testing.T) {
cases := []struct {
input string
want string
}{
{" Description ", "Description"},
{"!Order Status!", "Order_Status"},
{"Hello World", "Hello_World"},
{"___", ""}, // all special chars → empty → caller falls through to col{N}
}
for _, c := range cases {
got := sanitizeColumnName(c.input)
if got != c.want {
t.Errorf("sanitizeColumnName(%q) = %q, want %q", c.input, got, c.want)
}
}
}

// TestDeriveColumnNameBson_AllSpecialCharCaption verifies that a caption
// composed entirely of non-identifier characters falls back to col{N},
// matching deriveColumnName() on the DESCRIBE side.
func TestDeriveColumnNameBson_AllSpecialCharCaption(t *testing.T) {
typeID := makePropTypeID(0x03)
propKeyMap := map[string]string{propKey(typeID): "header"}

colDoc := bson.D{
{Key: "Properties", Value: bson.A{
int32(2),
bson.D{
{Key: "TypePointer", Value: typeID},
{Key: "Value", Value: bson.D{
{Key: "TextTemplate", Value: bson.D{
{Key: "Template", Value: bson.D{
{Key: "Items", Value: bson.A{
int32(2),
bson.D{{Key: "Text", Value: "---"}},
}},
}},
}},
}},
},
}},
}

got := deriveColumnNameBson(colDoc, propKeyMap, 0)
if got != "col1" {
t.Errorf("expected 'col1' for all-special-char caption, got %q", got)
}
}
40 changes: 24 additions & 16 deletions mdl/executor/cmd_alter_page.go
Original file line number Diff line number Diff line change
Expand Up @@ -700,13 +700,18 @@ func deriveColumnNameBson(colDoc bson.D, propKeyMap map[string]string, index int
attribute = dGetString(attrDoc, "Attribute")
}
case "header":
// Extract caption from TextTemplate
// TextTemplate → Template (Forms$Text) → Items[] → Translation{Text}.
// Must traverse the intermediate Template document — same path as
// deriveColumnName on the DESCRIBE side. No Template → no caption,
// matching DESCRIBE's extractTextContent behaviour exactly.
if tmpl := dGetDoc(valDoc, "TextTemplate"); tmpl != nil {
items := dGetArrayElements(dGet(tmpl, "Items"))
for _, item := range items {
if itemDoc, ok := item.(bson.D); ok {
if text := dGetString(itemDoc, "Text"); text != "" {
caption = text
if template := dGetDoc(tmpl, "Template"); template != nil {
items := dGetArrayElements(dGet(template, "Items"))
for _, item := range items {
if itemDoc, ok := item.(bson.D); ok {
if text := dGetString(itemDoc, "Text"); text != "" {
caption = text
}
}
}
}
Expand All @@ -720,22 +725,25 @@ func deriveColumnNameBson(colDoc bson.D, propKeyMap map[string]string, index int
return parts[len(parts)-1]
}
if caption != "" {
return sanitizeColumnName(caption)
if name := sanitizeColumnName(caption); name != "" {
return name
}
}
return fmt.Sprintf("col%d", index+1)
}

// sanitizeColumnName converts a caption string into a valid column identifier.
// sanitizeColumnName converts a caption string into a valid column identifier,
// matching deriveColumnName() in cmd_pages_describe_output.go exactly.
// Returns "" when the result would be all underscores so the caller can fall
// through to the col{N} index fallback.
func sanitizeColumnName(caption string) string {
var result []rune
for _, r := range caption {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
result = append(result, r)
} else {
result = append(result, '_')
sanitized := strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' {
return r
}
}
return string(result)
return '_'
}, caption)
return strings.TrimFunc(sanitized, func(r rune) bool { return r == '_' })
}

// columnPropertyAliases maps user-facing property names to internal column property keys.
Expand Down