Skip to content

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.ReferenceData can be exposed as lookup sources with localized labels without duplicating endpoints.
  • 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
PackageRoleDepends on
Granit.DataLookup.AbstractionsPure contracts (descriptors, DTOs, interfaces)Granit
Granit.DataLookupILookupRegistry, EnumLookupSource<TEnum>, metrics.Abstractions, Granit.Localization
Granit.DataLookup.EntityFrameworkCoreQueryableLookupSource<T> over IQueryable<T>Granit.DataLookup, Granit.Persistence
Granit.DataLookup.Endpoints/api/{version}/lookups minimal API, permissions, authGranit.DataLookup, Granit.Authorization, Granit.Validation

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 values
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.

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.

Implement ILookupSource directly for non-database sources (external APIs, computed values, in-memory collections).

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).

MethodRoutePurpose
GET/api/{version}/lookupsManifest 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-Language header — 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-injected 401 / 403 responses.

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 Request with a problem+json body.
  • FrontenduseLookup checks ScopeKeys before emitting the HTTP request. When a key is unresolved, React-Query is set to enabled: false and no request is sent.

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.

Meter / metricPurpose
Granit.DataLookupMeter name
granit.data_lookup.search.executedSuccessful search counter
granit.data_lookup.search.durationSearch latency histogram (seconds)
granit.data_lookup.resolve.executedSingle-value resolve counter
granit.data_lookup.scope.missingRejected 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.

  • QueryEngine — declarative search; filter pickers consume the LookupDescriptor emitted on FilterableField.Lookup.
  • ReferenceData — code lists exposed as lookup sources with localized labels.
  • ADR-028 — architectural rationale and scope (unified IDataLookupSource for ReferenceData and EF entities).