Is it possible to maintain flat input structure with nestjs/graphql Args? - node.js

I have a simple query:
#Query(() => ProfileOutput)
async profile(#Args('id') id: string) {
return await this.profileFacade.findProfileById(input.id);
}
The problem is that I want to apply #IsMongoId() from class-validator for the id here. I do not want to create new #InputType here, because I do not want to change API specification. Is there a way to apply validator like #IsMongoId here without the need to change query definition for frontend?

For anyone seeking an answer I found a feature called dedicated argument class. Instead of creating new input type as I thought like this:
#InputType()
export class MongoIdBaseInput {
#IsMongoId()
#Field()
id: string;
}
#Query(() => ProfileOutput)
async profile(#Args('data') input: MongoIdBaseInput) {
return await this.profileFacade.findProfileById(input.id);
}
We can define it almost the same, but annotate input with ArgsType it will maintain flat structure of args for us:
#ArgsType()
export class MongoIdBaseInput {
#IsMongoId()
#Field()
id: string;
}
#Query(() => ProfileOutput)
async profile(#Args() input: MongoIdBaseInput) {
return await this.profileFacade.findProfileById(input.id);
}

Related

NestJS: How to convert Mongoose document to regular class?

I have followed NestJS mongo docs to create a schema (without ID) from a regular class. Since this class has other general uses (unrelated to mongoose), I would like to have regular methods on that class as well:
#Schema({ _id: false })
export class Location {
#Prop()
lat: number;
#Prop()
lon: number;
regularMethod() { return something based on this.lat, this.lon }
}
export const LocationSchema = SchemaFactory.createForClass(Location);
export type CatDocument = HydratedDocument<Cat>;
#Schema()
export class Cat {
#Prop({ type: [LocationSchema] })
locations: Location[];
}
export const CatSchema = SchemaFactory.createForClass(Cat);
The problem is that if I query such an object from the db, regularMethod doesn't exist since the queried object is actually a Document based on Location, rather than Location. The document only exposes methods that were defined by the schema.methods, which is not what I need.
MyService {
constructor(#InjectModel(Cat.name) catModel: Model<CatDocument>) {}
async findLocations(catId: string) {
const cat = await catModel.findOneById(catId);
cat.location.forEach(loc => loc.regularMethod()) // no such method
}
}
Is there some obvious way to "cast" Location to the original class to have access to those methods?

How can I use the fields in a GraphQL query to perform nested reads with Prisma?

I'm using Prisma to implement a GraphQL interface to expose some data stored in a PostgreSQL database. My code is inspired by the GraphQL Tools (SDL-first) example. This logic is pretty inefficient though and I'd like to improve it.
Here is a minimal piece of code to show the problem and ask for a solution. My real code is of course more complicated.
My GraphQL schema
type Query {
allUsers: [User!]!
}
type User {
name: String!
posts: [Post!]!
}
type Post {
text: String!
author: User!
}
My resolver object, in the Node.JS code
const resolvers = {
Query: {
allUsers: ()=>prisma.users.findMany()
},
User: {
posts: (user)=>prisma.posts.findMany({where:{author:user.id}})
}
};
Problems
This code works but it's inefficient. Imagine you're running the query {allUsers{posts{text}}}:
My code runs N+1 queries against PostgreSQL to fetch the whole result: one to fetch the list of the users, then other N: one for each user. A single query, using a JOIN, should be enough.
My code selects every column from every table it queries, even though I only need user.id and don't need user.name or anything else.
Question
I know that Prisma supports nested searches (include and select options) which could fix both problems. However I don't know how to configure the options object using the GraphQL query.
How can I extract from the GraphQL query the list of fields that are requested? And how can I use these to create to options object to perform an optimal nested-search with Prisma?
This package can help you parse the request info: https://www.npmjs.com/package/graphql-parse-resolve-info
Then you need to transform it to a usable parameter that you can use in your ORM.
Here is an example with NestJS:
import {createParamDecorator, ExecutionContext} from '#nestjs/common';
import {GqlExecutionContext} from '#nestjs/graphql';
import {GraphQLResolveInfo} from 'graphql';
import {parseResolveInfo, ResolveTree} from 'graphql-parse-resolve-info';
export type PrismaSelect = {
select: {
[key: string]: true | PrismaSelect;
};
};
export const Relations = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const info = GqlExecutionContext.create(ctx).getInfo<GraphQLResolveInfo>();
const ast = parseResolveInfo(info);
return astToPrisma(Object.values((ast as ResolveTree).fieldsByTypeName)[0]);
},
);
export const astToPrisma = (ast: {
[str: string]: ResolveTree;
}): PrismaSelect => {
return {
select: Object.fromEntries(
Object.values(ast).map(field => [
field.name,
Object.keys(field.fieldsByTypeName).length === 0
? true
: astToPrisma(Object.values(field.fieldsByTypeName)[0]),
]),
),
};
};
Then you do:
import {Parent, Query, ResolveField, Resolver} from '#nestjs/graphql';
import {PrismaService} from '../services/prisma.service';
import {User} from '../entities/user.entity';
import {Relations} from 'src/decorators/relations.decorator';
import {Prisma} from '#prisma/client';
#Resolver(() => User)
export class UserResolver {
constructor(public prisma: PrismaService) {}
#Query(() => [User])
async usersWithRelationsResolver(
#Relations() relations: {select: Prisma.UserSelect},
): Promise<Partial<User>[]> {
return this.prisma.user.findMany({
...relations,
});
}
Alternatively, if you want to solve the N+1 problem you can use Prisma built-in findUnique method. See https://www.prisma.io/docs/guides/performance-and-optimization/query-optimization-performance#solving-the-n1-problem

Conditionally serialize the response attributes

I want to return a different response from a Game controller depending on whether or not a User owns the game or is simply invited to it. Essentially: filter out certain attributes from the response if the user is only invited to the game.
Here's a naive implementation of what I want using two different controllers:
#SerializeOptions({
groups: ['invited'],
})
#Get(':id')
async findOne(#User() user, #Param('id') id: string) {
const retGame = await this.gamesService.findOne(id);
const ability = this.caslAbilityFactory.createForUser(user);
Throw401(ability.can(Action.Read, retGame));
return retGame;
}
#SerializeOptions({
groups: ['owner'],
})
#Get('full/:id')
async findOneFull(#User() user, #Param('id') id: string) {
const retGame = await this.gamesService.findOne(id);
const ability = this.caslAbilityFactory.createForUser(user);
Throw401(ability.can(Action.FullRead, retGame));
// the main difference is ^^^^^^^^, using a different CASL rule for authorization
return retGame;
}
I'm using a different set of CASL rules to allow a "full read" or not, the full read being only allowed for the game owner. That way I can attach a different group through the SerializeOptions decorator, which allows me to conditionally expose an entity attribute:
#Column()
#Expose({ groups: ['owner'] })
inviteKey: string;
But it feels wrong to use different routes and methods to basically do the same thing, I'd like to pass a dynamic condition (user.id === ownerId) instead of a group to the Expose decorator, and I believe the best next thing would be to use an interceptor to filter certain fields from the response. I'm not sure how to proceed from there, would an interceptor be the right approach?
I have stumbled upon the same problem, where it makes sense to have one route but based on user rights give the result with some attributes filtered out from the response. Disclaimer: Since I'm fairly new to nestjs there are some gaps in my knowledge and it might be possible that it is not a proper way to achieve the intended result.
To achieve this you'd have to:
Use your already defined class with exposed/excluded attributes - using class-transformer and groups should add/remove attributes from the class when transforming to json object
Create an interceptor - this will intercept the response before it gets sent to clinet
Use the interceptor for desired routes
So in your case, it could look something like this:
db/models/game.model.ts
import { Exclude, Expose } from 'class-transformer';
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
#Entity()
export class Game {
#Exponse()
#PrimaryGeneratedColumn()
id: number;
#Column()
#Expose({ groups: ['owner'] })
inviteKey: string;
#Column()
#Expose({ groups: ['owner', 'invited']})
name: string;
}
transform.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '#nestjs/common';
import { classToPlain } from 'class-transformer';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Game } from 'db/models/game.model';
export interface Response<T> {
data: T;
}
#Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, Response<T>>
{
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<Response<T>> {
// This is how you can get access to requesting user (asuming it is in the request)
const request = context.switchToHttp().getRequest();
const user = request.user;
/* get your group based on the CASL rules. */
const group = 'invited';
// Transform the data (filter out private fields)
return next.handle().pipe(map((data) => {
if (data instanceof Game) {
return classToPlain(game, { groups: [group] });
}
// in case there is Game[]
if (
Array.isArray(data)
&& data[0] instanceof Game
) {
return data.map(game => classToPlain(game, { groups: [group] }));
}
// in case response is something else, don't touch it
return data;
}));
}
}
games.controller.ts
// Use the interceptor here - you no longer need `/games/full/:id` route
#UseInterceptors(TransformInterceptor)
#Get(':id')
async findOne(#User() user, #Param('id') id: string) {
// Return the Game instance here, interceptor will take care of transforming it
return this.gamesService.findOne(id);
}
As a result to your GET /games/:id as user with Action.Read access the output should be
{
"id": 1,
"name": "Best game in the business"
}
As a result to your GET /games/:id as user with Action.FullRead access the output should be
{
"id": 1,
"inviteKey": "super-secret-invite-key",
"name": "Best game in the business"
}
Hopefully this helps.

