#26090·abp

Low-Code: projected list pipeline unpacks rows with uncached reflection and repeated dictionary materializations

Author: mgochmuradovCreated Aug 27, 2026Updated Aug 31, 2026

Description

The generated Low-Code dataGrid list endpoint (DynamicPageController.GetDataAsyncEfCoreDynamicQueryServiceProvider.GetProjectedListAsync) does a significant amount of per-row, per-field work on the hot path that scales with page size × column count on every request:

  1. Uncached reflection to unpack projection rows. The projected list is materialized via a System.Linq.Dynamic.Core Select producing runtime DynamicClass rows, which are then unpacked per row × per field with row.GetType().GetProperty($"Item{i}").GetValue(row) — the PropertyInfo lookup is not cached, and the "Item{i}" string is rebuilt for every row/field. There is no compiled-accessor cache anywhere on this path.
  2. Three dictionary materializations per row before serialization: DynamicClassDynamicProjectionRow (case-insensitive dictionary) → DynamicProjectionCompatibilityMapper.Map (iterates the row, Split('.') on paths, re-nests FK display values) → DynamicEntityDto (another dictionary), followed by a FilterServerOnlyProperties pass over each row's dictionary.
  3. EntityDescriptor.FindProperty is a linear scan (Properties.FirstOrDefault(...) with a closure allocation) and is called repeatedly per row and per filter during mapping/translation — O(rows × fields × propertyCount).
  4. Dynamic LINQ filter/sort/select strings are parsed on every request. (UseParameterizedNamesInDynamicQuery = true is set, so the EF/DB plan caches survive — the SQL side is fine; the parse cost itself repeats per request.)
  5. Child-collection hydration is unbounded: ChildCollectionDtoHydrator issues its batched child query with MaxResultCount = int.MaxValue, so a list page over parents loads all their children regardless of what the page shows.

For comparison, a hand-written CrudAppService on the same data does one compiled-C# filtered query plus one compiled (Mapperly) entity→DTO map per row.

Configuration

  • ABP 10.7.0-rc.3 (Volo.Abp.LowCode.Application / Volo.Abp.LowCode.EntityFrameworkCore), Low-Code module
  • .NET 10, PostgreSQL (Npgsql), app-nolayers modular monolith, ARM64 macOS (dev)

Regression?

Not known to be a regression — observed while evaluating the Low-Code module (preview) for a telecom BSS back-office.

Data

Findings come from static inspection of the shipped assemblies (decompiled for analysis while evaluating the module), not from profiling — no benchmark numbers yet. The reflection unpack, the triple materialization and the int.MaxValue hydration are directly visible in EfCoreDynamicQueryServiceProvider, DynamicProjectionCompatibilityMapper, DynamicProjectionRow and ChildCollectionDtoHydrator.

Analysis

Suggested directions:

  • Cache a compiled accessor array per projection plan (the plan is already computed per request; keying a delegate cache by entity-descriptor version + selected paths would let rows be unpacked without reflection).
  • Collapse the three per-row dictionary materializations into one pass that writes the final DynamicEntityDto directly.
  • Give EntityDescriptor an O(1) name→property lookup.
  • Cache parsed Dynamic LINQ artifacts keyed by (descriptor version, filter/sort signature).
  • Cap or page child-collection hydration instead of MaxResultCount = int.MaxValue.