Data Lookup — Unified Pickers for Filters and Forms
Granit.DataLookup provides a unified primitive for typeahead pickers across the
framework. A single ILookupSource contract powers QueryEngine filter bars, edit-form
dropdowns, and any custom UI that needs a paginated, searchable list of values —
no duplication between backend declarations and frontend components.
- Filter pickers — foreign-key columns in a grid (
TenantId,UserId,MeterDefinitionId) currently force users to type a GUID by hand. A lookup source turns them into typeahead comboboxes. - Edit forms — selects in create/edit forms are often hardcoded (enums, constants) or wired ad-hoc (one-off hooks). A registry-backed lookup consolidates them.
- Reference data — code lists (countries, document types) already backed by
Granit.ReferenceDatacan be exposed as lookup sources with localized labels without duplicating endpoints.
Package structure
Section titled “Package structure”DirectoryGranit.DataLookup.Abstractions/ Contracts: LookupDescriptor, LookupItem, LookupResult, ILookupSource, ILookupRegistry
DirectoryGranit.DataLookup/ Runtime: scoped registry, EnumLookupSource<TEnum>, metrics, ActivitySource
- Granit.DataLookup.EntityFrameworkCore QueryableLookupSource<T> — wraps an IQueryable with value/label selectors
- Granit.DataLookup.Endpoints Minimal API: /api/{version}/lookups manifest, search, resolve
| Package | Role | Depends on |
|---|---|---|
Granit.DataLookup.Abstractions | Pure contracts (descriptors, DTOs, interfaces) | Granit |
Granit.DataLookup | ILookupRegistry, EnumLookupSource<TEnum>, metrics | .Abstractions, Granit.Localization |
Granit.DataLookup.EntityFrameworkCore | QueryableLookupSource<T> over IQueryable<T> | Granit.DataLookup, Granit.Persistence |
Granit.DataLookup.Endpoints | /api/{version}/lookups minimal API, permissions, auth | Granit.DataLookup, Granit.Authorization, Granit.Validation |
Canonical shapes
Section titled “Canonical shapes”All sources project to a single response shape — the frontend never re-maps.
public sealed record LookupItem( object Value, string Label, // already localized server-side IReadOnlyDictionary<string, object?>? Extra = null);
public sealed record LookupResult( IReadOnlyList<LookupItem> Items, int? TotalCount = null, string? ContinuationToken = null);The LookupDescriptor is emitted alongside column / field metadata:
public sealed record LookupDescriptor( string? Name = null, // registry key, preferred string? Endpoint = null, // custom URL, fallback LookupKind Kind = LookupKind.QueryEngine, // QueryEngine | Simple | ReferenceData | Enum string? RequiredPermission = null, string? SearchParam = "search", IReadOnlyList<string>? ScopeKeys = null); // e.g. ["tenantId"] — frontend pushes valuesDeclaring a source
Section titled “Declaring a source”CLR enum — zero DB roundtrip
Section titled “CLR enum — zero DB roundtrip”services.AddEnumLookup<AggregationType>( name: "enum-aggregation-type", requiredPermission: "Metering.Meters.Read");Labels resolved via Enum:{TypeName}.{Value} localization keys across the 18
cultures Granit supports.
IQueryable<T> — any entity with EF Core
Section titled “IQueryable<T> — any entity with EF Core”services.AddQueryableLookup<Tenant, PlatformDbContext>( name: "tenants", valueSelector: t => t.Id, labelSelector: t => t.Name, searchPredicate: (t, search) => t.Name.Contains(search), requiredPermission: "Platform.Tenants.Read");The source is resolved from DI per request and honors tenant filters,
soft-delete filters, and any convention applied by ApplyGranitConventions.
Custom implementation
Section titled “Custom implementation”Implement ILookupSource directly for non-database sources (external APIs,
computed values, in-memory collections).
Endpoints
Section titled “Endpoints”Mapped once at startup:
app.MapGranitDataLookups(opts =>{ opts.TagName = "Data Lookup";});The default RoutePrefix is "lookups". Mount on a versioned route group
(api.MapGranitDataLookups() where api = app.MapGroup("api/v{version:apiVersion}"))
to expose under /api/{version}/lookups, or mount on app directly for an unversioned
root mount (/lookups).
| Method | Route | Purpose |
|---|---|---|
GET | /api/{version}/lookups | Manifest of every registered source |
GET | /api/{version}/lookups/{name} | Paginated search (search, page, pageSize, scope.*) |
GET | /api/{version}/lookups/{name}/resolve?value=… | Resolve a single value for rehydration |
Every response honors:
Accept-Languageheader — labels projected in the caller’s culture server-side.- Ambient
ICurrentTenant— sources see the tenant-filtered queryable. RequiredPermission— enforced by the dispatch handler, with the framework’s auto-injected401/403responses.
Empty Scope Trap — two-layer mitigation
Section titled “Empty Scope Trap — two-layer mitigation”Scoped lookups (e.g., meters filtered by tenantId) declare their required scope
keys. Missing values are caught at both layers to avoid silent full-table scans:
- Backend — the endpoint validates that every declared scope key is present and
non-empty. Missing keys return
400 Bad Requestwith aproblem+jsonbody. - Frontend —
useLookupchecksScopeKeysbefore emitting the HTTP request. When a key is unresolved, React-Query is set toenabled: falseand no request is sent.
Authorization
Section titled “Authorization”The group policy DataLookup.Lookups.Read gates the entire module. Per-source
RequiredPermission values layer on top and are enforced before the source is invoked.
Diagnostics
Section titled “Diagnostics”| Meter / metric | Purpose |
|---|---|
Granit.DataLookup | Meter name |
granit.data_lookup.search.executed | Successful search counter |
granit.data_lookup.search.duration | Search latency histogram (seconds) |
granit.data_lookup.resolve.executed | Single-value resolve counter |
granit.data_lookup.scope.missing | Rejected queries due to missing scope keys |
All tags include tenant_id (coalesced to "global") and lookup_name.
Distributed tracing is emitted via the Granit.DataLookup ActivitySource.
See also
Section titled “See also”- QueryEngine — declarative search; filter pickers
consume the
LookupDescriptoremitted onFilterableField.Lookup. - ReferenceData — code lists exposed as lookup sources with localized labels.
- ADR-028 —
architectural rationale and scope (unified
IDataLookupSourcefor ReferenceData and EF entities).