Node.JS: How do you validate QueryParams in routing-controllers?

lets say you have an interface like this:
import { Get, QueryParam } from 'routing-controllers';
// ...
#Get('/students')
async getStudents(
#QueryParam('count') count?: number,
): Promise<void> {
console.log(count);
}
How do you ensure count is an int and not a float, for example? Something like this is not valid:
#IsInt() #QueryParam('count') count?: number,
IsInt can only be used on a class property, eg for a body model, not for a single parameter value. But according to. this https://github.com/typestack/routing-controllers#auto-validating-action-params it is possible:
This technique works not only with #Body but also with #Param,
#QueryParam, #BodyParam and other decorators.
I had missed this in the docs: https://github.com/typestack/routing-controllers#inject-query-parameters By injecting all of the QueryParams instead of individual QueryParam, you can validate them as a class model:
enum Roles {
Admin = "admin",
User = "user",
Guest = "guest",
}
class GetUsersQuery {
#IsPositive()
limit: number;
#IsAlpha()
city: string;
#IsEnum(Roles)
role: Roles;
#IsBoolean()
isActive: boolean;
}
#Get("/users")
getUsers(#QueryParams() query: GetUsersQuery) {
// here you can access query.role, query.limit
// and others valid query parameters
}
Also, make sure you don't use barrel-imports to import Enums, or open-api-generator will produce an error that the enum is undefined and not and object; eg avoid this: import { Roles } from '../..'

