Aller au contenu

Relations

When using type-graphql-query you can specify relations that should be exposed for the DTO using the following decorators. Relations allow you to define how entities are connected and automatically generate GraphQL endpoints for querying and managing those relationships.

This guide will help you understand:

  • ✅ Available relation decorators and when to use them
  • ✅ How to define single-value and collection relations
  • ✅ How to enable filtering on relations
  • ✅ How to configure pagination for relations
  • ✅ How to manage relation mutations (add, set, remove)
  • ✅ Best practices for relation design
  • @Relation - A relation that is a single value (one-to-one, many-to-one)
  • @FilterableRelation - A @Relation that enables filtering the parent by fields of the relation DTO.
  • @UnPagedRelation - An array of relations (e.g, many-to-many, one-to-many) that returns all of the related records.
  • @FilterableUnPagedRelation - An @UnPagedRelation that enables filtering the parent by fields of the relation DTO.
  • @OffsetConnection - A connection that represents a collection (e.g, many-to-many, one-to-many) that uses offset based pagination.
  • @FilterableOffsetConnection - An @OffsetConnection that enables filtering the parent by fields of the connection DTO.
  • @CursorConnection - A connection that represents a collection (e.g, many-to-many, one-to-many) that uses cursor based pagination.
  • @FilterableCursorConnection - A @CursorConnection that enables filtering the parent by fields of the connection DTO.

In the following examples we are going to use the following entities.

  • Product
  • OrderLine
  • Order

A relation that is a single value (one-to-one, many-to-one)

Based on the entities defined above we can add a product relation to the OrderLine by creating the following OrderLineDTO with a @Relation decorator.

import { ObjectType, ID } from '@lib/type-graphql';
import { Relation, IDField, EnhancedField } from '@lib/type-graphql-query';
import { Product } from '../product/product.type';
@ObjectType()
@Relation('product', () => Product, {
nullable: true,
})
export class OrderLine {
@IDField(() => ID)
id!: string;
@EnhancedField(() => String)
productId!: string;
@EnhancedField(() => Number)
unitPrice!: number;
}

The @Relation decorator lets type-graphql-query know to expose the following endpoints:

  • orderLine.product - A query to retrieve the OrderLine’s Product
  • setProductOnOrderLine - A mutation to set the Product on an OrderLine (if update.enabled is set to true).
  • removeProductFromOrderLine - A mutation to remove a Product/OrderLine relation (if remove.enabled is set to true).
    • NOTE This does not typically remove either record just removes the relation.

To set up the resolver you can use buildSchema or buildTypeDefsAndResolvers.

import { buildTypeDefsAndResolvers } from '@lib/type-graphql-query';
import { OrderLineDTO } from './order-line/order-line.type';
const { resolvers, typeDefs } = await buildTypeDefsAndResolvers({
resolvers: [
{
DTOClass: OrderLineDTO,
// ... other options
},
],
// ... other options
});

type-graphql-query will then automatically create the following graphql definition:

type OrderLine {
id: ID!
productId: String!
unitPrice: Float!
product: Product
}

The following mutations will also be automatically exposed (if enabled):

type Mutation {
setProductOnOrderLine(input: SetProductOnOrderLineInput!): OrderLine!
}
input SetProductOnOrderLineInput {
id: ID!
relationId: ID!
}

The @FilterableRelation extends the @Relation decorator exposing the ability to filter the DTO that defines the relation by relation properties.

The @FilterableRelation decorator will only work with relations defined by the ORM used (e.g. drizzle-orm). If your relations are federated you cannot use the @FilterableRelation decorator.

In this example we’ll use the same entities defined above to create a graphql endpoint that allows filtering OrderLines by Product properties.

import { ObjectType, ID } from '@lib/type-graphql';
import { FilterableRelation, IDField, EnhancedField } from '@lib/type-graphql-query';
import { Product } from '../product/product.type';
@ObjectType()
@FilterableRelation('product', () => Product, {
nullable: true,
})
export class OrderLine {
@IDField(() => ID)
id!: string;
@EnhancedField(() => String)
productId!: string;
@EnhancedField(() => Number)
unitPrice!: number;
}

