Aller au contenu

ContextFilter

The @ContextFilter() decorator allows you to automatically apply filters to all queries and mutations for a specific DTO class. This is useful for implementing row-level security, multi-tenancy, or automatic filtering based on user context.

This guide will help you understand:

  • ✅ How to use the @ContextFilter() decorator
  • ✅ How to access context information in filter functions
  • ✅ How to apply different filters based on operation type
  • ✅ How to return Filter objects or raw SQL strings
  • ✅ How to implement multi-tenant filtering
  • ✅ Best practices for context filtering

The @ContextFilter() decorator automatically combines with user-provided filters using AND logic. This means:

  1. The filter from @ContextFilter() is always applied
  2. User-provided filters are combined with the automatic filter
  3. Both filters must be satisfied for a record to be returned or modified

Apply a filter function directly to a DTO class:

import { ObjectType, Field } from '@lib/type-graphql';
import { ContextFilter, OperationContext } from '@lib/type-graphql-query';
@ObjectType()
@ContextFilter<Product>((ctx: OperationContext) => {
// Automatically filter to only show products owned by the current user
// Access ExecutionContext properties directly from resolverData.context
return {
userId: { eq: ctx.resolverData.context.userId },
};
})
export class Product {
@Field()
id!: string;
@Field()
name!: string;
@Field()
userId!: string;
}

The OperationContext type provides comprehensive information about the current operation:

  • resolverData - GraphQL resolver data including the GraphQL context
    • resolverData.context - The execution context (extends ExecutionContext) with user information, workspace, locale, etc.
    • You can access userId, locale, workspaceId, and other ExecutionContext properties directly from resolverData.context
  • operationType - Type of operation: 'READ', 'CREATE', 'UPDATE', 'DELETE', or 'UPSERT'
  • operationName - Name of the GraphQL operation (e.g., 'products', 'createOneProduct', 'updateManyProducts')
  • many - Whether this is an operation for multiple items (true) or a single item (false)

You can apply different filters based on the operation type:

import { ObjectType, Field } from '@lib/type-graphql';
import { ContextFilter, OperationContext } from '@lib/type-graphql-query';
@ObjectType()
@ContextFilter<Product>((ctx: OperationContext) => {
const userId = ctx.resolverData.context.userId;
// For read operations, show only user's own products
if (ctx.operationType === 'READ') {
return {
userId: { eq: userId },
};
}
// For mutations, allow access to products the user can modify
if (['CREATE', 'UPDATE', 'DELETE', 'UPSERT'].includes(ctx.operationType)) {
return {
or: [{ userId: { eq: userId } }, { sharedWith: { in: [userId] } }],
};
}
return undefined;
})
export class Product {
@Field()
id!: string;
@Field()
name!: string;
@Field()
userId!: string;
}

When using @ContextFilter<DTO>((ctx: OperationContext) => ...), TypeScript will automatically infer the extended ExecutionContext type from module augmentation. This means you can access extended properties like platformRole, workspaceId, etc. without explicit type annotations:

import { ObjectType, Field } from '@lib/type-graphql';
import { ContextFilter, OperationContext } from '@lib/type-graphql-query';
@ObjectType()
@ContextFilter<Product>((ctx: OperationContext) => {
// TypeScript automatically infers the extended ExecutionContext type
// Access ExecutionContext properties directly from resolverData.context
const userId = ctx.resolverData.context.userId;
const workspaceId = ctx.resolverData.context.workspaceId;
const platformRole = ctx.resolverData.context.platformRole;
return {
userId: { eq: userId },
workspaceId: { eq: workspaceId },
};
})
export class Product {
@Field()
id!: string;
@Field()
name!: string;
}

For more complex filtering logic, you can create a class that implements CustomContextFilter:

import { ObjectType, Field } from '@lib/type-graphql';
import { ContextFilter, CustomContextFilter, Operation } from '@lib/type-graphql-query';
import type { Filter } from '@lib/type-graphql-query';
class ProductContextFilter implements CustomContextFilter<Product, MyContext> {
filter(ctx: OperationContext<MyContext>): Filter<Product> | undefined {
const user = ctx.resolverData.context.user;
if (!user) {
// No user, return empty result filter
return { id: { eq: null } }; // Will return no results
}
// Admin users can see all products
if (user.roles.includes('admin')) {
return undefined; // No filter
}
// Regular users can only see their own products
return {
userId: { eq: user.id },
};
}
}
@ObjectType()
@ContextFilter(new ProductContextFilter())
export class Product {
@Field()
id!: string;
@Field()
name!: string;
@Field()
userId!: string;
}

