Skip to content
bitzorcas
中EN

Reference

List-query and export-range contract

The W0 list-query envelope, composed paging, range filter value objects, QueryShape scalar expansion, remote options, related snapshots, and export-range validation.

Last updated

This page defines the type boundaries from an HTTP list request through QueryShape to a paged result or export. The target envelope is Fields + Paging + Sort. The transport remains QUERY; the Platform SDK falls back to POST /_query when the network path does not support that method.

1. Standard list envelope

A new or migrated HTTP list Query has this shape:

Application query contract
// The HTTP envelope contains serializable query values; trusted tenant and user state stay outside it.
public sealed record ListResourcesQuery(
ResourceListFields Fields,
PaginationParams Paging,
SortRequest? Sort = null)
: IQuery<Result<PagedResult<ResourceSummaryDto>>>,
IPaginatedRequest;
// Business criteria stay in Fields so a new range does not widen the top-level envelope.
public sealed record ResourceListFields(
string? SearchText = null,
DateRange? CreatedAt = null,
AmountRange? Amount = null,
EnumFilterSet<ResourceStatus>? Statuses = null);
MemberResponsibilityConstraint
FieldsBusiness filter criteriaNames use domain language; multiple named ranges are valid, but a generic top-level Period is not
PagingHTTP offset pagingUses PaginationParams; IPaginatedRequest is a marker for architecture rules, not data inheritance
SortOne standard sort requestSortRequest(Field, Descending); QueryShape must still validate the field against its allowlist
ResultList responseUses Result<PagedResult<TSummaryDto>>; do not introduce a module-specific *Page wrapper

Tenant, current-user, and authorization scope are not filter fields. The Handler obtains them from trusted context and passes them to the Store or base predicate. JSON content cannot override them.

2. Paging normalization

PaginationParams is one-based and defaults to PageIndex=1, PageSize=20. Normalize() delegates to PagingLimits with the following fixed behavior:

InputNormalized value
PageIndex < 11
PageSize <= 020
PageSize > 10001000
Any other valid valueUnchanged
var paging = (request.Paging ?? new PaginationParams()).Normalize();
var pageRequest = paging.ToPageRequest();
var window = paging.ToWindow();

ToWindow() also applies PageWindow deep-paging protection. The default maximum offset is 100_000. Beyond that boundary, CanRead=false; the caller returns an empty page or changes to cursor/keyset paging instead of issuing an overflowing or unbounded database offset.

Platform limits do not all mean the same thing

GlobalPlatformConstants contains stable, cross-module limits that tenants cannot configure:

ConstantCurrent valueScope
Pagination.DefaultPageSize20Alias for the online-list default in PagingLimits
Pagination.MaxPageSize1000Alias for the online-list maximum in PagingLimits
Batch.PageSize2000Background reads for SAR, archival, and other workers; not an HTTP page size
Text.IdentifierMaxLength128Cross-module stable identifiers
Text.DescriptionMaxLength1000Short descriptions
Text.RemarkMaxLength2000Remarks and audit reasons

Remote options at 50, Workflow QueryStore at 200, and SCIM at 200 are named protocol or engine boundaries. They do not change the online-list constants.

3. Filter value objects

DateRange

DateRange represents a closed time range with either side optionally unbounded. Its JSON shape is an object:

{
"from": "2026-08-01T00:00:00+08:00",
"to": "2026-08-13T00:00:00+08:00"
}

Both direct construction and deserialization go through DateRange.Create. A lower bound later than the upper bound returns DateRange.Invalid; the JSON converter turns that failure into JsonException, so deserialization cannot bypass the invariant.

The upper bound has two forms:

Supplied upper boundToQuery expansion
Start of a day, such as 2026-08-13T00:00:00+08:00Normalized to the last tick of 13 AugustQueryToExclusive is the start of 14 August
Explicit instant, such as 2026-08-13T15:30:45+08:00PreservedThe expansion point uses To.AddTicks(1) for an exclusive upper bound
nullThat side is unboundedNo upper-bound predicate

Contains and display code use the closed To value. Database predicates use an exclusive upper bound with LessThan; they do not use LessThanOrEqual against a normalized end-of-day value.

Amounts, numbers, and enum sets

TypeJSON/domain meaningFailure and equality behavior
AmountRangeA decimal? Min/Max closed range with either side optionalMin > Max returns NumberRange.Invalid; both endpoints are included
NumberRange<TNumber>A closed range for comparable value types such as quantity, rating, or percentageMin > Max returns NumberRange.Invalid
EnumFilterSet<TEnum>Deduplicates, orders, and freezes its input; exposes only IReadOnlySet<TEnum>Equality ignores caller ordering and duplicates; null becomes an empty set

These types belong in the HTTP/Application TFields contract. They express request semantics; they are not generated database fields by themselves.

4. QueryShape scalar expansion boundary

The QueryShape generator continues to consume scalar declarations. *ListInput.From or MapFrom is the single expansion point for composed values:

Infrastructure query input
public sealed partial class ResourceListInput : IDeclareQueryShape
{
// BQRY005 requires both paging scalars and keeps them off QueryField.
public int PageIndex { get; init; }
public int PageSize { get; init; }
[QueryField(DefaultOperator = FilterOperator.GreaterThanOrEqual)]
public DateTimeOffset? CreatedAtFrom { get; init; }
[QueryField(DefaultOperator = FilterOperator.LessThan)]
public DateTimeOffset? CreatedAtTo { get; init; }
public static ResourceListInput From(
ResourceListFields fields,
PaginationParams paging)
{
var normalized = paging.Normalize();
return new ResourceListInput
{
// Expand the range once; the upper bound becomes an exclusive value for LessThan.
CreatedAtFrom = fields.CreatedAt?.From,
CreatedAtTo = fields.CreatedAt?.QueryToExclusive
?? fields.CreatedAt?.To?.AddTicks(1),
PageIndex = normalized.PageIndex,
PageSize = normalized.PageSize,
};
}
}