Notice the use of @FilterableRelation instead of @Relation, by using the @FilterableRelation version type-graphql-query will allow filtering on the product relation.

The resolver definition remains the same.

Now you can filter OrderLines by Product properties:

query {
queryManyOrderLine(filter: { product: { name: { like: "%chicken%" } } }) {
id
unitPrice
product {
name
}
}
}

An array of relations (e.g, many-to-many, one-to-many) that returns all of the related records.

In this example we’ll add a lines relation to the Order DTO that returns all OrderLines for an Order.

import { ObjectType, ID } from '@lib/type-graphql';
import { UnPagedRelation, IDField, EnhancedField } from '@lib/type-graphql-query';
import { OrderLine } from '../order-line/order-line.type';
@ObjectType()
@UnPagedRelation('lines', () => OrderLine, {
nullable: true,
})
export class Order {
@IDField(() => ID)
id!: string;
@EnhancedField(() => String)
number!: string;
@EnhancedField(() => Number)
totalAmount!: number;
}

The @UnPagedRelation decorator will expose:

  • order.lines - A query to retrieve all OrderLines for an Order
  • addOrderLinesToOrder - A mutation to add OrderLines to an Order (if update.enabled is set to true)
  • setOrderLinesOnOrder - A mutation to set OrderLines on an Order (if update.enabled is set to true)
  • removeOrderLinesFromOrder - A mutation to remove OrderLines from an Order (if remove.enabled is set to true)

The GraphQL schema will include:

type Order {
id: ID!
number: String!
totalAmount: Float!
lines: [OrderLine!]
}

The @FilterableUnPagedRelation extends the @UnPagedRelation decorator exposing the ability to filter the DTO that defines the relation by relation properties.

import { ObjectType, ID } from '@lib/type-graphql';
import { FilterableUnPagedRelation, IDField, EnhancedField } from '@lib/type-graphql-query';
import { OrderLine } from '../order-line/order-line.type';
@ObjectType()
@FilterableUnPagedRelation('lines', () => OrderLine, {
nullable: true,
})
export class Order {
@IDField(() => ID)
id!: string;
@EnhancedField(() => String)
number!: string;
}

Now you can filter Orders by OrderLine properties:

query {
queryManyOrder(filter: { lines: { unitPrice: { gte: 100 } } }) {
id
number
lines {
id
unitPrice
}
}
}

A connection that represents a collection (e.g, many-to-many, one-to-many) that uses offset based pagination.

import { ObjectType, ID } from '@lib/type-graphql';
import { OffsetConnection, IDField, EnhancedField } from '@lib/type-graphql-query';
import { OrderLine } from '../order-line/order-line.type';
@ObjectType()
@OffsetConnection('lines', () => OrderLine, {
nullable: true,
enableTotalCount: true,
})
export class Order {
@IDField(() => ID)
id!: string;
@EnhancedField(() => String)
number!: string;
}

The @OffsetConnection decorator will expose:

  • order.lines - A query to retrieve paginated OrderLines for an Order using offset pagination
  • addOrderLinesToOrder - A mutation to add OrderLines to an Order (if update.enabled is set to true)
  • setOrderLinesOnOrder - A mutation to set OrderLines on an Order (if update.enabled is set to true)
  • removeOrderLinesFromOrder - A mutation to remove OrderLines from an Order (if remove.enabled is set to true)

The GraphQL schema will include:

type Order {
id: ID!
number: String!
lines(
paging: OffsetPaging
filter: OrderLineFilter
sorting: [OrderLineSort!]
): OrderOrderLineConnection
}
type OrderOrderLineConnection {
pageInfo: OffsetPageInfo!
nodes: [OrderLine!]!
totalCount: Int
}

To enable total count in the connection, set enableTotalCount: true:

@OffsetConnection('lines', () => OrderLine, {
enableTotalCount: true,
})

The @FilterableOffsetConnection extends the @OffsetConnection decorator exposing the ability to filter the DTO that defines the relation by relation properties.

