OData, Filtering & Facets¶
Cargonerds layers a full Microsoft.AspNetCore.OData v8 query surface on top of the Hub
domain. It is the read/query backbone for shipments, organizations, code lists and the
analytics dashboards: $filter, $orderby, $top/$skip, $select, $expand, $search
and $apply/groupby/aggregate are all supported, plus a bespoke faceting engine
(the "filter sidebar" with per-value counts) that is exposed both as an OData instance
annotation and as plain DTO properties.
Everything OData lives in the Hub module
The entire OData/facet stack is in modules/hub (Hub.HttpApi, Hub.Application,
Hub.Domain*, Hub.EntityFrameworkCore, Hub.UI). The root src/Cargonerds.* projects
only carry the small ABP built-in OData configs (Identity / OrganizationUnit). The whole
layer is mounted into the single host Cargonerds.HttpApi.Host via
[DependsOn(typeof(HubHttpApiModule))] — see HTTP API & auto-API.
Two parallel query surfaces¶
There are two query paths over Hub entities, both driven by the same EDM model and the
same FacetBuilder. Knowing which one you are on explains most of the behaviour and the
gotchas below.
| Surface | Route | Driver | Returns | Used by |
|---|---|---|---|---|
| OData controllers | /odata/... |
[EnableQuery] applies options to the returned IQueryable |
OData JSON payload (optionally with cn.facets annotation) |
$apply aggregation tiles (React rockline dashboard, POC-report chart widgets), the generic OData viewer |
| ABP application services | /api/app/..., /api/hub/... |
ODataServerQueryApplier parses the query string manually |
PagedResultDtoWithFilters<TDto> (data + facet groups + applied-filter chips) |
Paged grids that need the facet sidebar and applied-filter chips inline |
flowchart TD
C[HTTP request] -->|/odata/Shipment| O[ODataControllerBase<T>.Get]
C -->|/api/app/...| A[HubEntityBaseService]
subgraph ODATA[OData controller path]
O --> O1[GetQueryable .AsNoTracking]
O1 --> O2[FilterAll: ABP IQueryFilter org/tenant]
O2 --> O3[ApplyAllWithoutODataApply: every IQueryApplier EXCEPT ODataServerQueryApplier]
O3 --> O4[ApplyODataSearch: $search to full-text]
O4 --> O5[TagWith recompile]
O5 --> O6[EnableQuery applies $filter/$orderby/$top/$skip/$expand/$apply]
O6 --> O7[FacetResourceSetSerializer adds cn.facets]
end
subgraph APP[AppService path]
A --> A1[ODataServerQueryApplier: ApplyOData search-filter-orderby-skip-top]
A1 --> A2[FacetBuilder.BuildAsync]
A2 --> A3[ODataAppliedExtractor to AppliedFilterDto chips]
A3 --> A4[PagedResultDtoWithFilters]
end
O7 --> R[Response]
A4 --> R
A single IEdmModel singleton is built once at startup by HubEdmModelContributor
(convention-based, camelCase) and shared by everything.
The OData controller layer¶
Base classes¶
ODataControllerBase<T> is the generic base for ~60 thin per-entity controllers. It has
two flavours, both [Authorize]:
// modules/hub/src/Hub.HttpApi/Controllers/ODataControllerBase.cs
[Authorize]
public class ODataControllerBase<T>(IServiceProvider serviceProvider) : ODataController
where T : HubBaseGuidEntity
{
protected virtual Task<IQueryable<T>> GetQueryable(IServiceProvider p) =>
Task.FromResult(p.GetRequiredService<HubDbContext>().Set<T>().AsNoTracking());
// ...
}
[Authorize]
public class ODataControllerBase<TRepository, TEntity>(IServiceProvider serviceProvider)
: ODataControllerBase<TEntity>(serviceProvider)
where TRepository : IRepository<TEntity>
where TEntity : HubBaseGuidEntity
{
protected override async Task<IQueryable<TEntity>> GetQueryable(IServiceProvider p) =>
(await p.GetRequiredService<TRepository>().GetQueryableAsync()).AsNoTracking();
}
ODataControllerBase<T>queriesHubDbContext.Set<T>().AsNoTracking()directly.ODataControllerBase<TRepository, TEntity>instead resolves an ABP repository and callsGetQueryableAsync()— used byCalculatedShipmentController(ODataControllerBase<ICalculatedShipmentRepository, CalculatedShipment>).
Concrete controllers are almost all one-liners:
// ShipmentController.cs
[Authorize(HubPermissions.Shipment.View)]
public class ShipmentController(IServiceProvider sp) : ODataControllerBase<Shipment>(sp);
Most code-list controllers have no permission attribute
Only the heavy entities (Shipment, Organization, Order, CalculatedShipment) add an
[Authorize(HubPermissions...)]. The entire #region CodeEntities set
(ContainerType, Currency, PackType, WeightUnit, …) relies on the base [Authorize],
i.e. any authenticated user can read them. See
Authorization & permissions.
MyInsightsController.cs is the exception that does not derive from the base:
MyInsightsDataController : ODataController returns an in-memory
data.AsQueryable() from IMyInsightsDataService with [EnableQuery(MaxNodeCount = 1000)].
Because the source is in-memory, $apply runs as LINQ-to-Objects there — which is why the
$apply crash below does not affect /odata/MyInsightsData.
The Get() pipeline¶
The single most important method in the layer:
// ODataControllerBase<T>.Get
[EnableQuery]
public virtual async Task<IQueryable<T>> Get(CancellationToken ct = default)
{
var res = (await GetQueryable(serviceProvider))
.FilterAll(serviceProvider) // ABP IQueryFilter org/tenant scoping
.ApplyAllWithoutODataApply(serviceProvider); // every IQueryApplier EXCEPT the OData one
res = res.ApplyODataSearch(
Request.ODataQueryOptions<T>(Get<IEdmModel>()).Search,
dbContext: serviceProvider.GetRequiredService<HubDbContext>()
);
res = res.TagWith(RecompileCommandInterceptor.Tag); // adds "-- recompile" (see gotcha)
await TryBuildFacets(ct); // optional facet sidebar
return res; // [EnableQuery] then applies the OData options
}
The action returns an unmaterialized IQueryable<T>. The [EnableQuery] attribute then
applies $filter/$orderby/$top/$skip/$expand/$apply to it. The order of operations
inside the action matters:
FilterAll— applies ABP global query filters (IQueryFilter) for org/tenant scoping before OData runs. See Org filtering and the ABP data filtering docs.ApplyAllWithoutODataApply— runs every registeredIQueryApplierexceptODataServerQueryApplier. The OData applier is excluded deliberately:[EnableQuery]already applies the OData options, so running the applier too would double-apply them.$searchis applied manually (the rest of OData is left to[EnableQuery]).TagWith(recompile)tags the SQL so an EF interceptor appendsOPTION (RECOMPILE).
// modules/hub/src/Hub.Domain/Extensions/QueryableExtensions.cs
public static IQueryable<T> ApplyAllWithoutODataApply<T>(
this IQueryable<T> queryable, IServiceProvider serviceProvider) where T : class
{
var appliers = serviceProvider
.GetServices<IQueryApplier>()
.Where(applier => applier.GetType().Name != "ODataServerQueryApplier"); // literal name!
return appliers.Aggregate(queryable, (current, applier) => applier.Apply(current));
}
Double-apply avoidance hinges on a class-name string
The exclusion is a literal string compare on applier.GetType().Name, not a typeof.
Renaming ODataServerQueryApplier would silently double-apply OData options. This is
non-obvious and fragile.
EDM model¶
The EDM is built once at startup and registered as a singleton:
// modules/hub/src/Hub.HttpApi/HubHttpApiModule.cs (PreConfigureServices)
context.Services.AddSingleton<IEdmModel>(sp =>
sp.GetRequiredService<IEdmModelContributor>().GetEdmModel());
context.Services.AddSingleton<IConfigureOptions<ODataOptions>, ODataOptionsConfiguration>();
PreConfigure<IMvcBuilder>(mvcBuilder =>
{
mvcBuilder.AddApplicationPartIfNotExists(typeof(HubHttpApiModule).Assembly);
mvcBuilder.AddOData();
});
HubEdmModelContributor (ISingletonDependency, [ExposeServices(typeof(IEdmModelContributor))])
is the active builder. It uses an ODataConventionModelBuilder with EnableLowerCamelCase(),
explicitly registers every EntitySet<> (Shipment, Organization, CalculatedShipment, ~40
code entities, etc.), lets injected IODataEntityTypeConfiguration services contribute, and
declares one bound collection action Shipment/export:
// modules/hub/src/Hub.Application/OData/HubEdmModelContributor.cs
var builder = new ODataConventionModelBuilder();
builder.EnableLowerCamelCase();
// ...
var shipmentEntitySet = builder.EntitySet<Shipment>(nameof(Shipment));
// ...
var exportAction = shipmentEntitySet.EntityType.Collection.Action("export");
exportAction.Parameter<ExportFilterDto>("input");
exportAction.Returns<Guid>();
Legacy EDM switch
HubEdmModelContributor has a private legacy = false flag. When true it falls back to
EdmByAttributeContributor.GetEdmModel() — an attribute scanner ([ProvideEdm]) that caches
the model statically and reflects over all loaded assemblies (heavier and order-sensitive).
The default is the explicit convention builder.
OData options¶
// modules/hub/src/Hub.HttpApi/OData/ODataOptionsConfiguration.cs
public void Configure(ODataOptions options)
{
options.RouteOptions?.EnablePropertyNameCaseInsensitive = true;
options
.EnableQueryFeatures()
?.AddRouteComponents(HubConsts.ODataRoute, edmModel, services =>
{
services.AddSingleton<IODataSerializerProvider, FacetSerializerProvider>();
services.AddSingleton<ODataUriResolver>(_ => new ODataUriResolver
{
EnableCaseInsensitive = true,
});
});
}
- Route prefix is
"odata"(HubConsts.ODataRoute), so URLs are/odata/Shipment,/odata/CalculatedShipment,/odata/MyInsightsData, etc. - Property names and the URI resolver are case-insensitive.
- A custom
FacetSerializerProvideris registered (drives thecn.facetsannotation).
HubHttpApiModule.OnApplicationInitialization also wires app.UseDelta<HubDbContext>(),
which adds ETag / Last-Modified-style 304 support keyed off DB changes.
$apply / groupby / aggregate¶
There is no server C# that builds $apply
$apply aggregation (groupby/aggregate) is purely an [EnableQuery] feature. The
clients construct the expression; OData translates it into
GroupBy(...).Select(new GroupByWrapper/AggregationWrapper{...}) on top of the IQueryable
the Get action returns.
The React rockline dashboard (frontend/realtime/src/services/rocklineDashboardService.ts)
builds the query with the odata-query npm package and follows @odata.nextLink /
@odata.count:
// rocklineDashboardService.ts (URL-encoded before sending)
const applySegments = [];
if (request.filter) {
applySegments.push(`filter(${request.filter})`);
}
applySegments.push(`groupby((${groupedFields.join(',')}),aggregate($count as shipmentCount))`);
let nextUrl = `/odata/MyInsightsData?$apply=${encodeURIComponent(applySegments.join('/'))}`;
So the effective request looks like:
For MyInsightsData the source is in-memory (LINQ-to-Objects). The same shape against the
SQL-backed /odata/CalculatedShipment (and any HubDbContext-backed entity) runs against
SQL Server — which is exactly where the TagWith gotcha bites.
The POC-report chart widgets consume the same OData surface for their groupby/aggregate tiles. See POC reports DTO/config for the widget-config DTO story.
Faceting¶
Faceting is the "filter sidebar with per-value counts". It is declarative: you put
[Facet] attributes on entity properties, and a single FacetBuilder service does the rest.
Declaring facets¶
// modules/hub/src/Hub.Domain.Shared/Attributes/Facets/FacetAttribute.cs
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public sealed class FacetAttribute : Attribute
{
public string? Label { get; set; }
public FilterType Type { get; set; } = FilterType.CheckboxList;
public bool MultiSelect { get; set; } = true;
public GroupOperator GroupOperator { get; set; } = GroupOperator.Or;
public int Order { get; set; }
public int TopDistinct { get; set; }
public string? ValuePath { get; set; } // e.g. "TransportMode/Id" or "TransportModeId"
public string? LabelPath { get; set; } // e.g. "TransportMode/Name"
public Type? ValueType { get; set; } // required when ValuePath points at a navigation
}
Real usage on CalculatedShipment (the entity behind the dashboards):
// modules/hub/src/Hub.Domain/Entities/Calculated/CalculatedShipment.cs
[Facet] public ShipmentState State { get; set; }
[Facet] public string? TransportMode { get; set; }
[Facet] public string? OriginCountryName { get; set; }
[Facet] public string? DestinationCountryName { get; set; }
[Facet] public string? ConsignorName { get; set; }
[Facet] public string? ConsigneeName { get; set; }
FilterType values: CheckboxList, Radio, Range, DateRange, Search, TokenList.
GroupOperator is And/Or.
[Facet] paths are stringly-typed
ValuePath/LabelPath are slash-delimited strings, translated at runtime to dynamic-LINQ
(slashes → dots) and OData (dots → slashes). Wrong casing or a wrong path does not fail
at compile time — the facet just produces no options or throws a runtime dynamic-LINQ error.
Set ValueType whenever ValuePath targets a navigation property (otherwise it defaults to
string).
The FacetBuilder¶
FacetBuilder (modules/hub/src/Hub.Application/Services/FacetBuilder.cs,
[ExposeServices(typeof(IFacetBuilder))], IScopedDependency) reflects the [Facet]
properties (cached per type in a ConcurrentDictionary<Type, FacetTypeCache>), then:
- List facets (
CheckboxList/TokenList/Radio) — builds one combined query by.Concat-ing per-facetGroupBy(...).Select(new(... Count() ...))projections viaSystem.Linq.Dynamic.Core, tags itFacetBuilder: Dynamic list facets, and materializes it. Each option carries a ready-to-use OData fragment. - Range / DateRange facets — produce preset buckets (e.g. last 7/14/30 days); each preset
count is a separate
CountAsync, optionally parallelized via aSemaphoreSlim(MaxDbParallelism = 4), each in its ownrequiresNewUoW + fresh DI scope (uses the ABP UoW manager). - Disjunctive faceting — when enabled, each group's counts are computed against the query with all other groups' filters applied but not its own, so an already-selected value doesn't zero out its sibling options.
Behaviour is controlled by FacetBuilderOptions:
| Option | Default | Meaning |
|---|---|---|
BuildFacets |
false |
Build facets at all (else only extract applied filters) |
ComputeDynamicLists |
true |
Compute distinct value lists for list facets |
DisjunctiveFacets |
true |
Count each group ignoring its own filter |
DisjunctiveByGroupOperator |
true |
Honour AND/OR when not fully disjunctive |
DefaultTopDistinct |
15 |
Default cap on discrete options |
ParallelizeRanges |
true |
Run range counts in parallel |
MaxDbParallelism |
4 |
Parallel-count degree |
BuildLiterals |
false |
Build OData literals (false for EDM-provided models) |
Facet serialization (the cn.facets annotation)¶
On the OData controller path, facets are returned as an OData instance annotation rather
than a separate response shape. ODataControllerBase.ShouldBuildFacets() returns true only
when the request carries a Prefer header containing odata.include-annotations="*"
(HubConsts.ODataAnnotations) or the literal cn.facets:
// HubConsts.cs
public const string ODataRoute = "odata";
public const string ODataPrefer = "Prefer";
public const string ODataAnnotations = "odata.include-annotations=\"*\"";
If so, TryBuildFacets stashes the List<FilterGroupDto> in
HttpContext.Items["cn.facets"], and FacetResourceSetSerializer reads it back and emits it
as a kebab-cased JSON instance annotation:
// modules/hub/src/Hub.HttpApi/OData/FacetResourceSetSerializer.cs
public const string FacetsAnnotationName = "cn.facets";
public override ODataResourceSet CreateResourceSet(
IEnumerable resourceSet, IEdmCollectionTypeReference collectionType,
ODataSerializerContext writeContext)
{
var set = base.CreateResourceSet(resourceSet, collectionType, writeContext);
if (writeContext.Request?.HttpContext.Items.TryGetValue(FacetsAnnotationName, out var facetsObj) == true
&& facetsObj is not null)
{
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower };
var raw = JsonSerializer.Serialize(facetsObj, options);
set.InstanceAnnotations.Add(
new ODataInstanceAnnotation(FacetsAnnotationName, new ODataUntypedValue { RawValue = raw }));
}
return set;
}
FacetSerializerProvider swaps this serializer in for any resource-set payload.
Hub.UI OData clients (Blazor)¶
The Blazor Hub.UI consumes the same /odata surface. The relevant clients live under
modules/hub/src/Hub.UI/:
Services/ODataApiClient.cs(ITransientDependency) — a generic client for the dynamic OData viewer page. Queries/odata/{entitySet}with dynamic JSON parsing (no typed DTOs), supports$top,$skip,$count,$search,$filter,$orderby, and reads facet annotations via thePreferheader. It deserializes the annotation withPropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower(matching the serializer above).Services/HubApiClient.cs— lightweight HTTP client (withSimple.OData.Client) whose default headers always include{ HubConsts.ODataPrefer, HubConsts.ODataAnnotations }, so Hub.UI requests always ask for thecn.facetsannotation. It binds the response property@cn.facetsback into typed DTOs.OData/IEntitySetClient.cs/OData/BoundClientExtensions.cs— typedIBoundClient<T>per entity set.
See API clients and Blazor UI for the wider client story.
The AppService faceting path¶
The /api/app and /api/hub paths do not use [EnableQuery]. Instead a base service
(HubEntityBaseService, plus CalculatedShipmentAppService, MyInsightsDataService) reads
the OData query string with ODataServerQueryApplier, pages the result, and returns a
PagedResultDtoWithFilters<TDto>:
// modules/hub/src/Hub.Application.Contracts/Dtos/PagedResultDtoWithFilters.cs
public class PagedResultDtoWithFilters<T> : PagedResultDto<T>, IFacetResponse
{
public List<FilterGroupDto> Filters { get; set; } = new(); // groups for UI rendering
public List<AppliedFilterDto> Applied { get; set; } = new(); // active filters as chips
public string? ODataFilter { get; set; } // combined OData filter
public QueryMetaDto Query { get; set; } = new(); // $orderby/$top/$skip, raw query
}
This extends ABP's PagedResultDto<T> (see ABP
DTOs
and application services).
ODataServerQueryApplier.ApplyOData runs search → filter → orderby → skip → top itself,
with HandleNullPropagation = HandleNullPropagationOption.False. ODataAppliedExtractor
walks the $filter AST (BinaryOperatorNode, SingleValuePropertyAccessNode) to turn the
active filter into AppliedFilterDto "chips". The full facet DTO family
(FilterGroupDto, FilterOptionDto, AppliedFilterDto, RangeDefinitionDto,
IFacetResponse, QueryMetaDto) is documented in DTOs.
$expand is translated to EF .Include(...)
On both paths ApplyODataExpandIncludes walks the SelectExpandClause, maps EDM nav names
to CLR property names (case-insensitive), and issues nested .Include("A.B.C") chains. This
is why $expand works on the AppService path even though it bypasses the OData formatter.
EF perf interceptors¶
Two DbCommandInterceptors sit on the pooled HubDbContext (pool size 256) and rewrite SQL
based on tags added by the OData/facet layer. Both are singletons registered in
HubEntityFrameworkCoreModule. See Entity Framework.
RecompileCommandInterceptor¶
Appends OPTION (RECOMPILE) to any command whose text contains the -- recompile tag
(added by ODataControllerBase.Get via TagWith):
// modules/hub/src/Hub.EntityFrameworkCore/EntityFrameworkCore/RecompileCommandInterceptor.cs
public const string Tag = "recompile";
private const string TagMarker = "-- " + Tag;
private const string Hint = "\nOPTION (RECOMPILE)";
private static void ApplyHint(DbCommand command)
{
var text = command.CommandText;
if (text.Contains(TagMarker, StringComparison.Ordinal)
&& !text.Contains("OPTION (RECOMPILE)", StringComparison.Ordinal))
{
command.CommandText = text + Hint;
}
}
This forces SQL Server to pick a fresh plan per parameter set — a deliberate fix for the "app slower than Rider" parameter-sniffing problem.
FacetJoinInterceptor¶
Rewrites LEFT JOIN → INNER JOIN for any command tagged FacetBuilder: — safe because the
facet queries always add WHERE {path} != null, making the two joins equivalent and giving
SQL Server better plans:
// modules/hub/src/Hub.EntityFrameworkCore/EntityFrameworkCore/FacetJoinInterceptor.cs
private void RewriteIfFacetQuery(DbCommand command)
{
if (!command.CommandText.Contains(FacetConstants.FacetQueryTagPrefix, StringComparison.OrdinalIgnoreCase))
return;
command.CommandText = command.CommandText.Replace(
"LEFT JOIN", "INNER JOIN", StringComparison.OrdinalIgnoreCase);
}
Gotchas¶
TagWith(recompile) + $apply → NullReferenceException¶
Live, currently unguarded on main
ODataControllerBase.Get calls res = res.TagWith(RecompileCommandInterceptor.Tag) and
returns the query to [EnableQuery]. When the request is an OData $apply aggregation
(groupby/aggregate), OData builds GroupBy(...).Select(new GroupByWrapper/AggregationWrapper{...})
on top of the already-tagged query. EF Core cannot translate "EF query tag + OData
aggregation wrappers" in one expression tree and throws a NullReferenceException.
Plain $filter/$top queries (no wrapper) are unaffected — which is why some tiles work and
others fail. This hits the POC-report chart widgets and the React rockline tiles that hit
/odata/CalculatedShipment (and any SQL-backed entity). /odata/MyInsightsData is not
affected because its controller returns an in-memory IQueryable.
The TagWith line arrived via a merge from main (a perf optimization to force
OPTION (RECOMPILE)), hence "it worked before the merge". As of the current main the line
is present and there is no $apply guard. Candidate fixes (not yet applied in code):
- Comment the line out — loses
OPTION (RECOMPILE)for all ODataGet, including non-aggregated lists; or - Guard it so it only tags non-aggregation queries:
if (string.IsNullOrEmpty(Request.Query["$apply"]))
{
res = res.TagWith(RecompileCommandInterceptor.Tag);
}
Dead ends during the original diagnosis (not the cause): encoded-comma %2C,
System.Uri re-canonicalization, EF.Constant inlining.
Nextended response filter must skip OData $apply wrapper types¶
The Nextended response filters (registered in HubHttpApiModule.ConfigureServices via
AddNextendedResponseFilters) cannot traverse OData's internal wrapper types,
so HubHttpApiModule configures SkipResponseType to detect them — both by namespace and by
inspecting the IEnumerable<T> element type (the root value of an aggregation is a
System.Linq.EnumerableQuery<GroupByWrapper>):
// modules/hub/src/Hub.HttpApi/HubHttpApiModule.cs
options.SkipResponseType = t =>
{
if (t.Namespace?.StartsWith("Microsoft.AspNetCore.OData") == true)
return true;
var elementType = Array
.Find(t.GetInterfaces(),
i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>))
?.GetGenericArguments()[0];
return elementType?.Namespace?.StartsWith("Microsoft.AspNetCore.OData") == true;
};
Forgetting this breaks every aggregation response (Nextended 10.1.6 cannot safely traverse
GroupByWrapper / SelectExpandWrapper).
$search is applied manually, not by [EnableQuery]¶
The controller pulls ODataQueryOptions<T>().Search and routes it to a full-text
WhereFTSContains (ApplyODataSearch); the rest of the OData options are left to
[EnableQuery]. OData's own search pipeline is bypassed. On the AppService path, ApplyOData
runs search → filter → orderby → skip → top itself.
ODataServerQueryApplier swallows OData parse errors¶
On any exception the applier logs (LogFailedODataQuery) and falls back to a
Nextended.Core.OData string parser. A malformed $filter may therefore silently return
unfiltered or differently-filtered data rather than a 400.
Facet localization is currently bypassed¶
FacetBuilder.L(...) has an unconditional return key; before the real
localization lookup
("Temporary bypass of localization"). Facet labels and option labels are not localized
today.
ABP patterns used¶
IQueryApplierstrategy + DI fan-out — appliers (ODataServerQueryApplier,QuickFilterQueryApplier) are registered with[ExposeServices]+IScopedDependencyand applied by enumeratingGetServices<IQueryApplier>(). See dependency injection.- ABP global query filters /
IQueryFilter—FilterAll<T>applies non-auto-active filters whoseEntityType == typeof(T)before OData runs; ties into ABP data filtering. - ABP repositories —
ODataControllerBase<TRepository, TEntity>resolves anIRepository<TEntity>and callsGetQueryableAsync(). - Lifetime markers /
[ExposeServices]—FacetBuilder(IScopedDependency),HubEdmModelContributor(ISingletonDependency),ODataApiClient(ITransientDependency). - ABP module lifecycle —
HubHttpApiModule.PreConfigureServicesregisters the EDM + OData options and callsmvcBuilder.AddOData();OnApplicationInitializationwiresUseDelta<HubDbContext>(). See modularity. - ABP application service + DTO mapping — the AppService path returns
PagedResultDtoWithFilters<T> : PagedResultDto<T>; see application services and DTOs.
Non-ABP framework pieces in play: Microsoft.AspNetCore.OData v8,
System.Linq.Dynamic.Core (dynamic facet LINQ), Nextended.ResponseFilters /
Nextended.Core.OData (response shaping + fallback parser), Simple.OData.Client (Blazor
client), and Delta (UseDelta).
Related pages¶
- HTTP API & ABP auto-API — host, conventional controllers, integer-enum default.
- API clients — generated proxies and the Hub.UI OData clients.
- Authentication — JWT + dynamic claims; the
[Authorize]controllers. - Entity Framework —
RecompileCommandInterceptor,FacetJoinInterceptor, DbContext pooling. - DTOs — the facet DTO family and the POC-report widget-config DTOs.
- Application service patterns —
HubEntityBaseServicefaceting,IQueryApplier,FilterAll/ApplyAll*. - Shipment service —
Shipment/CalculatedShipmentcontrollers and the insight dashboards. - Architecture overview and Hub module.