Generator rule BQRY005 requires PageIndex and PageSize together, typed as int or int?, with neither marked [QueryField]. DateRange, PaginationParams, and other composed filter values also stay off [QueryField]. A generator cannot infer a module’s field, operator, or upper-bound semantics.

The resulting path is:

HTTP QUERY: Fields + Paging + Sort

Handler: authorization and trusted tenant

ListInput.From: range expansion and paging normalization

QueryShape: allowlisted filters and sort

Read-model Store / provider

Result>

QueryOptionItem

All remote selectors return the same display shape:

new QueryOptionItem(
Value: "user-42",
Label: "Zhang San",
// Selected restores UI state; it does not grant access.
Description: "Matter owner",
Selected: true,
// Mapping contains only registered, field-secured display values.
Mapping: new Dictionary<string, string?>
{
["department"] = "Litigation",
});
  • Value is the stable identifier submitted to the filter field.
  • Selected restores recent or existing UI state; it is not an authorization result.
  • Mapping contains only fields registered by the selector catalog and processed by field-security policy.
  • QueryOptionRequest.MaximumPageSize is 50; bulk identifier resolution also retains no more than 50 identifiers.

RelatedEntitySnapshot

A detail or edit-fill model can carry RelatedEntitySnapshot(Relation, Id, Label, ResourceType, Mapping) so a client does not fetch another detail resource merely to render an associated name. This is not an aggregate relationship. It cannot contain the related aggregate, authorization facts, or unmasked fields. List rows do not carry the snapshot by default; use a QueryShape projection or option Mapping for the minimum associated display fields.

6. Export ranges

Export is not implemented by increasing the online PageSize. The numeric values of ExportScope are already persisted and must not be reordered:

NameValueRequired range data
CurrentPage0Reuses the current list criteria, sort, and page size
Selected1Non-empty, unique CheckedIds; at most 1000
All2No CheckedIds, PageFrom/PageTo, or Take
PageRange3PageFrom > 0, PageTo > 0, and PageFrom <= PageTo
Quantity4Take > 0; export policy owns the maximum, not PagingLimits

ExportRange expresses these choices as one value. The persisted ExportRequest currently stores Scope, CheckedIds, PageFrom, PageTo, and Take as flat properties. Supplying data for the wrong scope returns ApplicationErrors.Export.InvalidRequest.

Page-range export
// TenantId and RequestedBy come from trusted execution context, not unchecked page fields.
var request = new ExportRequest(
ExportBuilderKey: "billing-invoices",
Format: ExportFormat.Csv,
TenantId: tenantId,
RequestedBy: userId,
Parameters: serializedListFields)
{
// PageRange requires two positive page numbers; validation rejects a reversed range.
Scope = ExportScope.PageRange,
PageFrom = 2,
PageTo = 4,
};

The legacy IExportBuilder methods receive only Parameters, so they support All only. A builder that accepts CurrentPage, Selected, PageRange, or Quantity implements IScopedExportBuilder and estimates/reads from the complete validated ExportRequest. Otherwise, the scheduler rejects the request instead of silently widening it to an all-row export.

The public ExportRequestDisplaySnapshot preserves PageFrom, PageTo, and Take, but excludes selected row identifiers, tenant, user, and raw parameters. A business builder can add displayable criteria only through server-side allowlisted fields.

7. Registered stable exceptions

SurfaceExceptionBoundary
Chat messagesCursorPage<T> cursor pagingKeep the cursor; do not migrate it to PaginationParams
Remote selectorsMaximum 50 itemsSelector protocol boundary; do not copy it into ordinary lists
Workflow QueryStoreMaximum 200 itemsEngine guard, not an HTTP-list maximum
SCIM listsMaximum 200 itemsFollows the SCIM paging protocol
Bounded dictionariesA small enum, event type set, or catalog may be read onceMove to standard paging beyond 200 items or when interactive filtering appears
Operations tenant listMay read across tenantsTrusted operations surface only; the request does not accept an ambient TenantId
Single-tenant detailGetTenantQuery(string TenantId)A resource identifier on a single-resource GET, not a list environment field

A new exception records its owner, boundary, and removal condition. A one-time migration status is not a stable exception contract.

8. Migration and verification

When maintaining an existing list:

  1. Export the current OpenAPI document and determine whether the live request is still flat or already uses Fields/Paging/Sort.
  2. Move business criteria into {Resource}ListFields; use domain field names for time ranges and no top-level Period.
  3. Replace flat HTTP paging with PaginationParams and IPaginatedRequest.
  4. Expand value objects and normalize paging once in *ListInput.From/MapFrom.
  5. Keep QueryShape input scalar and use LessThan for date upper bounds.
  6. Update the OpenAPI artifact, Platform SDK types, and page calls together.
  7. Run Domain unit tests, module Query tests, generator tests, and architecture tests.

Direct evidence currently includes:

  • PaginationParamsTests for defaults, the 1000 limit, and PageWindow conversion;
  • DateRangeTests for unbounded ranges, reversed-range failure, end-of-day normalization, explicit instants, and JSON validation;
  • RangeValueObjectTests for numeric/amount closed ranges and enum-set equality;
  • ExportRangeContractTests for range validation, enum values, and snapshot round trips;
  • ListQueryShapeConvergenceArchitectureTests, whose migration scans remain Skip until W6.

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%