import { ObjectType, ID } from '@lib/type-graphql';
import { FilterableOffsetConnection, IDField, EnhancedField } from '@lib/type-graphql-query';
import { OrderLine } from '../order-line/order-line.type';
@ObjectType()
@FilterableOffsetConnection('lines', () => OrderLine, {
enableTotalCount: true,
})
export class Order {
@IDField(() => ID)
id!: string;
}

A connection that represents a collection (e.g, many-to-many, one-to-many) that uses cursor based pagination.

import { ObjectType, ID } from '@lib/type-graphql';
import { CursorConnection, IDField, EnhancedField } from '@lib/type-graphql-query';
import { OrderLine } from '../order-line/order-line.type';
@ObjectType()
@CursorConnection('lines', () => OrderLine, {
nullable: true,
enableTotalCount: true,
})
export class Order {
@IDField(() => ID)
id!: string;
@EnhancedField(() => String)
number!: string;
}

The @CursorConnection decorator will expose:

  • order.lines - A query to retrieve paginated OrderLines for an Order using cursor pagination
  • addOrderLinesToOrder - A mutation to add OrderLines to an Order (if update.enabled is set to true)
  • setOrderLinesOnOrder - A mutation to set OrderLines on an Order (if update.enabled is set to true)
  • removeOrderLinesFromOrder - A mutation to remove OrderLines from an Order (if remove.enabled is set to true)

The GraphQL schema will include:

type Order {
id: ID!
number: String!
lines(
paging: CursorPaging
filter: OrderLineFilter
sorting: [OrderLineSort!]
): OrderOrderLineConnection
}
type OrderOrderLineConnection {
pageInfo: PageInfo!
edges: [OrderOrderLineEdge!]!
totalCount: Int
}
type OrderOrderLineEdge {
node: OrderLine!
cursor: ConnectionCursor!
}

To enable total count in the connection, set enableTotalCount: true:

@CursorConnection('lines', () => OrderLine, {
enableTotalCount: true,
})

The @FilterableCursorConnection extends the @CursorConnection decorator exposing the ability to filter the DTO that defines the relation by relation properties.

import { ObjectType, ID } from '@lib/type-graphql';
import { FilterableCursorConnection, IDField, EnhancedField } from '@lib/type-graphql-query';
import { OrderLine } from '../order-line/order-line.type';
@ObjectType()
@FilterableCursorConnection('lines', () => OrderLine, {
enableTotalCount: true,
})
export class Order {
@IDField(() => ID)
id!: string;
}

All relation decorators accept the following options:

Option Type Default Description
nullable boolean false Set to true if the relation is nullable
disableRead boolean false Disable read relation GraphQL endpoints
enableLookAhead boolean false Enable look ahead mode, will join and select the relation when queried (only works with ORM-defined relations)
description string - Description of the relation
relationName string Auto-generated The name of the relation to use when fetching from the QueryService
update ResolverRelationMethodOpts { enabled: false } Options for update mutations
remove ResolverRelationMethodOpts { enabled: false } Options for remove mutations
enableAggregate boolean false Enable aggregation queries
aggregate ResolverRelationMethodOpts - Options for aggregate queries
enableTotalCount boolean false Include totalCount in connection (only for connections)
middlewares Middleware[] [] Array of GraphQL middlewares to apply
decorators (PropertyDecorator | MethodDecorator)[] [] Array of additional decorators to apply
complexity Complexity - Complexity function for the field

For @OffsetConnection, @FilterableOffsetConnection, @CursorConnection, and @FilterableCursorConnection, you can also pass options from QueryArgsTypeOpts to configure the query behavior:

  • defaultResultSize - Default number of results
  • maxResultsSize - Maximum number of results
  • defaultSort - Default sort order
  • enableSort - Enable sorting
  • enableFilter - Enable filtering
  • defaultFilter - Default filter
  • allowedBooleanExpressions - Allowed boolean operators
  • filterDepth - Maximum depth for nested filters

Sometimes you may want to expose a relation that has a different name when persisted from the GraphQL property. To do this use the relationName property.