The filter method can return:

  • Filter<DTO> - A filter object that will be applied to the query/mutation
  • string - A raw SQL filter string that will be passed as rawFilter to QueryService methods (advanced usage)
  • undefined - No filter will be applied
  • null - No filter will be applied

When you return a string from the filter method, it will be passed as the rawFilter parameter to all QueryService methods (query, findById, createOne, updateOne, deleteOne, etc.). This allows you to use raw SQL expressions for complex filtering scenarios.

If you’re using Drizzle ORM, you can use sql.raw() to create raw SQL expressions:

import { ObjectType, Field } from '@lib/type-graphql';
import { ContextFilter, OperationContext } from '@lib/type-graphql-query';
import { sql } from 'drizzle-orm';
@ObjectType()
@ContextFilter<Product>((ctx: OperationContext) => {
const userId = ctx.resolverData.context.userId;
// Return a raw SQL filter string
// This will be passed as rawFilter to QueryService methods
return sql`user_id = ${userId} AND status = 'active'`.toQuery().sql;
})
export class Product {
@Field()
id!: string;
@Field()
name!: string;
@Field()
userId!: string;
@Field()
status!: string;
}

For more complex scenarios, you can build raw SQL filters dynamically:

import { ObjectType, Field } from '@lib/type-graphql';
import { ContextFilter, OperationContext } from '@lib/type-graphql-query';
import { sql } from 'drizzle-orm';
@ObjectType()
@ContextFilter<Product>((ctx: OperationContext) => {
const userId = ctx.resolverData.context.userId;
const workspaceId = ctx.resolverData.context.workspaceId;
// Build a complex raw SQL filter
if (ctx.operationType === 'READ') {
// Users can read products they own or products shared with them
return sql`
(user_id = ${userId} OR workspace_id = ${workspaceId})
AND deleted_at IS NULL
`.toQuery().sql;
}
// For mutations, use a simpler filter
return sql`user_id = ${userId}`.toQuery().sql;
})
export class Product {
@Field()
id!: string;
@Field()
name!: string;
@Field()
userId!: string;
@Field()
workspaceId!: string;
}
@ContextFilter<Product>((ctx: OperationContext) => {
// Return a filter object (most common)
if (ctx.operationType === 'READ') {
return {
userId: { eq: ctx.resolverData.context.userId }
};
}
// Return a raw SQL string for complex filtering
if (ctx.operationType === 'UPDATE') {
return sql`user_id = ${ctx.resolverData.context.userId} AND status != 'archived'`.toQuery().sql;
}
// Return undefined to apply no filter
if (ctx.resolverData.context.user?.isAdmin) {
return undefined;
}
// Return null (same as undefined)
return null;
})

Automatically filter by workspace for multi-tenant applications:

import { ObjectType, Field } from '@lib/type-graphql';
import { ContextFilter, OperationContext } from '@lib/type-graphql-query';
@ObjectType()
@ContextFilter<Order>((ctx: OperationContext) => {
const workspaceId = ctx.resolverData.context.workspaceId;
// Automatically filter by workspace for all operations
if (workspaceId) {
return {
workspaceId: { eq: workspaceId },
};
}
// No workspace context, return no results
return { id: { eq: null } };
})
export class Order {
@Field()
id!: string;
@Field()
workspaceId!: string;
}

Automatically exclude soft-deleted items:

import { ObjectType, Field } from '@lib/type-graphql';
import { ContextFilter, OperationContext } from '@lib/type-graphql-query';
@ObjectType()
@ContextFilter<Product>((ctx: OperationContext) => {
// Automatically exclude soft-deleted items for read operations
// The withDeleted flag in the query will override this
if (ctx.operationType === 'READ') {
return {
deletedAt: { is: null },
};
}
// For mutations, allow access to soft-deleted items
return undefined;
})
export class Product {
@Field()
id!: string;
@Field()
name!: string;
@Field({ nullable: true })
deletedAt?: Date;
}

