diff --git a/package-lock.json b/package-lock.json index c8550d5..85f1576 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "debug": "^4.3.4", "sequelize": "^6.28.0", - "tslib": "^2.0.0" + "tslib": "^2.0.0", + "uuid": "^9.0.0" }, "devDependencies": { "@commitlint/cli": "^17.4.2", @@ -30,6 +31,7 @@ "@semantic-release/release-notes-generator": "^10.0.3", "@types/lodash": "^4.14.195", "@types/node": "^14.18.36", + "@types/uuid": "^9.0.2", "commitizen": "^4.3.0", "cz-conventional-changelog": "^3.3.0", "cz-customizable": "^7.0.0", @@ -2384,6 +2386,12 @@ "@types/node": "*" } }, + "node_modules/@types/uuid": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.2.tgz", + "integrity": "sha512-kNnC1GFBLuhImSnV7w4njQkUiJi0ZXUycu1rUaouPqiKlXkh77JKgdRnTAp1x5eBwcIwbtI+3otwzuIDEuDoxQ==", + "dev": true + }, "node_modules/@types/validator": { "version": "13.7.17", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.17.tgz", @@ -15880,7 +15888,6 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", - "dev": true, "bin": { "uuid": "dist/bin/uuid" } diff --git a/package.json b/package.json index 6b03620..77e7524 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "dependencies": { "debug": "^4.3.4", "sequelize": "^6.28.0", - "tslib": "^2.0.0" + "tslib": "^2.0.0", + "uuid": "^9.0.0" }, "devDependencies": { "@commitlint/cli": "^17.4.2", @@ -72,6 +73,7 @@ "@semantic-release/release-notes-generator": "^10.0.3", "@types/lodash": "^4.14.195", "@types/node": "^14.18.36", + "@types/uuid": "^9.0.2", "commitizen": "^4.3.0", "cz-conventional-changelog": "^3.3.0", "cz-customizable": "^7.0.0", diff --git a/src/__tests__/fixtures/controllers/index.ts b/src/__tests__/fixtures/controllers/index.ts index 18b394b..8078410 100644 --- a/src/__tests__/fixtures/controllers/index.ts +++ b/src/__tests__/fixtures/controllers/index.ts @@ -7,6 +7,7 @@ export * from './doctor.controller'; export * from './patient.controller'; export * from './product.controller'; export * from './programming-languange.controller'; +export * from './task.controller'; export * from './test.controller.base'; export * from './todo-list-todo.controller'; export * from './todo-list.controller'; diff --git a/src/__tests__/fixtures/controllers/task.controller.ts b/src/__tests__/fixtures/controllers/task.controller.ts new file mode 100644 index 0000000..293a38c --- /dev/null +++ b/src/__tests__/fixtures/controllers/task.controller.ts @@ -0,0 +1,186 @@ +import { + Count, + CountSchema, + Filter, + FilterExcludingWhere, + repository, + Where, +} from '@loopback/repository'; +import { + del, + get, + getModelSchemaRef, + param, + patch, + post, + put, + requestBody, + response, +} from '@loopback/rest'; +import {Task} from '../models'; +import {TaskRepository} from '../repositories'; +import {TestControllerBase} from './test.controller.base'; +export class TaskController extends TestControllerBase { + constructor( + @repository(TaskRepository) + public taskRepository: TaskRepository, + ) { + super(taskRepository); + } + + @post('/tasks') + @response(200, { + description: 'task model instance', + content: {'application/json': {schema: getModelSchemaRef(Task)}}, + }) + async create( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Task, { + title: 'NewTask', + exclude: ['id'], + }), + }, + }, + }) + task: Omit, + ): Promise { + return this.taskRepository.create(task); + } + + @post('/tasks-bulk') + @response(200, { + description: 'task model instances', + content: { + 'application/json': { + schema: { + type: 'array', + items: getModelSchemaRef(Task), + }, + }, + }, + }) + async createAll( + @requestBody({ + content: { + 'application/json': { + schema: { + type: 'array', + items: getModelSchemaRef(Task, { + title: 'NewTask', + exclude: ['id'], + }), + }, + }, + }, + }) + tasks: Array>, + ): Promise { + return this.taskRepository.createAll(tasks); + } + + @get('/tasks/count') + @response(200, { + description: 'Task model count', + content: {'application/json': {schema: CountSchema}}, + }) + async count(@param.where(Task) where?: Where): Promise { + return this.taskRepository.count(where); + } + + @get('/tasks') + @response(200, { + description: 'Array of Task model instances', + content: { + 'application/json': { + schema: { + type: 'array', + items: getModelSchemaRef(Task, {includeRelations: true}), + }, + }, + }, + }) + async find(@param.filter(Task) filter?: Filter): Promise { + return this.taskRepository.find(filter); + } + + @patch('/tasks') + @response(200, { + description: 'Task PATCH success count', + content: {'application/json': {schema: CountSchema}}, + }) + async updateAll( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Task, {partial: true}), + }, + }, + }) + task: Task, + @param.where(Task) where?: Where, + ): Promise { + return this.taskRepository.updateAll(task, where); + } + + @get('/tasks/{id}') + @response(200, { + description: 'Task model instance', + content: { + 'application/json': { + schema: getModelSchemaRef(Task, {includeRelations: true}), + }, + }, + }) + async findById( + @param.path.number('id') id: number, + @param.filter(Task, {exclude: 'where'}) + filter?: FilterExcludingWhere, + ): Promise { + return this.taskRepository.findById(id, filter); + } + + @patch('/tasks/{id}') + @response(204, { + description: 'Task PATCH success', + }) + async updateById( + @param.path.number('id') id: number, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Task, {partial: true}), + }, + }, + }) + task: Task, + ): Promise { + await this.taskRepository.updateById(id, task); + } + + @put('/tasks/{id}') + @response(204, { + description: 'Task PUT success', + }) + async replaceById( + @param.path.number('id') id: number, + @requestBody() task: Task, + ): Promise { + await this.taskRepository.replaceById(id, task); + } + + @del('/tasks/{id}') + @response(204, { + description: 'Task DELETE success', + }) + async deleteById(@param.path.number('id') id: number): Promise { + await this.taskRepository.deleteById(id); + } + + @get('/tasks/sync-sequelize-model') + @response(200) + async syncSequelizeModel(): Promise { + await this.beforeEach(); + } +} diff --git a/src/__tests__/fixtures/models/index.ts b/src/__tests__/fixtures/models/index.ts index eef3025..2e34a75 100644 --- a/src/__tests__/fixtures/models/index.ts +++ b/src/__tests__/fixtures/models/index.ts @@ -6,6 +6,7 @@ export * from './doctor.model'; export * from './patient.model'; export * from './product.model'; export * from './programming-language.model'; +export * from './task.model'; export * from './todo-list.model'; export * from './todo.model'; export * from './user.model'; diff --git a/src/__tests__/fixtures/models/task.model.ts b/src/__tests__/fixtures/models/task.model.ts new file mode 100644 index 0000000..ee8be80 --- /dev/null +++ b/src/__tests__/fixtures/models/task.model.ts @@ -0,0 +1,63 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model() +export class Task extends Entity { + @property({ + type: 'number', + id: true, + generated: true, + }) + id?: number; + + @property({ + type: 'string', + required: true, + }) + title: string; + + @property({ + type: 'string', + defaultFn: 'uuid', + }) + uuidv1: string; + + @property({ + type: 'string', + defaultFn: 'uuidv4', + }) + uuidv4: string; + + @property({ + type: 'string', + defaultFn: 'shortid', + }) + shortId: string; + + @property({ + type: 'string', + defaultFn: 'nanoid', + }) + nanoId: string; + + @property({ + type: 'number', + defaultFn: 'customAlias', + }) + customAlias: number; + + @property({ + type: 'string', + defaultFn: 'now', + }) + createdAt: string; + + constructor(data?: Partial) { + super(data); + } +} + +export interface TaskRelations { + // describe navigational properties here +} + +export type TaskWithRelations = Task & TaskRelations; diff --git a/src/__tests__/fixtures/repositories/index.ts b/src/__tests__/fixtures/repositories/index.ts index e691523..21cd72d 100644 --- a/src/__tests__/fixtures/repositories/index.ts +++ b/src/__tests__/fixtures/repositories/index.ts @@ -6,6 +6,7 @@ export * from './doctor.repository'; export * from './patient.repository'; export * from './product.repository'; export * from './programming-language.repository'; +export * from './task.repository'; export * from './todo-list.repository'; export * from './todo.repository'; export * from './user.repository'; diff --git a/src/__tests__/fixtures/repositories/task.repository.ts b/src/__tests__/fixtures/repositories/task.repository.ts new file mode 100644 index 0000000..5281219 --- /dev/null +++ b/src/__tests__/fixtures/repositories/task.repository.ts @@ -0,0 +1,21 @@ +import {inject} from '@loopback/core'; +import {SequelizeCrudRepository} from '../../../sequelize'; +import {PrimaryDataSource} from '../datasources/primary.datasource'; +import {Task, TaskRelations} from '../models/index'; + +export class TaskRepository extends SequelizeCrudRepository< + Task, + typeof Task.prototype.id, + TaskRelations +> { + constructor(@inject('datasources.primary') dataSource: PrimaryDataSource) { + super(Task, dataSource); + } + + protected getDefaultFnRegistry() { + return { + ...super.getDefaultFnRegistry(), + customAlias: () => Math.random(), + }; + } +} diff --git a/src/__tests__/integration/repository.integration.ts b/src/__tests__/integration/repository.integration.ts index 8e3e300..d6a0373 100644 --- a/src/__tests__/integration/repository.integration.ts +++ b/src/__tests__/integration/repository.integration.ts @@ -2,16 +2,18 @@ import {AnyObject} from '@loopback/repository'; import {RestApplication} from '@loopback/rest'; import { Client, + StubbedInstanceWithSinonAccessor, + TestSandbox, createRestAppClient, createStubInstance, expect, givenHttpServerConfig, - StubbedInstanceWithSinonAccessor, - TestSandbox, } from '@loopback/testlab'; import _ from 'lodash'; import {resolve} from 'path'; import {UniqueConstraintError} from 'sequelize'; +import {fail} from 'should'; +import {validate as uuidValidate, version as uuidVersion} from 'uuid'; import {SequelizeCrudRepository, SequelizeDataSource} from '../../sequelize'; import {SequelizeSandboxApplication} from '../fixtures/application'; import {config as primaryDataSourceConfig} from '../fixtures/datasources/primary.datasource'; @@ -21,6 +23,7 @@ import {Box, Event, eventTableName} from '../fixtures/models/test.model'; import { DeveloperRepository, ProgrammingLanguageRepository, + TaskRepository, UserRepository, } from '../fixtures/repositories'; @@ -40,6 +43,7 @@ describe('Sequelize CRUD Repository (integration)', function () { let app: SequelizeSandboxApplication; let userRepo: UserRepository; + let taskRepo: TaskRepository; let developerRepo: DeveloperRepository; let languagesRepo: ProgrammingLanguageRepository; let client: Client; @@ -71,6 +75,54 @@ describe('Sequelize CRUD Repository (integration)', function () { expect(err).to.be.instanceOf(UniqueConstraintError); } }); + + describe('defaultFn Support', () => { + beforeEach(async () => { + await client.get('/tasks/sync-sequelize-model').send(); + }); + it('supports defaultFn: "uuid" in property decorator', async () => { + const task = await taskRepo.create({title: 'Task 1'}); + + expect(uuidValidate(task.uuidv1)).to.be.true(); + expect(uuidVersion(task.uuidv1)).to.be.eql(1); + }); + + it('supports defaultFn: "uuidv4" in property decorator', async () => { + const task = await taskRepo.create({title: 'Task title'}); + + expect(uuidValidate(task.uuidv4)).to.be.true(); + expect(uuidVersion(task.uuidv4)).to.be.eql(4); + }); + + it('supports defaultFn: "nanoid" in property decorator', async () => { + const task = await taskRepo.create({title: 'Task title'}); + + expect(task.nanoId).to.be.String(); + expect(task.nanoId.length).to.be.eql(taskRepo.NANO_ID_LENGTH); + }); + + it('supports defaultFn: "now" in property decorator', async () => { + const task = await taskRepo.create({title: 'Task title'}); + if (task.createdAt) { + const isValidDate = _.isDate(new Date(task.createdAt)); + expect(isValidDate).to.be.true(); + } else { + fail( + task.createdAt, + '', + 'task.createdAt is falsy, date is expected.', + 'to be in date format', + ); + } + }); + + it('supports custom defining aliases for defaultFn in property decorator', async () => { + const task = await taskRepo.create({title: 'Task title'}); + expect(task.customAlias).to.be.Number(); + expect(task.customAlias).to.be.belowOrEqual(1); + expect(task.customAlias).to.be.above(0); + }); + }); }); describe('Without Relations', () => { @@ -785,6 +837,7 @@ describe('Sequelize CRUD Repository (integration)', function () { 'book.model', 'category.model', 'product.model', + 'task.model', ], repositories: [ 'index', @@ -799,6 +852,7 @@ describe('Sequelize CRUD Repository (integration)', function () { 'book.repository', 'category.repository', 'product.repository', + 'task.repository', ], controllers: [ 'index', @@ -819,6 +873,7 @@ describe('Sequelize CRUD Repository (integration)', function () { 'transaction.controller', 'product.controller', 'test.controller.base', + 'task.controller', ], }; @@ -853,6 +908,7 @@ describe('Sequelize CRUD Repository (integration)', function () { await app.start(); userRepo = await app.getRepository(UserRepository); + taskRepo = await app.getRepository(TaskRepository); developerRepo = await app.getRepository(DeveloperRepository); languagesRepo = await app.getRepository(ProgrammingLanguageRepository); datasource = createStubInstance(SequelizeDataSource); diff --git a/src/sequelize/sequelize.repository.base.ts b/src/sequelize/sequelize.repository.base.ts index 73b3147..1f9d75f 100644 --- a/src/sequelize/sequelize.repository.base.ts +++ b/src/sequelize/sequelize.repository.base.ts @@ -35,6 +35,7 @@ import { createReferencesManyAccessor, } from '@loopback/repository'; import debugFactory from 'debug'; +import {nanoid} from 'nanoid'; import { Attributes, DataType, @@ -54,6 +55,7 @@ import { WhereOptions, } from 'sequelize'; import {MakeNullishOptional} from 'sequelize/types/utils'; +import * as uuid from 'uuid'; import {operatorTranslations} from './operator-translation'; import {SequelizeDataSource} from './sequelize.datasource.base'; import {SequelizeModel} from './sequelize.model'; @@ -108,6 +110,29 @@ export class SequelizeCrudRepository< 'sqlite3', ] as const; + /** + * Length of the `nanoid` generated for defaultFn's `shortid` and `nanoid` aliases. + */ + public NANO_ID_LENGTH = 9; + + /** + * The alias registry for `defaultFn` option used in model property definition. + * + * See: https://loopback.io/doc/en/lb4/Model.html#property-decorator + */ + protected defaultFnRegistry: Record string | number> = { + guid: () => uuid.v1(), + uuid: () => uuid.v1(), + uuidv4: () => uuid.v4(), + now: () => new Date().toJSON(), + shortid: () => nanoid(this.NANO_ID_LENGTH), + nanoid: () => nanoid(this.NANO_ID_LENGTH), + }; + + protected getDefaultFnRegistry() { + return this.defaultFnRegistry; + } + public readonly inclusionResolvers: Map< string, InclusionResolver @@ -867,11 +892,18 @@ export class SequelizeCrudRepository< ); } + let defaultValue = definition[propName].default; + const originalDefaultFn = definition[propName]['defaultFn']; + + if (typeof originalDefaultFn === 'function') { + defaultValue = originalDefaultFn; + } else if (originalDefaultFn in this.getDefaultFnRegistry()) { + defaultValue = this.getDefaultFnRegistry()[originalDefaultFn]; + } + const columnOptions: ModelAttributeColumnOptions = { type: dataType, - ...('default' in definition[propName] - ? {defaultValue: definition[propName].default} - : {}), + defaultValue, }; // Set column as `primaryKey` when id is set to true (which is loopback way to define primary key)