diff --git a/.travis.yml b/.travis.yml index 483b5a67..5efb7d40 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,7 @@ before_install: - sudo mv docker-compose /usr/local/bin install: + - docker-compose up -d - docker-compose up -d - yarn bootstrap - yarn clean diff --git a/docker-compose.yml b/docker-compose.yml index 54d1e5d9..6beacab6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,16 @@ services: networks: - nestjsx_crud + mysql: + image: mysql:5.7 + ports: + - 3316:3306 + environment: + MYSQL_DATABASE: nestjsx_crud + MYSQL_USER: nestjsx_crud + MYSQL_PASSWORD: nestjsx_crud + MYSQL_ROOT_PASSWORD: nestjsx_crud + redis: image: redis:alpine ports: diff --git a/integration/crud-typeorm/companies/companies.controller.ts b/integration/crud-typeorm/companies/companies.controller.ts index d24fd456..a483bc17 100644 --- a/integration/crud-typeorm/companies/companies.controller.ts +++ b/integration/crud-typeorm/companies/companies.controller.ts @@ -19,10 +19,27 @@ import { serialize } from './response'; }, }, query: { - alwaysPaginate: true, + alwaysPaginate: false, + allow: ['name'], join: { - users: {}, - projects: {}, + users: { + alias: 'companyUsers', + exclude: ['email'], + eager: true, + }, + 'users.projects': { + eager: true, + alias: 'usersProjects', + allow: ['name'], + }, + 'users.projects.company': { + eager: true, + alias: 'usersProjectsCompany', + }, + projects: { + eager: true, + select: false, + }, }, }, }) diff --git a/integration/crud-typeorm/companies/companies.service.ts b/integration/crud-typeorm/companies/companies.service.ts index 4d917130..d0218a05 100644 --- a/integration/crud-typeorm/companies/companies.service.ts +++ b/integration/crud-typeorm/companies/companies.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { TypeOrmCrudService } from '@nestjsx/crud-typeorm'; +import { TypeOrmCrudService } from '../../../packages/crud-typeorm/src/typeorm-crud.service'; import { Company } from './company.entity'; diff --git a/integration/crud-typeorm/orm.config.ts b/integration/crud-typeorm/orm.config.ts index 44f0406f..f0f1c703 100644 --- a/integration/crud-typeorm/orm.config.ts +++ b/integration/crud-typeorm/orm.config.ts @@ -2,12 +2,14 @@ import { join } from 'path'; import { TypeOrmModuleOptions } from '@nestjs/typeorm'; import { isNil } from '@nestjsx/util'; +const type = (process.env.TYPEORM_CONNECTION as any) || 'postgres'; + export const withCache: TypeOrmModuleOptions = { - type: 'postgres', + type, host: '127.0.0.1', - port: 5455, - username: 'root', - password: 'root', + port: type === 'postgres' ? 5455 : 3316, + username: type === 'mysql' ? 'nestjsx_crud' : 'root', + password: type === 'mysql' ? 'nestjsx_crud' : 'root', database: 'nestjsx_crud', synchronize: false, logging: !isNil(process.env.TYPEORM_LOGGING) diff --git a/integration/crud-typeorm/orm.yaml b/integration/crud-typeorm/orm.yaml index dd9df1c8..d2fc17b2 100644 --- a/integration/crud-typeorm/orm.yaml +++ b/integration/crud-typeorm/orm.yaml @@ -10,3 +10,15 @@ default: migrationsTableName: orm_migrations migrations: - ./seeds.ts +mysql: + type: mysql + host: 127.0.0.1 + port: 3316 + username: nestjsx_crud + password: nestjsx_crud + database: nestjsx_crud + entities: + - ./**/*.entity.ts + migrationsTableName: orm_migrations + migrations: + - ./seeds.ts diff --git a/integration/crud-typeorm/seeds.ts b/integration/crud-typeorm/seeds.ts index 2a02cfba..dcb96a1a 100644 --- a/integration/crud-typeorm/seeds.ts +++ b/integration/crud-typeorm/seeds.ts @@ -1,125 +1,144 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { plainToClass } from 'class-transformer'; +import { MigrationInterface, Repository, QueryRunner } from 'typeorm'; +import { Company } from './companies'; +import { Project, UserProject } from './projects'; +import { Name, User } from './users'; +import { License, UserLicense } from './users-licenses'; +import { UserProfile } from './users-profiles'; export class Seeds1544303473346 implements MigrationInterface { + private save(repo: Repository, type: any, data: Partial[]): Promise { + return repo.save( + data.map((partial: Partial) => + plainToClass(type, partial, { ignoreDecorators: true }), + ), + ); + } + public async up(queryRunner: QueryRunner): Promise { + const { connection } = queryRunner; + + const companiesRepo = connection.getRepository(Company); + const projectsRepo = connection.getRepository(Project); + const usersProfilesRepo = connection.getRepository(UserProfile); + const usersRepo = connection.getRepository(User); + const licensesRepo = connection.getRepository(License); + const usersLincesesRepo = connection.getRepository(UserLicense); + const usersProjectsRepo = connection.getRepository(UserProject); + // companies - await queryRunner.query(` - INSERT INTO public.companies ("name", "domain") VALUES - ('Name1', 'Domain1'), - ('Name2', 'Domain2'), - ('Name3', 'Domain3'), - ('Name4', 'Domain4'), - ('Name5', 'Domain5'), - ('Name6', 'Domain6'), - ('Name7', 'Domain7'), - ('Name8', 'Domain8'), - ('Name9', 'Domain9'), - ('Name10', 'Domain10'); - `); + await this.save(companiesRepo, Company, [ + { name: 'Name1', domain: 'Domain1' }, + { name: 'Name2', domain: 'Domain2' }, + { name: 'Name3', domain: 'Domain3' }, + { name: 'Name4', domain: 'Domain4' }, + { name: 'Name5', domain: 'Domain5' }, + { name: 'Name6', domain: 'Domain6' }, + { name: 'Name7', domain: 'Domain7' }, + { name: 'Name8', domain: 'Domain8' }, + { name: 'Name9', domain: 'Domain9' }, + { name: 'Name10', domain: 'Domain10' }, + ]); // projects - await queryRunner.query(` - INSERT INTO public.projects ("name", "description", "isActive", "companyId") VALUES - ('Project1', 'description1', true, 1), - ('Project2', 'description2', true, 1), - ('Project3', 'description3', true, 2), - ('Project4', 'description4', true, 2), - ('Project5', 'description5', true, 3), - ('Project6', 'description6', true, 3), - ('Project7', 'description7', true, 4), - ('Project8', 'description8', true, 4), - ('Project9', 'description9', true, 5), - ('Project10', 'description10', true, 5), - ('Project11', 'description11', false, 6), - ('Project12', 'description12', false, 6), - ('Project13', 'description13', false, 7), - ('Project14', 'description14', false, 7), - ('Project15', 'description15', false, 8), - ('Project16', 'description16', false, 8), - ('Project17', 'description17', false, 9), - ('Project18', 'description18', false, 9), - ('Project19', 'description19', false, 10), - ('Project20', 'description20', false, 10); - `); + await this.save(projectsRepo, Project, [ + { name: 'Project1', description: 'description1', isActive: true, companyId: 1 }, + { name: 'Project2', description: 'description2', isActive: true, companyId: 1 }, + { name: 'Project3', description: 'description3', isActive: true, companyId: 2 }, + { name: 'Project4', description: 'description4', isActive: true, companyId: 2 }, + { name: 'Project5', description: 'description5', isActive: true, companyId: 3 }, + { name: 'Project6', description: 'description6', isActive: true, companyId: 3 }, + { name: 'Project7', description: 'description7', isActive: true, companyId: 4 }, + { name: 'Project8', description: 'description8', isActive: true, companyId: 4 }, + { name: 'Project9', description: 'description9', isActive: true, companyId: 5 }, + { name: 'Project10', description: 'description10', isActive: true, companyId: 5 }, + { name: 'Project11', description: 'description11', isActive: false, companyId: 6 }, + { name: 'Project12', description: 'description12', isActive: false, companyId: 6 }, + { name: 'Project13', description: 'description13', isActive: false, companyId: 7 }, + { name: 'Project14', description: 'description14', isActive: false, companyId: 7 }, + { name: 'Project15', description: 'description15', isActive: false, companyId: 8 }, + { name: 'Project16', description: 'description16', isActive: false, companyId: 8 }, + { name: 'Project17', description: 'description17', isActive: false, companyId: 9 }, + { name: 'Project18', description: 'description18', isActive: false, companyId: 9 }, + { name: 'Project19', description: 'description19', isActive: false, companyId: 10 }, + { name: 'Project20', description: 'description20', isActive: false, companyId: 10 }, + ]); // user-profiles - await queryRunner.query(` - INSERT INTO public.user_profiles ("name") VALUES - ('User1'), - ('User2'), - ('User3'), - ('User4'), - ('User5'), - ('User6'), - ('User7'), - ('User8'), - ('User9'), - ('User1'), - ('User1'), - ('User1'), - ('User1'), - ('User1'), - ('User1'), - ('User1'), - ('User1'), - ('User1'), - ('User1'), - ('User2'); - `); + await this.save(usersProfilesRepo, UserProfile, [ + { name: 'User1' }, + { name: 'User2' }, + { name: 'User3' }, + { name: 'User4' }, + { name: 'User5' }, + { name: 'User6' }, + { name: 'User7' }, + { name: 'User8' }, + { name: 'User9' }, + { name: 'User1' }, + { name: 'User1' }, + { name: 'User1' }, + { name: 'User1' }, + { name: 'User1' }, + { name: 'User1' }, + { name: 'User1' }, + { name: 'User1' }, + { name: 'User1' }, + { name: 'User1' }, + { name: 'User2' }, + ]); // users - await queryRunner.query(` - INSERT INTO public.users ("email", "isActive", "companyId", "profileId", "nameFirst", "nameLast") VALUES - ('1@email.com', true, 1, 1, 'firstname1', 'lastname1'), - ('2@email.com', true, 1, 2, NULL, NULL), - ('3@email.com', true, 1, 3, NULL, NULL), - ('4@email.com', true, 1, 4, NULL, NULL), - ('5@email.com', true, 1, 5, NULL, NULL), - ('6@email.com', true, 1, 6, NULL, NULL), - ('7@email.com', false, 1, 7, NULL, NULL), - ('8@email.com', false, 1, 8, NULL, NULL), - ('9@email.com', false, 1, 9, NULL, NULL), - ('10@email.com', true, 1, 10, NULL, NULL), - ('11@email.com', true, 2, 11, NULL, NULL), - ('12@email.com', true, 2, 12, NULL, NULL), - ('13@email.com', true, 2, 13, NULL, NULL), - ('14@email.com', true, 2, 14, NULL, NULL), - ('15@email.com', true, 2, 15, NULL, NULL), - ('16@email.com', true, 2, 16, NULL, NULL), - ('17@email.com', false, 2, 17, NULL, NULL), - ('18@email.com', false, 2, 18, NULL, NULL), - ('19@email.com', false, 2, 19, NULL, NULL), - ('20@email.com', false, 2, 20, NULL, NULL), - ('21@email.com', false, 2, NULL, NULL, NULL); - `); + const name: Name = { first: null, last: null }; + const name1: Name = { first: 'firstname1', last: 'lastname1' }; + await this.save(usersRepo, User, [ + { email: '1@email.com', isActive: true, companyId: 1, profileId: 1, name: name1 }, + { email: '2@email.com', isActive: true, companyId: 1, profileId: 2, name }, + { email: '3@email.com', isActive: true, companyId: 1, profileId: 3, name }, + { email: '4@email.com', isActive: true, companyId: 1, profileId: 4, name }, + { email: '5@email.com', isActive: true, companyId: 1, profileId: 5, name }, + { email: '6@email.com', isActive: true, companyId: 1, profileId: 6, name }, + { email: '7@email.com', isActive: false, companyId: 1, profileId: 7, name }, + { email: '8@email.com', isActive: false, companyId: 1, profileId: 8, name }, + { email: '9@email.com', isActive: false, companyId: 1, profileId: 9, name }, + { email: '10@email.com', isActive: true, companyId: 1, profileId: 10, name }, + { email: '11@email.com', isActive: true, companyId: 2, profileId: 11, name }, + { email: '12@email.com', isActive: true, companyId: 2, profileId: 12, name }, + { email: '13@email.com', isActive: true, companyId: 2, profileId: 13, name }, + { email: '14@email.com', isActive: true, companyId: 2, profileId: 14, name }, + { email: '15@email.com', isActive: true, companyId: 2, profileId: 15, name }, + { email: '16@email.com', isActive: true, companyId: 2, profileId: 16, name }, + { email: '17@email.com', isActive: false, companyId: 2, profileId: 17, name }, + { email: '18@email.com', isActive: false, companyId: 2, profileId: 18, name }, + { email: '19@email.com', isActive: false, companyId: 2, profileId: 19, name }, + { email: '20@email.com', isActive: false, companyId: 2, profileId: 20, name }, + { email: '21@email.com', isActive: false, companyId: 2, profileId: null, name }, + ]); // licenses - await queryRunner.query(` - INSERT INTO public.licenses ("name") VALUES - ('License1'), - ('License2'), - ('License3'), - ('License4'), - ('License5'); - `); + await this.save(licensesRepo, License, [ + { name: 'License1' }, + { name: 'License2' }, + { name: 'License3' }, + { name: 'License4' }, + { name: 'License5' }, + ]); // user-licenses - await queryRunner.query(` - INSERT INTO public.user_licenses ("userId", "licenseId", "yearsActive") VALUES - (1, 1, 3), - (1, 2, 5), - (1, 4, 7), - (2, 5, 1); - `); + await this.save(usersLincesesRepo, UserLicense, [ + { userId: 1, licenseId: 1, yearsActive: 3 }, + { userId: 1, licenseId: 2, yearsActive: 5 }, + { userId: 1, licenseId: 4, yearsActive: 7 }, + { userId: 2, licenseId: 5, yearsActive: 1 }, + ]); // user-projects - await queryRunner.query(` - INSERT INTO public.user_projects ("projectId", "userId", "review") VALUES - (1, 1, 'User project 1 1'), - (1, 2, 'User project 1 2'), - (2, 2, 'User project 2 2'), - (3, 3, 'User project 3 3'); - `); + await this.save(usersProjectsRepo, UserProject, [ + { projectId: 1, userId: 1, review: 'User project 1 1' }, + { projectId: 1, userId: 2, review: 'User project 1 2' }, + { projectId: 2, userId: 2, review: 'User project 2 2' }, + { projectId: 3, userId: 3, review: 'User project 3 3' }, + ]); } public async down(queryRunner: QueryRunner): Promise {} diff --git a/integration/shared/https-exception.filter.ts b/integration/shared/https-exception.filter.ts index c3000095..9a944e4d 100644 --- a/integration/shared/https-exception.filter.ts +++ b/integration/shared/https-exception.filter.ts @@ -12,6 +12,10 @@ export class HttpExceptionFilter implements ExceptionFilter { } prepareException(exc: any): { status: number; json: object } { + if (process.env.NODE_ENV !== 'test') { + console.log(exc); + } + const error = exc instanceof HttpException ? exc : new InternalServerErrorException(exc.message); const status = error.getStatus(); diff --git a/package.json b/package.json index 17f46518..8503b07c 100644 --- a/package.json +++ b/package.json @@ -14,15 +14,19 @@ "rebuild": "yarn clean && yarn build", "build": "yarn s build", "clean": "yarn s clean", - "pretest": "npm run db:prepare:typeorm", - "test": "yarn s test", - "pretest:coveralls": "yarn pretest", - "test:coveralls": "yarn s test.coveralls", + "test": "npx jest --runInBand -c=jest.config.js packages/ --verbose", + "test:coverage": "yarn test:all --coverage", + "test:coveralls": "yarn test:coverage --coverageReporters=text-lcov | coveralls", + "test:all": "yarn test:mysql && yarn test:postgres", + "test:postgres": "yarn db:prepare:typeorm && yarn test", + "test:mysql": "yarn db:prepare:typeorm:mysql && TYPEORM_CONNECTION=mysql yarn test", "start:typeorm": "npx nodemon -w ./integration/crud-typeorm -e ts node_modules/ts-node/dist/bin.js integration/crud-typeorm/main.ts", - "db:sync:typeorm": "cd ./integration/crud-typeorm && npx ts-node -r tsconfig-paths/register ../../node_modules/typeorm/cli.js schema:sync -f=orm", - "db:drop:typeorm": "cd ./integration/crud-typeorm && npx ts-node -r tsconfig-paths/register ../../node_modules/typeorm/cli.js schema:drop -f=orm", - "db:seeds:typeorm": "cd ./integration/crud-typeorm && npx ts-node -r tsconfig-paths/register ../../node_modules/typeorm/cli.js migration:run -f=orm", - "db:prepare:typeorm": "npm run db:drop:typeorm && npm run db:sync:typeorm && npm run db:seeds:typeorm", + "db:cli:typeorm": "cd ./integration/crud-typeorm && npx ts-node -r tsconfig-paths/register ../../node_modules/typeorm/cli.js", + "db:sync:typeorm": "yarn db:cli:typeorm schema:sync -f=orm", + "db:drop:typeorm": "yarn db:cli:typeorm schema:drop -f=orm", + "db:seeds:typeorm": "yarn db:cli:typeorm migration:run -f=orm", + "db:prepare:typeorm": "yarn db:drop:typeorm && yarn db:sync:typeorm && yarn db:seeds:typeorm", + "db:prepare:typeorm:mysql": "yarn db:drop:typeorm -c=mysql && yarn db:sync:typeorm -c=mysql && yarn db:seeds:typeorm -c=mysql", "format": "npx pretty-quick --pattern \"packages/**/!(*.d).ts\"", "lint": "npx tslint 'packages/**/*.ts'", "cm": "npx git-cz", @@ -74,6 +78,7 @@ "jest": "24.9.0", "jest-extended": "0.11.2", "lerna": "3.16.4", + "mysql": "^2.18.1", "nodemon": "1.19.2", "npm-check": "5.9.0", "nps": "5.9.8", diff --git a/packages/crud-typeorm/src/typeorm-crud.service.ts b/packages/crud-typeorm/src/typeorm-crud.service.ts index e859013b..972d0984 100644 --- a/packages/crud-typeorm/src/typeorm-crud.service.ts +++ b/packages/crud-typeorm/src/typeorm-crud.service.ts @@ -4,6 +4,7 @@ import { CrudRequestOptions, CrudService, GetManyDefaultResponse, + JoinOption, JoinOptions, QueryOptions, } from '@nestjsx/crud'; @@ -36,15 +37,25 @@ import { DeepPartial, WhereExpression, ConnectionOptions, + EntityMetadata, } from 'typeorm'; -import { RelationMetadata } from 'typeorm/metadata/RelationMetadata'; + +interface IAllowedRelation { + alias?: string; + nested: boolean; + name: string; + path: string; + columns: string[]; + primaryColumns: string[]; + allowedColumns: string[]; +} export class TypeOrmCrudService extends CrudService { protected dbName: ConnectionOptions['type']; protected entityColumns: string[]; protected entityPrimaryColumns: string[]; protected entityColumnsHash: ObjectLiteral = {}; - protected entityRelationsHash: ObjectLiteral = {}; + protected entityRelationsHash: Map = new Map(); protected sqlInjectionRegEx: RegExp[] = [ /(%27)|(\')|(--)|(%23)|(#)/gi, /((%3D)|(=))[^\n]*((%27)|(\')|(--)|(%3B)|(;))/gi, @@ -57,7 +68,6 @@ export class TypeOrmCrudService extends CrudService { this.dbName = this.repo.metadata.connection.options.type; this.onInitMapEntityColumns(); - this.onInitMapRelations(); } public get findOne(): Repository['findOne'] { @@ -242,17 +252,6 @@ export class TypeOrmCrudService extends CrudService { return filters; } - public decidePagination( - parsed: ParsedRequestParams, - options: CrudRequestOptions, - ): boolean { - return ( - options.query.alwaysPaginate || - ((Number.isFinite(parsed.page) || Number.isFinite(parsed.offset)) && - !!this.getTake(parsed, options.query)) - ); - } - /** * Create TypeOrm QueryBuilder * @param parsed @@ -373,22 +372,6 @@ export class TypeOrmCrudService extends CrudService { .map((prop) => prop.propertyName); } - protected onInitMapRelations() { - this.entityRelationsHash = this.repo.metadata.relations.reduce( - (hash, curr) => ({ - ...hash, - [curr.propertyName]: { - name: curr.propertyName, - columns: curr.inverseEntityMetadata.columns.map((col) => col.propertyName), - primaryColumns: curr.inverseEntityMetadata.primaryColumns.map( - (col) => col.propertyName, - ), - }, - }), - {}, - ); - } - protected async getOneOrFail(req: CrudRequest, shallow = false): Promise { const { parsed, options } = req; const builder = shallow @@ -448,27 +431,115 @@ export class TypeOrmCrudService extends CrudService { ); } - protected getRelationMetadata(field: string) { + protected getEntityColumns( + entityMetadata: EntityMetadata, + ): { columns: string[]; primaryColumns: string[] } { + const columns = + entityMetadata.columns.map((prop) => prop.propertyPath) || + /* istanbul ignore next */ []; + const primaryColumns = + entityMetadata.primaryColumns.map((prop) => prop.propertyPath) || + /* istanbul ignore next */ []; + + return { columns, primaryColumns }; + } + + protected getRelationMetadata(field: string, options: JoinOption): IAllowedRelation { try { - const fields = field.split('.'); - const target = fields[fields.length - 1]; - const paths = fields.slice(0, fields.length - 1); + let allowedRelation; + let nested = false; + + if (this.entityRelationsHash.has(field)) { + allowedRelation = this.entityRelationsHash.get(field); + } else { + const fields = field.split('.'); + let relationMetadata: EntityMetadata; + let name: string; + let path: string; + let parentPath: string; + + if (fields.length === 1) { + const found = this.repo.metadata.relations.find( + (one) => one.propertyName === fields[0], + ); - let relations = this.repo.metadata.relations; + if (found) { + name = fields[0]; + path = `${this.alias}.${fields[0]}`; + relationMetadata = found.inverseEntityMetadata; + } + } else { + nested = true; + parentPath = ''; + + const reduced = fields.reduce( + (res, propertyName: string, i) => { + const found = res.relations.length + ? res.relations.find((one) => one.propertyName === propertyName) + : null; + const relationMetadata = found ? found.inverseEntityMetadata : null; + const relations = relationMetadata ? relationMetadata.relations : []; + name = propertyName; + + if (i !== fields.length - 1) { + parentPath = !parentPath + ? propertyName + : /* istanbul ignore next */ `${parentPath}.${propertyName}`; + } + + return { + relations, + relationMetadata, + }; + }, + { + relations: this.repo.metadata.relations, + relationMetadata: null, + }, + ); + + relationMetadata = reduced.relationMetadata; + } - for (const propertyName of paths) { - relations = relations.find((o) => o.propertyName === propertyName) - .inverseEntityMetadata.relations; + if (relationMetadata) { + const { columns, primaryColumns } = this.getEntityColumns(relationMetadata); + + if (!path && parentPath) { + const parentAllowedRelation = this.entityRelationsHash.get(parentPath); + + /* istanbul ignore next */ + if (parentAllowedRelation) { + path = parentAllowedRelation.alias + ? `${parentAllowedRelation.alias}.${name}` + : field; + } + } + + allowedRelation = { + alias: options.alias, + name, + path, + columns, + nested, + primaryColumns, + }; + } } - const relation: RelationMetadata & { nestedRelation?: string } = relations.find( - (o) => o.propertyName === target, - ); + if (allowedRelation) { + const allowedColumns = this.getAllowedColumns(allowedRelation.columns, options); + const toSave: IAllowedRelation = { ...allowedRelation, allowedColumns }; - relation.nestedRelation = `${fields[fields.length - 2]}.${target}`; + this.entityRelationsHash.set(field, toSave); - return relation; - } catch (e) { + if (options.alias) { + this.entityRelationsHash.set(options.alias, toSave); + } + + return toSave; + } + } catch (_) { + /* istanbul ignore next */ return null; } } @@ -478,55 +549,32 @@ export class TypeOrmCrudService extends CrudService { joinOptions: JoinOptions, builder: SelectQueryBuilder, ) { - if (this.entityRelationsHash[cond.field] === undefined && cond.field.includes('.')) { - const curr = this.getRelationMetadata(cond.field); - if (!curr) { - this.entityRelationsHash[cond.field] = null; - return true; - } + const options = joinOptions[cond.field]; - this.entityRelationsHash[cond.field] = { - name: curr.propertyName, - columns: curr.inverseEntityMetadata.columns.map((col) => col.propertyName), - primaryColumns: curr.inverseEntityMetadata.primaryColumns.map( - (col) => col.propertyName, - ), - nestedRelation: curr.nestedRelation, - }; + if (!options) { + return true; } - /* istanbul ignore else */ - if (cond.field && this.entityRelationsHash[cond.field] && joinOptions[cond.field]) { - const relation = this.entityRelationsHash[cond.field]; - const options = joinOptions[cond.field]; - const allowed = this.getAllowedColumns(relation.columns, options); + const allowedRelation = this.getRelationMetadata(cond.field, options); - /* istanbul ignore if */ - if (!allowed.length) { - return true; - } + if (!allowedRelation) { + return true; + } - const alias = options.alias ? options.alias : relation.name; + const relationType = options.required ? 'innerJoin' : 'leftJoin'; + const alias = options.alias ? options.alias : allowedRelation.name; - const columns = - !cond.select || !cond.select.length - ? allowed - : cond.select.filter((col) => allowed.some((a) => a === col)); + builder[relationType](allowedRelation.path, alias); + if (options.select !== false) { const select = [ - ...relation.primaryColumns, + ...allowedRelation.primaryColumns, ...(options.persist && options.persist.length ? options.persist : []), - ...columns, + ...allowedRelation.allowedColumns, ].map((col) => `${alias}.${col}`); - const relationPath = relation.nestedRelation || `${this.alias}.${relation.name}`; - const relationType = options.required ? 'innerJoin' : 'leftJoin'; - - builder[relationType](relationPath, alias); builder.addSelect(select); } - - return true; } protected setAndWhere( @@ -781,14 +829,16 @@ export class TypeOrmCrudService extends CrudService { } protected getFieldWithAlias(field: string, sort: boolean = false) { + /* istanbul ignore next */ + const i = this.dbName === 'mysql' ? '`' : '"'; const cols = field.split('.'); - // relation is alias + switch (cols.length) { case 1: - if (sort || this.alias[0] === '"') { + if (sort) { return `${this.alias}.${field}`; } - return `"${this.alias}"."${field}"`; + return `${i}${this.alias}${i}.${i}${field}${i}`; case 2: return field; default: diff --git a/packages/crud-typeorm/test/__fixture__/users.service.ts b/packages/crud-typeorm/test/__fixture__/users.service.ts index 75ff5b04..754ac9b8 100644 --- a/packages/crud-typeorm/test/__fixture__/users.service.ts +++ b/packages/crud-typeorm/test/__fixture__/users.service.ts @@ -10,3 +10,10 @@ export class UsersService extends TypeOrmCrudService { super(repo); } } + +@Injectable() +export class UsersService2 extends TypeOrmCrudService { + constructor(@InjectRepository(User) repo) { + super(repo); + } +} diff --git a/packages/crud-typeorm/test/b.query-params.spec.ts b/packages/crud-typeorm/test/b.query-params.spec.ts index ef1c39f5..0ad568a7 100644 --- a/packages/crud-typeorm/test/b.query-params.spec.ts +++ b/packages/crud-typeorm/test/b.query-params.spec.ts @@ -15,7 +15,7 @@ import { HttpExceptionFilter } from '../../../integration/shared/https-exception import { Crud } from '../../crud/src/decorators'; import { CompaniesService } from './__fixture__/companies.service'; import { ProjectsService } from './__fixture__/projects.service'; -import { UsersService } from './__fixture__/users.service'; +import { UsersService, UsersService2 } from './__fixture__/users.service'; // tslint:disable:max-classes-per-file describe('#crud-typeorm', () => { @@ -106,6 +106,12 @@ describe('#crud-typeorm', () => { company: {}, 'company.projects': {}, userLicenses: {}, + invalid: { + eager: true, + }, + 'foo.bar': { + eager: true, + }, }, }, }) @@ -130,6 +136,23 @@ describe('#crud-typeorm', () => { constructor(public service: UsersService) {} } + @Crud({ + model: { type: User }, + query: { + join: { + company: { + alias: 'userCompany', + eager: true, + select: false, + }, + }, + }, + }) + @Controller('myusers') + class UsersController3 { + constructor(public service: UsersService2) {} + } + beforeAll(async () => { const fixture = await Test.createTestingModule({ imports: [ @@ -144,11 +167,13 @@ describe('#crud-typeorm', () => { ProjectsController4, UsersController, UsersController2, + UsersController3, ], providers: [ { provide: APP_FILTER, useClass: HttpExceptionFilter }, CompaniesService, UsersService, + UsersService2, ProjectsService, ], }).compile(); @@ -327,6 +352,18 @@ describe('#crud-typeorm', () => { done(); }); }); + it('should eager join without selection', (done) => { + const query = qb.search({ 'userCompany.id': { $eq: 1 } }).query(); + return request(server) + .get('/myusers') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.length).toBe(10); + expect(res.body[0].company).toBeUndefined(); + done(); + }); + }); }); describe('#query nested join', () => { @@ -774,12 +811,13 @@ describe('#crud-typeorm', () => { expect(res.body).toBeArrayOfSize(3); }); it('should return with $endsL search operator', async () => { - const query = qb.search({ domain: { $endsL: '0' } }).query(); + const query = qb.search({ domain: { $endsL: 'AiN10' } }).query(); const res = await request(server) .get('/companies') .query(query) .expect(200); expect(res.body).toBeArrayOfSize(1); + expect(res.body[0].domain).toBe('Domain10'); }); it('should return with $contL search operator', async () => { const query = qb.search({ email: { $contL: '1@' } }).query(); diff --git a/packages/crud-typeorm/test/c.basic-crud.spec.ts b/packages/crud-typeorm/test/c.basic-crud.spec.ts index 5c2a6dac..3e4e206e 100644 --- a/packages/crud-typeorm/test/c.basic-crud.spec.ts +++ b/packages/crud-typeorm/test/c.basic-crud.spec.ts @@ -17,6 +17,8 @@ import { CompaniesService } from './__fixture__/companies.service'; import { UsersService } from './__fixture__/users.service'; import { DevicesService } from './__fixture__/devices.service'; +const isMysql = process.env.TYPEORM_CONNECTION === 'mysql'; + // tslint:disable:max-classes-per-file no-shadowed-variable describe('#crud-typeorm', () => { describe('#basic crud using alwaysPaginate default respects global limit', () => { @@ -32,15 +34,15 @@ describe('#crud-typeorm', () => { limit: 3, }, }) - @Controller('companies') - class CompaniesController { + @Controller('companies0') + class CompaniesController0 { constructor(public service: CompaniesService) {} } beforeAll(async () => { const fixture = await Test.createTestingModule({ imports: [TypeOrmModule.forRoot(withCache), TypeOrmModule.forFeature([Company])], - controllers: [CompaniesController], + controllers: [CompaniesController0], providers: [ { provide: APP_FILTER, useClass: HttpExceptionFilter }, CompaniesService, @@ -65,7 +67,7 @@ describe('#crud-typeorm', () => { describe('#getAllBase', () => { it('should return an array of all entities', (done) => { return request(server) - .get('/companies') + .get('/companies0') .end((_, res) => { expect(res.status).toBe(200); expect(res.body.data.length).toBe(3); @@ -353,13 +355,22 @@ describe('#crud-typeorm', () => { }); }); it('should return an entities with offset', (done) => { - const query = qb.setOffset(3).query(); + const queryObj = qb.setOffset(3); + if (isMysql) { + queryObj.setLimit(10); + } + const query = queryObj.query(); return request(server) .get('/companies') .query(query) .end((_, res) => { expect(res.status).toBe(200); - expect(res.body.length).toBe(7); + if (isMysql) { + expect(res.body.count).toBe(7); + expect(res.body.data.length).toBe(7); + } else { + expect(res.body.length).toBe(7); + } done(); }); }); @@ -579,7 +590,7 @@ describe('#crud-typeorm', () => { describe('#deleteOneBase', () => { it('should return status 404', (done) => { return request(server) - .delete('/companies/333') + .delete('/companies/3333') .end((_, res) => { expect(res.status).toBe(404); done(); diff --git a/packages/crud/src/interfaces/query-options.interface.ts b/packages/crud/src/interfaces/query-options.interface.ts index e615f8ab..d2de7c22 100644 --- a/packages/crud/src/interfaces/query-options.interface.ts +++ b/packages/crud/src/interfaces/query-options.interface.ts @@ -23,10 +23,11 @@ export interface JoinOptions { } export interface JoinOption { + alias?: string; allow?: QueryFields; + eager?: boolean; exclude?: QueryFields; persist?: QueryFields; - eager?: boolean; + select?: false; required?: boolean; - alias?: string; } diff --git a/yarn.lock b/yarn.lock index fb8c7db1..bd8ddc36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1881,6 +1881,11 @@ before-after-hook@^2.0.0: resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.1.0.tgz#b6c03487f44e24200dd30ca5e6a1979c5d2fb635" integrity sha512-IWIbu7pMqyw3EAJHzzHbWa85b6oud/yfKYg5rqB5hNE8CeMi3nX+2C2sj0HswfblST86hpVEOAb9x34NZd6P7A== +bignumber.js@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.0.0.tgz#805880f84a329b5eac6e7cb6f8274b6d82bdf075" + integrity sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A== + binary-extensions@^1.0.0: version "1.13.1" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" @@ -2908,7 +2913,7 @@ debug@3.1.0, debug@=3.1.0: dependencies: ms "2.0.0" -debug@^3.1.0, debug@^3.2.6: +debug@^3.1.0: version "3.2.6" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== @@ -3068,11 +3073,6 @@ detect-indent@^5.0.0: resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d" integrity sha1-OHHMCmoALow+Wzz38zYmRnXwa50= -detect-libc@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= - detect-newline@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" @@ -4359,7 +4359,7 @@ husky@3.0.5: run-node "^1.0.0" slash "^3.0.0" -iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: +iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.13: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -6151,6 +6151,16 @@ mute-stream@~0.0.4: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== +mysql@^2.18.1: + version "2.18.1" + resolved "https://registry.yarnpkg.com/mysql/-/mysql-2.18.1.tgz#2254143855c5a8c73825e4522baf2ea021766717" + integrity sha512-Bca+gk2YWmqp2Uf6k5NFEurwY/0td0cpebAucFpY/3jhrwrVGuxU2uQFCHjU19SJfje0yQvi+rVWdq78hR5lig== + dependencies: + bignumber.js "9.0.0" + readable-stream "2.3.7" + safe-buffer "5.1.2" + sqlstring "2.3.1" + mz@^2.4.0, mz@^2.5.0: version "2.7.0" resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" @@ -6192,15 +6202,6 @@ ncp@2.0.0: resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M= -needle@^2.2.1: - version "2.3.3" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.3.3.tgz#a041ad1d04a871b0ebb666f40baaf1fb47867117" - integrity sha512-EkY0GeSq87rWp1hoq/sH/wnTWgFVhYlnIkbJ0YJFfRgEFlz2RraCjBpFQ+vrEgEdp0ThfyHADmkChEhcb7PKyw== - dependencies: - debug "^3.2.6" - iconv-lite "^0.4.4" - sax "^1.2.4" - negotiator@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" @@ -6292,22 +6293,6 @@ node-notifier@^5.4.2: shellwords "^0.1.1" which "^1.3.0" -node-pre-gyp@*: - version "0.14.0" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.14.0.tgz#9a0596533b877289bcad4e143982ca3d904ddc83" - integrity sha512-+CvDC7ZttU/sSt9rFjix/P05iS43qHCOOGzcr3Ry99bXG7VX953+vFyEuph/tfqoYu8dttBkE86JSKBO2OzcxA== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4.4.2" - nodemon@1.19.2: version "1.19.2" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.19.2.tgz#b0975147dc99b3761ceb595b3f9277084931dcc0" @@ -6434,7 +6419,7 @@ npm-normalize-package-bin@^1.0.0, npm-normalize-package-bin@^1.0.1: semver "^5.6.0" validate-npm-package-name "^3.0.0" -npm-packlist@^1.1.6, npm-packlist@^1.4.4: +npm-packlist@^1.4.4: version "1.4.8" resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.8.tgz#56ee6cc135b9f98ad3d51c1c95da22bbb9b2ef3e" integrity sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A== @@ -6466,7 +6451,7 @@ npm-run-path@^2.0.0: dependencies: path-key "^2.0.0" -npmlog@^4.0.2, npmlog@^4.1.2: +npmlog@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== @@ -7397,7 +7382,7 @@ raw-body@2.4.0: iconv-lite "0.4.24" unpipe "1.0.0" -rc@^1.0.1, rc@^1.1.6, rc@^1.2.7: +rc@^1.0.1, rc@^1.1.6: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== @@ -7516,7 +7501,7 @@ read@1, read@~1.0.1: dependencies: mute-stream "~0.0.4" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@2.3.7, readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -8239,6 +8224,11 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= +sqlstring@2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.1.tgz#475393ff9e91479aea62dcaf0ca3d14983a7fb40" + integrity sha1-R1OT/56RR5rqYtyvDKPRSYOn+0A= + sshpk@^1.7.0: version "1.16.1" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" @@ -8552,7 +8542,7 @@ symbol-tree@^3.2.2: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== -tar@^4.4.10, tar@^4.4.12, tar@^4.4.2, tar@^4.4.8: +tar@^4.4.10, tar@^4.4.12, tar@^4.4.8: version "4.4.13" resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==