Apply different filters based on the operation:

import { ObjectType, Field } from '@lib/type-graphql';
import { ContextFilter, OperationContext } from '@lib/type-graphql-query';
@ObjectType()
@ContextFilter<Product>((ctx: OperationContext) => {
const userId = ctx.resolverData.context.userId;
switch (ctx.operationType) {
case 'READ':
// Users can read their own products and public products
return {
or: [{ userId: { eq: userId } }, { isPublic: { eq: true } }],
};
case 'CREATE':
// Users can only create products for themselves
return {
userId: { eq: userId },
};
case 'UPDATE':
case 'DELETE':
// Users can only modify their own products
return {
userId: { eq: userId },
};
case 'UPSERT':
// Users can upsert their own products
return {
userId: { eq: userId },
};
default:
return undefined;
}
})
export class Product {
@Field()
id!: string;
@Field()
name!: string;
@Field()
userId!: string;
@Field()
isPublic!: boolean;
}

Differentiate between single and multiple item operations:

import { ObjectType, Field } from '@lib/type-graphql';
import { ContextFilter, OperationContext } from '@lib/type-graphql-query';
@ObjectType()
@ContextFilter<Product>((ctx: OperationContext) => {
const userId = ctx.resolverData.context.userId;
// For single item operations, allow access to own items
if (!ctx.many) {
return {
userId: { eq: userId },
};
}
// For multiple item operations, apply additional restrictions
return {
and: [{ userId: { eq: userId } }, { status: { neq: 'archived' } }],
};
})
export class Product {
@Field()
id!: string;
@Field()
name!: string;
@Field()
userId!: string;
@Field()
status!: string;
}

The @ContextFilter() automatically combines with user-provided filters using AND logic:

# User query
query {
products(filter: { name: { like: "Egg%" } }) {
id
name
}
}

The actual filter applied will be:

{
and: [
{ userId: { eq: currentUserId } }, // From @ContextFilter
{ name: { like: 'Egg%' } }, // From user query
];
}

The TContextType must extend ExecutionContext to ensure type safety. The context is accessible directly via resolverData.context without needing a separate executionContext parameter.

When using module augmentation to extend ExecutionContext, TypeScript will automatically infer the extended type:

// In your execution-context.d.ts file
declare module '@lib/type-graphql-query' {
interface ExecutionContext {
workspaceId?: string | null;
platformRole?: string;
}
}
// In your DTO file
@ContextFilter<Product>((ctx: OperationContext) => {
// TypeScript automatically knows about extended ExecutionContext properties
const userId = ctx.resolverData.context.userId; // ✅ Type-safe
const workspaceId = ctx.resolverData.context.workspaceId; // ✅ Type-safe
const platformRole = ctx.resolverData.context.platformRole; // ✅ Type-safe
return {
userId: { eq: userId },
workspaceId: { eq: workspaceId },
};
})
export class Product {
// ...
}
  1. Always specify the DTO type in the decorator - Use @ContextFilter<YourDTO>((ctx: OperationContext) => ...) for better type inference
  2. Annotate the context parameter - Use (ctx: OperationContext) to ensure TypeScript recognizes extended ExecutionContext properties
  3. Return undefined or null for no filter - This is clearer than returning an empty object
  4. Use operation-specific logic - Different operations may need different filters
  5. Consider performance - Complex filters can impact query performance
  6. Test with different contexts - Ensure filters work correctly for different user roles and scenarios
  7. Prefer Filter objects over raw SQL - Use Filter<DTO> objects when possible, as they are type-safe and database-agnostic
  8. Use raw SQL filters carefully - When using string return type for rawFilter:
    • Always use parameterized queries (e.g., sql.raw() with Drizzle)
    • Never concatenate user input directly into SQL strings
    • Test thoroughly to ensure the raw SQL works with your QueryService implementation
    • Consider the database-specific SQL syntax if you support multiple databases
Return Type Description Use Case
Filter<DTO> Filter object Standard filtering (recommended)
string Raw SQL filter Complex filtering scenarios
undefined No filter Allow all records
null No filter Allow all records

Continue learning:

  1. 🔍 Queries - Query operations
  2. ✏️ Mutations - Mutation operations
  3. 🔐 Authorization - Authorization and permissions
  4. 🔗 Relations - Entity relationships