// Expose todoItem as productRelation in GraphQL
@Relation('productRelation', () => Product, {
relationName: 'product'
})
// Expose subTasks as orderLinesConnection in GraphQL
@CursorConnection('orderLinesConnection', () => OrderLine, {
relationName: 'lines'
})
// Expose subTasks as orderLinesConnection in GraphQL
@OffsetConnection('orderLinesConnection', () => OrderLine, {
relationName: 'lines'
})
// Expose subTasks as orderLines in GraphQL
@UnPagedRelation('orderLines', () => OrderLine, {
relationName: 'lines'
})

When you want to optimize your database queries you can set enableLookAhead, this will join and select the relation when it’s being fetched in the query.

// Enable enableLookAhead for the product relation
@Relation('product', () => Product, {
enableLookAhead: true
})

To disable the read queries you can set the disableRead option to true.

// Disable reading the product relation
@Relation('product', () => Product, {
disableRead: true
})
// Disable reading the connection
@CursorConnection('lines', () => OrderLine, {
disableRead: true
})
// Disable reading the relation
@OffsetConnection('lines', () => OrderLine, {
disableRead: true
})
// Disable reading the relation
@UnPagedRelation('lines', () => OrderLine, {
disableRead: true
})

To disable the filter or sorting of relations you can set the disableFilter or/and disableSort option to true.

// Disable filtering and sorting for the connection
@CursorConnection('lines', () => OrderLine, {
disableFilter: true,
disableSort: true
})
// Disable filtering and sorting for the relation
@OffsetConnection('lines', () => OrderLine, {
disableFilter: true,
disableSort: true
})
// Disable filtering and sorting for the relation
@UnPagedRelation('lines', () => OrderLine, {
disableFilter: true,
disableSort: true
})

To enable the update mutations you can set the update.enabled option to true.

// Enable updates to the relation
@Relation('product', () => Product, {
update: { enabled: true }
})
// Enable updating lines
@CursorConnection('lines', () => OrderLine, {
update: { enabled: true }
})
// Enable updating lines
@OffsetConnection('lines', () => OrderLine, {
update: { enabled: true }
})
// Enable updating lines
@UnPagedRelation('lines', () => OrderLine, {
update: { enabled: true }
})

To enable the remove mutations you can set the remove.enabled option to true.

// Enable removing the relation
@Relation('product', () => Product, {
remove: { enabled: true }
})
// Enable removing lines from the connection
@CursorConnection('lines', () => OrderLine, {
remove: { enabled: true }
})
// Enable removing lines from the connection
@OffsetConnection('lines', () => OrderLine, {
remove: { enabled: true }
})
// Enable removing lines from the relations
@UnPagedRelation('lines', () => OrderLine, {
remove: { enabled: true }
})

In this example we’ll demonstrate using a middleware but the same pattern applies for decorators.

To set up a middleware for an endpoint you can use the middlewares option.

Assume we have the following auth middleware:

import { MiddlewareFn } from '@lib/type-graphql';
import { MyContext } from './my-context';
export const AuthMiddleware: MiddlewareFn<MyContext> = async ({ context }, next) => {
if (!context.userId) {
throw new Error('Not authenticated');
}
return next();
};

We can then add it to our relations:

// Add the AuthMiddleware using the middlewares option
@Relation('product', () => Product, {
middlewares: [AuthMiddleware]
})
// Add the AuthMiddleware using the middlewares option
@CursorConnection('lines', () => OrderLine, {
middlewares: [AuthMiddleware]
})
// Add the AuthMiddleware using the middlewares option
@OffsetConnection('lines', () => OrderLine, {
middlewares: [AuthMiddleware]
})
// Add the AuthMiddleware using the middlewares option
@UnPagedRelation('lines', () => OrderLine, {
middlewares: [AuthMiddleware]
})

Now any requests that go to the update or remove endpoints will require the middleware.

If you are using the resolvers individually you can use the following mixin to add relations functionality.

When using The Relatable mixin adds all relations functionality to a resolver.

In this example we expose read endpoints for orders with the relations defined on the OrderDTO.

