Browse Source

Merge pull request #25638 from abpframework/auto-merge/rel-10-5/4660

Merge branch dev with rel-10.5
pull/25647/head
Volosoft Agent 4 months ago
committed by GitHub
parent
commit
59e2a99ab1
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 17
      docs/en/low-code/model-json.md
  2. 61
      framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs
  3. 40
      framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/AbpDynamicSortingGuard_Tests.cs
  4. 39
      schemas/low-code/definitions/dashboard-descriptor.schema.json
  5. 37
      schemas/low-code/definitions/dashboard-row-descriptor.schema.json
  6. 18
      schemas/low-code/definitions/dashboard-visualization-descriptor.schema.json
  7. 10
      schemas/low-code/definitions/form-descriptor.schema.json
  8. 4
      schemas/low-code/definitions/form-field-descriptor.schema.json
  9. 79
      schemas/low-code/definitions/form-layout-descriptor.schema.json
  10. 27
      schemas/low-code/definitions/page-descriptor.schema.json
  11. 2
      schemas/low-code/definitions/page-type.schema.json

17
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 }
]
}
]

61
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;
@ -14,6 +15,12 @@ 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 <see cref="AbpValidationException"/>.
/// Property-bag / shadow-property access through a constant string indexer
/// (e.g. <c>it["Prop"]</c>, <c>Data["Prop"]</c>) 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.
/// </summary>
internal static class AbpDynamicSortingGuard
{
@ -81,7 +88,24 @@ 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"], ...). 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)
{
Visit(node.Object);
}
return node;
}
throw new AbpValidationException(Message);
}
protected override Expression VisitBinary(BinaryExpression node)
=> throw new AbpValidationException(Message);
@ -91,5 +115,40 @@ internal static class AbpDynamicSortingGuard
protected override Expression VisitConstant(ConstantExpression node)
=> throw new AbpValidationException(Message);
private static bool IsConstantStringIndexer(MethodCallExpression node)
{
// 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;
}
}
}

40
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<AbpValidationException>(() => FakeBagUsers().OrderBy(sorting).ToList())
.Message.ShouldBe("Sorting expression is not supported.");
}
private static IQueryable<FakeBagUser> FakeBagUsers()
{
return new List<FakeBagUser>
{
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<string, object> Data { get; set; } = new();
public object this[string key] => Data[key];
}
}

39
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 list of name-identified 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" }]
}
}
]
}

37
schemas/low-code/definitions/dashboard-row-descriptor.schema.json

@ -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" }
]
}
}
]
}
]
}

18
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"],

10
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": {
@ -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",
@ -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 }
]
}
]

4
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": {

79
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 }
]
}
]

27
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" }]
}
}
]
}

2
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",

Loading…
Cancel
Save