diff --git a/integration/crud-objection/app.module.ts b/integration/crud-objection/app.module.ts index 4539a1e9..79b3ff8d 100644 --- a/integration/crud-objection/app.module.ts +++ b/integration/crud-objection/app.module.ts @@ -3,13 +3,16 @@ import { CompaniesModule } from './companies/companies.module'; import { ProjectsModule } from './projects/projects.module'; import { UsersModule } from './users/users.module'; import { DatabaseModule } from './database.module'; +import { APP_GUARD } from '@nestjs/core'; +import { AuthGuard } from './auth.guard'; @Module({ - imports: [ - DatabaseModule, - CompaniesModule, - ProjectsModule, - UsersModule, + imports: [DatabaseModule, CompaniesModule, ProjectsModule, UsersModule], + providers: [ + { + provide: APP_GUARD, + useClass: AuthGuard, + }, ], }) export class AppModule {} diff --git a/integration/crud-objection/auth.guard.ts b/integration/crud-objection/auth.guard.ts new file mode 100644 index 00000000..46cf2c4f --- /dev/null +++ b/integration/crud-objection/auth.guard.ts @@ -0,0 +1,16 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; + +import { UsersService } from './users'; +import { USER_REQUEST_KEY } from './constants'; + +@Injectable() +export class AuthGuard implements CanActivate { + constructor(private usersService: UsersService) {} + + async canActivate(ctx: ExecutionContext): Promise { + const req = ctx.switchToHttp().getRequest(); + req[USER_REQUEST_KEY] = await this.usersService.modelClass.query().findById(1); + + return true; + } +} diff --git a/integration/crud-objection/base.model.ts b/integration/crud-objection/base.model.ts index fbb5555f..5a5af7eb 100644 --- a/integration/crud-objection/base.model.ts +++ b/integration/crud-objection/base.model.ts @@ -6,11 +6,11 @@ export class BaseModel extends Model { createdAt: Date; updatedAt: Date; - $beforeInsert() { - this.createdAt = new Date(); - } - $beforeUpdate() { this.updatedAt = new Date(); } + + $beforeInsert() { + this.createdAt = new Date(); + } } diff --git a/integration/crud-objection/companies/companies.controller.ts b/integration/crud-objection/companies/companies.controller.ts index e3883719..736e34a2 100644 --- a/integration/crud-objection/companies/companies.controller.ts +++ b/integration/crud-objection/companies/companies.controller.ts @@ -1,22 +1,47 @@ import { Controller } from '@nestjs/common'; -import { ApiUseTags } from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { Crud } from '@nestjsx/crud'; import { Company } from './company.model'; import { CompaniesService } from './companies.service'; +import { serialize } from './responses'; @Crud({ model: { type: Company, }, + serialize, + routes: { + deleteOneBase: { + returnDeleted: false, + }, + }, query: { + 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, + }, }, }, }) -@ApiUseTags('companies') +@ApiTags('companies') @Controller('companies') export class CompaniesController { constructor(public service: CompaniesService) {} diff --git a/integration/crud-objection/companies/company.model.ts b/integration/crud-objection/companies/company.model.ts index ccb32979..2200a162 100644 --- a/integration/crud-objection/companies/company.model.ts +++ b/integration/crud-objection/companies/company.model.ts @@ -43,16 +43,16 @@ export class Company extends BaseModel { modelClass: path.resolve(__dirname, '../users/user.model'), join: { from: 'companies.id', - to: 'users.companyId' - } + to: 'users.companyId', + }, }, projects: { relation: Model.HasManyRelation, modelClass: path.resolve(__dirname, '../projects/project.model'), join: { from: 'companies.id', - to: 'projects.companyId' - } - } - } + to: 'projects.companyId', + }, + }, + }; } diff --git a/integration/crud-objection/companies/requests/create-company.dto.ts b/integration/crud-objection/companies/requests/create-company.dto.ts new file mode 100644 index 00000000..e8032296 --- /dev/null +++ b/integration/crud-objection/companies/requests/create-company.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MaxLength } from 'class-validator'; + +export class CreateCompanyDto { + @ApiProperty({ type: 'string' }) + @IsString() + @MaxLength(100) + name: string; + + @ApiProperty({ type: 'string' }) + @IsString() + @MaxLength(100) + domain: string; + + @ApiProperty({ type: 'string' }) + @IsString() + @MaxLength(100) + description: string; +} diff --git a/integration/crud-objection/companies/requests/index.ts b/integration/crud-objection/companies/requests/index.ts new file mode 100644 index 00000000..1150f2d1 --- /dev/null +++ b/integration/crud-objection/companies/requests/index.ts @@ -0,0 +1,5 @@ +import { CreateCompanyDto } from './create-company.dto'; + +export const dto = { + create: CreateCompanyDto, +}; diff --git a/integration/crud-objection/companies/responses/get-company-response.dto.ts b/integration/crud-objection/companies/responses/get-company-response.dto.ts new file mode 100644 index 00000000..a93d5b7e --- /dev/null +++ b/integration/crud-objection/companies/responses/get-company-response.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Exclude } from 'class-transformer'; + +export class GetCompanyResponseDto { + @ApiProperty({ type: 'number' }) + id: string; + + @ApiProperty({ type: 'string' }) + name: string; + + @ApiProperty({ type: 'string' }) + domain: string; + + @ApiProperty({ type: 'string' }) + description: string; + + @Exclude() + createdAt: any; + + @Exclude() + updatedAt: any; +} diff --git a/integration/crud-objection/companies/responses/index.ts b/integration/crud-objection/companies/responses/index.ts new file mode 100644 index 00000000..3575c5b7 --- /dev/null +++ b/integration/crud-objection/companies/responses/index.ts @@ -0,0 +1,6 @@ +import { SerializeOptions } from '@nestjsx/crud'; +import { GetCompanyResponseDto } from './get-company-response.dto'; + +export const serialize: SerializeOptions = { + get: GetCompanyResponseDto, +}; diff --git a/integration/crud-objection/constants.ts b/integration/crud-objection/constants.ts new file mode 100644 index 00000000..a0b5752c --- /dev/null +++ b/integration/crud-objection/constants.ts @@ -0,0 +1 @@ +export const USER_REQUEST_KEY = 'user'; diff --git a/integration/crud-objection/database.module.ts b/integration/crud-objection/database.module.ts index f2938694..53e0876e 100644 --- a/integration/crud-objection/database.module.ts +++ b/integration/crud-objection/database.module.ts @@ -2,18 +2,30 @@ import { Global, Module } from '@nestjs/common'; import * as Knex from 'knex'; import { knexSnakeCaseMappers, Model } from 'objection'; import { UserProfile } from './users-profiles'; -import { Project } from './projects'; +import { Project, UserProject } from './projects'; import { Company } from './companies'; import { User } from './users'; import { KNEX_CONNECTION } from './injection-tokens'; -import { UserProject } from './users-projects'; +import { Device } from './devices'; +import { License, UserLicense } from './users-licenses'; +import { Note } from './notes'; -const models = [User, Company, Project, UserProfile, UserProject]; +const models = [ + User, + Company, + Project, + UserProfile, + UserProject, + Device, + License, + UserLicense, + Note, +]; -const modelProviders = models.map(model => { +const modelProviders = models.map((model) => { return { provide: model.name, - useValue: model + useValue: model, }; }); @@ -26,18 +38,18 @@ const providers = [ client: 'pg', connection: 'postgres://root:root@127.0.0.1:5455/nestjsx_crud_objection', debug: process.env.KNEX_DEBUG === 'true', - ...knexSnakeCaseMappers() + ...knexSnakeCaseMappers(), }); Model.knex(knex); return knex; - } - } + }, + }, ]; @Global() @Module({ providers: [...providers], - exports: [...providers] + exports: [...providers], }) export class DatabaseModule {} diff --git a/integration/crud-objection/devices/device.model.ts b/integration/crud-objection/devices/device.model.ts new file mode 100644 index 00000000..a6079e77 --- /dev/null +++ b/integration/crud-objection/devices/device.model.ts @@ -0,0 +1,18 @@ +import { IsOptional, IsString, IsUUID } from 'class-validator'; +import { Model } from 'objection'; + +export class Device extends Model { + static readonly tableName = 'devices'; + + static get idColumn() { + return ['deviceKey']; + } + + @IsOptional({ always: true }) + @IsUUID('4', { always: true }) + deviceKey: string; + + @IsOptional({ always: true }) + @IsString({ always: true }) + description?: string; +} diff --git a/integration/crud-objection/devices/devices.controller.ts b/integration/crud-objection/devices/devices.controller.ts new file mode 100644 index 00000000..abba6966 --- /dev/null +++ b/integration/crud-objection/devices/devices.controller.ts @@ -0,0 +1,29 @@ +import { Controller } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Crud } from '@nestjsx/crud'; + +import { DevicesService } from './devices.service'; +import { serialize } from './response'; +import { Device } from './device.model'; + +@Crud({ + model: { type: Device }, + serialize, + params: { + deviceKey: { + field: 'deviceKey', + type: 'uuid', + primary: true, + }, + }, + routes: { + deleteOneBase: { + returnDeleted: true, + }, + }, +}) +@ApiTags('devices') +@Controller('/devices') +export class DevicesController { + constructor(public service: DevicesService) {} +} diff --git a/integration/crud-objection/devices/devices.module.ts b/integration/crud-objection/devices/devices.module.ts new file mode 100644 index 00000000..0c599a49 --- /dev/null +++ b/integration/crud-objection/devices/devices.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { DevicesService } from './devices.service'; +import { DevicesController } from './devices.controller'; + +@Module({ + providers: [DevicesService], + exports: [DevicesService], + controllers: [DevicesController], +}) +export class DevicesModule {} diff --git a/integration/crud-objection/devices/devices.service.ts b/integration/crud-objection/devices/devices.service.ts new file mode 100644 index 00000000..52bd3353 --- /dev/null +++ b/integration/crud-objection/devices/devices.service.ts @@ -0,0 +1,12 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { Device } from './device.model'; +import { ObjectionCrudService } from '@nestjsx/crud-objection'; +import { ModelClass } from 'objection'; + +@Injectable() +export class DevicesService extends ObjectionCrudService { + constructor(@Inject('Device') modelClass: ModelClass) { + super(modelClass); + } +} diff --git a/integration/crud-objection/devices/index.ts b/integration/crud-objection/devices/index.ts new file mode 100644 index 00000000..46803be3 --- /dev/null +++ b/integration/crud-objection/devices/index.ts @@ -0,0 +1,2 @@ +export * from './device.model'; +export * from './devices.service'; diff --git a/integration/crud-objection/devices/response/delete-device-response.dto.ts b/integration/crud-objection/devices/response/delete-device-response.dto.ts new file mode 100644 index 00000000..3277f8b0 --- /dev/null +++ b/integration/crud-objection/devices/response/delete-device-response.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Exclude } from 'class-transformer'; + +export class DeleteDeviceResponseDto { + @ApiProperty({ type: 'string' }) + deviceKey: string; + + @Exclude() + description?: string; +} diff --git a/integration/crud-objection/devices/response/index.ts b/integration/crud-objection/devices/response/index.ts new file mode 100644 index 00000000..45fb7489 --- /dev/null +++ b/integration/crud-objection/devices/response/index.ts @@ -0,0 +1,6 @@ +import { SerializeOptions } from '@nestjsx/crud'; +import { DeleteDeviceResponseDto } from './delete-device-response.dto'; + +export const serialize: SerializeOptions = { + delete: DeleteDeviceResponseDto, +}; diff --git a/integration/crud-objection/knexfile.js b/integration/crud-objection/knexfile.js index 01596edd..825ef392 100644 --- a/integration/crud-objection/knexfile.js +++ b/integration/crud-objection/knexfile.js @@ -7,10 +7,12 @@ module.exports = { migrations: { directory: './migrations', stub: './migration.stub', + extension: 'ts', }, seeds: { directory: './seeds', - stub: './seed.stub' + stub: './seed.stub', + extension: 'ts', }, - ...knexSnakeCaseMappers() + ...knexSnakeCaseMappers(), }; diff --git a/integration/crud-objection/main.ts b/integration/crud-objection/main.ts index 882353af..70699ad2 100644 --- a/integration/crud-objection/main.ts +++ b/integration/crud-objection/main.ts @@ -1,9 +1,22 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; -import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { CrudConfigService } from '@nestjsx/crud'; +import { USER_REQUEST_KEY } from './constants'; import { HttpExceptionFilter } from '../shared/https-exception.filter'; import { AppModule } from './app.module'; +// Important: load config before (!!!) you import AppModule +// https://github.com/nestjsx/crud/wiki/Controllers#global-options +CrudConfigService.load({ + auth: { + property: USER_REQUEST_KEY, + }, + routes: { + // exclude: ['createManyBase'], + }, +}); + async function bootstrap() { const app = await NestFactory.create(AppModule); diff --git a/integration/crud-objection/migrations/20190609144759_CreateUsers.ts b/integration/crud-objection/migrations/20190609144759_CreateUsers.ts index d51be834..fc1778b9 100644 --- a/integration/crud-objection/migrations/20190609144759_CreateUsers.ts +++ b/integration/crud-objection/migrations/20190609144759_CreateUsers.ts @@ -10,6 +10,8 @@ export async function up(knex: Knex) { .notNullable() .unique(); + t.jsonb('name').nullable(); + t.boolean('is_active') .notNullable() .defaultTo(true); diff --git a/integration/crud-objection/migrations/20190609144779_CreateUsersProjects.ts b/integration/crud-objection/migrations/20190609144779_CreateUsersProjects.ts index dbc0d707..b2adbaa3 100644 --- a/integration/crud-objection/migrations/20190609144779_CreateUsersProjects.ts +++ b/integration/crud-objection/migrations/20190609144779_CreateUsersProjects.ts @@ -18,6 +18,8 @@ export async function up(knex: Knex) { .inTable('projects') .onDelete('CASCADE'); + t.text('review').nullable(); + t.unique(['user_id', 'project_id']); t.timestamps(); diff --git a/integration/crud-objection/migrations/20200624174231_CreateDevices.ts b/integration/crud-objection/migrations/20200624174231_CreateDevices.ts new file mode 100644 index 00000000..a84d6aed --- /dev/null +++ b/integration/crud-objection/migrations/20200624174231_CreateDevices.ts @@ -0,0 +1,17 @@ +import * as Knex from 'knex'; + +const tableName = 'devices'; + +export async function up(knex: Knex) { + await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";'); + return knex.schema.createTable(tableName, (t) => { + t.uuid('device_key') + .primary() + .defaultTo(knex.raw('uuid_generate_v4()')); + t.text('description').nullable(); + }); +} + +export async function down(knex: Knex) { + return knex.schema.dropTable(tableName); +} diff --git a/integration/crud-objection/migrations/20200624175848_CreateLicenses.ts b/integration/crud-objection/migrations/20200624175848_CreateLicenses.ts new file mode 100644 index 00000000..bd8e14c5 --- /dev/null +++ b/integration/crud-objection/migrations/20200624175848_CreateLicenses.ts @@ -0,0 +1,17 @@ +import * as Knex from 'knex'; + +const tableName = 'licenses'; + +export async function up(knex: Knex) { + return knex.schema.createTable(tableName, (t) => { + t.increments(); + + t.string('name', 32).nullable(); + + t.timestamps(); + }); +} + +export async function down(knex: Knex) { + return knex.schema.dropTable(tableName); +} diff --git a/integration/crud-objection/migrations/20200624175907_CreateUsersLicenses.ts b/integration/crud-objection/migrations/20200624175907_CreateUsersLicenses.ts new file mode 100644 index 00000000..7cc2608b --- /dev/null +++ b/integration/crud-objection/migrations/20200624175907_CreateUsersLicenses.ts @@ -0,0 +1,25 @@ +import * as Knex from 'knex'; + +const tableName = 'users_licenses'; + +export async function up(knex: Knex) { + return knex.schema.createTable(tableName, (t) => { + t.integer('user_id') + .references('id') + .inTable('users') + .onDelete('CASCADE'); + + t.integer('license_id') + .references('id') + .inTable('licenses') + .onDelete('CASCADE'); + + t.primary(['user_id', 'license_id']); + + t.integer('years_active').notNullable(); + }); +} + +export async function down(knex: Knex) { + return knex.schema.dropTable(tableName); +} diff --git a/integration/crud-objection/migrations/20200624182343_CreateNotes.ts b/integration/crud-objection/migrations/20200624182343_CreateNotes.ts new file mode 100644 index 00000000..243bc155 --- /dev/null +++ b/integration/crud-objection/migrations/20200624182343_CreateNotes.ts @@ -0,0 +1,17 @@ +import * as Knex from 'knex'; + +const tableName = 'notes'; + +export async function up(knex: Knex) { + return knex.schema.createTable(tableName, (t) => { + t.increments(); + + t.integer('revision_id').notNullable(); + + t.timestamps(); + }); +} + +export async function down(knex: Knex) { + return knex.schema.dropTable(tableName); +} diff --git a/integration/crud-objection/notes/index.ts b/integration/crud-objection/notes/index.ts new file mode 100644 index 00000000..ce36215f --- /dev/null +++ b/integration/crud-objection/notes/index.ts @@ -0,0 +1,2 @@ +export * from './note.model'; +export * from './notes.service'; diff --git a/integration/crud-objection/notes/note.model.ts b/integration/crud-objection/notes/note.model.ts new file mode 100644 index 00000000..51bf97a8 --- /dev/null +++ b/integration/crud-objection/notes/note.model.ts @@ -0,0 +1,8 @@ +import { Model } from 'objection'; + +export class Note extends Model { + static readonly tableName = 'notes'; + + readonly id: number; + revisionId: number; +} diff --git a/integration/crud-objection/notes/notes.controller.ts b/integration/crud-objection/notes/notes.controller.ts new file mode 100644 index 00000000..61a47d7f --- /dev/null +++ b/integration/crud-objection/notes/notes.controller.ts @@ -0,0 +1,22 @@ +import { Controller } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Crud } from '@nestjsx/crud'; + +import { Note } from './note.model'; +import { NotesService } from './notes.service'; +import { dto } from './requests'; +import { serialize } from './responses'; + +@Crud({ + model: { type: Note }, + dto, + serialize, + query: { + alwaysPaginate: true, + }, +}) +@ApiTags('notes') +@Controller('/notes') +export class NotesController { + constructor(public service: NotesService) {} +} diff --git a/integration/crud-objection/notes/notes.module.ts b/integration/crud-objection/notes/notes.module.ts new file mode 100644 index 00000000..67e92d3f --- /dev/null +++ b/integration/crud-objection/notes/notes.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { NotesService } from './notes.service'; +import { NotesController } from './notes.controller'; + +@Module({ + providers: [NotesService], + exports: [NotesService], + controllers: [NotesController], +}) +export class NotesModule {} diff --git a/integration/crud-objection/notes/notes.service.ts b/integration/crud-objection/notes/notes.service.ts new file mode 100644 index 00000000..edf7c29b --- /dev/null +++ b/integration/crud-objection/notes/notes.service.ts @@ -0,0 +1,12 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { ObjectionCrudService } from '@nestjsx/crud-objection'; +import { ModelClass } from 'objection'; +import { Note } from './note.model'; + +@Injectable() +export class NotesService extends ObjectionCrudService { + constructor(@Inject('Note') modelClass: ModelClass) { + super(modelClass); + } +} diff --git a/integration/crud-objection/notes/requests/create-note.dto.ts b/integration/crud-objection/notes/requests/create-note.dto.ts new file mode 100644 index 00000000..1e55b221 --- /dev/null +++ b/integration/crud-objection/notes/requests/create-note.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber } from 'class-validator'; + +export class CreateNoteDto { + @ApiProperty({ type: 'number' }) + @IsNumber() + revisionId: string; +} diff --git a/integration/crud-objection/notes/requests/index.ts b/integration/crud-objection/notes/requests/index.ts new file mode 100644 index 00000000..4717fae6 --- /dev/null +++ b/integration/crud-objection/notes/requests/index.ts @@ -0,0 +1,5 @@ +import { CreateNoteDto } from './create-note.dto'; + +export const dto = { + create: CreateNoteDto, +}; diff --git a/integration/crud-objection/notes/responses/get-note-response.dto.ts b/integration/crud-objection/notes/responses/get-note-response.dto.ts new file mode 100644 index 00000000..7793a18c --- /dev/null +++ b/integration/crud-objection/notes/responses/get-note-response.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber } from 'class-validator'; + +export class GetNoteResponseDto { + @ApiProperty({ type: 'number' }) + @IsNumber() + id: string; + + @ApiProperty({ type: 'number' }) + @IsNumber() + revisionId: string; +} diff --git a/integration/crud-objection/notes/responses/index.ts b/integration/crud-objection/notes/responses/index.ts new file mode 100644 index 00000000..6dc26d2b --- /dev/null +++ b/integration/crud-objection/notes/responses/index.ts @@ -0,0 +1,5 @@ +import { GetNoteResponseDto } from './get-note-response.dto'; + +export const serialize = { + get: GetNoteResponseDto, +}; diff --git a/integration/crud-objection/projects/index.ts b/integration/crud-objection/projects/index.ts index 6838c78d..7f750777 100644 --- a/integration/crud-objection/projects/index.ts +++ b/integration/crud-objection/projects/index.ts @@ -1,2 +1,4 @@ export * from './project.model'; +export * from './user-project.model'; export * from './projects.service'; +export * from './user-projects.service'; diff --git a/integration/crud-objection/projects/my-projects.controller.ts b/integration/crud-objection/projects/my-projects.controller.ts new file mode 100644 index 00000000..a31eb203 --- /dev/null +++ b/integration/crud-objection/projects/my-projects.controller.ts @@ -0,0 +1,39 @@ +import { Controller } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Crud, CrudAuth } from '@nestjsx/crud'; +import { UserProject } from './user-project.model'; +import { User } from '../users'; +import { UserProjectsService } from './user-projects.service'; + +@Crud({ + model: { + type: UserProject, + }, + params: { + projectId: { + field: 'projectId', + type: 'number', + primary: true, + }, + }, + query: { + join: { + project: { + eager: true, + }, + }, + }, +}) +@CrudAuth({ + filter: (user: User) => ({ + userId: user.id, + }), + persist: (user: User) => ({ + userId: user.id, + }), +}) +@ApiTags('my-projects') +@Controller('my-projects') +export class MyProjectsController { + constructor(public service: UserProjectsService) {} +} diff --git a/integration/crud-objection/projects/project.model.ts b/integration/crud-objection/projects/project.model.ts index 5f81407e..38af3dc7 100644 --- a/integration/crud-objection/projects/project.model.ts +++ b/integration/crud-objection/projects/project.model.ts @@ -1,10 +1,18 @@ -import { IsBoolean, IsDefined, IsNumber, IsOptional, IsString, MaxLength } from 'class-validator'; +import { + IsBoolean, + IsDefined, + IsNumber, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; import { CrudValidationGroups } from '@nestjsx/crud'; import { BaseModel } from '../base.model'; import * as path from 'path'; import { Model } from 'objection'; import { Company } from '../companies'; import { User } from '../users'; +import { UserProject } from './user-project.model'; const { CREATE, UPDATE } = CrudValidationGroups; @@ -34,6 +42,7 @@ export class Project extends BaseModel { users?: User[]; company?: Company; + userProjects?: UserProject[]; static relationMappings = { company: { @@ -41,8 +50,8 @@ export class Project extends BaseModel { modelClass: path.resolve(__dirname, '../companies/company.model'), join: { from: 'projects.companyId', - to: 'companies.id' - } + to: 'companies.id', + }, }, users: { relation: Model.ManyToManyRelation, @@ -51,10 +60,18 @@ export class Project extends BaseModel { from: 'projects.id', through: { from: 'users_projects.projectId', - to: 'users_projects.userId' + to: 'users_projects.userId', }, - to: 'users.id' - } - } - } + to: 'users.id', + }, + }, + userProjects: { + relation: Model.HasManyRelation, + modelClass: path.resolve(__dirname, '../projects/user-project.model'), + join: { + from: 'projects.id', + to: 'users_projects.projectId', + }, + }, + }; } diff --git a/integration/crud-objection/projects/projects.controller.ts b/integration/crud-objection/projects/projects.controller.ts index 143b17b4..d8204a73 100644 --- a/integration/crud-objection/projects/projects.controller.ts +++ b/integration/crud-objection/projects/projects.controller.ts @@ -1,5 +1,5 @@ import { Controller } from '@nestjs/common'; -import { ApiUseTags } from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { Crud } from '@nestjsx/crud'; import { Project } from './project.model'; @@ -26,7 +26,7 @@ import { ProjectsService } from './projects.service'; }, }, }) -@ApiUseTags('projects') +@ApiTags('projects') @Controller('/companies/:companyId/projects') export class ProjectsController { constructor(public service: ProjectsService) {} diff --git a/integration/crud-objection/projects/projects.module.ts b/integration/crud-objection/projects/projects.module.ts index 3fe386ed..620c3713 100644 --- a/integration/crud-objection/projects/projects.module.ts +++ b/integration/crud-objection/projects/projects.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { ProjectsService } from './projects.service'; import { ProjectsController } from './projects.controller'; +import { UserProjectsService } from './user-projects.service'; +import { MyProjectsController } from './my-projects.controller'; @Module({ - providers: [ProjectsService], - exports: [ProjectsService], - controllers: [ProjectsController], + providers: [ProjectsService, UserProjectsService], + exports: [ProjectsService, UserProjectsService], + controllers: [ProjectsController, MyProjectsController], }) export class ProjectsModule {} diff --git a/integration/crud-objection/users-projects/user-project.model.ts b/integration/crud-objection/projects/user-project.model.ts similarity index 94% rename from integration/crud-objection/users-projects/user-project.model.ts rename to integration/crud-objection/projects/user-project.model.ts index 658beccd..90b5ed07 100644 --- a/integration/crud-objection/users-projects/user-project.model.ts +++ b/integration/crud-objection/projects/user-project.model.ts @@ -3,16 +3,18 @@ import { IsNotEmpty } from 'class-validator'; import { User } from '../users'; import { Model } from 'objection'; import { BaseModel } from '../base.model'; -import { Project } from '../projects'; +import { Project } from './index'; export class UserProject extends BaseModel { static tableName = 'users_projects'; @IsNotEmpty() - userId: number; + projectId: number; @IsNotEmpty() - projectId: number; + userId: number; + + review: string; /** * Relations diff --git a/integration/crud-objection/projects/user-projects.service.ts b/integration/crud-objection/projects/user-projects.service.ts new file mode 100644 index 00000000..91258f1d --- /dev/null +++ b/integration/crud-objection/projects/user-projects.service.ts @@ -0,0 +1,12 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { ObjectionCrudService } from '@nestjsx/crud-objection'; +import { ModelClass } from 'objection'; +import { UserProject } from './user-project.model'; + +@Injectable() +export class UserProjectsService extends ObjectionCrudService { + constructor(@Inject('UserProject') modelClass: ModelClass) { + super(modelClass); + } +} diff --git a/integration/crud-objection/reset-db.sh b/integration/crud-objection/reset-db.sh index 235ef628..60b9d250 100644 --- a/integration/crud-objection/reset-db.sh +++ b/integration/crud-objection/reset-db.sh @@ -3,3 +3,4 @@ psql -U root -d postgres -h 127.0.0.1 -c 'DROP DATABASE IF EXISTS nestjsx_crud_o psql -U root -d postgres -h 127.0.0.1 -c 'CREATE DATABASE nestjsx_crud_objection;' psql -U root -d postgres -h 127.0.0.1 -c 'GRANT CONNECT ON DATABASE nestjsx_crud_objection TO root;' + diff --git a/integration/crud-objection/seeds/04-Users.ts b/integration/crud-objection/seeds/04-Users.ts index 119240f5..a67b72f9 100644 --- a/integration/crud-objection/seeds/04-Users.ts +++ b/integration/crud-objection/seeds/04-Users.ts @@ -1,30 +1,34 @@ import * as Knex from 'knex'; import { Model } from 'objection'; -import { User } from '../users'; +import { Name, User } from '../users'; export async function seed(knex: Knex): Promise { Model.knex(knex); + const name: Name = { first: null, last: null }; + const name1: Name = { first: 'firstname1', last: 'lastname1' }; + await User.query().insert([ - { email: '1@email.com', isActive: true, companyId: 1, profileId: 1 }, - { email: '2@email.com', isActive: true, companyId: 1, profileId: 2 }, - { email: '3@email.com', isActive: true, companyId: 1, profileId: 3 }, - { email: '4@email.com', isActive: true, companyId: 1, profileId: 4 }, - { email: '5@email.com', isActive: true, companyId: 1, profileId: 5 }, - { email: '6@email.com', isActive: true, companyId: 1, profileId: 6 }, - { email: '7@email.com', isActive: false, companyId: 1, profileId: 7 }, - { email: '8@email.com', isActive: false, companyId: 1, profileId: 8 }, - { email: '9@email.com', isActive: false, companyId: 1, profileId: 9 }, - { email: '10@email.com', isActive: true, companyId: 1, profileId: 10 }, - { email: '11@email.com', isActive: true, companyId: 2, profileId: 11 }, - { email: '12@email.com', isActive: true, companyId: 2, profileId: 12 }, - { email: '13@email.com', isActive: true, companyId: 2, profileId: 13 }, - { email: '14@email.com', isActive: true, companyId: 2, profileId: 14 }, - { email: '15@email.com', isActive: true, companyId: 2, profileId: 15 }, - { email: '16@email.com', isActive: true, companyId: 2, profileId: 16 }, - { email: '17@email.com', isActive: false, companyId: 2, profileId: 17 }, - { email: '18@email.com', isActive: false, companyId: 2, profileId: 18 }, - { email: '19@email.com', isActive: false, companyId: 2, profileId: 19 }, - { email: '20@email.com', isActive: false, companyId: 2, profileId: 20 }, + { 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 }, ]); } diff --git a/integration/crud-objection/seeds/05-UsersProjects.ts b/integration/crud-objection/seeds/05-UsersProjects.ts index 5be4db82..aa93e98d 100644 --- a/integration/crud-objection/seeds/05-UsersProjects.ts +++ b/integration/crud-objection/seeds/05-UsersProjects.ts @@ -1,12 +1,14 @@ import * as Knex from 'knex'; import { Model } from 'objection'; -import { UserProject } from '../users-projects'; +import { UserProject } from '../projects'; export async function seed(knex: Knex): Promise { Model.knex(knex); await UserProject.query().insert([ - { userId: 1, projectId: 1 }, - { userId: 1, projectId: 2 } - ]) + { 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' }, + ]); } diff --git a/integration/crud-objection/seeds/06-Licenses.ts b/integration/crud-objection/seeds/06-Licenses.ts new file mode 100644 index 00000000..bc02227c --- /dev/null +++ b/integration/crud-objection/seeds/06-Licenses.ts @@ -0,0 +1,15 @@ +import * as Knex from 'knex'; +import { Model } from 'objection'; +import { License } from '../users-licenses'; + +export async function seed(knex: Knex): Promise { + Model.knex(knex); + + await License.query().insert([ + { name: 'License1' }, + { name: 'License2' }, + { name: 'License3' }, + { name: 'License4' }, + { name: 'License5' }, + ]); +} diff --git a/integration/crud-objection/seeds/07-UsersLicenses.ts b/integration/crud-objection/seeds/07-UsersLicenses.ts new file mode 100644 index 00000000..dbc04dcb --- /dev/null +++ b/integration/crud-objection/seeds/07-UsersLicenses.ts @@ -0,0 +1,16 @@ +import * as Knex from 'knex'; +import { Model } from 'objection'; +import { UserLicense } from '../users-licenses'; + +export async function seed(knex: Knex): Promise { + Model.knex(knex); + + await UserLicense.query() + .insert([ + { userId: 1, licenseId: 1, yearsActive: 3 }, + { userId: 1, licenseId: 2, yearsActive: 5 }, + { userId: 1, licenseId: 4, yearsActive: 7 }, + { userId: 2, licenseId: 5, yearsActive: 1 }, + ]) + .returning(['userId', 'licenseId']); +} diff --git a/integration/crud-objection/seeds/08-Notes.ts b/integration/crud-objection/seeds/08-Notes.ts new file mode 100644 index 00000000..ac2a3163 --- /dev/null +++ b/integration/crud-objection/seeds/08-Notes.ts @@ -0,0 +1,16 @@ +import * as Knex from 'knex'; +import { Model } from 'objection'; +import { Note } from '../notes'; + +export async function seed(knex: Knex): Promise { + Model.knex(knex); + + await Note.query().insert([ + { revisionId: 1 }, + { revisionId: 1 }, + { revisionId: 2 }, + { revisionId: 2 }, + { revisionId: 3 }, + { revisionId: 3 }, + ]); +} diff --git a/integration/crud-objection/users-licenses/index.ts b/integration/crud-objection/users-licenses/index.ts new file mode 100644 index 00000000..2498d8de --- /dev/null +++ b/integration/crud-objection/users-licenses/index.ts @@ -0,0 +1,2 @@ +export * from './license.model'; +export * from './user-license.model'; diff --git a/integration/crud-objection/users-licenses/license.model.ts b/integration/crud-objection/users-licenses/license.model.ts new file mode 100644 index 00000000..045b1614 --- /dev/null +++ b/integration/crud-objection/users-licenses/license.model.ts @@ -0,0 +1,11 @@ +import { IsOptional, IsString, MaxLength } from 'class-validator'; +import { BaseModel } from '../base.model'; + +export class License extends BaseModel { + static readonly tableName = 'licenses'; + + @IsOptional({ always: true }) + @IsString({ always: true }) + @MaxLength(32, { always: true }) + name: string; +} diff --git a/integration/crud-objection/users-licenses/user-license.model.ts b/integration/crud-objection/users-licenses/user-license.model.ts new file mode 100644 index 00000000..9afa40c2 --- /dev/null +++ b/integration/crud-objection/users-licenses/user-license.model.ts @@ -0,0 +1,40 @@ +import { Type } from 'class-transformer'; +import { License } from './license.model'; +import { Model } from 'objection'; +import * as path from 'path'; +import { User } from '../users'; + +export class UserLicense extends Model { + static readonly tableName = 'users_licenses'; + static readonly idColumn = ['userId', 'licenseId']; + + userId: number; + licenseId: number; + + yearsActive: number; + + @Type((t) => User) + user: User; + + @Type((t) => License) + license: License; + + static relationMappings = { + user: { + relation: Model.BelongsToOneRelation, + modelClass: path.resolve(__dirname, '../users/user.model'), + join: { + from: 'users_licenses.userId', + to: 'users.id', + }, + }, + license: { + relation: Model.BelongsToOneRelation, + modelClass: path.resolve(__dirname, '../users-licenses/license.model'), + join: { + from: 'users_licenses.licenseId', + to: 'licenses.id', + }, + }, + }; +} diff --git a/integration/crud-objection/users-projects/index.ts b/integration/crud-objection/users-projects/index.ts deleted file mode 100644 index a1db6fb1..00000000 --- a/integration/crud-objection/users-projects/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './user-project.model'; diff --git a/integration/crud-objection/users/me.controller.ts b/integration/crud-objection/users/me.controller.ts new file mode 100644 index 00000000..0c2ecbfb --- /dev/null +++ b/integration/crud-objection/users/me.controller.ts @@ -0,0 +1,41 @@ +import { Controller } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Crud, CrudAuth } from '@nestjsx/crud'; + +import { UsersService } from './users.service'; +import { User } from './user.model'; + +@Crud({ + model: { + type: User, + }, + routes: { + only: ['getOneBase', 'updateOneBase'], + }, + params: { + id: { + primary: true, + disabled: true, + }, + }, + query: { + join: { + company: { + eager: true, + }, + profile: { + eager: true, + }, + }, + }, +}) +@CrudAuth({ + filter: (user: User) => ({ + id: user.id, + }), +}) +@ApiTags('me') +@Controller('me') +export class MeController { + constructor(public service: UsersService) {} +} diff --git a/integration/crud-objection/users/user.model.ts b/integration/crud-objection/users/user.model.ts index d459b833..3e2784f9 100644 --- a/integration/crud-objection/users/user.model.ts +++ b/integration/crud-objection/users/user.model.ts @@ -1,27 +1,40 @@ import { + IsBoolean, + IsEmail, + IsNotEmpty, IsOptional, IsString, MaxLength, - IsNotEmpty, - IsEmail, - IsBoolean, ValidateNested, } from 'class-validator'; import { Type } from 'class-transformer'; import { CrudValidationGroups } from '@nestjsx/crud'; -import { UserProfile } from '../users-profiles/user-profile.model'; -import { Company } from '../companies/company.model'; -import { Project } from '../projects/project.model'; -import * as path from "path"; +import { UserProfile } from '../users-profiles'; +import { Company } from '../companies'; +import { Project, UserProject } from '../projects'; +import * as path from 'path'; import { Model } from 'objection'; import { BaseModel } from '../base.model'; +import { UserLicense } from '../users-licenses'; const { CREATE, UPDATE } = CrudValidationGroups; +export class Name { + @IsString({ always: true }) + first: string; + + @IsString({ always: true }) + last: string; +} + export class User extends BaseModel { static tableName = 'users'; + static get jsonAttributes() { + return ['name']; + } + @IsOptional({ groups: [UPDATE] }) @IsNotEmpty({ groups: [CREATE] }) @IsString({ always: true }) @@ -34,6 +47,9 @@ export class User extends BaseModel { @IsBoolean({ always: true }) isActive: boolean; + @Type((t) => Name) + name: Name; + profileId?: number; companyId?: number; @@ -44,29 +60,32 @@ export class User extends BaseModel { @ValidateNested({ always: true }) @IsOptional({ groups: [UPDATE] }) @IsNotEmpty({ groups: [CREATE] }) - @Type(t => UserProfile) + @Type((t) => UserProfile) profile?: Partial; company?: Company; projects?: Project[]; + userProjects?: UserProject[]; + userLicenses?: UserLicense[]; + static relationMappings = { profile: { relation: Model.BelongsToOneRelation, modelClass: path.resolve(__dirname, '../users-profiles/user-profile.model'), join: { from: 'users.profileId', - to: 'user_profiles.id' - } + to: 'user_profiles.id', + }, }, company: { relation: Model.BelongsToOneRelation, modelClass: path.resolve(__dirname, '../companies/company.model'), join: { from: 'users.companyId', - to: 'companies.id' - } + to: 'companies.id', + }, }, projects: { relation: Model.ManyToManyRelation, @@ -75,10 +94,26 @@ export class User extends BaseModel { from: 'users.id', through: { from: 'users_projects.userId', - to: 'users_projects.projectId' + to: 'users_projects.projectId', }, - to: 'projects.id' - } - } - } + to: 'projects.id', + }, + }, + userProjects: { + relation: Model.HasManyRelation, + modelClass: path.resolve(__dirname, '../projects/user-project.model'), + join: { + from: 'users.id', + to: 'users_projects.userId', + }, + }, + userLicenses: { + relation: Model.HasManyRelation, + modelClass: path.resolve(__dirname, '../users-licenses/user-license.model'), + join: { + from: 'users.id', + to: 'users_licenses.userId', + }, + }, + }; } diff --git a/integration/crud-objection/users/users.controller.ts b/integration/crud-objection/users/users.controller.ts index 8b48056b..2bc3f580 100644 --- a/integration/crud-objection/users/users.controller.ts +++ b/integration/crud-objection/users/users.controller.ts @@ -4,8 +4,8 @@ import { Crud, CrudController, CrudRequest, - ParsedRequest, Override, + ParsedRequest, } from '@nestjsx/crud'; import { User } from './user.model'; diff --git a/integration/crud-objection/users/users.module.ts b/integration/crud-objection/users/users.module.ts index b401e160..7ff741b2 100644 --- a/integration/crud-objection/users/users.module.ts +++ b/integration/crud-objection/users/users.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { UsersService } from './users.service'; import { UsersController } from './users.controller'; +import { MeController } from './me.controller'; @Module({ providers: [UsersService], exports: [UsersService], - controllers: [UsersController], + controllers: [UsersController, MeController], }) export class UsersModule {} diff --git a/package-scripts.js b/package-scripts.js index 8fda6791..333bc803 100644 --- a/package-scripts.js +++ b/package-scripts.js @@ -1,7 +1,7 @@ const utils = require('nps-utils'); const getSeries = (args) => utils.series.nps(...args); -const names = ['util', 'crud-request', 'crud', 'crud-typeorm']; +const names = ['util', 'crud-request', 'crud', 'crud-typeorm', 'crud-objection']; const getBuildCmd = (pkg) => { const str = 'npx lerna run build'; diff --git a/package.json b/package.json index 1780e75a..e8a94670 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,13 @@ "rebuild": "yarn clean && yarn build", "build": "yarn s build", "clean": "yarn s clean", - "test": "npx jest --runInBand -c=jest.config.js packages/ --verbose", - "test:coverage": "yarn test:all --coverage", + "test": "jest --runInBand -c=jest.config.js packages/ --verbose --detectOpenHandles", + "test:coverage": "yarn test:all-and-preserve-coverage --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", + "test:all": "yarn test:mysql && yarn test:postgres && yarn test:objection:postgres", + "test:all-and-preserve-coverage": "yarn test:mysql && yarn db:prepare:typeorm && yarn db:prepare:objection && yarn test", + "test:postgres": "yarn db:prepare:typeorm && yarn test --testPathIgnorePatterns=objection", + "test:mysql": "yarn db:prepare:typeorm:mysql && TYPEORM_CONNECTION=mysql yarn test --testPathIgnorePatterns=objection", "start:typeorm": "npx nodemon -w ./integration/crud-typeorm -e ts node_modules/ts-node/dist/bin.js integration/crud-typeorm/main.ts", "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", @@ -27,11 +28,12 @@ "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", + "test:objection:postgres": "yarn db:prepare:objection && yarn test --testPathIgnorePatterns=typeorm", "start:objection": "npx nodemon -w ./integration/crud-objection -e ts node_modules/.bin/ts-node integration/crud-objection/main.ts", - "db:sync:objection": "cd ./integration/crud-objection && npm run knex migrate:latest", - "db:drop:objection": "cd ./integration/crud-objection && docker exec -i $(docker-compose ps -q postresql) sh < reset-db.sh", - "db:seeds:objection": "cd ./integration/crud-typeorm && npm run knex seed:run", - "db:prepare:objection": "npm run db:drop:objection && npm run db:sync:objection && npm run db:seeds:objection", + "db:sync:objection": "cd ./integration/crud-objection && yarn knex migrate:latest", + "db:drop:objection": "cd ./integration/crud-objection && docker exec -i $(docker-compose ps -q postgres) sh < reset-db.sh", + "db:seeds:objection": "cd ./integration/crud-typeorm && yarn knex seed:run", + "db:prepare:objection": "yarn db:drop:objection && yarn db:sync:objection && yarn db:seeds:objection", "knex": "knex --knexfile integration/crud-objection/knexfile.js", "format": "npx pretty-quick --pattern \"packages/**/!(*.d).ts\"", "lint": "npx tslint 'packages/**/*.ts'", @@ -83,14 +85,14 @@ "husky": "3.0.5", "jest": "24.9.0", "jest-extended": "0.11.2", + "knex": "0.20.15", "lerna": "3.16.4", - "knex": "^0.19.1", "mysql": "^2.18.1", "nodemon": "1.19.2", "npm-check": "5.9.0", "nps": "5.9.8", "nps-utils": "1.7.0", - "objection": "^1.6.9", + "objection": "2.2.0", "pg": "7.12.1", "pluralize": "^8.0.0", "prettier": "1.18.2", diff --git a/packages/crud-objection/src/objection-crud.service.ts b/packages/crud-objection/src/objection-crud.service.ts index 5f9ba8e7..159ebbb3 100644 --- a/packages/crud-objection/src/objection-crud.service.ts +++ b/packages/crud-objection/src/objection-crud.service.ts @@ -8,22 +8,36 @@ import { QueryOptions, } from '@nestjsx/crud'; import { + ComparisonOperator, + CondOperator, ParsedRequestParams, QueryFilter, QueryJoin, QuerySort, - CondOperator, + QuerySortOperator, + SCondition, + SConditionKey, } from '@nestjsx/crud-request'; -import { hasLength, isArrayFull, isObject, isUndefined, objKeys } from '@nestjsx/util'; +import { + hasLength, + isArrayFull, + isNil, + isObject, + isUndefined, + objKeys, +} from '@nestjsx/util'; import { Model, ModelClass, - QueryBuilder, + Raw, + raw, Relation as ObjectionRelation, Transaction, transaction, } from 'objection'; import { OnModuleInit } from '@nestjs/common'; +import { ObjectLiteral } from 'typeorm'; +import { oO } from '@zmotivat0r/o0'; interface ModelRelation { name: string; @@ -34,148 +48,212 @@ interface ModelRelation { referencedColumnProps: string[]; } +type DbmsType = 'pg' | 'mysql'; + +interface OperatorOptions { + value: any; + dbmsType: DbmsType; +} + +interface NormalizedOperator { + columnProp: string | Raw; + operator: string; + value?: any; +} + +interface OperatorNormalizer { + (columnProp: string, options?: OperatorOptions): NormalizedOperator; +} + const CHUNK_SIZE = 1000; const OBJECTION_RELATION_SEPARATOR = ':'; const PATH_SEPARATOR = '.'; const OPERATORS: { - [operator: string]: ( - columnProp: string, - val?: any, - ) => { columnProp: string; operator: string; value?: any }; + [operator: string]: OperatorNormalizer; } = { - [CondOperator.EQUALS]: (columnProp: string, val: any) => { - return { columnProp, operator: '=', value: val }; + [CondOperator.EQUALS]: (columnProp, { value }) => { + return { columnProp, operator: '=', value }; }, - [CondOperator.NOT_EQUALS]: (columnProp: string, val: any) => { - return { columnProp, operator: '!=', value: val }; + [CondOperator.NOT_EQUALS]: (columnProp, { value }) => { + return { columnProp, operator: '!=', value }; }, - [CondOperator.GREATER_THAN]: (columnProp: string, val: any) => { - return { columnProp, operator: '>', value: val }; + [CondOperator.GREATER_THAN]: (columnProp, { value }) => { + return { columnProp, operator: '>', value }; }, - [CondOperator.LOWER_THAN]: (columnProp: string, val: any) => { - return { columnProp, operator: '<', value: val }; + [CondOperator.LOWER_THAN]: (columnProp, { value }) => { + return { columnProp, operator: '<', value }; }, - [CondOperator.GREATER_THAN_EQUALS]: (columnProp: string, val: any) => { - return { columnProp, operator: '>=', value: val }; + [CondOperator.GREATER_THAN_EQUALS]: (columnProp, { value }) => { + return { columnProp, operator: '>=', value }; }, - [CondOperator.LOWER_THAN_EQAULS]: (columnProp: string, val: any) => { - return { columnProp, operator: '<=', value: val }; + [CondOperator.LOWER_THAN_EQUALS]: (columnProp, { value }) => { + return { columnProp, operator: '<=', value }; }, - [CondOperator.STARTS]: (columnProp: string, val: any) => { + [CondOperator.STARTS]: (columnProp, { value }) => { return { columnProp, operator: 'LIKE', - value: `${val}%`, + value: `${value}%`, }; }, - [CondOperator.ENDS]: (columnProp: string, val: any) => { + [CondOperator.ENDS]: (columnProp, { value }) => { return { columnProp, operator: 'LIKE', - value: `%${val}`, + value: `%${value}`, }; }, - [CondOperator.CONTAINS]: (columnProp: string, val: any) => { + [CondOperator.CONTAINS]: (columnProp, { value }) => { return { columnProp, operator: 'LIKE', - value: `%${val}%`, + value: `%${value}%`, }; }, - [CondOperator.EXCLUDES]: (columnProp: string, val: any) => { + [CondOperator.EXCLUDES]: (columnProp, { value }) => { return { columnProp, - operator: 'NOT LIKE', - value: `%${val}%`, + operator: `NOT LIKE`, + value: `%${value}%`, }; }, - [CondOperator.IN]: (columnProp: string, val: any) => { + [CondOperator.IN]: (columnProp, { value }) => { /* istanbul ignore if */ - if (!isArrayFull(val)) { + if (!isArrayFull(value)) { throw new Error(`Invalid column '${columnProp}' value`); } return { columnProp, operator: 'IN', - value: val, + value, }; }, - [CondOperator.NOT_IN]: (columnProp: string, val: any) => { + [CondOperator.NOT_IN]: (columnProp, { value }) => { /* istanbul ignore if */ - if (!isArrayFull(val)) { + if (!isArrayFull(value)) { throw new Error(`Invalid column '${columnProp}' value`); } return { columnProp, operator: 'NOT IN', - value: val, + value, }; }, - [CondOperator.IS_NULL]: (columnProp: string) => { + [CondOperator.IS_NULL]: (columnProp) => { return { columnProp, operator: 'IS NULL', }; }, - [CondOperator.NOT_NULL]: (columnProp: string) => { + [CondOperator.NOT_NULL]: (columnProp) => { return { columnProp, operator: 'IS NOT NULL', }; }, - [CondOperator.BETWEEN]: (columnProp: string, val: any) => { + [CondOperator.BETWEEN]: (columnProp, { value }) => { /* istanbul ignore if */ - if (!Array.isArray(val) || val.length !== 2) { + if (!Array.isArray(value) || value.length !== 2) { throw new Error(`Invalid column '${columnProp}' value`); } return { columnProp, operator: 'BETWEEN', - value: [val[0], val[1]], + value: [value[0], value[1]], + }; + }, + // case insensitive + [CondOperator.EQUALS_LOW]: (columnProp, { value }) => { + return { columnProp: raw('LOWER(??)', [columnProp]), operator: '=', value }; + }, + [CondOperator.NOT_EQUALS_LOW]: (columnProp, { value }) => { + return { columnProp: raw('LOWER(??)', [columnProp]), operator: '!=', value }; + }, + [CondOperator.STARTS_LOW]: (columnProp, { value, dbmsType }) => { + return { + columnProp: raw('LOWER(??)', [columnProp]), + /* istanbul ignore next */ + operator: dbmsType === 'pg' ? 'ILIKE' : /* istanbul ignore next */ 'LIKE', + value: `${value}%`, + }; + }, + [CondOperator.ENDS_LOW]: (columnProp, { value, dbmsType }) => { + return { + columnProp: raw('LOWER(??)', [columnProp]), + /* istanbul ignore next */ + operator: dbmsType === 'pg' ? 'ILIKE' : /* istanbul ignore next */ 'LIKE', + value: `%${value}`, + }; + }, + [CondOperator.CONTAINS_LOW]: (columnProp, { value, dbmsType }) => { + return { + columnProp: raw('LOWER(??)', [columnProp]), + /* istanbul ignore next */ + operator: dbmsType === 'pg' ? 'ILIKE' : /* istanbul ignore next */ 'LIKE', + value: `%${value}%`, + }; + }, + [CondOperator.EXCLUDES_LOW]: (columnProp, { value, dbmsType }) => { + return { + columnProp: raw('LOWER(??)', [columnProp]), + operator: `NOT ${dbmsType === 'pg' ? 'ILIKE' : /* istanbul ignore next */ 'LIKE'}`, + value: `%${value}%`, + }; + }, + [CondOperator.IN_LOW]: (columnProp, { value }) => { + /* istanbul ignore if */ + if (!isArrayFull(value)) { + throw new Error(`Invalid column '${columnProp}' value`); + } + return { + columnProp: raw('LOWER(??)', [columnProp]), + operator: 'IN', + value, + }; + }, + [CondOperator.NOT_IN_LOW]: (columnProp, { value }) => { + /* istanbul ignore if */ + if (!isArrayFull(value)) { + throw new Error(`Invalid column '${columnProp}' value`); + } + return { + columnProp: raw('LOWER(??)', [columnProp]), + operator: 'NOT IN', + value, }; }, }; export class ObjectionCrudService extends CrudService implements OnModuleInit { + protected dbmsType: DbmsType; private modelColumnProps: string[]; private modelColumnPropsSet: Set = new Set(); private modelIdColumnProps: string[]; private modelRelations: { [relationName: string]: ModelRelation } = {}; private notRecognizedModelRelations: Set = new Set(); + protected sqlInjectionRegEx: RegExp[] = [ + /(%27)|(\')|(--)|(%23)|(#)/gi, + /((%3D)|(=))[^\n]*((%27)|(\')|(--)|(%3B)|(;))/gi, + /w*((%27)|(\'))((%6F)|o|(%4F))((%72)|r|(%52))/gi, + /((%27)|(\'))union/gi, + ]; constructor(public readonly modelClass: ModelClass) { super(); } + query(trx?: Transaction): T['QueryBuilderType'] { + return this.modelClass.query(trx); + } + async onModuleInit() { await this.fetchTableMetadata(this.modelClass.tableName); await this.initModelRelations(); await this.initModelColumnProps(); - } - - private async fetchTableMetadata(tableName: string) { - return Model.fetchTableMetadata({ table: tableName }); - } - - private get tableName(): string { - return this.modelClass.tableName; - } - - private get idColumns(): string[] { - return [].concat(this.modelClass.idColumn); - } - - private columnToProp(column: string): string { - return (Model as any).columnNameToPropertyName(column); - } - - private getObjectionRelations( - modelClass: ModelClass, - ): { [relationName: string]: ObjectionRelation } { - return (modelClass as any).getRelations(); + this.dbmsType = this.modelClass.knex().client.config.client; } public async withTransaction( @@ -185,11 +263,10 @@ export class ObjectionCrudService extends CrudService return transaction(trx || this.modelClass.knex(), (innerTrx) => callback(innerTrx)); } - /** - * Get many - * @param req - * @param trx - */ + public async getOne(req: CrudRequest, trx?: Transaction): Promise { + return this.getOneOrFail(req, { trx }); + } + public async getMany( req: CrudRequest, trx?: Transaction, @@ -202,51 +279,48 @@ export class ObjectionCrudService extends CrudService const { total, data } = await builder.then((data) => builder.resultSize().then((total) => ({ total, data })), ); - return this.createPageInfo(data, total, limit, offset); + /* istanbul ignore next */ + return this.createPageInfo(data, total, limit || total, offset || 0); } - return builder; - } - - /** - * Get one - * @param req - * @param trx - */ - public async getOne(req: CrudRequest, trx?: Transaction): Promise { - return this.getOneOrFail(req, trx); + return builder as any; } - /** - * Create one - * @param req - * @param dto - * @param trx - */ public async createOne( req: CrudRequest, dto: Partial, trx?: Transaction, ): Promise { - const model = this.prepareModelBeforeSave(dto, req.parsed.paramsFilter); + const { returnShallow } = req.options.routes.createOneBase; + const model = this.prepareModelBeforeSave(dto, req.parsed); /* istanbul ignore if */ if (!model) { this.throwBadRequestException(`Empty data. Nothing to save.`); } - return this.withTransaction((innerTrx) => { - // @ts-ignore - return this.modelClass.query(innerTrx).insertGraph(model); - }, trx); + return (await this.withTransaction(async (innerTrx) => { + const saved = await this.modelClass.query(innerTrx).insertGraph(model); + + if (returnShallow) { + return saved; + } else { + const primaryParams = this.getPrimaryParams(req.options); + + /* istanbul ignore next */ + if (!primaryParams.length || primaryParams.some((p) => isNil(saved[p]))) { + return saved; + } else { + req.parsed.search = primaryParams.reduce( + (acc, p) => ({ ...acc, [p]: saved[p] }), + {}, + ); + return this.getOneOrFail(req, { trx: innerTrx }); + } + } + }, trx)) as any; } - /** - * Create many - * @param req - * @param dto - * @param trx - */ public async createMany( req: CrudRequest, dto: CreateManyDto>, @@ -258,7 +332,7 @@ export class ObjectionCrudService extends CrudService } const bulk = dto.bulk - .map((one) => this.prepareModelBeforeSave(one, req.parsed.paramsFilter)) + .map((one) => this.prepareModelBeforeSave(one, req.parsed)) .filter((d) => !isUndefined(d)); /* istanbul ignore if */ @@ -271,7 +345,6 @@ export class ObjectionCrudService extends CrudService const chunks = toChunks(bulk); for (const chunk of chunks) { - // @ts-ignore result = result.concat(await this.modelClass.query(innerTrx).insertGraph(chunk)); } @@ -279,102 +352,214 @@ export class ObjectionCrudService extends CrudService }, trx); } - /** - * Update one - * @param req - * @param dto - * @param trx - */ public async updateOne( req: CrudRequest, dto: Partial, trx?: Transaction, ): Promise { - const found = await this.getOneOrFail(req, trx); + const { allowParamsOverride, returnShallow } = req.options.routes.updateOneBase; + + return (await this.withTransaction(async (innerTrx) => { + const found = await this.getOneOrFail(req, { + trx: innerTrx, + shallow: returnShallow, + }); + + const paramsFilters = this.getParamFilters(req.parsed); + if (allowParamsOverride) { + Object.assign(found, dto, req.parsed.authPersist); + } else { + Object.assign(found, dto, paramsFilters, req.parsed.authPersist); + } + + const updated = (await this.modelClass.query(innerTrx).upsertGraph(found, { + noDelete: true, + noInsert: true, + noRelate: true, + noUnrelate: true, + })) as any; + + if (returnShallow) { + return updated; + } else { + req.parsed.paramsFilter.forEach((filter) => { + filter.value = updated[filter.field]; + }); + + return this.getOneOrFail(req, { trx: innerTrx }); + } + }, trx)) as any; + } + + public getParamFilters(parsed: CrudRequest['parsed']): ObjectLiteral { + let filters = {}; /* istanbul ignore else */ - if ( - hasLength(req.parsed.paramsFilter) && - !req.options.routes.updateOneBase.allowParamsOverride - ) { - for (const filter of req.parsed.paramsFilter) { - dto[filter.field] = filter.value; + if (hasLength(parsed.paramsFilter)) { + for (const filter of parsed.paramsFilter) { + filters[filter.field] = filter.value; } } - return this.withTransaction((innerTrx) => { - return found.$query(innerTrx).upsertGraph( - // @ts-ignore - { ...found, ...dto }, - { - noDelete: true, - noInsert: true, - noRelate: true, - noUnrelate: true, - }, - ); - }, trx); + return filters; } - /** - * Replace one - * @param req - * @param dto - * @param trx - */ public async replaceOne( req: CrudRequest, dto: Partial, trx?: Transaction, ): Promise { - /* istanbul ignore else */ - if ( - hasLength(req.parsed.paramsFilter) && - !req.options.routes.replaceOneBase.allowParamsOverride - ) { - for (const filter of req.parsed.paramsFilter) { - dto[filter.field] = filter.value; + const { allowParamsOverride, returnShallow } = req.options.routes.replaceOneBase; + + return (await this.withTransaction(async (innerTrx) => { + const [_, found = {}] = await oO( + this.getOneOrFail(req, { + trx: innerTrx, + shallow: returnShallow, + }), + ); + + const paramsFilters = this.getParamFilters(req.parsed); + if (allowParamsOverride) { + Object.assign(found, paramsFilters, dto, req.parsed.authPersist); + } else { + Object.assign(found, dto, paramsFilters, req.parsed.authPersist); } - } - return this.withTransaction((innerTrx) => { - // @ts-ignore - return this.modelClass.query(innerTrx).upsertGraph(dto, { + const replaced = await this.modelClass.query(innerTrx).upsertGraph(found, { noDelete: true, noUnrelate: true, + insertMissing: true, }); - }, trx); + + if (returnShallow) { + return replaced; + } else { + const primaryParams = this.getPrimaryParams(req.options); + + /* istanbul ignore next */ + if (!primaryParams.length) { + return replaced; + } + + req.parsed.search = primaryParams.reduce( + (acc, p) => ({ ...acc, [p]: replaced[p] }), + {}, + ); + return this.getOneOrFail(req, { trx: innerTrx }); + } + }, trx)) as any; } - /** - * Delete one - * @param req - * @param trx - */ public async deleteOne(req: CrudRequest, trx?: Transaction): Promise { - const found = await this.getOneOrFail(req, trx); + const { returnDeleted } = req.options.routes.deleteOneBase; + const found = await this.getOneOrFail(req, { trx, shallow: returnDeleted }); await found.$query(trx).delete(); + return returnDeleted ? found : undefined; + } - /* istanbul ignore else */ - if (req.options.routes.deleteOneBase.returnDeleted) { - for (const filter of req.parsed.paramsFilter) { - found[filter.field] = filter.value; - } + private get tableName(): string { + return this.modelClass.tableName; + } - return found; - } + private get idColumns(): string[] { + return [].concat(this.modelClass.idColumn); + } + + private columnToProp(column: string): string { + return (Model as any).columnNameToPropertyName(column); + } + + private async fetchTableMetadata(tableName: string) { + return Model.fetchTableMetadata({ table: tableName }); + } + + private getObjectionRelations( + modelClass: ModelClass, + ): { [relationName: string]: ObjectionRelation } { + return (modelClass as any).getRelations(); } - private async getOneOrFail(req: CrudRequest, trx?: Transaction): Promise { + private async initModelRelations() { + const objectionRelations: ObjectionRelation[] = Object.values( + this.getObjectionRelations(this.modelClass), + ); + + await Promise.all( + objectionRelations.map(async (relation) => { + this.modelRelations[relation.name] = await this.toModelRelation(relation); + }), + ); + } + + private async initModelColumnProps() { + this.modelColumnProps = (await this.fetchTableMetadata( + this.modelClass.tableName, + )).columns.map((column) => { + const columnProp = this.columnToProp(column); + this.modelColumnPropsSet.add(columnProp); + return columnProp; + }); + + this.modelIdColumnProps = this.idColumns.map((column) => this.columnToProp(column)); + } + + private async getOneOrFail( + req: CrudRequest, + fetchOptions: { trx?: Transaction; shallow?: boolean }, + ): Promise { const { parsed, options } = req; - const { builder } = await this.createBuilder(parsed, options, { trx }); - const found = await builder.limit(1).first(); + const { trx, shallow } = fetchOptions; + + const builder = shallow + ? this.modelClass.query(trx).skipUndefined() + : (await this.createBuilder(parsed, options, { trx })).builder; + + if (shallow) { + this.setSearchCondition(builder, parsed.search); + } + + const found = await builder.first(); if (!found) { this.throwNotFoundException(this.tableName); } - return found; + return found as any; + } + + private prepareModelBeforeSave( + dto: Partial, + parsed: CrudRequest['parsed'], + ): Partial { + /* istanbul ignore if */ + if (!isObject(dto)) { + return undefined; + } + + if (hasLength(parsed.paramsFilter)) { + for (const filter of parsed.paramsFilter) { + dto[filter.field] = filter.value; + } + } + + /* istanbul ignore if */ + if (!hasLength(objKeys(dto))) { + return undefined; + } + + return { ...dto, ...parsed.authPersist }; + } + + private hasColumnProp(columnProp: string): boolean { + return this.modelColumnPropsSet.has(columnProp); + } + + private hasModelRelationColumnProp(relationPath: string, columnProp: string): boolean { + return ( + this.hasModelRelation(relationPath) && + this.modelRelations[relationPath].columnProps.includes(columnProp) + ); } private async createBuilder( @@ -391,76 +576,23 @@ export class ObjectionCrudService extends CrudService const select = this.getSelect(parsedReq, options.query); builder.select(select); - if (isArrayFull(options.query.filter)) { - options.query.filter.forEach((filter) => { - this.setAndWhere(filter, builder); - }); - } - - const filters = [...parsedReq.paramsFilter, ...parsedReq.filter]; - const hasFilter = isArrayFull(filters); - const hasOr = isArrayFull(parsedReq.or); + const joinAliases = objKeys(options.query.join || {}).reduce( + (acc, joinField: string) => { + if (options.query.join[joinField].alias) { + acc[options.query.join[joinField].alias] = joinField; + } + return acc; + }, + {}, + ); - if (hasFilter && hasOr) { - if (filters.length === 1 && parsedReq.or.length === 1) { - // WHERE :filter OR :or - builder.andWhere((qb) => { - this.setOrWhere(filters[0], qb); - this.setOrWhere(parsedReq.or[0], qb); - }); - } else if (filters.length === 1) { - builder.andWhere((qb) => { - this.setAndWhere(filters[0], qb); - qb.orWhere((orQb) => { - parsedReq.or.forEach((filter) => { - this.setAndWhere(filter, orQb); - }); - }); - }); - } else if (parsedReq.or.length === 1) { - builder.andWhere((qb) => { - this.setAndWhere(parsedReq.or[0], qb); - qb.orWhere((orQb) => { - filters.forEach((filter) => { - this.setAndWhere(filter, orQb); - }); - }); - }); - } else { - builder.andWhere((qb) => { - qb.andWhere((andQb) => { - filters.forEach((filter) => { - this.setAndWhere(filter, andQb); - }); - }); - qb.orWhere((orQb) => { - parsedReq.or.forEach((filter) => { - this.setAndWhere(filter, orQb); - }); - }); - }); - } - } else if (hasOr) { - // WHERE :or OR :or OR ... - builder.andWhere((qb) => { - parsedReq.or.forEach((filter) => { - this.setOrWhere(filter, qb); - }); - }); - } else if (hasFilter) { - // WHERE :filter AND :filter AND ... - builder.andWhere((qb) => { - filters.forEach((filter) => { - this.setAndWhere(filter, qb); - }); - }); - } + this.setSearchCondition(builder, parsedReq.search, '$and', joinAliases); const joinOptions = options.query.join || {}; const allowedJoins = objKeys(joinOptions); if (hasLength(allowedJoins)) { - const eagerJoins: any = {}; + const eagerJoins = new Set(); for (const allowedJoin of allowedJoins) { if (joinOptions[allowedJoin].eager) { @@ -471,13 +603,13 @@ export class ObjectionCrudService extends CrudService }; await this.setJoin(cond, joinOptions, builder); - eagerJoins[allowedJoin] = true; + eagerJoins.add(allowedJoin); } } if (isArrayFull(parsedReq.join)) { for (const join of parsedReq.join) { - if (!eagerJoins[join.field]) { + if (!eagerJoins.has(join.field)) { await this.setJoin(join, joinOptions, builder); } } @@ -500,7 +632,7 @@ export class ObjectionCrudService extends CrudService } if (options.query.cache && parsedReq.cache !== 0) { - // TODO: Consider implementing this in this module + // TODO: Find a workaround to implement caching for ObjectionCrudService console.warn(`Objection.js doesn't support query caching`); } @@ -509,68 +641,206 @@ export class ObjectionCrudService extends CrudService }; } - private async initModelColumnProps() { - this.modelColumnProps = (await this.fetchTableMetadata( - this.modelClass.tableName, - )).columns.map((column) => { - const columnProp = this.columnToProp(column); - this.modelColumnPropsSet.add(columnProp); - return columnProp; - }); - - this.modelIdColumnProps = this.idColumns.map((column) => this.columnToProp(column)); - } + protected setSearchCondition( + builder: T['QueryBuilderType'], + search: SCondition, + condition: SConditionKey = '$and', + joinAliases: object = {}, + ) { + /* istanbul ignore else */ + if (!isObject(search)) { + return; + } - private prepareModelBeforeSave( - dto: Partial, - paramsFilter: QueryFilter[], - ): Partial { - /* istanbul ignore if */ - if (!isObject(dto)) { - return undefined; + const searchProps = objKeys(search); + if (!searchProps.length) { + return; } - if (hasLength(paramsFilter)) { - for (const filter of paramsFilter) { - dto[filter.field] = filter.value; + /* istanbul ignore else */ + // search: {$and: [...], ...} + if (isArrayFull(search.$and)) { + if (search.$and.length === 1) { + // search: {$and: [{}]} + this.setSearchCondition(builder, search.$and[0], condition, joinAliases); + } else { + // search: {$and: [{}, {}, ...]} + this.builderAddBrackets(builder, condition, (qb: T['QueryBuilderType']) => { + search.$and.forEach((item: SCondition) => { + this.setSearchCondition(qb, item, '$and', joinAliases); + }); + }); } + return; } - /* istanbul ignore if */ - if (!hasLength(objKeys(dto))) { - return undefined; - } + if (isArrayFull(search.$or)) { + // search: {$or: [...], ...} + if (searchProps.length === 1) { + // search: {$or: [...]} + if (search.$or.length === 1) { + // search: {$or: [{}]} + this.setSearchCondition(builder, search.$or[0], condition, joinAliases); + } else { + // search: {$or: [{}, {}, ...]} + this.builderAddBrackets(builder, condition, (qb: T['QueryBuilderType']) => { + search.$or.forEach((item: SCondition) => { + this.setSearchCondition(qb, item, '$or', joinAliases); + }); + }); + } + } else { + // search: {$or: [...], foo, ...} + this.builderAddBrackets(builder, condition, (qb: T['QueryBuilderType']) => { + searchProps.forEach((field: string) => { + if (field !== '$or') { + const value = search[field]; + if (!isObject(value)) { + this.setAndWhere( + { field, value, operator: CondOperator.EQUALS }, + qb, + joinAliases, + ); + } else { + this.setSearchFieldObjectCondition(qb, '$and', field, value, joinAliases); + } + } else { + if (search.$or.length === 1) { + this.setSearchCondition(qb, search.$or[0], '$and', joinAliases); + } else { + this.builderAddBrackets(qb, '$and', (qb2: T['QueryBuilderType']) => { + search.$or.forEach((item: SCondition) => { + this.setSearchCondition(qb2, item, '$or', joinAliases); + }); + }); + } + } + }); + }); + } - return dto; - } + return; + } - private hasColumnProp(columnProp: string): boolean { - return this.modelColumnPropsSet.has(columnProp); - } + // search: {...} + if (searchProps.length === 1) { + // search: {foo} + const field = searchProps[0]; + const value = search[field]; + if (!isObject(value)) { + this.builderSetWhere( + builder, + { field, operator: CondOperator.EQUALS, value }, + condition, + joinAliases, + ); + } else { + this.setSearchFieldObjectCondition(builder, condition, field, value, joinAliases); + } + return; + } - private hasModelRelationColumnProp(relationPath: string, columnProp: string): boolean { - return ( - this.hasModelRelation(relationPath) && - this.modelRelations[relationPath].columnProps.includes(columnProp) - ); + // search: {foo, ...} + this.builderAddBrackets(builder, condition, (qb: T['QueryBuilderType']) => { + searchProps.forEach((field: string) => { + const value = search[field]; + if (!isObject(value)) { + this.builderSetWhere( + qb, + { field, operator: CondOperator.EQUALS, value }, + condition, + joinAliases, + ); + } else { + this.setSearchFieldObjectCondition(qb, '$and', field, value, joinAliases); + } + }); + }); } - private getAllowedColumnProps(columnProps: string[], options: QueryOptions): string[] { - if (!isArrayFull(options.exclude) && !isArrayFull(options.allow)) { - return columnProps; + protected setSearchFieldObjectCondition( + builder: T['QueryBuilderType'], + condition: SConditionKey, + field: string, + obj: any, + joinAliases: object, + ) { + /* istanbul ignore if */ + if (!isObject(obj)) { + return; } - return columnProps.filter((columnProp) => { - if (isArrayFull(options.exclude) && options.exclude.includes(columnProp)) { - return false; + const operators = objKeys(obj); + if (operators.length === 1) { + if (isObject(obj.$or)) { + const orKeys = objKeys(obj.$or); + this.setSearchFieldObjectCondition( + builder, + orKeys.length === 1 ? condition : '$or', + field, + obj.$or, + joinAliases, + ); + } else { + const operator = operators[0] as ComparisonOperator; + const value = obj[operator]; + this.builderSetWhere(builder, { field, operator, value }, condition, joinAliases); } + return; + } - return isArrayFull(options.allow) ? options.allow.includes(columnProp) : true; + this.builderAddBrackets(builder, condition, (qb: T['QueryBuilderType']) => { + operators.forEach((operator: ComparisonOperator) => { + const value = obj[operator]; + if (operator !== '$or') { + this.builderSetWhere(qb, { field, operator, value }, condition, joinAliases); + return; + } + + const orKeys = objKeys(obj.$or); + if (orKeys.length === 1) { + this.setSearchFieldObjectCondition(qb, condition, field, obj.$or, joinAliases); + } else { + this.builderAddBrackets(qb, condition, (qb2: T['QueryBuilderType']) => { + this.setSearchFieldObjectCondition(qb2, '$or', field, obj.$or, joinAliases); + }); + } + }); }); } - private setAndWhere(cond: QueryFilter, builder: QueryBuilder) { - this.validateHasColumnProp(cond.field); + private builderAddBrackets( + builder: T['QueryBuilderType'], + condition: SConditionKey, + brackets: (qb: T['QueryBuilderType']) => void, + ) { + if (condition === '$and') { + builder.andWhere(brackets); + } else { + builder.orWhere(brackets); + } + } + + private builderSetWhere( + builder: T['QueryBuilderType'], + filter: QueryFilter, + condition: SConditionKey, + joinAliases: object, + ) { + if (condition === '$and') { + this.setAndWhere(filter, builder, joinAliases); + } else { + this.setOrWhere(filter, builder, joinAliases); + } + } + + private setAndWhere( + cond: QueryFilter, + builder: T['QueryBuilderType'], + /* istanbul ignore next */ + joinAliases: object = {}, + ) { + this.validateHasColumnProp(cond.field, joinAliases); const { columnProp, operator, value } = this.mapOperatorsToQuery(cond); if (operator === 'IS NULL') { @@ -582,10 +852,16 @@ export class ObjectionCrudService extends CrudService } } - private setOrWhere(cond: QueryFilter, builder: QueryBuilder) { - this.validateHasColumnProp(cond.field); + private setOrWhere( + cond: QueryFilter, + builder: T['QueryBuilderType'], + /* istanbul ignore next */ + joinAliases: object = {}, + ) { + this.validateHasColumnProp(cond.field, joinAliases); const { columnProp, operator, value } = this.mapOperatorsToQuery(cond); + /* istanbul ignore else */ if (operator === 'IS NULL') { builder.orWhereNull(columnProp); } else if (operator === 'IS NOT NULL') { @@ -611,6 +887,20 @@ export class ObjectionCrudService extends CrudService ); } + private getAllowedColumnProps(columnProps: string[], options: QueryOptions): string[] { + if (!isArrayFull(options.exclude) && !isArrayFull(options.allow)) { + return columnProps; + } + + return columnProps.filter((columnProp) => { + if (isArrayFull(options.exclude) && options.exclude.includes(columnProp)) { + return false; + } + + return isArrayFull(options.allow) ? options.allow.includes(columnProp) : true; + }); + } + private getSort(query: ParsedRequestParams, options: QueryOptions) { if (isArrayFull(query.sort)) { return this.mapSort(query.sort); @@ -623,11 +913,12 @@ export class ObjectionCrudService extends CrudService return []; } - private mapSort(sort: QuerySort[]): { columnProp: string; order: string }[] { + private mapSort(sort: QuerySort[]): { columnProp: string; order: QuerySortOperator }[] { return sort.map(({ field, order }) => { this.validateHasColumnProp(field); + const checkedFiled = this.checkSqlInjection(this.getColumnPropWithAlias(field)); return { - columnProp: this.getColumnPropWithAlias(field), + columnProp: checkedFiled, order, }; }); @@ -646,27 +937,31 @@ export class ObjectionCrudService extends CrudService return `${relations.join(OBJECTION_RELATION_SEPARATOR)}.${columnProp}`; } - private mapOperatorsToQuery( - cond: QueryFilter, - ): { columnProp: string; operator: string; value?: any } { + private mapOperatorsToQuery(cond: QueryFilter): NormalizedOperator { try { const normalizedColumn = this.getColumnPropWithAlias(cond.field); + + if (cond.operator[0] !== '$') { + cond.operator = `$${cond.operator}` as ComparisonOperator; + } + return (OPERATORS[cond.operator] || - /* istanbul ignore next */ OPERATORS[CondOperator.EQUALS])( - normalizedColumn, - cond.value, - ); + /* istanbul ignore next */ OPERATORS[CondOperator.EQUALS])(normalizedColumn, { + value: cond.value, + dbmsType: this.dbmsType, + }); } catch (e) { /* istanbul ignore next */ this.throwBadRequestException(e.message); } } - private validateHasColumnProp(path: string) { + private validateHasColumnProp(path: string, joinAliases: object = {}) { if (isPath(path)) { const { relations, columnProp } = splitPath(path); - const relationsPath = relations.join(PATH_SEPARATOR); + let relationsPath = relations.join(PATH_SEPARATOR); + relationsPath = joinAliases[relationsPath] || relationsPath; if (!this.hasModelRelation(relationsPath)) { this.throwBadRequestException(`Invalid relation name '${relationsPath}'`); @@ -688,18 +983,6 @@ export class ObjectionCrudService extends CrudService return !!this.modelRelations[relationPath]; } - private async initModelRelations() { - const objectionRelations: ObjectionRelation[] = Object.values( - this.getObjectionRelations(this.modelClass), - ); - - await Promise.all( - objectionRelations.map(async (relation) => { - this.modelRelations[relation.name] = await this.toModelRelation(relation); - }), - ); - } - private async toModelRelation( objectionRelation: ObjectionRelation, overrides: Partial = {}, @@ -746,7 +1029,7 @@ export class ObjectionCrudService extends CrudService private async setJoin( cond: QueryJoin, joinOptions: JoinOptions, - builder: QueryBuilder, + builder: T['QueryBuilderType'], ) { if (!this.notRecognizedModelRelations.has(cond.field) && isPath(cond.field)) { const objectionRelation = this.getObjectionRelationByPath(cond.field); @@ -778,16 +1061,43 @@ export class ObjectionCrudService extends CrudService ? cond.select.filter((col) => allowedColumnProps.includes(col)) : allowedColumnProps; + // There is no point of using fetch and having "select === false" + /* istanbul ignore next */ + if (options.select !== false && options.fetch) { + // To filter fetched queries it's better to use Objection's filters + // https://vincit.github.io/objection.js/api/query-builder/eager-methods.html#withgraphjoined + builder.withGraphFetched(relation.path); + } else { + builder.withGraphJoined( + options.alias ? `${relation.path} as ${options.alias}` : relation.path, + { + joinOperation: options.required ? 'innerJoin' : 'leftJoin', + }, + ); + } + + if (options.select === false) { + return; + } + const select = unique([ ...relation.referencedColumnProps, ...(isArrayFull(options.persist) ? options.persist : []), ...columnProps, - ]); + ]).map((columnProp) => `${relation.tableName}${PATH_SEPARATOR}${columnProp}`); + + builder.modifyGraph(relation.path, (qb) => qb.select(select)); + } + } - builder - .mergeJoinEager(relation.path) - .modifyEager(relation.path, (qb) => qb.select(select)); + private checkSqlInjection(field: string): string { + for (const regex of this.sqlInjectionRegEx) { + /* istanbul ignore next */ + if (regex.test(field)) { + this.throwBadRequestException(`SQL injection detected: "${field}"`); + } } + return field; } } @@ -808,15 +1118,19 @@ function isPath(path: string) { } function toChunks(items: T[], size = CHUNK_SIZE): T[][] { + /* istanbul ignore next */ if (items.length < size) { return [items]; } + /* istanbul ignore next */ const chunks = []; + /* istanbul ignore next */ let currentChunk = []; + /* istanbul ignore next */ items.forEach((item) => { - /* istanbul ignore if */ + /* istanbul ignore next */ if (currentChunk.length > size) { currentChunk = []; chunks.push(currentChunk); @@ -825,11 +1139,12 @@ function toChunks(items: T[], size = CHUNK_SIZE): T[][] { currentChunk.push(item); }); - /* istanbul ignore else */ + /* istanbul ignore next */ if (currentChunk.length) { chunks.push(currentChunk); } + /* istanbul ignore next */ return chunks; } @@ -837,6 +1152,15 @@ function getOffsetLimit( req: ParsedRequestParams, options: CrudRequestOptions, ): { offset: number; limit: number } { + if (options.query.alwaysPaginate) { + if (req.page === undefined) { + req.page = 1; + } + if (!req.limit && !options.query.limit) { + options.query.limit = 10; + } + } + const limit = getLimit(req, options.query); const offset = getOffset(req, limit); diff --git a/packages/crud-objection/test/0.basic-crud.spec.ts b/packages/crud-objection/test/0.basic-crud.spec.ts deleted file mode 100644 index ae9204bb..00000000 --- a/packages/crud-objection/test/0.basic-crud.spec.ts +++ /dev/null @@ -1,389 +0,0 @@ -import * as request from 'supertest'; -import { Test } from '@nestjs/testing'; -import { Controller, INestApplication } from '@nestjs/common'; -import { APP_FILTER } from '@nestjs/core'; -import { RequestQueryBuilder } from '@nestjsx/crud-request'; - -import { Crud } from '../../crud/src/decorators/crud.decorator'; -import { HttpExceptionFilter } from '../../../integration/shared/https-exception.filter'; -import { Company } from '../../../integration/crud-objection/companies'; -import { User } from '../../../integration/crud-objection/users'; -import { CompaniesService } from './__fixture__/companies.service'; -import { UsersService } from './__fixture__/users.service'; -import { DatabaseModule } from '../../../integration/crud-objection/database.module'; - -describe('#crud-objection', () => { - describe('#basic crud', () => { - let app: INestApplication; - let server: any; - let qb: RequestQueryBuilder; - let service: CompaniesService; - - @Crud({ - model: { type: Company }, - }) - @Controller('companies') - class CompaniesController { - constructor(public service: CompaniesService) {} - } - - @Crud({ - model: { type: User }, - params: { - companyId: { - field: 'companyId', - type: 'number', - }, - id: { - field: 'id', - type: 'number', - primary: true, - }, - }, - routes: { - deleteOneBase: { - returnDeleted: true, - }, - }, - query: { - persist: ['isActive'], - cache: 10, - }, - validation: { - transform: true, - }, - }) - @Controller('companies/:companyId/users') - class UsersController { - constructor(public service: UsersService) {} - } - - beforeAll(async () => { - const fixture = await Test.createTestingModule({ - imports: [DatabaseModule], - controllers: [CompaniesController, UsersController], - providers: [ - { provide: APP_FILTER, useClass: HttpExceptionFilter }, - CompaniesService, - UsersService, - ], - }).compile(); - - app = fixture.createNestApplication(); - service = app.get(CompaniesService); - - await app.init(); - server = app.getHttpServer(); - }); - - beforeEach(() => { - qb = RequestQueryBuilder.create(); - }); - - afterAll(async () => { - app.close(); - }); - - describe('#find', () => { - it('should return entities', async () => { - const data = await service.modelClass.query(); - expect(data.length).toBe(10); - }); - }); - - describe('#findOne', () => { - it('should return one entity', async () => { - const data = await service.modelClass.query().findById(1); - expect(data.id).toBe(1); - }); - }); - - describe('#getAllBase', () => { - it('should return an array of all entities', (done) => { - return request(server) - .get('/companies') - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.length).toBe(10); - done(); - }); - }); - it('should return an entities with limit', (done) => { - const query = qb.setLimit(5).query(); - return request(server) - .get('/companies') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.length).toBe(5); - done(); - }); - }); - it('should return an entities with limit and page', (done) => { - const query = qb - .setLimit(3) - .setPage(1) - .sortBy({ field: 'id', order: 'DESC' }) - .query(); - return request(server) - .get('/companies') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(3); - expect(res.body.count).toBe(3); - expect(res.body.total).toBe(10); - expect(res.body.page).toBe(1); - expect(res.body.pageCount).toBe(4); - done(); - }); - }); - it('should return an entities with offset', (done) => { - const query = qb.setOffset(3).query(); - return request(server) - .get('/companies') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.length).toBe(7); - done(); - }); - }); - }); - - describe('#getOneBase', () => { - it('should return status 404', (done) => { - return request(server) - .get('/companies/333') - .end((_, res) => { - expect(res.status).toBe(404); - done(); - }); - }); - it('should return an entity, 1', (done) => { - return request(server) - .get('/companies/1') - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.id).toBe(1); - done(); - }); - }); - it('should return an entity, 2', (done) => { - const query = qb.select(['domain']).query(); - return request(server) - .get('/companies/1') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.id).toBe(1); - expect(res.body.domain).toBeTruthy(); - done(); - }); - }); - it('should return a user entity', (done) => { - return request(server) - .get('/companies/1/users/1') - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.id).toBe(1); - expect(res.body.companyId).toBe(1); - done(); - }); - }); - }); - - describe('#createOneBase', () => { - it('should return status 400', (done) => { - return request(server) - .post('/companies') - .send('') - .end((_, res) => { - expect(res.status).toBe(400); - expect(res.body.message.length).toBe(2); - - expect(res.body.message[0].constraints.maxLength).toBe( - `name must be shorter than or equal to 100 characters`, - ); - expect(res.body.message[0].constraints.isString).toBe( - `name must be a string`, - ); - expect(res.body.message[0].constraints.isNotEmpty).toBe( - `name should not be empty`, - ); - - expect(res.body.message[1].constraints.maxLength).toBe( - `domain must be shorter than or equal to 100 characters`, - ); - expect(res.body.message[1].constraints.isString).toBe( - `domain must be a string`, - ); - expect(res.body.message[1].constraints.isNotEmpty).toBe( - `domain should not be empty`, - ); - done(); - }); - }); - it('should return saved entity', (done) => { - const dto = { - name: 'test0', - domain: 'test0', - }; - return request(server) - .post('/companies') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(201); - expect(res.body.id).toBeTruthy(); - done(); - }); - }); - it('should return saved entity with param', (done) => { - const dto: Partial = { - email: 'test@test.com', - isActive: true, - profile: { - name: 'testName', - }, - }; - return request(server) - .post('/companies/1/users') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(201); - expect(res.body.id).toBeTruthy(); - expect(res.body.companyId).toBe(1); - expect(res.body.profile).toBeTruthy(); - expect(res.body.profile.name).toBe('testName'); - done(); - }); - }); - }); - - describe('#createManyBase', () => { - it('should return status 400', (done) => { - const dto = { bulk: [] }; - return request(server) - .post('/companies/bulk') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(400); - expect(res.body.message.length).toBe(1); - expect(res.body.message[0].constraints.arrayNotEmpty).toBe( - `bulk should not be empty`, - ); - done(); - }); - }); - [ - { companyPrefix: 'foo', amount: 50 }, - { companyPrefix: 'bar', amount: 1001 }, - ].forEach(({ amount, companyPrefix }) => { - it(`should return ${amount} created entities for ${companyPrefix}-* companies`, (done) => { - const dto = { - bulk: Array.from({ length: amount }, (_, idx: number) => { - return { - name: `${companyPrefix}-${idx}`, - domain: `${companyPrefix}-${idx}`, - }; - }), - }; - return request(server) - .post('/companies/bulk') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(201); - expect(res.body.length).toBe(amount); - res.body.forEach((company) => { - expect(company.id).toBeTruthy(); - }); - - done(); - }); - }); - }); - }); - - describe('#updateOneBase', () => { - it('should return status 404', (done) => { - const dto = { name: 'updated0' }; - return request(server) - .patch('/companies/33333333') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(404); - done(); - }); - }); - it('should return updated entity, 1', (done) => { - const dto = { name: 'updated0' }; - return request(server) - .patch('/companies/1') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.name).toBe('updated0'); - done(); - }); - }); - it('should return updated entity, 2', (done) => { - const dto = { isActive: false, companyId: 5 }; - return request(server) - .patch('/companies/1/users/21') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.isActive).toBe(false); - expect(res.body.companyId).toBe(1); - done(); - }); - }); - }); - - describe('#replaceOneBase', () => { - it('should create entity, 1', (done) => { - const dto = { name: 'updated0', domain: 'domain0' }; - return request(server) - .put('/companies/333') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.name).toBe('updated0'); - done(); - }); - }); - it('should return updated entity, 1', (done) => { - const dto = { name: 'updated-foo' }; - return request(server) - .put('/companies/1') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.name).toBe('updated-foo'); - done(); - }); - }); - }); - - describe('#deleteOneBase', () => { - it('should return status 404', (done) => { - return request(server) - .delete('/companies/33333333') - .end((_, res) => { - expect(res.status).toBe(404); - done(); - }); - }); - it('should return deleted entity', (done) => { - return request(server) - .delete('/companies/1/users/21') - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.id).toBe(21); - expect(res.body.companyId).toBe(1); - done(); - }); - }); - }); - }); -}); diff --git a/packages/crud-objection/test/1.query-params.spec.ts b/packages/crud-objection/test/1.query-params.spec.ts deleted file mode 100644 index 1b5b18cb..00000000 --- a/packages/crud-objection/test/1.query-params.spec.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { Controller, INestApplication } from '@nestjs/common'; -import { APP_FILTER } from '@nestjs/core'; -import { Test } from '@nestjs/testing'; -import { RequestQueryBuilder } from '@nestjsx/crud-request'; -import * as request from 'supertest'; - -import { Company } from '../../../integration/crud-objection/companies'; -import { Project } from '../../../integration/crud-objection/projects'; -import { User } from '../../../integration/crud-objection/users'; -import { HttpExceptionFilter } from '../../../integration/shared/https-exception.filter'; -import { Crud } from '../../crud/src/decorators/crud.decorator'; -import { CompaniesService } from './__fixture__/companies.service'; -import { ProjectsService } from './__fixture__/projects.service'; -import { UsersService } from './__fixture__/users.service'; -import { DatabaseModule } from '../../../integration/crud-objection/database.module'; - -// tslint:disable:max-classes-per-file -describe('#crud-objection', () => { - describe('#query params', () => { - let app: INestApplication; - let server: any; - let qb: RequestQueryBuilder; - - @Crud({ - model: { type: Company }, - query: { - exclude: ['updatedAt'], - allow: ['id', 'name', 'domain', 'description'], - filter: [{ field: 'id', operator: 'ne', value: 1 }], - join: { - users: { - allow: ['id'], - }, - }, - maxLimit: 5, - }, - }) - @Controller('companies') - class CompaniesController { - constructor(public service: CompaniesService) {} - } - - @Crud({ - model: { type: Project }, - query: { - join: { - company: { - eager: true, - persist: ['id'], - exclude: ['updatedAt', 'createdAt'], - }, - }, - sort: [{ field: 'id', order: 'ASC' }], - limit: 100, - }, - }) - @Controller('projects') - class ProjectsController { - constructor(public service: ProjectsService) {} - } - - @Crud({ - model: { type: User }, - query: { - join: { - company: {}, - 'company.projects': {}, - projects: {}, - }, - }, - }) - @Controller('users') - class UsersController { - constructor(public service: UsersService) {} - } - - beforeAll(async () => { - const fixture = await Test.createTestingModule({ - imports: [DatabaseModule], - controllers: [CompaniesController, ProjectsController, UsersController], - providers: [ - { provide: APP_FILTER, useClass: HttpExceptionFilter }, - CompaniesService, - UsersService, - ProjectsService, - ], - }).compile(); - - app = fixture.createNestApplication(); - - await app.init(); - server = app.getHttpServer(); - }); - - beforeEach(() => { - qb = RequestQueryBuilder.create(); - }); - - afterAll(async () => { - app.close(); - }); - - describe('#select', () => { - it('should throw status 400', (done) => { - const query = qb.setFilter({ field: 'invalid', operator: 'isnull' }).query(); - return request(server) - .get('/companies') - .query(query) - .end((_, res) => { - expect(res.body.message).toBe(`Invalid column name 'invalid'`); - expect(res.status).toBe(400); - done(); - }); - }); - }); - - describe('#query filter', () => { - it('should return data with limit', (done) => { - const query = qb.setLimit(4).query(); - return request(server) - .get('/companies') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.length).toBe(4); - res.body.forEach((e: Company) => { - expect(e.id).not.toBe(1); - }); - done(); - }); - }); - it('should return with maxLimit', (done) => { - const query = qb.setLimit(7).query(); - return request(server) - .get('/companies') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.length).toBe(5); - done(); - }); - }); - it('should return with filter and or, 1', (done) => { - const query = qb - .setFilter({ field: 'name', operator: 'notin', value: ['Name2', 'Name3'] }) - .setOr({ field: 'domain', operator: 'cont', value: 5 }) - .query(); - return request(server) - .get('/companies') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.length).toBe(5); - done(); - }); - }); - it('should return with filter and or, 2', (done) => { - const query = qb - .setFilter({ field: 'name', operator: 'ends', value: 'foo' }) - .setOr({ field: 'name', operator: 'starts', value: 'P' }) - .setOr({ field: 'isActive', operator: 'eq', value: true }) - .query(); - return request(server) - .get('/projects') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.length).toBe(10); - done(); - }); - }); - it('should return with filter and or, 3', (done) => { - const query = qb - .setOr({ field: 'companyId', operator: 'gt', value: 22 }) - .setFilter({ field: 'companyId', operator: 'gte', value: 6 }) - .setFilter({ field: 'companyId', operator: 'lt', value: 10 }) - .query(); - return request(server) - .get('/projects') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.length).toBe(8); - done(); - }); - }); - it('should return with filter and or, 4', (done) => { - const query = qb - .setOr({ field: 'companyId', operator: 'in', value: [6, 10] }) - .setOr({ field: 'companyId', operator: 'lte', value: 10 }) - .setFilter({ field: 'isActive', operator: 'eq', value: false }) - .setFilter({ field: 'description', operator: 'notnull' }) - .query(); - return request(server) - .get('/projects') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.length).toBe(10); - done(); - }); - }); - it('should return with filter and or, 5', (done) => { - const query = qb.setOr({ field: 'companyId', operator: 'isnull' }).query(); - return request(server) - .get('/projects') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.length).toBe(0); - done(); - }); - }); - it('should return with filter and or, 6', (done) => { - const query = qb - .setOr({ field: 'companyId', operator: 'between', value: [1, 5] }) - .query(); - return request(server) - .get('/projects') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.length).toBe(10); - done(); - }); - }); - it('should return with filter and or, 7', (done) => { - const query = qb - .setFilter({ field: 'name', operator: 'eq', value: 'Name2' }) - .setOr({ field: 'description', operator: 'notnull' }) - .query(); - return request(server) - .get('/companies') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.length).toBe(1); - expect(res.body[0].description || res.body[0].name === 'Name2').toBe(true); - done(); - }); - }); - it('should return with filter, 1', (done) => { - const query = qb.setOr({ field: 'companyId', operator: 'eq', value: 1 }).query(); - return request(server) - .get('/projects') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.length).toBe(2); - done(); - }); - }); - it('should return with filter, 2', (done) => { - const query = qb.setFilter({ field: 'description', operator: 'isnull' }).query(); - return request(server) - .get('/companies') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.length).toBe(5); - res.body.forEach((company) => { - expect(company.description).toBeNull(); - }); - done(); - }); - }); - it('should return with filter, 3: only allowed props get returned', (done) => { - const query = qb.select(['id', 'updatedAt']).query(); - return request(server) - .get('/projects') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - res.body.forEach((project) => { - expect(project.id).toBeTruthy(); - expect(project.updatedAt).toBeNull(); - }); - done(); - }); - }); - }); - - describe('#query join', () => { - it('should return joined entity, 1', (done) => { - const query = qb.setJoin({ field: 'company', select: ['name'] }).query(); - return request(server) - .get('/projects/2') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.company).toBeDefined(); - done(); - }); - }); - it('should return joined entity, 2', (done) => { - const query = qb.setJoin({ field: 'users', select: ['name'] }).query(); - return request(server) - .get('/companies/2') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.users).toBeDefined(); - expect(res.body.users.length).not.toBe(0); - done(); - }); - }); - }); - - describe('#query nested join', () => { - it('should return status 400, 1', (done) => { - const query = qb - .setJoin({ field: 'company' }) - .setJoin({ field: 'company.projects' }) - .setFilter({ - field: 'company.projects.foo', - operator: 'excl', - value: 'invalid', - }) - .query(); - return request(server) - .get('/users/1') - .query(query) - .end((_, res) => { - expect(res.body.message).toBe( - `Invalid column name 'foo' for relation 'company.projects'`, - ); - expect(res.status).toBe(400); - done(); - }); - }); - it('should return status 400, 2', (done) => { - const query = qb - .setJoin({ field: 'company' }) - .setJoin({ field: 'company.projects' }) - .setFilter({ - field: 'invalid.projects', - operator: 'excl', - value: 'invalid', - }) - .query(); - return request(server) - .get('/users/1') - .query(query) - .end((_, res) => { - expect(res.body.message).toBe(`Invalid relation name 'invalid'`); - expect(res.status).toBe(400); - done(); - }); - }); - it('should return status 400, 3', (done) => { - const query = qb - .setJoin({ field: 'company' }) - .setJoin({ field: 'company.projects' }) - .setFilter({ - field: 'company.foo', - operator: 'excl', - value: 'invalid', - }) - .query(); - return request(server) - .get('/users/1') - .query(query) - .end((_, res) => { - expect(res.body.message).toBe( - `Invalid column name 'foo' for relation 'company'`, - ); - expect(res.status).toBe(400); - done(); - }); - }); - it('should return status 200', (done) => { - const query = qb - .setJoin({ field: 'company' }) - .setJoin({ field: 'company.projectsinvalid' }) - .query(); - return request(server) - .get('/users/1') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - done(); - }); - }); - it('should return joined entity, 1', (done) => { - const query = qb - .setFilter({ field: 'company.name', operator: 'excl', value: 'invalid' }) - .setJoin({ field: 'company' }) - .setJoin({ field: 'company.projects' }) - .query(); - return request(server) - .get('/users/1') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.company).toBeDefined(); - expect(res.body.company.projects).toBeDefined(); - done(); - }); - }); - it('should return joined entity, 2', (done) => { - const query = qb - .setFilter({ field: 'company.projects.id', operator: 'notnull' }) - .setJoin({ field: 'company' }) - .setJoin({ field: 'company.projects' }) - .query(); - return request(server) - .get('/users/1') - .query(query) - .end((_, res) => { - expect(res.body.message).toBeUndefined(); - expect(res.status).toBe(200); - expect(res.body.company).toBeDefined(); - expect(res.body.company.projects).toBeDefined(); - done(); - }); - }); - }); - - describe('#sort', () => { - it('should sort by field', async () => { - const query = qb.sortBy({ field: 'id', order: 'DESC' }).query(); - const res = await request(server) - .get('/users') - .query(query) - .expect(200); - expect(res.body[1].id).toBeLessThan(res.body[0].id); - }); - - it('should sort by nested field, 1', async () => { - const query = qb - .setFilter({ field: 'company.id', operator: 'notnull' }) - .setJoin({ field: 'company' }) - .sortBy({ field: 'company.id', order: 'DESC' }) - .query(); - const res = await request(server) - .get('/users') - .query(query) - .expect((res) => { - expect(res.body.message).toBeUndefined(); - }) - .expect(200); - expect(res.body[res.body.length - 1].company.id).toBeLessThan( - res.body[0].company.id, - ); - }); - - it('should sort by nested field, 2', async () => { - const query = qb - .setFilter({ field: 'id', operator: 'eq', value: 1 }) - .setFilter({ field: 'company.id', operator: 'notnull' }) - .setFilter({ field: 'projects.id', operator: 'notnull' }) - .setJoin({ field: 'company' }) - .setJoin({ field: 'projects' }) - .sortBy({ field: 'projects.id', order: 'DESC' }) - .query(); - const res = await request(server) - .get('/users') - .query(query) - .expect((res) => { - expect(res.body.message).toBeUndefined(); - }) - .expect(200); - expect(res.body[0].projects[1].id).toBeLessThan(res.body[0].projects[0].id); - }); - - it('should sort by nested field, 3', async () => { - const query = qb - .setFilter({ field: 'id', operator: 'eq', value: 1 }) - .setFilter({ field: 'company.id', operator: 'notnull' }) - .setFilter({ field: 'company.projects.id', operator: 'notnull' }) - .setJoin({ field: 'company' }) - .setJoin({ field: 'company.projects' }) - .sortBy({ field: 'company.projects.id', order: 'DESC' }) - .query(); - const res = await request(server) - .get('/users') - .query(query) - .expect((res) => { - expect(res.body.message).toBeUndefined(); - }) - .expect(200); - expect(res.body[0].company.projects[1].id).toBeLessThan( - res.body[0].company.projects[0].id, - ); - }); - }); - }); -}); diff --git a/packages/crud-objection/test/__fixture__/devices.service.ts b/packages/crud-objection/test/__fixture__/devices.service.ts new file mode 100644 index 00000000..23dc0a97 --- /dev/null +++ b/packages/crud-objection/test/__fixture__/devices.service.ts @@ -0,0 +1,11 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Device } from '../../../../integration/crud-objection/devices'; +import { ModelClass } from 'objection'; +import { ObjectionCrudService } from '../../../crud-objection/src/objection-crud.service'; + +@Injectable() +export class DevicesService extends ObjectionCrudService { + constructor(@Inject('Device') modelClass: ModelClass) { + super(modelClass); + } +} diff --git a/packages/crud-objection/test/__fixture__/notes.service.ts b/packages/crud-objection/test/__fixture__/notes.service.ts new file mode 100644 index 00000000..5abe60df --- /dev/null +++ b/packages/crud-objection/test/__fixture__/notes.service.ts @@ -0,0 +1,12 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { ObjectionCrudService } from '../../../crud-objection/src/objection-crud.service'; +import { Note } from '../../../../integration/crud-objection/notes'; +import { ModelClass } from 'objection'; + +@Injectable() +export class NotesService extends ObjectionCrudService { + constructor(@Inject('Note') modelClass: ModelClass) { + super(modelClass); + } +} diff --git a/packages/crud-objection/test/__fixture__/users.service.ts b/packages/crud-objection/test/__fixture__/users.service.ts index 44720b74..9b36ac49 100644 --- a/packages/crud-objection/test/__fixture__/users.service.ts +++ b/packages/crud-objection/test/__fixture__/users.service.ts @@ -10,3 +10,10 @@ export class UsersService extends ObjectionCrudService { super(modelClass); } } + +@Injectable() +export class UsersService2 extends ObjectionCrudService { + constructor(@Inject('User') modelClass: ModelClass) { + super(modelClass); + } +} diff --git a/packages/crud-objection/test/a.params-options.spec.ts b/packages/crud-objection/test/a.params-options.spec.ts new file mode 100644 index 00000000..ec0e6de1 --- /dev/null +++ b/packages/crud-objection/test/a.params-options.spec.ts @@ -0,0 +1,163 @@ +import 'jest-extended'; +import { Controller, INestApplication } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { User } from '../../../integration/crud-objection/users'; +import { HttpExceptionFilter } from '../../../integration/shared/https-exception.filter'; +import { Crud } from '../../crud/src/decorators/crud.decorator'; +import { UsersService } from './__fixture__/users.service'; +import { DatabaseModule } from '../../../integration/crud-objection/database.module'; +import { KNEX_CONNECTION } from '../../../integration/crud-objection/injection-tokens'; + +// tslint:disable:max-classes-per-file +describe('#crud-objection', () => { + describe('#params options', () => { + let app: INestApplication; + let server: any; + + @Crud({ + model: { type: User }, + params: { + companyId: { + field: 'companyId', + type: 'number', + }, + id: { + field: 'id', + type: 'number', + primary: true, + }, + }, + routes: { + updateOneBase: { + allowParamsOverride: true, + returnShallow: true, + }, + replaceOneBase: { + allowParamsOverride: true, + returnShallow: true, + }, + }, + }) + @Controller('/companiesA/:companyId/users') + class UsersController1 { + constructor(public service: UsersService) {} + } + + @Crud({ + model: { type: User }, + params: { + companyId: { + field: 'companyId', + type: 'number', + }, + id: { + field: 'id', + type: 'number', + primary: true, + }, + }, + query: { + join: { + company: { + eager: true, + }, + }, + }, + }) + @Controller('/companiesB/:companyId/users') + class UsersController2 { + constructor(public service: UsersService) {} + } + + beforeAll(async () => { + const fixture = await Test.createTestingModule({ + imports: [DatabaseModule], + controllers: [UsersController1, UsersController2], + providers: [{ provide: APP_FILTER, useClass: HttpExceptionFilter }, UsersService], + }).compile(); + + app = fixture.createNestApplication(); + + await app.init(); + server = app.getHttpServer(); + }); + + afterAll(async () => { + const knex = app.get(KNEX_CONNECTION); + await knex.destroy(); + await app.close(); + }); + + describe('#updateOneBase', () => { + it('should override params', async () => { + const dto = { isActive: false, companyId: 2 }; + const res = await request(server) + .patch('/companiesA/1/users/2') + .send(dto) + .expect(200); + expect(res.body.companyId).toBe(2); + }); + it('should not override params', async () => { + const dto = { isActive: false, companyId: 2 }; + const res = await request(server) + .patch('/companiesB/1/users/3') + .send(dto) + .expect(200); + expect(res.body.companyId).toBe(1); + }); + it('should return full entity', async () => { + const dto = { isActive: false }; + const res = await request(server) + .patch('/companiesB/2/users/2') + .send(dto) + .expect(200); + expect(res.body.company.id).toBe(2); + }); + it('should return shallow entity', async () => { + const dto = { isActive: false }; + const res = await request(server) + .patch('/companiesA/2/users/2') + .send(dto) + .expect(200); + expect(res.body.company).toBeUndefined(); + }); + }); + + describe('#replaceOneBase', () => { + it('should override params', async () => { + const dto = { isActive: false, companyId: 2, email: '4@email.com' }; + const res = await request(server) + .put('/companiesA/1/users/4') + .send(dto) + .expect(200); + expect(res.body.companyId).toBe(2); + }); + it('should not override params', async () => { + const dto = { isActive: false, companyId: 1 }; + const res = await request(server) + .put('/companiesB/2/users/4') + .send(dto) + .expect(200); + expect(res.body.companyId).toBe(2); + }); + it('should return full entity', async () => { + const dto = { isActive: false }; + const res = await request(server) + .put('/companiesB/2/users/4') + .send(dto) + .expect(200); + expect(res.body.company.id).toBe(2); + }); + it('should return shallow entity', async () => { + const dto = { isActive: false }; + const res = await request(server) + .put('/companiesA/2/users/4') + .send(dto) + .expect(200); + expect(res.body.company).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/crud-objection/test/b.query-params.spec.ts b/packages/crud-objection/test/b.query-params.spec.ts new file mode 100644 index 00000000..458651c7 --- /dev/null +++ b/packages/crud-objection/test/b.query-params.spec.ts @@ -0,0 +1,1016 @@ +import { Controller, INestApplication } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import { RequestQueryBuilder } from '@nestjsx/crud-request'; +import 'jest-extended'; +import * as request from 'supertest'; + +import { Company } from '../../../integration/crud-typeorm/companies'; +import { Project } from '../../../integration/crud-typeorm/projects'; +import { User } from '../../../integration/crud-typeorm/users'; +import { Note } from '../../../integration/crud-typeorm/notes'; +import { HttpExceptionFilter } from '../../../integration/shared/https-exception.filter'; +import { Crud } from '../../crud/src/decorators'; +import { CompaniesService } from './__fixture__/companies.service'; +import { ProjectsService } from './__fixture__/projects.service'; +import { UsersService, UsersService2 } from './__fixture__/users.service'; +import { NotesService } from './__fixture__/notes.service'; +import { DatabaseModule } from '../../../integration/crud-objection/database.module'; +import { KNEX_CONNECTION } from '../../../integration/crud-objection/injection-tokens'; +import { isNil } from '@nestjsx/util'; + +jest.setTimeout(600000000); +// tslint:disable:max-classes-per-file +describe('#crud-typeorm', () => { + describe('#query params', () => { + let app: INestApplication; + let server: any; + let qb: RequestQueryBuilder; + + @Crud({ + model: { type: Company }, + query: { + exclude: ['updatedAt'], + allow: ['id', 'name', 'domain', 'description'], + filter: [{ field: 'id', operator: 'ne', value: 1 }], + join: { + users: { + allow: ['id'], + }, + }, + maxLimit: 5, + }, + }) + @Controller('companies') + class CompaniesController { + constructor(public service: CompaniesService) {} + } + + @Crud({ + model: { type: Project }, + routes: { + updateOneBase: { + returnShallow: true, + }, + }, + query: { + join: { + company: { + eager: true, + persist: ['id'], + exclude: ['updatedAt', 'createdAt'], + }, + users: {}, + userProjects: {}, + }, + sort: [{ field: 'id', order: 'ASC' }], + limit: 100, + }, + }) + @Controller('projects') + class ProjectsController { + constructor(public service: ProjectsService) {} + } + + @Crud({ + model: { type: Project }, + }) + @Controller('projects2') + class ProjectsController2 { + constructor(public service: ProjectsService) {} + } + + @Crud({ + model: { type: Project }, + query: { + filter: [{ field: 'isActive', operator: 'eq', value: false }], + }, + }) + @Controller('projects3') + class ProjectsController3 { + constructor(public service: ProjectsService) {} + } + + @Crud({ + model: { type: Project }, + query: { + filter: { isActive: true }, + }, + }) + @Controller('projects4') + class ProjectsController4 { + constructor(public service: ProjectsService) {} + } + + @Crud({ + model: { type: User }, + query: { + join: { + company: {}, + 'company.projects': {}, + userLicenses: {}, + invalid: { + eager: true, + }, + 'foo.bar': { + eager: true, + }, + }, + }, + }) + @Controller('users') + class UsersController { + constructor(public service: UsersService) {} + } + + @Crud({ + model: { type: User }, + query: { + join: { + company: {}, + 'company.projects': { + alias: 'pr', + }, + }, + }, + }) + @Controller('users2') + class UsersController2 { + 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) {} + } + + @Crud({ + model: { type: Note }, + }) + @Controller('notes') + class NotesController { + constructor(public service: NotesService) {} + } + + beforeAll(async () => { + const fixture = await Test.createTestingModule({ + imports: [DatabaseModule], + controllers: [ + CompaniesController, + ProjectsController, + ProjectsController2, + ProjectsController3, + ProjectsController4, + UsersController, + UsersController2, + UsersController3, + NotesController, + ], + providers: [ + { provide: APP_FILTER, useClass: HttpExceptionFilter }, + CompaniesService, + UsersService, + UsersService2, + ProjectsService, + NotesService, + ], + }).compile(); + + app = fixture.createNestApplication(); + + await app.init(); + server = app.getHttpServer(); + }); + + beforeEach(() => { + qb = RequestQueryBuilder.create(); + }); + + afterAll(async () => { + const knex = app.get(KNEX_CONNECTION); + await knex.destroy(); + await app.close(); + }); + + describe('#select', () => { + it('should throw status 400', (done) => { + const query = qb.setFilter({ field: 'invalid', operator: 'isnull' }).query(); + return request(server) + .get('/companies') + .query(query) + .end((_, res: any) => { + expect(res.status).toBe(400); + expect(res.body.message).toBe(`Invalid column name 'invalid'`); + done(); + }); + }); + }); + + describe('#query filter', () => { + it('should return data with limit', (done) => { + const query = qb.setLimit(4).query(); + return request(server) + .get('/companies') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.length).toBe(4); + res.body.forEach((e: Company) => { + expect(e.id).not.toBe(1); + }); + done(); + }); + }); + it('should return with maxLimit', (done) => { + const query = qb.setLimit(7).query(); + return request(server) + .get('/companies') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.length).toBe(5); + done(); + }); + }); + it('should return with filter and or, 1', (done) => { + const query = qb + .setFilter({ field: 'name', operator: 'notin', value: ['Name2', 'Name3'] }) + .setOr({ field: 'domain', operator: 'cont', value: 5 }) + .query(); + return request(server) + .get('/companies') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.length).toBe(5); + res.body.forEach((c) => { + expect(!['Name2', 'Name3'].includes(c.name) || c.domain.includes('5')).toBe( + true, + ); + }); + done(); + }); + }); + it('should return with filter and or, 2', (done) => { + const query = qb + .setFilter({ field: 'name', operator: 'ends', value: 'foo' }) + .setOr({ field: 'name', operator: 'starts', value: 'P' }) + .setOr({ field: 'isActive', operator: 'eq', value: true }) + .query(); + return request(server) + .get('/projects') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.length).toBe(10); + res.body.forEach((p) => { + expect( + p.name.endsWith('foo') || (p.name.startsWith('P') && p.isActive), + ).toBe(true); + }); + done(); + }); + }); + it('should return with filter and or, 3', (done) => { + const query = qb + .setOr({ field: 'companyId', operator: 'gt', value: 22 }) + .setFilter({ field: 'companyId', operator: 'gte', value: 6 }) + .setFilter({ field: 'companyId', operator: 'lt', value: 10 }) + .query(); + return request(server) + .get('/projects') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.length).toBe(8); + res.body.forEach((p) => { + expect(p.companyId > 22 || (p.companyId >= 6 && p.companyId < 10)).toBe( + true, + ); + }); + done(); + }); + }); + it('should return with filter and or, 4', (done) => { + const query = qb + .setOr({ field: 'companyId', operator: 'in', value: [6, 10] }) + .setOr({ field: 'companyId', operator: 'lte', value: 10 }) + .setFilter({ field: 'isActive', operator: 'eq', value: false }) + .setFilter({ field: 'description', operator: 'notnull' }) + .query(); + return request(server) + .get('/projects') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.length).toBe(10); + res.body.forEach((p) => { + expect( + ([6, 10].includes(p.companyId) && p.companyId <= 10) || + (p.isActive === false && !isNil(p.description)), + ).toBe(true); + }); + done(); + }); + }); + it('should return with filter and or, 6', (done) => { + const query = qb.setOr({ field: 'companyId', operator: 'isnull' }).query(); + return request(server) + .get('/projects') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.length).toBe(0); + done(); + }); + }); + it('should return with filter and or, 6', (done) => { + const query = qb + .setOr({ field: 'companyId', operator: 'between', value: [1, 5] }) + .query(); + return request(server) + .get('/projects') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.length).toBe(10); + res.body.forEach((p) => { + expect(p.companyId >= 1 && p.companyId <= 5).toBe(true); + }); + done(); + }); + }); + it('should return with filter, 1', (done) => { + const query = qb.setOr({ field: 'companyId', operator: 'eq', value: 1 }).query(); + return request(server) + .get('/projects') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.length).toBe(2); + done(); + res.body.forEach((p) => { + expect(p.companyId).toBe(1); + }); + }); + }); + }); + + describe('#query join', () => { + it('should return joined entity, 1', (done) => { + const query = qb.setJoin({ field: 'company', select: ['name'] }).query(); + return request(server) + .get('/projects/2') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + + const project = res.body; + const company = project.company; + + expect(project.id).toBe(2); + expect(company).toBeDefined(); + expect(Object.keys(company)).toBeArrayOfSize(2); + expect(company).toContainKeys(['id', 'name']); + done(); + }); + }); + it('should return joined entity, 2', (done) => { + const query = qb.setJoin({ field: 'users', select: ['name'] }).query(); + return request(server) + .get('/companies/2') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + const company = res.body; + expect(company.users).toBeDefined(); + expect(company.users.length).not.toBe(0); + + company.users.forEach((u) => { + expect(Object.keys(u)).toBeArrayOfSize(1); + expect(u).toContainKeys(['companyId']); + }); + 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(); + + res.body.forEach((u) => { + expect(u.companyId).toBe(1); + }); + + done(); + }); + }); + }); + + describe('#query nested join', () => { + it('should return status 400, 1', (done) => { + const query = qb + .setJoin({ field: 'company' }) + .setJoin({ field: 'company.projects' }) + .setFilter({ + field: 'company.projects.foo', + operator: 'excl', + value: 'invalid', + }) + .query(); + return request(server) + .get('/users/1') + .query(query) + .end((_, res) => { + expect(res.status).toBe(400); + expect(res.body.message).toBe( + `Invalid column name 'foo' for relation 'company.projects'`, + ); + done(); + }); + }); + it('should return status 400, 2', (done) => { + const query = qb + .setJoin({ field: 'company' }) + .setJoin({ field: 'company.projects' }) + .setFilter({ + field: 'invalid.projects', + operator: 'excl', + value: 'invalid', + }) + .query(); + return request(server) + .get('/users/1') + .query(query) + .end((_, res) => { + expect(res.status).toBe(400); + expect(res.body.message).toBe(`Invalid relation name 'invalid'`); + done(); + }); + }); + it('should return status 400, 3', (done) => { + const query = qb + .setJoin({ field: 'company' }) + .setJoin({ field: 'company.projects' }) + .setFilter({ + field: 'company.foo', + operator: 'excl', + value: 'invalid', + }) + .query(); + return request(server) + .get('/users/1') + .query(query) + .end((_, res) => { + expect(res.status).toBe(400); + expect(res.body.message).toBe( + `Invalid column name 'foo' for relation 'company'`, + ); + done(); + }); + }); + it('should return status 200', (done) => { + const query = qb + .setJoin({ field: 'company' }) + .setJoin({ field: 'company.projectsinvalid' }) + .query(); + return request(server) + .get('/users/1') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + + const user = res.body; + expect(user).toBeDefined(); + expect(user.id).toBe(1); + expect(user.company).toBeDefined(); + done(); + }); + }); + it('should return joined entity, 1', (done) => { + const query = qb + .setFilter({ field: 'company.name', operator: 'excl', value: 'invalid' }) + .setJoin({ field: 'company' }) + .setJoin({ field: 'company.projects' }) + .query(); + return request(server) + .get('/users/1') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + + const user = res.body; + expect(user.company).toBeDefined(); + expect(user.company.projects).toBeDefined(); + + expect(user.id).toBe(1); + expect(user.company.name).not.toMatch('invalid'); + done(); + }); + }); + it('should return joined entity, 2', (done) => { + const query = qb + .setFilter({ field: 'company.projects.id', operator: 'notnull' }) + .setJoin({ field: 'company' }) + .setJoin({ field: 'company.projects' }) + .query(); + return request(server) + .get('/users/1') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + + const user = res.body; + expect(user.id).toBe(1); + + expect(user.company).toBeDefined(); + expect(user.company.projects).toBeDefined(); + done(); + }); + }); + it.skip('should return joined entity with alias', (done) => { + const query = qb + .setFilter({ field: 'pr.id', operator: 'notnull' }) + .setJoin({ field: 'company' }) + .setJoin({ field: 'company.projects' }) + .query(); + return request(server) + .get('/users2/1') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + const user = res.body; + + expect(user.id).toBe(1); + expect(user.company).toBeDefined(); + expect(user.company.projects).toBeDefined(); + done(); + }); + }); + it('should return joined entity with ManyToMany pivot table', (done) => { + const query = qb + .setJoin({ field: 'users' }) + .setJoin({ field: 'userProjects' }) + .query(); + return request(server) + .get('/projects/1') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + + const project = res.body; + expect(project.id).toBe(1); + expect(project.company.id).toBe(1); + expect(project.users).toBeDefined(); + expect(project.users.length).toBe(2); + expect(project.users[0].id).toBe(1); + expect(project.users[1].id).toBe(2); + expect(project.userProjects).toBeDefined(); + expect(project.userProjects.length).toBe(2); + expect(project.userProjects[0].review).toBe('User project 1 1'); + expect(project.userProjects[1].review).toBe('User project 1 2'); + done(); + }); + }); + }); + + describe('#query composite key join', () => { + it('should return joined relation', (done) => { + const query = qb.setJoin({ field: 'userLicenses' }).query(); + return request(server) + .get('/users/1') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + + const user = res.body; + expect(user.id).toBe(1); + expect(user.userLicenses).toBeDefined(); + expect(user.userLicenses).toBeArrayOfSize(3); + user.userLicenses.forEach((ul) => { + expect(ul.userId).toBe(1); + }); + expect(user.userLicenses.map((ul) => ul.licenseId)).toContainValues([ + 1, + 2, + 4, + ]); + done(); + }); + }); + }); + + describe('#sort', () => { + it('should sort by field', async () => { + const query = qb.sortBy({ field: 'id', order: 'DESC' }).query(); + const res = await request(server) + .get('/users') + .query(query) + .expect(200); + expect(res.body[1].id).toBeLessThan(res.body[0].id); + }); + + it('should sort by nested field, 1', async () => { + const query = qb + .setFilter({ field: 'company.id', operator: 'notnull' }) + .setJoin({ field: 'company' }) + .sortBy({ field: 'company.id', order: 'DESC' }) + .query(); + const res = await request(server) + .get('/users') + .query(query) + .expect(200); + expect(res.body[res.body.length - 1].company.id).toBeLessThan( + res.body[0].company.id, + ); + }); + + it('should sort by nested field, 2', async () => { + const query = qb + .setFilter({ field: 'id', operator: 'eq', value: 1 }) + .setFilter({ field: 'company.id', operator: 'notnull' }) + .setFilter({ field: 'company.projects.id', operator: 'notnull' }) + .setJoin({ field: 'company' }) + .setJoin({ field: 'company.projects' }) + .sortBy({ field: 'company.projects.id', order: 'DESC' }) + .query(); + const res = await request(server) + .get('/users') + .query(query) + .expect(200); + expect(res.body[0].company.projects[1].id).toBeLessThan( + res.body[0].company.projects[0].id, + ); + }); + + it('should sort by nested field, 3', async () => { + const query = qb + .setFilter({ field: 'id', operator: 'eq', value: 1 }) + .setFilter({ field: 'company.id', operator: 'notnull' }) + .setFilter({ field: 'company.projects.id', operator: 'notnull' }) + .setJoin({ field: 'company' }) + .setJoin({ field: 'company.projects' }) + .sortBy({ field: 'company.projects.id', order: 'DESC' }) + .query(); + const res = await request(server) + .get('/users') + .query(query) + .expect(200); + expect(res.body[0].company.projects[1].id).toBeLessThan( + res.body[0].company.projects[0].id, + ); + }); + + it('should throw 400 if SQL injection has been detected', (done) => { + const query = qb + .sortBy({ + field: ' ASC; SELECT CAST( version() AS INTEGER); --', + order: 'DESC', + }) + .query(); + + return request(server) + .get('/companies') + .query(query) + .end((_, res) => { + expect(res.status).toBe(400); + expect(res.body.message).toBe( + `Invalid column name ' ASC; SELECT CAST( version() AS INTEGER); --'`, + ); + done(); + }); + }); + }); + + describe('#search', () => { + const projects2 = () => request(server).get('/projects2'); + const projects3 = () => request(server).get('/projects3'); + const projects4 = () => request(server).get('/projects4'); + + it('should return with search, 1', async () => { + const query = qb.search({ id: 1 }).query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(1); + expect(res.body[0].id).toBe(1); + }); + it('should return with search, 2', async () => { + const query = qb.search({ id: 1, name: 'Project1' }).query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(1); + expect(res.body[0].id).toBe(1); + expect(res.body[0].name).toBe('Project1'); + }); + it('should return with search, 3', async () => { + const query = qb.search({ id: 1, name: { $eq: 'Project1' } }).query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(1); + expect(res.body[0].id).toBe(1); + expect(res.body[0].name).toBe('Project1'); + }); + it('should return with search, 4', async () => { + const query = qb.search({ name: { $eq: 'Project1' } }).query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(1); + expect(res.body[0].id).toBe(1); + expect(res.body[0].name).toBe('Project1'); + }); + it('should return with search, 5', async () => { + const query = qb.search({ id: { $notnull: true, $eq: 1 } }).query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(1); + expect(res.body[0].id).toBe(1); + }); + it('should return with search, 6', async () => { + const query = qb.search({ id: { $or: { $isnull: true, $eq: 1 } } }).query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(1); + expect(res.body[0].id).toBe(1); + }); + it('should return with search, 7', async () => { + const query = qb.search({ id: { $or: { $eq: 1 } } }).query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(1); + expect(res.body[0].id).toBe(1); + }); + it('should return with search, 8', async () => { + const query = qb + .search({ id: { $notnull: true, $or: { $eq: 1, $in: [3, 4] } } }) + .query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(3); + expect(res.body.map((p) => p.id)).toContainValues([1, 3, 4]); + }); + it('should return with search, 9', async () => { + const query = qb.search({ id: { $notnull: true, $or: { $eq: 1 } } }).query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(1); + expect(res.body[0].id).toBe(1); + }); + it('should return with search, 10', async () => { + const query = qb.search({ id: null }).query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(0); + }); + it('should return with search, 11', async () => { + const query = qb + .search({ $and: [{ id: { $notin: [5, 6, 7, 8, 9, 10] } }, { isActive: true }] }) + .query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(4); + expect(res.body.map((p) => p.id)).toContainValues([1, 2, 3, 4]); + res.body.forEach((p) => { + expect(p.isActive).toBe(true); + }); + }); + it('should return with search, 12', async () => { + const query = qb + .search({ $and: [{ id: { $notin: [5, 6, 7, 8, 9, 10] } }] }) + .query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(14); + expect(res.body.map((p) => p.id)).not.toContainValues([5, 6, 7, 8, 9, 10]); + }); + it('should return with search, 13', async () => { + const query = qb.search({ $or: [{ id: 54 }] }).query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(0); + }); + it('should return with search, 14', async () => { + const query = qb + .search({ $or: [{ id: 54 }, { id: 33 }, { id: { $in: [1, 2] } }] }) + .query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(2); + expect(res.body[0].id).toBe(1); + expect(res.body[1].id).toBe(2); + }); + it('should return with search, 15', async () => { + const query = qb.search({ $or: [{ id: 54 }], name: 'Project1' }).query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(0); + }); + it('should return with search, 16', async () => { + const query = qb + .search({ $or: [{ isActive: false }, { id: 3 }], name: 'Project3' }) + .query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(1); + expect(res.body[0].id).toBe(3); + expect(res.body[0].name).toBe('Project3'); + }); + it('should return with search, 17', async () => { + const query = qb + .search({ $or: [{ isActive: false }, { id: { $eq: 3 } }], name: 'Project3' }) + .query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(1); + expect(res.body[0].id).toBe(3); + expect(res.body[0].name).toBe('Project3'); + }); + it('should return with search, 18', async () => { + const query = qb + .search({ + $or: [{ isActive: false }, { id: { $eq: 3 } }], + name: { $eq: 'Project3' }, + }) + .query(); + const res = await projects2() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(1); + expect(res.body[0].id).toBe(3); + }); + it('should return with default filter, 1', async () => { + const query = qb.search({ name: 'Project11' }).query(); + const res = await projects3() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(1); + expect(res.body[0].id).toBe(11); + expect(res.body[0].name).toBe('Project11'); + }); + it('should return with default filter, 2', async () => { + const query = qb.search({ name: 'Project1' }).query(); + const res = await projects3() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(0); + }); + it('should return with default filter, 3', async () => { + const query = qb.search({ name: 'Project2' }).query(); + const res = await projects4() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(1); + expect(res.body[0].id).toBe(2); + expect(res.body[0].name).toBe('Project2'); + }); + it('should return with default filter, 4', async () => { + const query = qb.search({ name: 'Project11' }).query(); + const res = await projects4() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(0); + }); + it('should return with $eqL search operator', async () => { + const query = qb.search({ name: { $eqL: 'project1' } }).query(); + const res = await projects4() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(1); + expect(res.body[0].name).toEqualCaseInsensitive('project1'); + }); + it('should return with $neL search operator', async () => { + const query = qb.search({ name: { $neL: 'project1' } }).query(); + const res = await projects4() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(9); + res.body.forEach((p) => { + expect(p.name).not.toEqualCaseInsensitive('project1'); + }); + }); + it('should return with $startsL search operator', async () => { + const query = qb.search({ email: { $startsL: '2' } }).query(); + const res = await request(server) + .get('/users') + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(3); + res.body.forEach((p) => { + expect(p.email).toStartWith('2'); + }); + }); + it('should return with $endsL search operator', async () => { + 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(); + const res = await request(server) + .get('/users') + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(3); + res.body.forEach((u) => { + expect(u.email.includes('1@')).toBe(true); + }); + }); + it('should return with $exclL search operator', async () => { + const query = qb.search({ email: { $exclL: '1@' } }).query(); + const res = await request(server) + .get('/users') + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(18); + res.body.forEach((u) => { + expect(u.email.includes('1@')).toBe(false); + }); + }); + it('should return with $inL search operator', async () => { + const query = qb.search({ name: { $inL: ['name2', 'name3'] } }).query(); + const res = await request(server) + .get('/companies') + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(2); + res.body.forEach((c) => { + expect(c.name).toBeOneOf(['Name2', 'Name3']); + }); + }); + it('should return with $notinL search operator', async () => { + const query = qb + .search({ name: { $notinL: ['project7', 'project8', 'project9'] } }) + .query(); + const res = await projects4() + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(7); + res.body.forEach((c) => { + expect(c.name).not.toBeOneOf(['Project7', 'Project8', 'Project9']); + }); + }); + it('should search by display column name, but use dbName in sql query', async () => { + const query = qb.search({ revisionId: 2 }).query(); + const res = await request(server) + .get('/notes') + .query(query) + .expect(200); + expect(res.body).toBeArrayOfSize(2); + expect(res.body[0].revisionId).toBe(2); + expect(res.body[1].revisionId).toBe(2); + }); + }); + + describe('#update', () => { + it('should update company id of project', async () => { + await request(server) + .patch('/projects/18') + .send({ companyId: 10 }) + .expect(200); + + const modified = await request(server) + .get('/projects/18') + .expect(200); + + expect(modified.body.companyId).toBe(10); + }); + }); + }); +}); diff --git a/packages/crud-objection/test/c.basic-crud.spec.ts b/packages/crud-objection/test/c.basic-crud.spec.ts new file mode 100644 index 00000000..8c69d67a --- /dev/null +++ b/packages/crud-objection/test/c.basic-crud.spec.ts @@ -0,0 +1,653 @@ +import { Controller, INestApplication } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; + +import { Crud } from '@nestjsx/crud'; +import { RequestQueryBuilder } from '@nestjsx/crud-request'; +import * as request from 'supertest'; +import { Company } from '../../../integration/crud-typeorm/companies'; +import { Device } from '../../../integration/crud-typeorm/devices'; +import { User } from '../../../integration/crud-typeorm/users'; +import { HttpExceptionFilter } from '../../../integration/shared/https-exception.filter'; +import { CompaniesService } from './__fixture__/companies.service'; +import { UsersService } from './__fixture__/users.service'; +import { DevicesService } from './__fixture__/devices.service'; +import { KNEX_CONNECTION } from '../../../integration/crud-objection/injection-tokens'; +import { DatabaseModule } from '../../../integration/crud-objection/database.module'; + +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', () => { + let app: INestApplication; + let server: any; + let qb: RequestQueryBuilder; + let service: CompaniesService; + + @Crud({ + model: { type: Company }, + query: { + alwaysPaginate: true, + limit: 3, + }, + }) + @Controller('companies0') + class CompaniesController0 { + constructor(public service: CompaniesService) {} + } + + beforeAll(async () => { + const fixture = await Test.createTestingModule({ + imports: [DatabaseModule], + controllers: [CompaniesController0], + providers: [ + { provide: APP_FILTER, useClass: HttpExceptionFilter }, + CompaniesService, + ], + }).compile(); + + app = fixture.createNestApplication(); + service = app.get(CompaniesService); + + await app.init(); + server = app.getHttpServer(); + }); + + beforeEach(() => { + qb = RequestQueryBuilder.create(); + }); + + afterAll(async () => { + const knex = app.get(KNEX_CONNECTION); + await knex.destroy(); + await app.close(); + }); + + describe('#getAllBase', () => { + it('should return an array of all entities', (done) => { + return request(server) + .get('/companies0') + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(3); + expect(res.body.page).toBe(1); + done(); + }); + }); + }); + }); + + describe('#basic crud using alwaysPaginate default', () => { + let app: INestApplication; + let server: any; + let qb: RequestQueryBuilder; + let service: CompaniesService; + + @Crud({ + model: { type: Company }, + query: { alwaysPaginate: true }, + }) + @Controller('companies') + class CompaniesController { + constructor(public service: CompaniesService) {} + } + + beforeAll(async () => { + const fixture = await Test.createTestingModule({ + imports: [DatabaseModule], + controllers: [CompaniesController], + providers: [ + { provide: APP_FILTER, useClass: HttpExceptionFilter }, + CompaniesService, + ], + }).compile(); + + app = fixture.createNestApplication(); + service = app.get(CompaniesService); + + await app.init(); + server = app.getHttpServer(); + }); + + beforeEach(() => { + qb = RequestQueryBuilder.create(); + }); + + afterAll(async () => { + const knex = app.get(KNEX_CONNECTION); + await knex.destroy(); + await app.close(); + }); + + describe('#getAllBase', () => { + it('should return an array of all entities', (done) => { + return request(server) + .get('/companies') + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(10); + expect(res.body.page).toBe(1); + done(); + }); + }); + it('should return an entities with limit', (done) => { + const query = qb.setLimit(5).query(); + return request(server) + .get('/companies') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(5); + expect(res.body.page).toBe(1); + done(); + }); + }); + it('should return an entities with limit and page', (done) => { + const query = qb + .setLimit(3) + .setPage(1) + .sortBy({ field: 'id', order: 'DESC' }) + .query(); + return request(server) + .get('/companies') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(3); + expect(res.body.count).toBe(3); + expect(res.body.page).toBe(1); + done(); + }); + }); + }); + }); + + describe('#basic crud', () => { + let app: INestApplication; + let server: any; + let qb: RequestQueryBuilder; + let service: CompaniesService; + + @Crud({ + model: { type: Company }, + }) + @Controller('companies') + class CompaniesController { + constructor(public service: CompaniesService) {} + } + + @Crud({ + model: { type: User }, + params: { + companyId: { + field: 'companyId', + type: 'number', + }, + id: { + field: 'id', + type: 'number', + primary: true, + }, + }, + routes: { + deleteOneBase: { + returnDeleted: true, + }, + }, + query: { + persist: ['isActive'], + cache: 10, + }, + validation: { + transform: true, + }, + }) + @Controller('companies/:companyId/users') + class UsersController { + constructor(public service: UsersService) {} + } + + @Crud({ + model: { type: User }, + query: { + join: { + profile: { + eager: true, + required: true, + }, + }, + }, + }) + @Controller('/users2') + class UsersController2 { + constructor(public service: UsersService) {} + } + + @Crud({ + model: { type: User }, + query: { + join: { + profile: { + eager: true, + }, + }, + }, + }) + @Controller('/users3') + class UsersController3 { + constructor(public service: UsersService) {} + } + + @Crud({ + model: { type: User }, + params: { + companyId: { field: 'companyId', type: 'number', primary: true }, + profileId: { field: 'profileId', type: 'number', primary: true }, + }, + }) + @Controller('users4') + class UsersController4 { + constructor(public service: UsersService) {} + } + + @Crud({ + model: { type: Device }, + params: { + deviceKey: { + field: 'deviceKey', + type: 'uuid', + primary: true, + }, + }, + routes: { + createOneBase: { + returnShallow: true, + }, + }, + }) + @Controller('devices') + class DevicesController { + constructor(public service: DevicesService) {} + } + + beforeAll(async () => { + const fixture = await Test.createTestingModule({ + imports: [DatabaseModule], + controllers: [ + CompaniesController, + UsersController, + UsersController2, + UsersController3, + UsersController4, + DevicesController, + ], + providers: [ + { provide: APP_FILTER, useClass: HttpExceptionFilter }, + CompaniesService, + UsersService, + DevicesService, + ], + }).compile(); + + app = fixture.createNestApplication(); + service = app.get(CompaniesService); + + await app.init(); + server = app.getHttpServer(); + }); + + beforeEach(() => { + qb = RequestQueryBuilder.create(); + }); + + afterAll(async () => { + const knex = app.get(KNEX_CONNECTION); + await knex.destroy(); + await app.close(); + }); + + describe('#find', () => { + it('should return entities', async () => { + const data = await service.query(); + expect(data.length).toBe(10); + }); + }); + + describe('#findOne', () => { + it('should return one entity', async () => { + const data = await service.query().findById(1); + expect(data.id).toBe(1); + }); + }); + + describe('#count', () => { + it('should return number', async () => { + const data = await (service + .query() + .count() + .first() as any); + expect(Number(data.count)).toBe(10); + }); + }); + + describe('#getAllBase', () => { + it('should return an array of all entities', (done) => { + return request(server) + .get('/companies') + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.length).toBe(10); + done(); + }); + }); + it('should return an entities with limit', (done) => { + const query = qb.setLimit(5).query(); + return request(server) + .get('/companies') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.length).toBe(5); + done(); + }); + }); + it('should return an entities with limit and page', (done) => { + const query = qb + .setLimit(3) + .setPage(1) + .sortBy({ field: 'id', order: 'DESC' }) + .query(); + return request(server) + .get('/companies') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(3); + expect(res.body.count).toBe(3); + expect(res.body.total).toBe(10); + expect(res.body.page).toBe(1); + expect(res.body.pageCount).toBe(4); + done(); + }); + }); + it('should return an entities with offset', (done) => { + 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); + if (isMysql) { + expect(res.body.count).toBe(7); + expect(res.body.data.length).toBe(7); + } else { + expect(res.body.length).toBe(7); + } + done(); + }); + }); + }); + + describe('#getOneBase', () => { + it('should return status 404', (done) => { + return request(server) + .get('/companies/333') + .end((_, res) => { + expect(res.status).toBe(404); + done(); + }); + }); + it('should return an entity, 1', (done) => { + return request(server) + .get('/companies/1') + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.id).toBe(1); + done(); + }); + }); + it('should return an entity, 2', (done) => { + const query = qb.select(['domain']).query(); + return request(server) + .get('/companies/1') + .query(query) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.id).toBe(1); + expect(res.body.domain).toBeTruthy(); + done(); + }); + }); + it('should return an entity with compound key', (done) => { + return request(server) + .get('/users4/1/5') + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.id).toBe(5); + done(); + }); + }); + it.skip('should return an entity with and set cache', (done) => { + // Cache is not supported by objection + return request(server) + .get('/companies/1/users/1') + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.id).toBe(1); + expect(res.body.companyId).toBe(1); + done(); + }); + }); + + it('should return an entity with its embedded entity properties', (done) => { + return request(server) + .get('/companies/1/users/1') + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.id).toBe(1); + expect(res.body.name.first).toBe('firstname1'); + expect(res.body.name.last).toBe('lastname1'); + done(); + }); + }); + }); + + describe('#createOneBase', () => { + it('should return status 400', (done) => { + return request(server) + .post('/companies') + .send('') + .end((_, res) => { + expect(res.status).toBe(400); + done(); + }); + }); + it('should return saved entity', (done) => { + const dto = { + name: 'test0', + domain: 'test0', + }; + return request(server) + .post('/companies') + .send(dto) + .end((_, res) => { + expect(res.status).toBe(201); + expect(res.body.id).toBeTruthy(); + done(); + }); + }); + it('should return saved entity with param', (done) => { + const dto: any = { + email: 'test@test.com', + isActive: true, + name: { + first: 'test', + last: 'last', + }, + profile: { + name: 'testName', + }, + }; + return request(server) + .post('/companies/1/users') + .send(dto) + .end((_, res) => { + expect(res.status).toBe(201); + expect(res.body.id).toBeTruthy(); + expect(res.body.companyId).toBe(1); + done(); + }); + }); + it('should return with `returnShallow`', (done) => { + const dto: any = { description: 'returnShallow is true' }; + return request(server) + .post('/devices') + .send(dto) + .end((_, res) => { + expect(res.status).toBe(201); + expect(res.body.deviceKey).toBeTruthy(); + expect(res.body.description).toBeTruthy(); + done(); + }); + }); + }); + + describe('#createManyBase', () => { + it('should return status 400', (done) => { + const dto = { bulk: [] }; + return request(server) + .post('/companies/bulk') + .send(dto) + .end((_, res) => { + expect(res.status).toBe(400); + done(); + }); + }); + it('should return created entities', (done) => { + const dto = { + bulk: [ + { + name: 'test1', + domain: 'test1', + }, + { + name: 'test2', + domain: 'test2', + }, + ], + }; + return request(server) + .post('/companies/bulk') + .send(dto) + .end((_, res) => { + expect(res.status).toBe(201); + expect(res.body[0].id).toBeTruthy(); + expect(res.body[1].id).toBeTruthy(); + done(); + }); + }); + }); + + describe('#updateOneBase', () => { + it('should return status 404', (done) => { + const dto = { name: 'updated0' }; + return request(server) + .patch('/companies/333') + .send(dto) + .end((_, res) => { + expect(res.status).toBe(404); + done(); + }); + }); + it('should return updated entity, 1', (done) => { + const dto = { name: 'updated0' }; + return request(server) + .patch('/companies/1') + .send(dto) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.name).toBe('updated0'); + done(); + }); + }); + it('should return updated entity, 2', (done) => { + const dto = { isActive: false, companyId: 5 }; + return request(server) + .patch('/companies/1/users/22') + .send(dto) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.isActive).toBe(false); + expect(res.body.companyId).toBe(1); + done(); + }); + }); + }); + + describe('#replaceOneBase', () => { + it('should create entity', (done) => { + const dto = { name: 'updated0', domain: 'domain0' }; + return request(server) + .put('/companies/333') + .send(dto) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.name).toBe('updated0'); + done(); + }); + }); + it('should return updated entity, 1', (done) => { + const dto = { name: 'updated0' }; + return request(server) + .put('/companies/1') + .send(dto) + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.name).toBe('updated0'); + done(); + }); + }); + }); + + describe('#deleteOneBase', () => { + it('should return status 404', (done) => { + return request(server) + .delete('/companies/3333') + .end((_, res) => { + expect(res.status).toBe(404); + done(); + }); + }); + it('should return deleted entity', (done) => { + return request(server) + .delete('/companies/1/users/22') + .end((_, res) => { + expect(res.status).toBe(200); + expect(res.body.id).toBe(22); + expect(res.body.companyId).toBe(1); + done(); + }); + }); + }); + + describe('join options: required', () => { + const users2 = () => request(server).get('/users2/21'); + const users3 = () => request(server).get('/users3/21'); + + it('should return status 404', async () => { + await users2().expect(404); + }); + + it('should return status 200', async () => { + const res = await users3().expect(200); + expect(res.body.id).toBe(21); + expect(res.body.profile).toBe(null); + }); + }); + }); +}); diff --git a/packages/crud-objection/test/d.crud-auth.spec.ts b/packages/crud-objection/test/d.crud-auth.spec.ts new file mode 100644 index 00000000..8bfd1dd3 --- /dev/null +++ b/packages/crud-objection/test/d.crud-auth.spec.ts @@ -0,0 +1,177 @@ +import { + CanActivate, + Controller, + ExecutionContext, + INestApplication, + Injectable, +} from '@nestjs/common'; +import { APP_FILTER, APP_GUARD } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; + +import { Crud, CrudAuth } from '@nestjsx/crud'; +import * as request from 'supertest'; +import { User } from '../../../integration/crud-typeorm/users'; +import { Project } from '../../../integration/crud-typeorm/projects'; +import { HttpExceptionFilter } from '../../../integration/shared/https-exception.filter'; +import { UsersService } from './__fixture__/users.service'; +import { ProjectsService } from './__fixture__/projects.service'; +import { KNEX_CONNECTION } from '../../../integration/crud-objection/injection-tokens'; +import { DatabaseModule } from '../../../integration/crud-objection/database.module'; + +describe('#crud-typeorm', () => { + describe('#CrudAuth', () => { + const USER_REQUEST_KEY = 'user'; + let app: INestApplication; + let server: request.SuperTest; + + @Injectable() + class AuthGuard implements CanActivate { + constructor(private usersService: UsersService) {} + + async canActivate(ctx: ExecutionContext): Promise { + const req = ctx.switchToHttp().getRequest(); + req[USER_REQUEST_KEY] = await this.usersService.query().findById(1); + + return true; + } + } + + @Crud({ + model: { + type: User, + }, + routes: { + only: ['getOneBase', 'updateOneBase'], + }, + params: { + id: { + primary: true, + disabled: true, + }, + }, + }) + @CrudAuth({ + property: USER_REQUEST_KEY, + filter: (user: User) => ({ + id: user.id, + }), + persist: (user: User) => ({ + email: user.email, + }), + }) + @Controller('me') + class MeController { + constructor(public service: UsersService) {} + } + + @Crud({ + model: { + type: Project, + }, + routes: { + only: ['createOneBase', 'deleteOneBase'], + }, + }) + @CrudAuth({ + property: USER_REQUEST_KEY, + filter: (user: User) => ({ + companyId: user.companyId, + }), + persist: (user: User) => ({ + companyId: user.companyId, + }), + }) + @Controller('projects') + class ProjectsController { + constructor(public service: ProjectsService) {} + } + + beforeAll(async () => { + const fixture = await Test.createTestingModule({ + imports: [DatabaseModule], + controllers: [MeController, ProjectsController], + providers: [ + { + provide: APP_GUARD, + useClass: AuthGuard, + }, + { + provide: APP_FILTER, + useClass: HttpExceptionFilter, + }, + UsersService, + ProjectsService, + ], + }).compile(); + + app = fixture.createNestApplication(); + + await app.init(); + server = request(app.getHttpServer()); + }); + + afterAll(async () => { + const knex = app.get(KNEX_CONNECTION); + await knex.destroy(); + await app.close(); + }); + + describe('#getOneBase', () => { + it('should return a user with id 1', async () => { + const res = await server.get('/me').expect(200); + expect(res.body.id).toBe(1); + }); + }); + + describe('#updateOneBase', () => { + it('should update user with auth persist, 1', async () => { + const res = await server + .patch('/me') + .send({ + email: 'some@dot.com', + isActive: false, + }) + .expect(200); + expect(res.body.id).toBe(1); + expect(res.body.email).toBe('1@email.com'); + expect(res.body.isActive).toBe(false); + }); + it('should update user with auth persist, 2', async () => { + const res = await server + .patch('/me') + .send({ + email: 'some@dot.com', + isActive: true, + }) + .expect(200); + expect(res.body.id).toBe(1); + expect(res.body.email).toBe('1@email.com'); + expect(res.body.isActive).toBe(true); + }); + }); + + describe('#createOneBase', () => { + it('should create an entity with auth persist', async () => { + const res = await server + .post('/projects') + .send({ + name: 'Test', + description: 'foo', + isActive: false, + companyId: 10, + }) + .expect(201); + expect(res.body.companyId).toBe(1); + }); + }); + + describe('#deleteOneBase', () => { + it('should delete an entity with auth filter', async () => { + const res = await server.delete('/projects/21').expect(200); + }); + it('should throw an error with auth filter', async () => { + const res = await server.delete('/projects/20').expect(404); + }); + }); + }); +}); diff --git a/packages/crud/src/interfaces/query-options.interface.ts b/packages/crud/src/interfaces/query-options.interface.ts index d2de7c22..8f5c3290 100644 --- a/packages/crud/src/interfaces/query-options.interface.ts +++ b/packages/crud/src/interfaces/query-options.interface.ts @@ -30,4 +30,5 @@ export interface JoinOption { persist?: QueryFields; select?: false; required?: boolean; + fetch?: boolean; } diff --git a/yarn.lock b/yarn.lock index 7dab7720..edcace2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1498,6 +1498,16 @@ agentkeepalive@^3.4.1: dependencies: humanize-ms "^1.2.1" +ajv@^6.12.0: + version "6.12.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.2.tgz#c629c5eced17baf314437918d2da88c99d5958cd" + integrity sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + ajv@^6.5.5: version "6.12.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.0.tgz#06d60b96d87b8454a5adaba86e7854da629db4b7" @@ -1643,6 +1653,11 @@ array-differ@^2.0.3: resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-2.1.0.tgz#4b9c1c3f14b906757082925769e8ab904f4801b1" integrity sha512-KbUpJgx909ZscOc/7CLATBFam7P1Z1QRQInvgT0UztM9Q72aGKCunKASAl7WNW0tnPmPyEMeMhdsfWhfmW037w== +array-each@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/array-each/-/array-each-1.0.1.tgz#a794af0c05ab1752846ee753a1f211a05ba0c44f" + integrity sha1-p5SvDAWrF1KEbudTofIRoFugxE8= + array-equal@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93" @@ -1663,6 +1678,11 @@ array-ify@^1.0.0: resolved "https://registry.yarnpkg.com/array-ify/-/array-ify-1.0.0.tgz#9e528762b4a9066ad163a6962a364418e9626ece" integrity sha1-nlKHYrSpBmrRY6aWKjZEGOlibs4= +array-slice@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/array-slice/-/array-slice-1.1.0.tgz#e368ea15f89bc7069f7ffb89aec3a6c7d4ac22d4" + integrity sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w== + array-union@^1.0.1, array-union@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" @@ -2401,6 +2421,11 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +colorette@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.1.0.tgz#1f943e5a357fac10b4e0f5aaef3b14cdc1af6ec7" + integrity sha512-6S062WDQUXi6hOfkO/sBPVwE5ASXY4G2+b4atvhJfSsuUUhIaUKlkjLe9692Ipyt5/a+IPF5aVTu3V5gvXq5cg== + colors@~0.6.0-1: version "0.6.2" resolved "https://registry.yarnpkg.com/colors/-/colors-0.6.2.tgz#2423fe6678ac0c5dae8852e5d0e5be08c997abcc" @@ -2431,6 +2456,11 @@ commander@^2.12.1, commander@~2.20.3: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +commander@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== + commander@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.1.0.tgz#d121bbae860d9992a3d517ba96f56588e47c6781" @@ -2899,6 +2929,11 @@ dateformat@^3.0.0: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== +db-errors@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/db-errors/-/db-errors-0.2.3.tgz#a6a38952e00b20e790f2695a6446b3c65497ffa2" + integrity sha512-OOgqgDuCavHXjYSJoV2yGhv6SeG8nk42aoCSoyXLZUH7VwFG27rxbavU1z+VrZbZjphw5UkDQwUlD21MwZpUng== + debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -2913,6 +2948,13 @@ debug@3.1.0, debug@=3.1.0: dependencies: ms "2.0.0" +debug@4.1.1, debug@^4.1.0, debug@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" + integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + dependencies: + ms "^2.1.1" + debug@^3.1.0: version "3.2.6" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" @@ -2920,13 +2962,6 @@ debug@^3.1.0: dependencies: ms "^2.1.1" -debug@^4.1.0, debug@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" - integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== - dependencies: - ms "^2.1.1" - debuglog@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" @@ -3329,6 +3364,11 @@ escodegen@^1.9.1: optionalDependencies: source-map "~0.6.1" +esm@^3.2.25: + version "3.2.25" + resolved "https://registry.yarnpkg.com/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10" + integrity sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA== + esprima@^4.0.0, esprima@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" @@ -3731,6 +3771,22 @@ findup@0.1.5: colors "~0.6.0-1" commander "~2.1.0" +fined@^1.0.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/fined/-/fined-1.2.0.tgz#d00beccf1aa2b475d16d423b0238b713a2c4a37b" + integrity sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng== + dependencies: + expand-tilde "^2.0.2" + is-plain-object "^2.0.3" + object.defaults "^1.1.0" + object.pick "^1.2.0" + parse-filepath "^1.0.1" + +flagged-respawn@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/flagged-respawn/-/flagged-respawn-1.0.1.tgz#e7de6f1279ddd9ca9aac8a5971d618606b3aab41" + integrity sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q== + flush-write-stream@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8" @@ -3746,11 +3802,18 @@ follow-redirects@1.5.10: dependencies: debug "=3.1.0" -for-in@^1.0.2: +for-in@^1.0.1, for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= +for-own@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/for-own/-/for-own-1.0.0.tgz#c63332f415cedc4b04dbfe70cf836494c53cb44b" + integrity sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs= + dependencies: + for-in "^1.0.1" + forever-agent@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" @@ -3935,6 +3998,11 @@ get-value@^2.0.3, get-value@^2.0.6: resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= +getopts@2.2.5: + version "2.2.5" + resolved "https://registry.yarnpkg.com/getopts/-/getopts-2.2.5.tgz#67a0fe471cacb9c687d817cab6450b96dde8313b" + integrity sha512-9jb7AW5p3in+IiJWhQiZmmwkpLaR/ccTWdWQCtZM66HJcHHLegowh4q4tSD7gouUyeNvFWRavfK9GXosQHDpFA== + getpass@^0.1.1: version "0.1.7" resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" @@ -4457,7 +4525,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -4548,6 +4616,11 @@ interpret@^1.0.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw== +interpret@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" + integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== + invariant@^2.2.2, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" @@ -4575,6 +4648,14 @@ ipaddr.js@1.9.1: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== +is-absolute@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-absolute/-/is-absolute-1.0.0.tgz#395e1ae84b11f26ad1795e73c17378e48a301576" + integrity sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA== + dependencies: + is-relative "^1.0.0" + is-windows "^1.0.1" + is-accessor-descriptor@^0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" @@ -4803,6 +4884,13 @@ is-regex@^1.0.5: dependencies: has "^1.0.3" +is-relative@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-1.0.0.tgz#a1bb6935ce8c5dba1e8b9754b9b2dcc020e2260d" + integrity sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA== + dependencies: + is-unc-path "^1.0.0" + is-retry-allowed@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4" @@ -4839,6 +4927,13 @@ is-typedarray@~1.0.0: resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= +is-unc-path@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-unc-path/-/is-unc-path-1.0.0.tgz#d731e8898ed090a12c352ad2eaed5095ad322c9d" + integrity sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ== + dependencies: + unc-path-regex "^0.1.2" + is-utf8@^0.2.0, is-utf8@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" @@ -5478,6 +5573,27 @@ kleur@^3.0.3: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== +knex@0.20.15: + version "0.20.15" + resolved "https://registry.yarnpkg.com/knex/-/knex-0.20.15.tgz#b7e9e1efd9cf35d214440d9439ed21153574679d" + integrity sha512-WHmvgfQfxA5v8pyb9zbskxCS1L1WmYgUbwBhHojlkmdouUOazvroUWlCr6KIKMQ8anXZh1NXOOtIUMnxENZG5Q== + dependencies: + colorette "1.1.0" + commander "^4.1.1" + debug "4.1.1" + esm "^3.2.25" + getopts "2.2.5" + inherits "~2.0.4" + interpret "^2.0.0" + liftoff "3.1.0" + lodash "^4.17.15" + mkdirp "^0.5.1" + pg-connection-string "2.1.0" + tarn "^2.0.0" + tildify "2.0.0" + uuid "^7.0.1" + v8flags "^3.1.3" + latest-version@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15" @@ -5545,6 +5661,20 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +liftoff@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/liftoff/-/liftoff-3.1.0.tgz#c9ba6081f908670607ee79062d700df062c52ed3" + integrity sha512-DlIPlJUkCV0Ips2zf2pJP0unEoT1kwYhiiPUGF3s/jtxTCjziNLoiVVh+jqWOWeFi6mmwQ5fNxvAUyPad4Dfog== + dependencies: + extend "^3.0.0" + findup-sync "^3.0.0" + fined "^1.0.1" + flagged-respawn "^1.0.0" + is-plain-object "^2.0.4" + object.map "^1.0.0" + rechoir "^0.6.2" + resolve "^1.1.7" + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" @@ -5789,6 +5919,13 @@ make-fetch-happen@^5.0.0: socks-proxy-agent "^4.0.0" ssri "^6.0.0" +make-iterator@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/make-iterator/-/make-iterator-1.0.1.tgz#29b33f312aa8f547c4a5e490f56afcec99133ad6" + integrity sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw== + dependencies: + kind-of "^6.0.2" + makeerror@1.0.x: version "1.0.11" resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c" @@ -5808,7 +5945,7 @@ map-age-cleaner@^0.1.1: dependencies: p-defer "^1.0.0" -map-cache@^0.2.2: +map-cache@^0.2.0, map-cache@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= @@ -6556,6 +6693,16 @@ object.assign@^4.1.0: has-symbols "^1.0.0" object-keys "^1.0.11" +object.defaults@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/object.defaults/-/object.defaults-1.1.0.tgz#3a7f868334b407dea06da16d88d5cd29e435fecf" + integrity sha1-On+GgzS0B96gbaFtiNXNKeQ1/s8= + dependencies: + array-each "^1.0.1" + array-slice "^1.0.0" + for-own "^1.0.0" + isobject "^3.0.0" + object.getownpropertydescriptors@^2.0.3, object.getownpropertydescriptors@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649" @@ -6564,13 +6711,29 @@ object.getownpropertydescriptors@^2.0.3, object.getownpropertydescriptors@^2.1.0 define-properties "^1.1.3" es-abstract "^1.17.0-next.1" -object.pick@^1.3.0: +object.map@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object.map/-/object.map-1.0.1.tgz#cf83e59dc8fcc0ad5f4250e1f78b3b81bd801d37" + integrity sha1-z4Plncj8wK1fQlDh94s7gb2AHTc= + dependencies: + for-own "^1.0.0" + make-iterator "^1.0.0" + +object.pick@^1.2.0, object.pick@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= dependencies: isobject "^3.0.1" +objection@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/objection/-/objection-2.2.0.tgz#477d659c9c4667a024d845fdde6bf8bb0225f7d8" + integrity sha512-CijzCWeTTiFf6RCOMAUBWX0cPCvjLjEnQtGacTRQQ/sKlvXi6Mp22AxdD4N6ApjCgwpo8qHF3H73+ZYGOQ8u8w== + dependencies: + ajv "^6.12.0" + db-errors "^0.2.3" + octokit-pagination-methods@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4" @@ -6844,6 +7007,15 @@ parent-require@^1.0.0: resolved "https://registry.yarnpkg.com/parent-require/-/parent-require-1.0.0.tgz#746a167638083a860b0eef6732cb27ed46c32977" integrity sha1-dGoWdjgIOoYLDu9nMssn7UbDKXc= +parse-filepath@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/parse-filepath/-/parse-filepath-1.0.2.tgz#a632127f53aaf3d15876f5872f3ffac763d6c891" + integrity sha1-pjISf1Oq89FYdvWHLz/6x2PWyJE= + dependencies: + is-absolute "^1.0.0" + map-cache "^0.2.0" + path-root "^0.1.1" + parse-github-repo-url@^1.3.0: version "1.4.1" resolved "https://registry.yarnpkg.com/parse-github-repo-url/-/parse-github-repo-url-1.4.1.tgz#9e7d8bb252a6cb6ba42595060b7bf6df3dbc1f50" @@ -6971,6 +7143,18 @@ path-parse@^1.0.6: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== +path-root-regex@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/path-root-regex/-/path-root-regex-0.1.2.tgz#bfccdc8df5b12dc52c8b43ec38d18d72c04ba96d" + integrity sha1-v8zcjfWxLcUsi0PsONGNcsBLqW0= + +path-root@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/path-root/-/path-root-0.1.1.tgz#9a4a6814cac1c0cd73360a95f32083c8ea4745b7" + integrity sha1-mkpoFMrBwM1zNgqV8yCDyOpHRbc= + dependencies: + path-root-regex "^0.1.0" + path-to-regexp@0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" @@ -7021,6 +7205,11 @@ pg-connection-string@0.1.3: resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-0.1.3.tgz#da1847b20940e42ee1492beaf65d49d91b245df7" integrity sha1-2hhHsglA5C7hSSvq9l1J2RskXfc= +pg-connection-string@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.1.0.tgz#e07258f280476540b24818ebb5dca29e101ca502" + integrity sha512-bhlV7Eq09JrRIvo1eKngpwuqKtJnNhZdpdOlvrPrA4dxqXPjxSrbNrfnIDmTpwMyRszrcV4kU5ZA4mMsQUrjdg== + pg-int8@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" @@ -7791,6 +7980,13 @@ resolve@1.x, resolve@^1.1.6, resolve@^1.10.0, resolve@^1.3.2: dependencies: path-parse "^1.0.6" +resolve@^1.1.7: + version "1.17.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" + integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== + dependencies: + path-parse "^1.0.6" + restore-cursor@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" @@ -8560,6 +8756,11 @@ tar@^4.4.10, tar@^4.4.12, tar@^4.4.8: safe-buffer "^5.1.2" yallist "^3.0.3" +tarn@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/tarn/-/tarn-2.0.0.tgz#c68499f69881f99ae955b4317ca7d212d942fdee" + integrity sha512-7rNMCZd3s9bhQh47ksAQd92ADFcJUjjbyOvyFjNLwTPpGieFHMC84S+LOzw0fx1uh6hnDz/19r8CPMnIjJlMMA== + temp-dir@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-1.0.0.tgz#0a7c0ea26d3a39afa7e0ebea9c1fc0bc4daa011d" @@ -8660,6 +8861,11 @@ through@2, "through@>=2.2.7 <3", through@^2.3.4, through@^2.3.6, through@~2.3, t resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= +tildify@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/tildify/-/tildify-2.0.0.tgz#f205f3674d677ce698b7067a99e949ce03b4754a" + integrity sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw== + timed-out@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f" @@ -8945,6 +9151,11 @@ umask@^1.1.0: resolved "https://registry.yarnpkg.com/umask/-/umask-1.1.0.tgz#f29cebf01df517912bb58ff9c4e50fde8e33320d" integrity sha1-8pzr8B31F5ErtY/5xOUP3o4zMg0= +unc-path-regex@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" + integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo= + undefsafe@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.3.tgz#6b166e7094ad46313b2202da7ecc2cd7cc6e7aae" @@ -9107,6 +9318,18 @@ uuid@^3.0.1, uuid@^3.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== +uuid@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" + integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== + +v8flags@^3.1.3: + version "3.2.0" + resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-3.2.0.tgz#b243e3b4dfd731fa774e7492128109a0fe66d656" + integrity sha512-mH8etigqMfiGWdeXpaaqGfs6BndypxusHHcv2qSHyZkGEznCd/qAXCWWRzeowtL54147cktFOC4P5y+kl8d8Jg== + dependencies: + homedir-polyfill "^1.0.1" + validate-commit-msg@2.14.0: version "2.14.0" resolved "https://registry.yarnpkg.com/validate-commit-msg/-/validate-commit-msg-2.14.0.tgz#e5383691012cbb270dcc0bc2a4effebe14890eac"