From 64e9f1c2e1c17ee127889f78498b48fe0487e71d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?SAL=C4=B0H=20=C3=96ZKARA?= Date: Mon, 15 Jun 2026 20:49:40 +0300 Subject: [PATCH 1/3] Update low-code JSON schemas and sorting guard for flat layouts Flatten dashboard and form descriptor schemas for identity-keyed placements, remove the obsolete dashboard row schema, and allow constant string indexer access in AbpDynamicSortingGuard so low-code dynamic sorting works with property bags. Co-authored-by: Cursor --- .../Services/AbpDynamicSortingGuard.cs | 29 ++++++- .../Services/AbpDynamicSortingGuard_Tests.cs | 40 ++++++++++ .../dashboard-descriptor.schema.json | 39 +++++---- .../dashboard-row-descriptor.schema.json | 37 --------- ...board-visualization-descriptor.schema.json | 18 ++++- .../definitions/form-descriptor.schema.json | 8 +- .../form-field-descriptor.schema.json | 4 +- .../form-layout-descriptor.schema.json | 79 ++++++++++--------- .../definitions/page-descriptor.schema.json | 27 +++---- .../definitions/page-type.schema.json | 2 +- 10 files changed, 165 insertions(+), 118 deletions(-) delete mode 100644 schemas/low-code/definitions/dashboard-row-descriptor.schema.json diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs index 63caa66af5..9546890786 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs @@ -14,6 +14,10 @@ namespace Volo.Abp.Application.Services; /// every OrderBy / ThenBy expression built from a user-supplied sorting string is /// constrained to plain property or field access. Methods, comparisons, ternaries /// and constants in the sort key are rejected with . +/// Property-bag / shadow-property access through a constant string indexer +/// (e.g. it["Prop"], Data["Prop"]) is treated as plain property access +/// and allowed, since it carries no side effects and is the canonical way to sort +/// dynamically-mapped entities. /// internal static class AbpDynamicSortingGuard { @@ -81,7 +85,23 @@ internal static class AbpDynamicSortingGuard private const string Message = "Sorting expression is not supported."; protected override Expression VisitMethodCall(MethodCallExpression node) - => throw new AbpValidationException(Message); + { + // Allow property-bag / shadow-property access through a constant string + // indexer (it["Prop"], Data["Prop"], mainEntity["Prop"], ...). This is a + // pure read of a named member, not an arbitrary method invocation, so it + // does not open the injection surface the guard protects against. + if (IsConstantStringIndexer(node)) + { + if (node.Object != null) + { + Visit(node.Object); + } + + return node; + } + + throw new AbpValidationException(Message); + } protected override Expression VisitBinary(BinaryExpression node) => throw new AbpValidationException(Message); @@ -91,5 +111,12 @@ internal static class AbpDynamicSortingGuard protected override Expression VisitConstant(ConstantExpression node) => throw new AbpValidationException(Message); + + private static bool IsConstantStringIndexer(MethodCallExpression node) + { + return node.Method.Name == "get_Item" + && node.Arguments.Count == 1 + && node.Arguments[0] is ConstantExpression { Value: string }; + } } } diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/AbpDynamicSortingGuard_Tests.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/AbpDynamicSortingGuard_Tests.cs index 0806a1d824..60f5dcd55a 100644 --- a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/AbpDynamicSortingGuard_Tests.cs +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/AbpDynamicSortingGuard_Tests.cs @@ -257,6 +257,37 @@ public class AbpDynamicSortingGuard_Tests : AbpDddApplicationTestBase Should.NotThrow(() => children.OrderBy("Name desc, ExtraField asc").ToList()); } + [Theory] + [InlineData("Data[\"Score\"]")] // dictionary indexer with constant string key + [InlineData("Data[\"Score\"] desc")] + [InlineData("Name asc, Data[\"Score\"] desc")] // multi-column mixing plain + indexer + [InlineData("it[\"Score\"] desc")] // entity-level property-bag indexer (it["Prop"]) + public void Should_Accept_Constant_String_Indexer_Sorting(string sorting) + { + // Dynamically-mapped entities sort their shadow / property-bag members through a + // constant-string indexer; the guard must treat this as plain property access. + Should.NotThrow(() => FakeBagUsers().OrderBy(sorting).ToList()); + } + + [Theory] + [InlineData("Data[Name] desc")] // non-constant key (member access) + [InlineData("Data[PasswordHash.Substring(0,1)] desc")] // method call inside the key + public void Should_Reject_Non_Constant_Indexer_Sorting(string sorting) + { + Should.Throw(() => FakeBagUsers().OrderBy(sorting).ToList()) + .Message.ShouldBe("Sorting expression is not supported."); + } + + private static IQueryable FakeBagUsers() + { + return new List + { + new() { Name = "alice", PasswordHash = "AQAA", Data = { ["Score"] = 30 } }, + new() { Name = "bob", PasswordHash = "BQAA", Data = { ["Score"] = 25 } }, + new() { Name = "carl", PasswordHash = "CQAA", Data = { ["Score"] = 40 } }, + }.AsQueryable(); + } + private class FakeUser { public string Name { get; set; } = ""; @@ -274,4 +305,13 @@ public class AbpDynamicSortingGuard_Tests : AbpDddApplicationTestBase { public string Name { get; set; } = ""; } + + private class FakeBagUser + { + public string Name { get; set; } = ""; + public string PasswordHash { get; set; } = ""; + public Dictionary Data { get; set; } = new(); + + public object this[string key] => Data[key]; + } } diff --git a/schemas/low-code/definitions/dashboard-descriptor.schema.json b/schemas/low-code/definitions/dashboard-descriptor.schema.json index 068031a18b..0d4a55d038 100644 --- a/schemas/low-code/definitions/dashboard-descriptor.schema.json +++ b/schemas/low-code/definitions/dashboard-descriptor.schema.json @@ -2,8 +2,8 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "dashboard-descriptor.schema.json", "title": "DashboardDescriptor", - "description": "Describes a dashboard page configuration with global filters and rows of visualizations.", - "markdownDescription": "AI guidance: a dashboard belongs to a page with `type: \"dashboard\"`. It contains one or more rows; each row contains chart, list, or numberContainer visualizations. Use visualization `entityName` to select data for chart/list items. Use numberContainer for KPI tiles, charts for grouped aggregations, and lists for recent/top records.", + "description": "Describes a dashboard page configuration with global filters and a flat list of visualizations.", + "markdownDescription": "AI guidance: a dashboard belongs to a page with `type: \"dashboard\"`. It contains a flat `visualizations` list; each visualization carries its grid position via `row`, `order`, and `width`. Rows are derived at render time by grouping visualizations that share the same zero-based `row`. Use visualization `entityName` to select data for chart/list items. Use numberContainer for KPI tiles, charts for grouped aggregations, and lists for recent/top records.", "type": "object", "properties": { "description": { @@ -17,35 +17,34 @@ "$ref": "dashboard-global-filter-descriptor.schema.json" } }, - "rows": { + "visualizations": { "type": "array", - "description": "Dashboard rows. Each row contains one or more visualization items; width 2 items usually take the full row.", + "description": "Flat, name-keyed list of dashboard visualizations. Each visualization positions itself on the grid via row, order, and width.", "items": { - "$ref": "dashboard-row-descriptor.schema.json" + "$ref": "dashboard-visualization-descriptor.schema.json" }, "minItems": 1 } }, - "required": ["rows"], + "required": ["visualizations"], "additionalProperties": false, "examples": [ { "description": "Operational overview", - "rows": [ + "visualizations": [ { - "items": [ - { - "name": "events-by-status", - "type": "chart", - "title": "Events by Status", - "entityName": "Acme.Events.Event", - "chart": { - "chartType": "bar", - "xAxis": { "property": "Status" }, - "yAxis": [{ "aggregation": "count", "label": "Events" }] - } - } - ] + "name": "events-by-status", + "type": "chart", + "title": "Events by Status", + "row": 0, + "order": 0, + "width": 2, + "entityName": "Acme.Events.Event", + "chart": { + "chartType": "bar", + "xAxis": { "property": "Status" }, + "yAxis": [{ "aggregation": "count", "label": "Events" }] + } } ] } diff --git a/schemas/low-code/definitions/dashboard-row-descriptor.schema.json b/schemas/low-code/definitions/dashboard-row-descriptor.schema.json deleted file mode 100644 index 909048ec29..0000000000 --- a/schemas/low-code/definitions/dashboard-row-descriptor.schema.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "dashboard-row-descriptor.schema.json", - "title": "DashboardRowDescriptor", - "description": "Describes one row in the dashboard layout grid. Each row contains one or two visualization items.", - "markdownDescription": "AI guidance: organize dashboard visualizations into rows by visual importance. Use one item for a full-width chart/list/number panel, and two items when related visualizations should be displayed side by side. Do not put more than two visualizations in a row; create another row instead.", - "type": "object", - "properties": { - "items": { - "type": "array", - "description": "Visualization items in this row. Use one item for full-width content or two items for a two-column row.", - "items": { - "$ref": "dashboard-visualization-descriptor.schema.json" - }, - "minItems": 1, - "maxItems": 2 - } - }, - "required": ["items"], - "additionalProperties": false, - "examples": [ - { - "items": [ - { - "name": "overview", - "type": "numberContainer", - "title": "Overview", - "numberContainer": { - "items": [ - { "name": "total-records", "title": "Total Records", "entityName": "Acme.Events.Event", "aggregation": "count" } - ] - } - } - ] - } - ] -} diff --git a/schemas/low-code/definitions/dashboard-visualization-descriptor.schema.json b/schemas/low-code/definitions/dashboard-visualization-descriptor.schema.json index 6fd6e91f31..5634498f74 100644 --- a/schemas/low-code/definitions/dashboard-visualization-descriptor.schema.json +++ b/schemas/low-code/definitions/dashboard-visualization-descriptor.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "dashboard-visualization-descriptor.schema.json", "title": "DashboardVisualizationDescriptor", - "description": "A single visualization element in a dashboard row: chart, list, or number container.", + "description": "A single visualization element in a dashboard: chart, list, or number container. Positioned on the grid via row, order, and width.", "markdownDescription": "AI guidance: set `type` and then provide the matching payload: `chart` for type chart, `list` for type list, or `numberContainer` for type numberContainer. Do not populate unrelated payloads. Chart and list visualizations require `entityName`; number containers define entityName per KPI item.", "type": "object", "properties": { @@ -23,6 +23,16 @@ "type": ["string", "null"], "description": "Optional description text. Can be shown inline or as tooltip depending on showDescriptionAsTooltip." }, + "row": { + "type": "integer", + "description": "Zero-based visual row index inside the dashboard grid. Visualizations sharing the same row are rendered side-by-side.", + "minimum": 0, + "default": 0 + }, + "order": { + "type": ["integer", "null"], + "description": "Order of the visualization inside its row. When omitted, array order is used." + }, "width": { "type": "integer", "enum": [1, 2], @@ -146,6 +156,9 @@ "name": "events-by-status", "type": "chart", "title": "Events by Status", + "row": 0, + "order": 0, + "width": 1, "entityName": "Acme.Events.Event", "chart": { "chartType": "bar", @@ -157,6 +170,9 @@ "name": "recent-events", "type": "list", "title": "Recent Events", + "row": 0, + "order": 1, + "width": 1, "entityName": "Acme.Events.Event", "list": { "fields": ["Title", "StartDate", "Status"], diff --git a/schemas/low-code/definitions/form-descriptor.schema.json b/schemas/low-code/definitions/form-descriptor.schema.json index f8bec6dfc8..3508ff58a9 100644 --- a/schemas/low-code/definitions/form-descriptor.schema.json +++ b/schemas/low-code/definitions/form-descriptor.schema.json @@ -3,7 +3,7 @@ "$id": "form-descriptor.schema.json", "title": "FormDescriptor", "description": "Describes a named create/edit form definition bound to one entity.", - "markdownDescription": "AI guidance: a form is referenced by page `formName`, `createFormName`, or `editFormName`. Keep `entityName` aligned with the page entityName. Define all fields in `fields`, then place every visible field in `layout.tabs[].groups[].rows[].cells[]` by field id. Use form `rules` for conditional visibility/enabled state; use entity validators for core data validation.", + "markdownDescription": "AI guidance: a form is referenced by page `formName`, `createFormName`, or `editFormName`. Keep `entityName` aligned with the page entityName. Define all fields in `fields`, then place every visible field in `layout.tabs[].groups[].fields[]` by field id (each placement carries `row`, `colSpan`, and `colStart`). Use form `rules` for conditional visibility/enabled state; use entity validators for core data validation.", "type": "object", "properties": { "$schema": { @@ -65,9 +65,9 @@ "id": "details", "title": "Details", "isDefault": true, - "rows": [ - { "cells": [{ "fieldId": "title", "colSpan": 4 }] }, - { "cells": [{ "fieldId": "status", "colSpan": 2 }] } + "fields": [ + { "fieldId": "title", "row": 0, "colSpan": 4 }, + { "fieldId": "status", "row": 1, "colSpan": 2 } ] } ] diff --git a/schemas/low-code/definitions/form-field-descriptor.schema.json b/schemas/low-code/definitions/form-field-descriptor.schema.json index fd5f731e19..4875100078 100644 --- a/schemas/low-code/definitions/form-field-descriptor.schema.json +++ b/schemas/low-code/definitions/form-field-descriptor.schema.json @@ -3,12 +3,12 @@ "$id": "form-field-descriptor.schema.json", "title": "FormFieldDescriptor", "description": "Describes a single field in a form. A field may be bound to an entity property or unbound for computed/display-only UI.", - "markdownDescription": "AI guidance: use a stable camelCase `id` for each field. For ordinary data entry, set `binding` to an entity property and choose a field `type` compatible with that property. For enum selects, set `type: \"select\"` and `enumType`. For FK lookups, use `type: \"lookup\"` and bind to the FK property. Put fields into layout cells by id.", + "markdownDescription": "AI guidance: use a stable camelCase `id` for each field. For ordinary data entry, set `binding` to an entity property and choose a field `type` compatible with that property. For enum selects, set `type: \"select\"` and `enumType`. For FK lookups, use `type: \"lookup\"` and bind to the FK property. Place fields into the layout by adding a placement that references this id in a group's `fields`.", "type": "object", "properties": { "id": { "type": "string", - "description": "Unique identifier for this field within the form. Prefer camelCase, for example 'title' or 'customerId'. Layout cells and rules reference this id.", + "description": "Unique identifier for this field within the form. Prefer camelCase, for example 'title' or 'customerId'. Layout placements and rules reference this id.", "minLength": 1 }, "label": { diff --git a/schemas/low-code/definitions/form-layout-descriptor.schema.json b/schemas/low-code/definitions/form-layout-descriptor.schema.json index abadb86e21..364c0423dd 100644 --- a/schemas/low-code/definitions/form-layout-descriptor.schema.json +++ b/schemas/low-code/definitions/form-layout-descriptor.schema.json @@ -2,8 +2,8 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "form-layout-descriptor.schema.json", "title": "FormLayoutDescriptor", - "description": "Describes the visual layout of a form as tabs, groups, rows, and field cells.", - "markdownDescription": "AI guidance: the layout is a tree: tabs -> groups -> rows -> cells. Each cell `fieldId` must reference a field from the form's `fields` array. Use `colSpan` 4 for full-width fields, 2+2 for two columns, or 1+1+1+1 for four compact controls. The total effective width in a row should not exceed 4.", + "description": "Describes the visual layout of a form as tabs and groups. Each group holds a flat list of field placements positioned on a 4-column grid.", + "markdownDescription": "AI guidance: the layout is a tree: tabs -> groups -> fields. The stored schema is flat (no row/cell wrappers): each placement carries `row`, `colSpan`, and `colStart`. Rows are derived at render time by grouping placements that share the same zero-based `row`. Each placement `fieldId` must reference a field from the form's `fields` array. Use `colSpan` 4 for full-width fields, 2+2 for two columns (colStart 1 and 3), or 1+1+1+1 for four compact controls. The sum of `colSpan` values within a row should not exceed 4.", "type": "object", "properties": { "tabs": { @@ -28,6 +28,10 @@ "description": "Whether this is the default tab. The designer uses the default tab as the safe target for orphaned fields.", "default": false }, + "order": { + "type": "integer", + "description": "Optional display order for this tab. When omitted, array order is used." + }, "groups": { "type": "array", "description": "Ordered list of groups within this tab. Use one default group for simple forms.", @@ -49,49 +53,47 @@ "description": "Whether this is the default group. The designer uses the default group as the safe target for orphaned fields.", "default": false }, - "rows": { + "order": { + "type": "integer", + "description": "Optional display order for this group. When omitted, array order is used." + }, + "fields": { "type": "array", - "description": "Ordered list of layout rows; each row contains one or more cells placed side-by-side.", + "description": "Flat list of field placements in this group. Each placement positions a single field on the 4-column grid via row, colSpan, and colStart.", "items": { "type": "object", "properties": { - "cells": { - "type": "array", - "description": "Fields placed side-by-side in this row. The sum of colSpan values should not exceed 4.", - "minItems": 1, - "items": { - "type": "object", - "properties": { - "fieldId": { - "type": "string", - "description": "Reference to a field id in the form's fields array. Every field shown in the layout needs a matching field descriptor.", - "minLength": 1 - }, - "colSpan": { - "type": "integer", - "description": "Number of grid columns this field spans from 1 to 4. Use 4 for full-width fields.", - "minimum": 1, - "maximum": 4, - "default": 4 - }, - "colStart": { - "type": ["integer", "null"], - "description": "Starting grid column from 1 to 4. Omit or null to auto-place after the previous cell.", - "minimum": 1, - "maximum": 4 - } - }, - "required": ["fieldId"], - "additionalProperties": false - } + "fieldId": { + "type": "string", + "description": "Reference to a field id in the form's fields array. Every field shown in the layout needs a matching field descriptor.", + "minLength": 1 + }, + "row": { + "type": "integer", + "description": "Zero-based visual row index inside the group. Placements sharing the same row are rendered side-by-side.", + "minimum": 0, + "default": 0 + }, + "colSpan": { + "type": "integer", + "description": "Number of grid columns this field spans from 1 to 4. Use 4 for full-width fields.", + "minimum": 1, + "maximum": 4, + "default": 4 + }, + "colStart": { + "type": ["integer", "null"], + "description": "Starting grid column from 1 to 4. Omit or null to auto-place after the previous placement in the same row.", + "minimum": 1, + "maximum": 4 } }, - "required": ["cells"], + "required": ["fieldId"], "additionalProperties": false } } }, - "required": ["id", "rows"], + "required": ["id", "fields"], "additionalProperties": false } } @@ -115,9 +117,10 @@ "id": "details", "title": "Details", "isDefault": true, - "rows": [ - { "cells": [{ "fieldId": "title", "colSpan": 4 }] }, - { "cells": [{ "fieldId": "startDate", "colSpan": 2 }, { "fieldId": "endDate", "colSpan": 2 }] } + "fields": [ + { "fieldId": "title", "row": 0, "colSpan": 4 }, + { "fieldId": "startDate", "row": 1, "colSpan": 2, "colStart": 1 }, + { "fieldId": "endDate", "row": 1, "colSpan": 2, "colStart": 3 } ] } ] diff --git a/schemas/low-code/definitions/page-descriptor.schema.json b/schemas/low-code/definitions/page-descriptor.schema.json index 3b5c1a2fcd..475de3ad63 100644 --- a/schemas/low-code/definitions/page-descriptor.schema.json +++ b/schemas/low-code/definitions/page-descriptor.schema.json @@ -237,21 +237,20 @@ "title": "Event Dashboard", "type": "dashboard", "dashboard": { - "rows": [ + "visualizations": [ { - "items": [ - { - "name": "events-by-status", - "type": "chart", - "title": "Events by Status", - "entityName": "Acme.Events.Event", - "chart": { - "chartType": "bar", - "xAxis": { "property": "Status" }, - "yAxis": [{ "aggregation": "count", "label": "Events" }] - } - } - ] + "name": "events-by-status", + "type": "chart", + "title": "Events by Status", + "row": 0, + "order": 0, + "width": 2, + "entityName": "Acme.Events.Event", + "chart": { + "chartType": "bar", + "xAxis": { "property": "Status" }, + "yAxis": [{ "aggregation": "count", "label": "Events" }] + } } ] } diff --git a/schemas/low-code/definitions/page-type.schema.json b/schemas/low-code/definitions/page-type.schema.json index 5ddeec0eb5..a3005eed50 100644 --- a/schemas/low-code/definitions/page-type.schema.json +++ b/schemas/low-code/definitions/page-type.schema.json @@ -3,7 +3,7 @@ "$id": "page-type.schema.json", "title": "PageType", "description": "The runtime page renderer to use. Prefer lower-case values in descriptor JSON; PascalCase aliases are accepted for compatibility.", - "markdownDescription": "`dataGrid` renders searchable/filterable tabular CRUD for an entity. `kanban` renders grouped cards and requires `groupByProperty`. `calendar` renders entity records on a calendar and requires `calendarStartProperty`. `gallery` renders visual cards and may use `galleryImageProperty`. `form` renders a standalone create/edit form page and requires `formName`. `dashboard` renders dashboard rows/visualizations and requires `dashboard`.", + "markdownDescription": "`dataGrid` renders searchable/filterable tabular CRUD for an entity. `kanban` renders grouped cards and requires `groupByProperty`. `calendar` renders entity records on a calendar and requires `calendarStartProperty`. `gallery` renders visual cards and may use `galleryImageProperty`. `form` renders a standalone create/edit form page and requires `formName`. `dashboard` renders a flat list of dashboard visualizations and requires `dashboard`.", "type": "string", "enum": [ "dataGrid", From 43e0c9301527b5046e0390a643376ce3bf64313f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?SAL=C4=B0H=20=C3=96ZKARA?= Date: Mon, 15 Jun 2026 23:23:26 +0300 Subject: [PATCH 2/3] Improve string-indexer detection and update schema Enhance AbpDynamicSortingGuard.IsConstantStringIndexer to more robustly recognize true string-keyed indexers: verify the call is instance-bound with a single constant string argument, ensure the resolved method is a special-name instance getter with a single string parameter, and confirm it matches an actual indexer property via reflection. This prevents arbitrary methods named "get_Item" from being treated as safe indexer accesses. Also update the dashboard descriptor schema description to clarify visualizations are a flat list of name-identified items (wording change only). --- .../Services/AbpDynamicSortingGuard.cs | 35 +++++++++++++++++-- .../dashboard-descriptor.schema.json | 2 +- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs index 9546890786..25f7f72439 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs @@ -2,6 +2,7 @@ using System; using System.Linq; using System.Linq.Dynamic.Core; using System.Linq.Expressions; +using System.Reflection; using System.Runtime.CompilerServices; using Volo.Abp.Validation; @@ -114,9 +115,37 @@ internal static class AbpDynamicSortingGuard private static bool IsConstantStringIndexer(MethodCallExpression node) { - return node.Method.Name == "get_Item" - && node.Arguments.Count == 1 - && node.Arguments[0] is ConstantExpression { Value: string }; + // Must be an instance call with a single constant string argument. + if (node.Object == null + || node.Arguments.Count != 1 + || node.Arguments[0] is not ConstantExpression { Value: string }) + { + return false; + } + + // And the resolved method must be the get accessor of a real string-keyed + // indexer (a special-name property getter), not an arbitrary method that + // merely shares the compiler-generated "get_Item" name. This keeps the + // guard's "no method calls" rule intact for everything except indexers. + var method = node.Method; + var parameters = method.GetParameters(); + if (!method.IsSpecialName + || method.IsStatic + || parameters.Length != 1 + || parameters[0].ParameterType != typeof(string)) + { + return false; + } + + return method.DeclaringType? + .GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Any(property => + { + var indexParameters = property.GetIndexParameters(); + return indexParameters.Length == 1 + && indexParameters[0].ParameterType == typeof(string) + && property.GetGetMethod(nonPublic: true) == method; + }) == true; } } } diff --git a/schemas/low-code/definitions/dashboard-descriptor.schema.json b/schemas/low-code/definitions/dashboard-descriptor.schema.json index 0d4a55d038..c0c77d7be9 100644 --- a/schemas/low-code/definitions/dashboard-descriptor.schema.json +++ b/schemas/low-code/definitions/dashboard-descriptor.schema.json @@ -19,7 +19,7 @@ }, "visualizations": { "type": "array", - "description": "Flat, name-keyed list of dashboard visualizations. Each visualization positions itself on the grid via row, order, and width.", + "description": "Flat list of name-identified dashboard visualizations. Each visualization positions itself on the grid via row, order, and width.", "items": { "$ref": "dashboard-visualization-descriptor.schema.json" }, From ce3945386d0d3ddfc329749c057478b68b01576e Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 09:51:26 +0800 Subject: [PATCH 3/3] Update low-code form docs and clarify sorting guard wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync the form layout examples in docs/en/low-code/model-json.md to the flat fields[]/row/colSpan shape introduced in this PR. Switch the form descriptor schema description from "layout cell" to "layout placement" to match the new vocabulary. Soften the AbpDynamicSortingGuard XML doc and inline comment so they no longer claim the allowed constant-string indexer carries no side effects — the guard cannot enforce that on user-defined indexers, so the doc now states it assumes the matching getter behaves like a property getter. --- docs/en/low-code/model-json.md | 17 ++++++++++------- .../Services/AbpDynamicSortingGuard.cs | 15 +++++++++------ .../definitions/form-descriptor.schema.json | 2 +- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/docs/en/low-code/model-json.md b/docs/en/low-code/model-json.md index ce59b0b3ec..2c11503553 100644 --- a/docs/en/low-code/model-json.md +++ b/docs/en/low-code/model-json.md @@ -362,9 +362,10 @@ Forms are named definitions referenced by pages through `formName`, `createFormN "id": "details", "title": "Details", "isDefault": true, - "rows": [ - { "cells": [{ "fieldId": "name", "colSpan": 4 }] }, - { "cells": [{ "fieldId": "status", "colSpan": 2 }, { "fieldId": "ownerId", "colSpan": 2 }] } + "fields": [ + { "fieldId": "name", "row": 0, "colSpan": 4 }, + { "fieldId": "status", "row": 1, "colSpan": 2 }, + { "fieldId": "ownerId", "row": 1, "colSpan": 2 } ] } ] @@ -526,10 +527,12 @@ The complete example below shows the logical aggregate shape. Split projects sto "id": "details", "title": "Details", "isDefault": true, - "rows": [ - { "cells": [{ "fieldId": "name", "colSpan": 4 }] }, - { "cells": [{ "fieldId": "status", "colSpan": 2 }, { "fieldId": "budget", "colSpan": 2 }] }, - { "cells": [{ "fieldId": "startDate", "colSpan": 2 }, { "fieldId": "coverImage", "colSpan": 2 }] } + "fields": [ + { "fieldId": "name", "row": 0, "colSpan": 4 }, + { "fieldId": "status", "row": 1, "colSpan": 2 }, + { "fieldId": "budget", "row": 1, "colSpan": 2 }, + { "fieldId": "startDate", "row": 2, "colSpan": 2 }, + { "fieldId": "coverImage", "row": 2, "colSpan": 2 } ] } ] diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs index 25f7f72439..2f67547b0b 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs @@ -16,9 +16,11 @@ namespace Volo.Abp.Application.Services; /// constrained to plain property or field access. Methods, comparisons, ternaries /// and constants in the sort key are rejected with . /// Property-bag / shadow-property access through a constant string indexer -/// (e.g. it["Prop"], Data["Prop"]) is treated as plain property access -/// and allowed, since it carries no side effects and is the canonical way to sort -/// dynamically-mapped entities. +/// (e.g. it["Prop"], Data["Prop"]) is treated like plain property +/// access and allowed, because it is the canonical way to sort dynamically-mapped +/// entities. The guard assumes the matching indexer getter behaves like a property +/// getter; defining an indexer that performs IO or mutates state will expose those +/// effects through sorting. /// internal static class AbpDynamicSortingGuard { @@ -88,9 +90,10 @@ internal static class AbpDynamicSortingGuard protected override Expression VisitMethodCall(MethodCallExpression node) { // Allow property-bag / shadow-property access through a constant string - // indexer (it["Prop"], Data["Prop"], mainEntity["Prop"], ...). This is a - // pure read of a named member, not an arbitrary method invocation, so it - // does not open the injection surface the guard protects against. + // indexer (it["Prop"], Data["Prop"], mainEntity["Prop"], ...). The + // constant key cannot smuggle in an arbitrary method invocation, so this + // does not widen the injection surface the guard protects against; it + // treats the indexer access like ordinary property access. if (IsConstantStringIndexer(node)) { if (node.Object != null) diff --git a/schemas/low-code/definitions/form-descriptor.schema.json b/schemas/low-code/definitions/form-descriptor.schema.json index 3508ff58a9..102193ee10 100644 --- a/schemas/low-code/definitions/form-descriptor.schema.json +++ b/schemas/low-code/definitions/form-descriptor.schema.json @@ -34,7 +34,7 @@ }, "layout": { "$ref": "form-layout-descriptor.schema.json", - "description": "Visual layout for the fields. Every layout cell fieldId must refer to a field in fields." + "description": "Visual layout for the fields. Every layout placement fieldId must refer to a field in fields." }, "rules": { "type": "array",