@nestjs!/graphql-filter
v1.0.8
Published
GraphQL tools for Nest framework that gives a set of decorators and pipe transforms for make filtering, ordering, pagination and selection inputs easily for use with a ORM.
Downloads
42
Readme
Description
This is an unofficial Nest package that gives a set of decorators for facilitate the filtering, ordering, pagination and selection tasks when using GraphQL with a ORM.
The package includes decorators @FilterField
, @FilterWhereType
and @FilterOrderType
for make Where and Order input types for GraphQL from entities definitions, and then they correspondent pipe transforms for convert the inputs to valid TypeORM repository find method parameters. Also there's included a @SelectionSet
decorator, that can be used in resolver arguments, which returns a defined class object that gives methods for obtain the relationships that can be used in the TypeORM repository find method, or for authorization guards. As well is provided a PaginationInput
type that gives a simple input type structure for receive pagination parameters.
Installation
$ npm install @nestjs!/graphql-filter
Usage
For explain the usage of the package utilities we explore a example with User, Profile, Session and Device entities.
Code First
First decorate the entities attributes you want to be filters with @FilterField
decorator. And then, export Where and Order input classes with the @FilterWhereType
and @FilterOrderType
decorators referencing to the entity class:
Entities
user.entity.ts
import {
Field,
InputType,
IntersectionType,
ObjectType,
OmitType,
PartialType,
PickType,
registerEnumType
} from '@nestjs/graphql';
import { FilterField, FilterOrderType, FilterWhereType } from '@nestjs!/graphql-filter';
import { IsEmail, MaxLength, MinLength } from 'class-validator';
import { GraphQLEmailAddress, GraphQLUUID } from 'graphql-scalars';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn
} from 'typeorm';
import { Profile, ProfileOrderInput, ProfileWhereInput } from './profile.entity';
import { Session, SessionOrderInput, SessionWhereInput } from 'src/sessions/entities/session.entity';
export enum Role {
USER = 'user',
ADMIN = 'admin'
}
registerEnumType(Role, {
name: 'Role',
description: 'Defines wich permissions user has.',
valuesMap: {
USER: {
description: 'User role can access to application basic features.'
},
ADMIN: {
description: 'Admin role can access to all application features.'
}
}
});
@ObjectType()
@InputType('UserInput', { isAbstract: true })
@Entity()
export class User {
@Field(() => GraphQLUUID)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@FilterField()
@Column()
@Unique(['username'])
@MinLength(4)
@MaxLength(30)
username: string;
@Field(() => GraphQLEmailAddress)
@FilterField()
@Column()
@Unique(['email'])
@IsEmail()
@MaxLength(100)
email: string;
@Field()
@Column()
@MinLength(8)
@MaxLength(100)
password: string;
@Field(() => Role, { nullable: true })
@FilterField()
@Column({ type: 'enum', enum: Role, default: Role.USER })
role: Role;
@Field(() => Profile, { nullable: true })
@FilterField(() => ProfileWhereInput, () => ProfileOrderInput)
@Column(() => Profile)
profile: Profile;
@Field(() => [Session], { nullable: true })
@FilterField(() => SessionWhereInput, () => SessionOrderInput)
@OneToMany(() => Session, (session) => session.user, { cascade: true })
sessions: Session[];
}
@InputType()
export class UserCreateInput extends OmitType(
User,
['id', 'createdAt', 'updatedAt', 'deletedAt', 'sessions'],
InputType
) {}
@InputType()
export class UserUpdateInput extends IntersectionType(
PartialType(UserCreateInput),
PickType(User, ['id'], InputType)
) {}
@FilterWhereType(User)
export class UserWhereInput {}
@FilterOrderType(User)
export class UserOrderInput {}
profile.entity.ts
...
import { FilterField, FilterOrderType, FilterWhereType, FloatWhereInput, IntWhereInput } from '@nestjs!/graphql-filter';
@ObjectType()
@InputType('ProfileInput')
export class Profile {
@Field({ nullable: true })
@FilterField()
@Column({ nullable: true })
name: string;
@Field({ nullable: true })
@FilterField()
@Column({ nullable: true })
bio: string;
@Field(() => GraphQLInt)
@FilterField(() => IntWhereInput)
age: number
@Field(() => GraphQLFloat)
@FilterField(() => FloatWhereInput)
height: number
}
@FilterWhereType(Profile)
export class ProfileWhereInput {}
@FilterOrderType(Profile)
export class ProfileOrderInput {}
session.entity.ts
...
import { FilterField, FilterOrderType, FilterWhereType } from '@nestjs!/graphql-filter';
import { Device, DeviceOrderInput, DeviceWhereInput } from './device.entity';
import { User, UserOrderInput, UserWhereInput } from 'src/users/entities/user.entity';
@ObjectType()
@InputType('SessionInput', { isAbstract: true })
@Entity()
export class Session {
@Field(() => GraphQLUUID)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field(() => User, { nullable: true })
@FilterField(() => UserWhereInput, () => UserOrderInput)
@ManyToOne(() => User, (user) => user.sessions)
user: User;
@Field()
@FilterField()
@Column()
token: string;
@Field(() => Device, { nullable: true })
@FilterField(() => DeviceWhereInput, () => DeviceOrderInput)
@Column(() => Device)
device: Device;
}
@FilterWhereType(Session)
export class SessionWhereInput {}
@FilterOrderType(Session)
export class SessionOrderInput {}
device.entity.ts
...
import { FilterField, FilterOrderType, FilterWhereType } from '@nestjs!/graphql-filter';
@ObjectType()
@InputType('DeviceInput')
export class Device {
@Field({ nullable: true })
@FilterField()
@Column({ nullable: true })
client: string;
@Field({ nullable: true })
@FilterField()
@Column({ nullable: true })
os: string;
@Field({ nullable: true })
@FilterField()
@Column({ nullable: true })
ip: string;
}
@FilterWhereType(Device)
export class DeviceWhereInput {}
@FilterOrderType(Device)
export class DeviceOrderInput {}
Resolvers
Now in the entities resolvers we can define a findMany method that receives the correspondent filtering, ordering, pagination and selection parameters and apply the pipe transforms for get, in this case, the TypeORM formatted parameters for use with the repository find method. For example for the Users resolver:
users.resolver.ts
...
import {
PaginationInput,
SelectionInput,
SelectionSet,
TypeORMOrderTransform,
TypeORMWhereTransform
} from '@nestjs!/graphql-filter';
import { FindOptionsOrder, FindOptionsWhere } from 'typeorm';
import { User, UserCreateInput, UserOrderInput, UserUpdateInput, UserWhereInput } from './entities/user.entity';
@Resolver(() => User)
export class UsersResolver {
constructor(private readonly usersService: UsersService) {}
...
@Query(() => [User], { name: 'users', nullable: 'items' })
async findMany(
@Args('where', { type: () => [UserWhereInput], nullable: true }, TypeORMWhereTransform<User>)
where: FindOptionsWhere<User>,
@Args('order', { type: () => [UserOrderInput], nullable: true }, TypeORMOrderTransform<User>)
order: FindOptionsOrder<User>,
@Args('pagination', { nullable: true }) pagination: PaginationInput,
@SelectionSet() selection: SelectionInput
) {
return await this.usersService.findMany( where, order, pagination, selection );
}
}
Services
Last step is define the correspondent services using the transformed parameters with the entity repository provided by TypeORM. For example for the Users service:
users.service.ts
...
import { PaginationInput, SelectionInput } from '@nestjs!/graphql-filter';
import { FindOptionsOrder, FindOptionsWhere, Repository } from 'typeorm';
import { User, UserCreateInput, UserUpdateInput } from './entities/user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>
) {}
...
async findMany(
where: FindOptionsWhere<User>,
order: FindOptionsOrder<User>,
pagination: PaginationInput,
selection: SelectionInput
) {
return await this.usersRepository.find({
relations: selection?.getTypeORMRelations(),
where: where,
order: order,
skip: pagination ? (pagination.page - 1) * pagination?.count : null,
take: pagination ? pagination.count : null
});
}
}
Schema
With decorators defined the package will generate the correspondent GraphQL basic filtering, ordering and pagination input types and the specific entities input types at compilation time with the rest of types.
For example for User entity it will generate:
schema.gql
input UserWhereInput {
email: StringWhereInput
profile: ProfileWhereInput
role: StringWhereInput
username: StringWhereInput
sessions: SessionWhereInput
}
input UserOrderInput {
email: OrderDirection
profile: ProfileOrderInput
role: OrderDirection
username: OrderDirection
sessions: SessionOrderInput
}
Query
Now we can execute our Nest project and make GraphQL queries with the filtering, ordering and pagination parameters defined. For example for the defined users query:
QUERY
query Users($where: [UserWhereInput!], $order: [UserOrderInput!], $pagination: PaginationInput) {
users(where: $where, order: $order, pagination: $pagination) {
id
username
email
role
profile {
name
bio
age
height
}
sessions {
id
token
device {
client
os
ip
}
}
}
}
GRAPHQL VARIABLES
{
"where": [
{
"username": {
"or": [
{
"ne": "something"
},
{
"like": "%some%"
}
]
},
"profile": {
"name": {
"ilike": "Georgy Something"
}
},
"sessions": {
"device": {
"client": {
"like": "%Postman%"
}
}
}
},
{
"role": {
"eq": "admin"
},
"email": {
"and": [
{
"in": ["[email protected]", "[email protected]"]
},
{
"not": {
"any": ["[email protected]"]
}
}
]
}
}
],
"order": [
{
"username": "ASC"
},
{
"email": "DESC"
},
{
"profile": {
"name": "ASC"
}
}
],
"pagination": {
"page": 2,
"count": 10
}
}
Here we can note that the "where" variable can be an array or a object, meaning that it's an OR clause or AND clause respectively.
For "order" variable something similar happen, we can define the variable as an array or object, but if it's a object we can't ensure the priority of the ordering parameters.
Utils
Many
When we want to create a findMany
query where you want to return the result set aside with the total count
of elements (which is a pretty common and efficient way of query the data), you have to create a custom ObjectType that allow send the data set along with the count number. Here is where the @Many
decorator appears to automatize this task:
session.entity
@ObjectType()
// ...
export class Session {
// ...
}
// ...
@Many(Session)
export class Sessions {}
This decorated class will generate the next ObjectType in the schema:
type Sessions {
sessions: [Session!]
count: Int!
}
Later our resolver can use the Sessions
class for reference to this type:
sessions.resolver.ts
import { Sessions } from './entities/session.entity';
@Resolver(() => Session)
export class SessionsResolver {
//...
// we use 'Sessions' class as return type
@Query(() => Sessions, { name: 'sessions' })
async findMany(
...,
// remember use the 'root' parameter for make SelectionInput using the set of sessions
@SelectionSet({ root: 'sessions' }) selection: SelectionInput
) {
return await this.sessionsService.findMany(..., selection);
}
}
And finally the service function will be like this:
sessions.service.ts
async findMany(
where: FindOptionsWhere<Session>,
order: FindOptionsOrder<Session>,
pagination: PaginationInput,
selection: SelectionInput
) {
// we use 'findAndCount' method instead of 'find'
const [sessions, count] = await this.sessionsRepository.findAndCount({
relations: selection?.getTypeORMRelations(),
where: where,
order: order,
skip: pagination ? (pagination.page - 1) * pagination.count : null,
take: pagination ? pagination.count : null
});
return { sessions, count };
}
SelectionSet
When using the @SelectionSet
decorator you will get a SelectionInput
object which is obtained by processing the GraphQL context info and organized for easily accessing the selection.
From this object you have the next methods at disposition:
getAttributes()
: returns a string array with only the entity attributes selected, it means that relationships are not included. For example, for the query of the next section we get:
["id", "username", "email", "role"]
getFullAttributes()
: returns a string array with all the entity attributes selected and the relationships ones. For example, for the query of the next section we get:
[
"id", "username", "email", "role", "profile.name", "profile.bio", "profile.age", "profile.height", "sessions.id", "sessions.token", "sessions.device.client", "sessions.device.os", "sessions.device.ip"
]
getRelations(include?)
: returns a object formatted like TypeORM repository find relations parameter. Also some extra relations can be added using include parameter with a valid TypeORM relations object. For example, for the query of the next section we get:
{
profile: true,
sessions: true
}
Note: it also return nested relations (if there nested subfields).
options
You can use the root
option for define the initial node from where the SelectionInput will be generated. For example:
If you have this query:
query Sessions {
sessions {
collections {
results {
id
objects {
id
...
}
...
}
}
count
}
}
And you want to receive only the data from results
for compute the relations, fields, etc of the SelectionInput. You can use this on resolver:
@Query(() => Sessions, { name: 'sessions' })
async findMany(
...,
@SelectionSet({ root: 'collections.results' }) selection: SelectionInput
) {
return await this.sessionsService.findMany(..., selection);
}
AuthUser
When using the @AuthUser
decorator you will get a object which is obtained from the GraphQL context request. You can also send a string parameter with the name you use for store the authenticated user on the request. For example:
sessions.resolver.ts
@Query(() => [Session], { name: 'sessions', nullable: 'items' })
async findMany(
...,
@AuthUser('user') authUser: User
) {
return await this.sessionsService.findMany(..., authUser);
}
This is pretty much usefull for when use findMany queries where the set of data is returned along the count number.
Owner
This util function permits modify the TypeORM FindOptionsWhere for adding checks of ownership with an authenticated user and defined roles. For example if we want to return only the sessions that are owned by the authenticated user:
sessions.resolver.ts
@Query(() => Session, { name: 'session', nullable: true })
async findOne(
@Args('id', { type: () => GraphQLUUID }) id: string,
@SelectionSet() selection: SelectionInput,
@AuthUser() authUser: User
) {
return await this.sessionsService.findOne(id, selection, authUser);
}
@Query(() => [Session], { name: 'sessions', nullable: 'items' })
async findMany(
@Args('where', { type: () => [SessionWhereInput], nullable: true }, TypeORMWhereTransform<Session>)
where: FindOptionsWhere<Session>,
@Args('order', { type: () => [SessionOrderInput], nullable: true }, TypeORMOrderTransform<Session>)
order: FindOptionsOrder<Session>,
@Args('pagination', { nullable: true }) pagination: PaginationInput,
@SelectionSet() selection: SelectionInput,
@AuthUser() authUser: User
) {
return await this.sessionsService.findMany(where, order, pagination, selection, authUser);
}
Note: you previously have to add user to the context request with your own authentication guard.
sessions.service.ts
async findOne(id: string, selection: SelectionInput, authUser: User) {
return await this.sessionsRepository.findOne({
relations: selection?.getTypeORMRelations(),
where: Owner({ id: id }, 'user.id', authUser, [Role.ADMIN])
});
}
async findMany(
where: FindOptionsWhere<Session>,
order: FindOptionsOrder<Session>,
pagination: PaginationInput,
selection: SelectionInput,
authUser: User
) {
return await this.sessionsRepository.find({
relations: selection?.getTypeORMRelations(),
where: Owner(where, 'user.id', authUser, [Role.ADMIN]),
order: order,
skip: pagination ? (pagination.page - 1) * pagination.count : null,
take: pagination ? pagination.count : null
});
}
Here users with role "admin" can bypass the ownership check so the where option is not modified for them. You can put all the roles you want to bypass in that parameter.
There's also a fifth parameter that allow to modify the acceded authUser idField and roleField to be different as default ones ('id' and 'role').
InputTypes Reference
StringWhereInput
input StringWhereInput {
eq: String
ne: String
like: String
ilike: String
in: [String!]
any: [String!]
and: [StringWhereInput!]
or: [StringWhereInput!]
not: StringWhereInput
}
IntWhereInput
input IntWhereInput {
eq: Int
ne: Int
gt: Int
gte: Int
lt: Int
lte: Int
in: [Int!]
any: [Int!]
between: [Int!]
and: [IntWhereInput!]
or: [IntWhereInput!]
not: IntWhereInput
}
FloatWhereInput
input FloatWhereInput {
eq: Float
ne: Float
gt: Float
gte: Float
lt: Float
lte: Float
in: [Float!]
any: [Float!]
between: [Float!]
and: [FloatWhereInput!]
or: [FloatWhereInput!]
not: FloatWhereInput
}
DateTimeWhereInput
input DateTimeWhereInput {
eq: DateTime
ne: DateTime
gt: DateTime
gte: DateTime
lt: DateTime
lte: DateTime
in: [DateTime!]
any: [DateTime!]
between: [DateTime!]
and: [DateTimeWhereInput!]
or: [DateTimeWhereInput!]
not: DateTimeWhereInput
}
BooleanWhereInput
input BooleanWhereInput {
eq: Boolean
ne: Boolean
and: [BooleanWhereInput!]
or: [BooleanWhereInput!]
not: BooleanWhereInput
}
PaginationInput
input PaginationInput {
count: Int!
page: Int!
}
OrderDirection
"""
Defines the order direction.
"""
enum OrderDirection {
"""
Ascending order.
"""
ASC
"""
Descending order.
"""
DESC
}
Stay in touch
Me
- Author - Giuliano Marinelli
- Website - https://github.com/giuliano-marinelli
Nest
- Website - https://nestjs.com
- Twitter - @nestframework
License
Nest and this package are MIT licensed.