import { ReadResolver, Relatable } from '@lib/type-graphql-query';
import { Resolver } from '@lib/type-graphql';
import { OrderDTO } from './order/order.type';
import { OrderService } from './order/order.service';
@Resolver(() => OrderDTO)
export class OrderResolver extends Relatable(OrderDTO, {
enableTotalCount: true,
})(ReadResolver(OrderDTO)) {
constructor(readonly service: OrderService) {
super(service);
}
}

The Relatable mixin will automatically add:

  • Read relation endpoints (field resolvers)
  • Update relation endpoints (if update.enabled is set)
  • Remove relation endpoints (if remove.enabled is set)
  • Aggregate relation endpoints (if enableAggregate is set)
  • Reference relation endpoints (if @Reference decorators are used)

Here’s a complete example showing multiple relation types:

import { ObjectType, ID } from '@lib/type-graphql';
import {
Relation,
UnPagedRelation,
CursorConnection,
IDField,
EnhancedField,
} from '@lib/type-graphql-query';
import { Product } from './product.type';
import { OrderLine } from './order-line.type';
import { Category } from './category.type';
@ObjectType()
@Relation('organization', () => Organization, {
nullable: true,
})
@UnPagedRelation('images', () => ProductImage, {
nullable: true,
})
@UnPagedRelation('categories', () => Category, {
nullable: true,
})
export class Product {
@IDField(() => ID)
id!: string;
@EnhancedField(() => String)
name!: string;
@EnhancedField(() => String, { nullable: true })
organizationId!: string;
}
  1. Use the Right Relation Type

    • Use @Relation for single-value relations (one-to-one, many-to-one)
    • Use @UnPagedRelation for small collections that don’t need pagination
    • Use @OffsetConnection or @CursorConnection for large collections
  2. Enable Filtering When Needed

    • Use @FilterableRelation, @FilterableUnPagedRelation, etc. when you need to filter by relation properties
    • Only works with ORM-defined relations
  3. Optimize with Lookahead

    • Enable enableLookAhead for frequently accessed relations to reduce N+1 queries
    • Only works with ORM-defined relations
  4. Control Mutations

    • Only enable update and remove mutations when needed
    • Use middlewares and decorators to secure mutation endpoints
  5. Use Total Count Sparingly

    • Enable enableTotalCount only when needed as it can impact performance
    • Consider caching total count results for large datasets
Decorator Use Case Pagination Filtering
@Relation Single value (one-to-one, many-to-one) N/A No
@FilterableRelation Single value with filtering N/A Yes
@UnPagedRelation Small collections No No
@FilterableUnPagedRelation Small collections with filtering No Yes
@OffsetConnection Large collections Offset No
@FilterableOffsetConnection Large collections with filtering Offset Yes
@CursorConnection Large collections Cursor No
@FilterableCursorConnection Large collections with filtering Cursor Yes

When using upsertOne mutations, you can manage relations automatically using the RelationService. This allows you to create, update, and delete related records in a single operation.

The system supports two types of relations:

  1. “One” relations (many-to-one, one-to-one): Stored via a foreign key in the main table

    • Example: Organization.logo → updates logoFileId in the organization table
  2. “Many” relations (one-to-many, many-to-many): Stored in a separate relation table

    • Example: Organization.memberships → manages the organizationMembership table

You can configure relation management using decorator options:

For “One” Relations (@Relation, @FilterableRelation)

Section intitulée « For “One” Relations (@Relation, @FilterableRelation) »
@Relation('logo', () => File, {
nullable: true,
// RelationService options
foreignKeyFieldName: 'logoFileId', // Optional: auto-detected if not provided
primaryKeyFieldName: 'id' // Optional: defaults to 'id'
})

For “Many” Relations (@UnPagedRelation, @OffsetConnection, etc.)

Section intitulée « For “Many” Relations (@UnPagedRelation, @OffsetConnection, etc.) »
@UnPagedRelation('memberships', () => OrganizationMembership, {
nullable: true,
// RelationService options
preserveExisting: false, // If true, preserves existing records not in input
conflictColumns: ['userId', 'organizationId'], // Optional: auto-detected if not provided
foreignKeyFieldName: 'organizationId', // Optional: auto-detected if not provided
primaryKeyFieldName: 'id' // Optional: defaults to 'id'
})