Nest.js + Mikro-ORM: Collection of entity not initialized when using createQueryBuilder and leftJoin

I'm using Nest.js, and considering migrating from TypeORM to Mikro-ORM. I'm using the nestjs-mikro-orm module. But I'm stuck on something that seems very simple...
I've 3 entities, AuthorEntity, BookEntity and BookMetadata. From my Author module, I try to left join the Book and BookMetadata tables with the createQueryBuilder method. But when running my query, I'm getting an error where Collection<BookEntity> of entity AuthorEntity[3390] not initialized. However columns from the Author table are well retrieved.
My 3 entities:
#Entity()
#Unique({ properties: ['key'] })
export class AuthorEntity {
#PrimaryKey()
id!: number;
#Property({ length: 255 })
key!: string;
#OneToMany('BookEntity', 'author', { orphanRemoval: true })
books? = new Collection<BookEntity>(this);
}
#Entity()
export class BookEntity {
#PrimaryKey()
id!: number;
#ManyToOne(() => AuthorEntity)
author!: AuthorEntity;
#OneToMany('BookMetadataEntity', 'book', { orphanRemoval: true })
bookMetadata? = new Collection<BookMetadataEntity>(this);
}
#Entity()
#Unique({ properties: ['book', 'localeKey'] })
export class BookMetadataEntity {
#PrimaryKey()
id!: number;
#Property({ length: 5 })
localeKey!: string;
#ManyToOne(() => BookEntity)
book!: BookEntity;
}
And the service file where I run my query:
#Injectable()
export class AuthorService {
constructor(
#InjectRepository(AuthorEntity)
private readonly authorRepository: EntityRepository<AuthorEntity>,
) {}
async findOneByKey(props: { key: string; localeKey: string; }): Promise<AuthorEntity> {
const { key, localeKey } = props;
return this.authorRepository
.createQueryBuilder('a')
.select(['a.*', 'b.*', 'c.*'])
.leftJoin('a.books', 'b')
.leftJoin('b.bookMetadata', 'c')
.where('a.key = ?', [key])
.andWhere('c.localeKey = ?', [localeKey])
.getSingleResult();
}
}
Am I missing something? Might be not related, but I also noticed that there is a special autoLoadEntities: true for TypeORM users using Nest.js. Is there something similar for Mikro-ORM? Thanks ;)
Mapping of multiple entities from single query is not yet supported, it is planned for v4. You can subscribe here: https://github.com/mikro-orm/mikro-orm/issues/440
In v3 you need to use 2 queries to load 2 entities, which for your use case is much easier without the QB involved.
return this.authorRepository.findOne({ key }, ['books']);
Or you could use qb.execute() to get the raw results and map them yourself, but you would also have to manually alias all the fields to get around duplicities (Author.name vs Book.name), as doing qb.select(['a.*', 'b.*']) will result in query select a.*, b.* ... and the duplicate columns would not be correctly mapped.
https://mikro-orm.io/docs/query-builder/#mapping-raw-results-to-entities
About the autoLoadEntities thing, never heard of that, will take a look how it works, but in general, the nestjs adapter is not developed by me, so if its something only nest related, it would be better to ask on their GH repo.
Or you could use folder based discovery (entitiesDirs).
here is the new example with 3 entities:
return this.authorRepository.findOne({
key,
books: { bookMetadata: localeKey } },
}, ['books.bookMetadata']);
This will produce 3 queries, one for each db table, but the first one will auto-join books and bookMetadata to be able to filter by them. The condition will be propagated down in the second and third query.
If you omit the populate parameter (['books.bookMetadata']), then only the first query will be fired and you will end up with books not being populated (but the Author will be queried with the joined condition).

Resources