Query Models
Query models expose EF Core entities directly as GraphQL queries with automatic cursor pagination, filtering, sorting, and projection. Unlike [TraxQuery] which wraps a train (business logic), [TraxQueryModel] maps a database table to a GraphQL field with zero boilerplate.
Quick Start
- Mark your entity with
[TraxQueryModel]:
[TraxQueryModel(Description = "Player profiles")]
public class PlayerRecord
{
public long Id { get; set; }
public string PlayerId { get; set; } = "";
public string DisplayName { get; set; } = "";
public int Rating { get; set; }
}- Add the entity to a
DbSet<T>on aDbContext:
public class GameDbContext(DbContextOptions<GameDbContext> options) : DbContext(options)
{
public DbSet<PlayerRecord> Players { get; set; } = null!;
}- Register the DbContext and enable model discovery:
builder.Services.AddDbContextFactory<GameDbContext>(options =>
options.UseNpgsql(connectionString));
builder.Services.AddTraxGraphQL(graphql =>
graphql.AddDbContext<GameDbContext>());This generates a playerRecords query field under discover:
query {
discover {
playerRecords(first: 10, where: { rating: { gte: 1500 } }, order: { rating: DESC }) {
nodes {
playerId
displayName
rating
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
}TraxQueryModel Attribute
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public class TraxQueryModelAttribute : Attribute
{
public string? Name { get; init; }
public string? Description { get; init; }
public string? DeprecationReason { get; init; }
public string? Namespace { get; init; }
public bool Paging { get; init; } = true;
public bool Filtering { get; init; } = true;
public bool Sorting { get; init; } = true;
public bool Projection { get; init; } = true;
public FieldBindingBehavior BindFields { get; init; } = FieldBindingBehavior.Implicit;
public Type? ExposeAs { get; init; }
}Properties
| Property | Type | Default | Description |
|---|---|---|---|
Name | string? | null | Overrides the auto-derived GraphQL field name. When null, derived by pluralizing and camelCasing the class name (e.g. Player → players). |
Description | string? | null | Human-readable description that appears in the GraphQL schema documentation. |
DeprecationReason | string? | null | Marks the generated field as deprecated in the schema. |
Namespace | string? | null | Groups this field under a sub-namespace. When set, the field appears under discover { namespace { field } } instead of directly under discover. |
Paging | bool | true | Enables cursor-based pagination (Relay Connection spec). When true, the field returns a Connection type with nodes, edges, pageInfo, and totalCount. |
Filtering | bool | true | Enables filtering via a where argument. HotChocolate generates filter input types for all entity properties. |
Sorting | bool | true | Enables sorting via an order argument. HotChocolate generates sort input types for all entity properties. |
Projection | bool | true | Enables field projection. Only the columns requested by the GraphQL client are selected from the database. |
BindFields | FieldBindingBehavior | Implicit | Controls how fields are bound on the generated GraphQL ObjectType. When Explicit, only properties with [Column] are exposed; [NotMapped], methods, and non-column members are excluded. |
ExposeAs | Type? | null | Restricts the GraphQL surface to the property set declared by the supplied interface. The entity must implement the interface implicitly. Filter and sort input types are constrained to the same set unless a custom override is supplied. Mutually exclusive with BindFields = Explicit. |
Feature Configuration
Each feature can be independently disabled per model. All default to true.
// Full-featured (default)
[TraxQueryModel]
public class Player { ... }
// Pagination and filtering only, no sorting or projection
[TraxQueryModel(Sorting = false, Projection = false)]
public class AuditLog { ... }
// Simple list query, no middleware at all
[TraxQueryModel(Paging = false, Filtering = false, Sorting = false, Projection = false)]
public class StatusCode { ... }When Paging = false, the field returns a plain list ([Entity!]!) instead of a Connection type.
Field Binding
By default, HotChocolate exposes all public properties on an entity as GraphQL fields. When your entity has [NotMapped] aliases, DataLoader methods, or infrastructure methods that should not appear in the schema, use explicit binding:
[TraxQueryModel(BindFields = FieldBindingBehavior.Explicit)]
[Table("players", Schema = "game")]
public class Player
{
[Column("id")]
public long Id { get; set; }
[Column("display_name")]
public string DisplayName { get; set; } = "";
[NotMapped]
public string Alias => $"Player-{Id}"; // excluded from schema
public void AddToDbContext(GameDb db) { } // excluded from schema
}With BindFields = FieldBindingBehavior.Explicit, only Id and DisplayName appear in the GraphQL schema. The Alias property and AddToDbContext method are excluded.
| Value | Behavior |
|---|---|
Implicit (default) | All public properties exposed (standard HotChocolate behavior) |
Explicit | Only properties with [Column] are exposed |
FK fields added via ObjectTypeExtension (from custom TypeModules registered with AddTypeModule<T>()) still work when using explicit binding, since extensions are separate from the base type's field set.
ExposeAs
When an entity is shared across DbContexts (typical pattern: a "reference" projection of a cross-schema entity), the entity class carries every column required by every owning context, but consumers reading it through a non-owning context cannot navigate the relationships. ExposeAs constrains the GraphQL schema to a separately-declared interface so the schema reflects what the consumer can actually query, rather than auto-binding every public property on the entity.
public interface IBookReference
{
int Id { get; }
string Title { get; }
string Author { get; }
int Rating { get; }
}
[TraxQueryModel(ExposeAs = typeof(IBookReference))]
public class Book : IBookReference
{
public int Id { get; set; }
public string Title { get; set; } = "";
public string Author { get; set; } = "";
public int Rating { get; set; }
// Owned only by the authoring context; not on IBookReference, so
// it is hidden from the GraphQL schema produced here.
public ICollection<Review>? Reviews { get; set; }
}The generated schema contains only the four interface fields. reviews does not appear on the object type, in the FilterInput, or in the SortInput. Queries referencing it fail at schema validation, not at LINQ-translation time.
| Aspect | Behavior |
|---|---|
| GraphQL type name | Still derived from the entity (Book), not the interface. Consumers see type Book { ... }. |
| Object type fields | The intersection of the entity's public properties and the interface's property names. |
| Filter input type | Restricted to the same property set. Filtering on hidden properties produces a schema-validation error. |
| Sort input type | Restricted to the same property set. |
| Custom filter/sort overrides | When AddFilterType<T> or AddSortType<T> is registered, the override wins and ExposeAs is not consulted for that input type. |
| Interface inheritance | The full inherited interface graph is walked. Properties declared on parent interfaces are exposed. |
| Field metadata | Description, deprecation, and other attributes are read from the entity property (interface declarations cannot carry attributes that influence the schema). |
Validation
The configuration is validated at Build() time. Each failure mode throws InvalidOperationException with a message that names both the entity and the interface.
| Misconfiguration | Error |
|---|---|
ExposeAs combined with BindFields = Explicit | Both restrict the field set; pick one. |
ExposeAs references a class instead of an interface | Must be an interface. |
| Entity does not implement the interface | Add the interface to the entity declaration. |
| Interface declares no properties | A GraphQL type with no fields is invalid. |
| Interface declares a property the entity implements explicitly | ExposeAs cannot bind explicit-interface implementations; make it implicit. |
Mutations
ExposeAs only applies to [TraxQueryModel] (the query surface). Mutations are trains, not query models, and are not affected.
Authorization
A [TraxQueryModel] entity is exposed via GraphQL, so it must declare its authorization posture explicitly: [TraxAuthorize] to gate it or [TraxAllowAnonymous] to open it. An entity with neither fails at TraxGraphQLBuilder.Build() (unless the endpoint is gated with RequireAuthorization(), which covers it). See Authorization guide - Required Exposure Posture.
Apply [TraxAuthorize] to a [TraxQueryModel] entity to gate access. The directive attaches at GraphQL type level and at the entry field, so the gate enforces uniformly:
- the top-level field under
discover(including Connection-shaped scalars liketotalCountandpageInfo), - any other field elsewhere in the schema whose return type is this entity (e.g. a navigation property on an ungated parent).
[TraxQueryModel(Namespace = "library")]
[TraxAuthorize(Roles = "Subscriber")]
public class Article { ... }Combinator semantics, role normalization, and inheritance behavior match the per-train [TraxAuthorize] surface. Policy names referenced by a [TraxQueryModel] entity must be registered with services.AddAuthorization(...); a QueryModelAuthorizationValidator hosted service throws at host start if any policy is missing.
The inverse opt-in, [TraxAllowAnonymous], opens an entity to unauthenticated reads. It is mutually exclusive with [TraxAuthorize] and does not cascade through navigation properties to gated children. See Authorization guide - Anonymous Access via TraxAllowAnonymous.
See the Authorization guide - Per-Model Authorization for the full semantics table and limitations (no field-level gating, no row-level filtering).
Name Derivation
When Name is null, the field name is derived automatically:
- Pluralize the class name (naive English rules:
Player→Players,Match→Matches,Category→Categories) - camelCase the result (
Players→players)
Override with Name for cases where the automatic pluralization is incorrect:
[TraxQueryModel(Name = "people")]
public class Person { ... }Custom Filter and Sort Types
By default, HotChocolate generates FilterInputType<TEntity> and SortInputType<TEntity> based on all public properties of the entity. When you need to hide properties, rename filter fields, or customize the generated input types, register custom overrides via the builder:
builder.Services.AddTraxGraphQL(graphql => graphql
.AddDbContext<GameDbContext>()
.AddFilterType<Player, PlayerFilterInputType>()
.AddSortType<Player, PlayerSortInputType>());Create the custom types by extending FilterInputType<TEntity> or SortInputType<TEntity>:
public class PlayerFilterInputType : FilterInputType<Player>
{
protected override void Configure(IFilterInputTypeDescriptor<Player> descriptor)
{
// Hide internal properties from the schema
descriptor.Field(x => x.InternalMappedId).Ignore();
// Rename a property for the public API
descriptor.Field(x => x.MappedId).Name("playerId");
}
}
public class PlayerSortInputType : SortInputType<Player>
{
protected override void Configure(ISortInputTypeDescriptor<Player> descriptor)
{
descriptor.Field(x => x.InternalMappedId).Ignore();
descriptor.Field(x => x.MappedId).Name("playerId");
}
}When an override is registered, it replaces the default for that entity only. Entities without overrides continue to use the auto-generated types.
Case-Insensitive Filtering
The auto-generated string filters (contains, eq, startsWith, ...) are case-sensitive, since they map to plain LIKE / = on a deterministic collation. To add case-insensitive operators, opt in with ConfigureFiltering:
builder.Services.AddTraxGraphQL(graphql => graphql
.AddDbContext<GameDbContext>()
.ConfigureFiltering(filter => filter.AddCaseInsensitiveStringOperations()));This adds icontains (case-insensitive substring) and ieq (case-insensitive equality) to every string filter input, including ExposeAs-projected and custom filter types. The existing case-sensitive operators are unchanged; a client opts in per query by choosing the operator. See ConfigureFiltering for the translation, indexing, and extension details.
AddDbContext
Register one or more DbContext types whose DbSet<T> properties contain attributed entities:
builder.Services.AddTraxGraphQL(graphql => graphql
.AddDbContext<GameDbContext>()
.AddDbContext<InventoryDbContext>());Only DbSet<T> properties where T has [TraxQueryModel] are exposed. Other DbSet properties on the same DbContext are ignored.
The DbContext must be registered in DI separately (via AddDbContext, AddDbContextFactory, or AddPooledDbContextFactory).
vs TraxQuery
[TraxQuery] | [TraxQueryModel] | |
|---|---|---|
| Target | Train class (workflow) | Entity class (data model) |
| Resolves via | ITrainBus.RunAsync | DbContext.Set<T>() → IQueryable |
| Input | Typed input DTO | Filter/sort/page arguments (auto-generated) |
| Output | Typed output DTO | Entity properties (with projection) |
| Use case | Business logic, computed results | Direct CRUD reads, admin dashboards |
| Schema location | discover { trainName(input: ...) } | discover { modelNames(first: ..., where: ...) } |
Both appear under the discover namespace in the GraphQL schema.