If options are not provided, the system automatically detects:

  • relationType: Determined by checking if relation fields exist in the main table
  • foreignKeyFieldName: Extracted from Drizzle relation metadata
  • conflictColumns: For “many” relations, identifies foreign key columns in the relation table

No action is taken. The relation is not modified.

  • If preserveExisting: false: Deletes all relations
  • If preserveExisting: true: Does nothing (preserves existing relations)
  • Create: Items that don’t exist yet are created
  • Update: Existing items are updated if their values have changed
  • Delete: Items that are no longer in the input are deleted (unless preserveExisting: true)
@ObjectType()
@Relation('logo', () => File, {
nullable: true,
foreignKeyFieldName: 'logoFileId',
})
export class Organization {
@IDField(() => ID)
id!: string;
@EnhancedField(() => String)
name!: string;
}

When upserting:

// Set logo
await upsertOneOrganization({
input: {
id: 'org-123',
name: 'My Organization',
logo: { id: 'file-456' },
},
});
// Remove logo
await upsertOneOrganization({
input: {
id: 'org-123',
name: 'My Organization',
logo: null,
},
});

Behavior:

  • If logo: { id: 'file-456' } → Updates logoFileId = 'file-456' in organization
  • If logo: null → Updates logoFileId = null in organization
  • If logo: undefined → Does not modify logoFileId

Example 2: “Many” Relation - Organization.memberships

Section intitulée « Example 2: “Many” Relation - Organization.memberships »
@ObjectType()
@UnPagedRelation('memberships', () => OrganizationMembership, {
nullable: true,
preserveExisting: false,
conflictColumns: ['userId', 'organizationId'],
foreignKeyFieldName: 'organizationId',
})
export class Organization {
@IDField(() => ID)
id!: string;
@EnhancedField(() => String)
name!: string;
}

When upserting:

await upsertOneOrganization({
input: {
id: 'org-123',
name: 'My Organization',
memberships: [
{ userId: 'user-1', type: 'OWNER' },
{ userId: 'user-2', type: 'MEMBER' },
],
},
});

Behavior:

  • Creates memberships that don’t exist yet
  • Updates existing memberships if their values have changed
  • Deletes memberships that are no longer in the input

Example 3: “Many” Relation with preserveExisting - Product.translations

Section intitulée « Example 3: “Many” Relation with preserveExisting - Product.translations »
@ObjectType()
@UnPagedRelation('translations', () => ProductTranslation, {
nullable: true,
preserveExisting: true, // Don't delete other locales
conflictColumns: ['locale', 'productId'],
foreignKeyFieldName: 'productId',
})
export class Product {
@IDField(() => ID)
id!: string;
@EnhancedField(() => String)
name!: string;
}

When upserting:

await upsertOneProduct({
input: {
id: 'product-123',
name: 'My Product',
translations: [{ locale: 'fr', name: 'Mon Produit' }],
},
});

Behavior:

  • Creates or updates the translation for fr
  • Does not delete translations for en, es, etc. (because preserveExisting: true)

The service automatically manages metadata according to TableDescription configuration:

  • Creation: createdAt, createdBy (if configured)
  • Update: updatedAt, updatedBy (if configured)
  • Deletion: deletedAt, deletedBy (if soft delete configured)
  1. Operation Order: Relations are managed after the main table upsert, within the same transaction.

  2. Transactions: All operations are performed in the same transaction, ensuring data consistency.

  3. Soft Delete: If the relation table has soft delete configured, deletions use deletedAt/deletedBy instead of hard delete.

  4. Performance: For “many” relations with many items, consider optimizing with bulk operations if necessary.

  5. Validation: The service does not validate relation data. Ensure data is valid before calling.

  6. “One” Relations Pre-processing: For “one” relations, the related record is upserted before the main record to obtain the foreign key ID.

Continue learning:

  1. 🔧 Resolvers - Learn about resolver implementation
  2. 🔄 DataLoaders - Optimize data loading
  3. 🔍 Queries - Understand query endpoints
  4. ✏️ Mutations - Learn about mutation endpoints