From 841249f58c86793f79d35866b48cddb48369d2e3 Mon Sep 17 00:00:00 2001 From: Junyi Date: Fri, 11 Dec 2020 15:41:03 +0800 Subject: [PATCH] Feature/sort (#36) * feat: add sort value initialization via beforeCreate hook * fix: after reinitialization, hooks are lost * test: temp test for hook * fix: hooks defined in the table options does not work * refactor: change sort config into sort type field and fix updateAssociations to create with foreignKey * refactor: abstract utility functions * fix: type definition * fix: type and where value type Co-authored-by: chenos --- packages/actions/src/__tests__/create.test.ts | 2 +- .../actions/src/__tests__/middleware.test.ts | 10 +- packages/actions/src/__tests__/sort.test.ts | 121 ++++++++++-------- .../actions/src/__tests__/tables/comments.ts | 15 ++- .../actions/src/__tests__/tables/posts.ts | 21 +-- packages/database/src/__tests__/hooks.test.ts | 2 +- .../src/__tests__/model/getScopeWhere.test.ts | 38 ++++++ .../src/__tests__/model/model.test.ts | 1 - packages/database/src/fields/field-types.ts | 79 +++++++++++- packages/database/src/fields/option-types.ts | 19 +++ packages/database/src/model.ts | 43 +++++-- packages/database/src/table.ts | 5 + packages/database/src/utils.ts | 4 + 13 files changed, 276 insertions(+), 84 deletions(-) create mode 100644 packages/database/src/__tests__/model/getScopeWhere.test.ts diff --git a/packages/actions/src/__tests__/create.test.ts b/packages/actions/src/__tests__/create.test.ts index 559bf458ad..dfe442151f 100644 --- a/packages/actions/src/__tests__/create.test.ts +++ b/packages/actions/src/__tests__/create.test.ts @@ -47,7 +47,7 @@ describe('create', () => { { content: 'comment2', status: 'draft' }, ] }); - expect(response.body.sort).toBe(null); + expect(response.body.sort).toBe(1); expect(response.body.user_id).toBe(1); const postWithUser = await agent diff --git a/packages/actions/src/__tests__/middleware.test.ts b/packages/actions/src/__tests__/middleware.test.ts index 2a2f2b73d2..632ae11dd7 100644 --- a/packages/actions/src/__tests__/middleware.test.ts +++ b/packages/actions/src/__tests__/middleware.test.ts @@ -8,7 +8,7 @@ describe('list', () => { beforeAll(async () => { resourcer.define({ - name: 'posts', + name: 'articles', middlewares: [ jsonReponse, ], @@ -16,8 +16,8 @@ describe('list', () => { }); db = await initDatabase(); db.table({ - name: 'posts', - tableName: 'actions__m__posts', + name: 'articles', + tableName: 'actions__articles', fields: [ { type: 'string', @@ -48,7 +48,7 @@ describe('list', () => { it('create', async () => { const response = await agent - .post('/posts') + .post('/articles') .send({ title: 'title1', }); @@ -56,7 +56,7 @@ describe('list', () => { }); it('list', async () => { - const response = await agent.get('/posts?fields=title&page=1'); + const response = await agent.get('/articles?fields=title&page=1'); expect(response.body).toEqual({ data: [ { title: 'title1' } ], meta: { count: 1, page: 1, per_page: 20 } diff --git a/packages/actions/src/__tests__/sort.test.ts b/packages/actions/src/__tests__/sort.test.ts index b3f806b1d5..c2bc7998ff 100644 --- a/packages/actions/src/__tests__/sort.test.ts +++ b/packages/actions/src/__tests__/sort.test.ts @@ -6,13 +6,12 @@ describe('get', () => { beforeEach(async () => { db = await initDatabase(); const User = db.getModel('users'); - const users = await User.bulkCreate('abcdefg'.split('').map(name => ({ name }))); + const users = await User.bulkCreate(Array.from('abcdefg').map(name => ({ name }))); const Post = db.getModel('posts'); - const posts = await Post.bulkCreate(Array(22).fill(null).map((_, i) => ({ + const posts = await Post.bulkCreate(Array(10).fill(null).map((_, i) => ({ title: `title_${i}`, status: i % 2 ? 'publish' : 'draft', - sort: i, user_id: users[i % users.length].id }))); @@ -20,58 +19,76 @@ describe('get', () => { comments: Array(post.sort % 5).fill(null).map((_, index) => ({ content: `content_${index}`, status: index % 2 ? 'published' : 'draft', - user_id: users[index % users.length].id, - sort: index + user_id: users[index % users.length].id })) })), Promise.resolve()); - // [ - // { id: 1, post_id: 2, sort: 0 }, - // { id: 2, post_id: 3, sort: 0 }, - // { id: 3, post_id: 3, sort: 1 }, - // { id: 4, post_id: 4, sort: 0 }, - // { id: 5, post_id: 4, sort: 1 }, - // { id: 6, post_id: 4, sort: 2 }, - // { id: 7, post_id: 5, sort: 0 }, - // { id: 8, post_id: 5, sort: 1 }, - // { id: 9, post_id: 5, sort: 2 }, - // { id: 10, post_id: 5, sort: 3 }, - // { id: 11, post_id: 7, sort: 0 }, - // { id: 12, post_id: 8, sort: 0 }, - // { id: 13, post_id: 8, sort: 1 }, - // { id: 14, post_id: 9, sort: 0 }, - // { id: 15, post_id: 9, sort: 1 }, - // { id: 16, post_id: 9, sort: 2 }, - // { id: 17, post_id: 10, sort: 0 }, - // { id: 18, post_id: 10, sort: 1 }, - // { id: 19, post_id: 10, sort: 2 }, - // { id: 20, post_id: 10, sort: 3 }, - // { id: 21, post_id: 15, sort: 0 }, - // { id: 22, post_id: 15, sort: 1 }, - // { id: 23, post_id: 15, sort: 2 }, - // { id: 24, post_id: 15, sort: 3 }, - // { id: 25, post_id: 12, sort: 0 }, - // { id: 26, post_id: 13, sort: 0 }, - // { id: 27, post_id: 13, sort: 1 }, - // { id: 28, post_id: 14, sort: 0 }, - // { id: 29, post_id: 14, sort: 1 }, - // { id: 30, post_id: 14, sort: 2 }, - // { id: 31, post_id: 17, sort: 0 }, - // { id: 32, post_id: 18, sort: 0 }, - // { id: 33, post_id: 18, sort: 1 }, - // { id: 34, post_id: 19, sort: 0 }, - // { id: 35, post_id: 19, sort: 1 }, - // { id: 36, post_id: 19, sort: 2 }, - // { id: 37, post_id: 20, sort: 0 }, - // { id: 38, post_id: 20, sort: 1 }, - // { id: 39, post_id: 20, sort: 2 }, - // { id: 40, post_id: 20, sort: 3 }, - // { id: 41, post_id: 22, sort: 0 } - // ] }); afterAll(() => db.close()); + describe.only('sort value initialization', () => { + it('initialization by bulkCreate', async () => { + const Post = db.getModel('posts'); + const posts = await Post.findAll({ + order: [['id', 'ASC']] + }); + expect(posts.map(({ id, sort, sort_in_status, sort_in_user }) => ({ id, sort, sort_in_status, sort_in_user }))).toEqual([ + { id: 1, sort: 1, sort_in_status: 1, sort_in_user: 1 }, + { id: 2, sort: 2, sort_in_status: 1, sort_in_user: 1 }, + { id: 3, sort: 3, sort_in_status: 2, sort_in_user: 1 }, + { id: 4, sort: 4, sort_in_status: 2, sort_in_user: 1 }, + { id: 5, sort: 5, sort_in_status: 3, sort_in_user: 1 }, + { id: 6, sort: 6, sort_in_status: 3, sort_in_user: 1 }, + { id: 7, sort: 7, sort_in_status: 4, sort_in_user: 1 }, + { id: 8, sort: 8, sort_in_status: 4, sort_in_user: 2 }, + { id: 9, sort: 9, sort_in_status: 5, sort_in_user: 2 }, + { id: 10, sort: 10, sort_in_status: 5, sort_in_user: 2 } + ]); + }); + + it('initialization by updateAssociations', async () => { + const Comment = db.getModel('comments'); + const comments = await Comment.findAll({ + order: [['id', 'ASC']] + }); + expect(comments.map(({ id, sort, sort_in_status, sort_in_post }) => ({ id, sort, sort_in_status, sort_in_post }))).toEqual([ + { id: 1, sort: 1, sort_in_status: 1, sort_in_post: 1 }, + { id: 2, sort: 2, sort_in_status: 2, sort_in_post: 1 }, + { id: 3, sort: 3, sort_in_status: 1, sort_in_post: 2 }, + { id: 4, sort: 4, sort_in_status: 3, sort_in_post: 1 }, + { id: 5, sort: 5, sort_in_status: 2, sort_in_post: 2 }, + { id: 6, sort: 6, sort_in_status: 4, sort_in_post: 3 }, + { id: 7, sort: 7, sort_in_status: 5, sort_in_post: 1 }, + { id: 8, sort: 8, sort_in_status: 3, sort_in_post: 2 }, + { id: 9, sort: 9, sort_in_status: 6, sort_in_post: 3 }, + { id: 10, sort: 10, sort_in_status: 4, sort_in_post: 4 }, + { id: 11, sort: 11, sort_in_status: 7, sort_in_post: 1 }, + { id: 12, sort: 12, sort_in_status: 8, sort_in_post: 1 }, + { id: 13, sort: 13, sort_in_status: 5, sort_in_post: 2 }, + { id: 14, sort: 14, sort_in_status: 9, sort_in_post: 1 }, + { id: 15, sort: 15, sort_in_status: 6, sort_in_post: 2 }, + { id: 16, sort: 16, sort_in_status: 10, sort_in_post: 3 }, + { id: 17, sort: 17, sort_in_status: 11, sort_in_post: 1 }, + { id: 18, sort: 18, sort_in_status: 7, sort_in_post: 2 }, + { id: 19, sort: 19, sort_in_status: 12, sort_in_post: 3 }, + { id: 20, sort: 20, sort_in_status: 8, sort_in_post: 4 } + ]); + }); + + it('sort value of append item', async () => { + const Post = db.getModel('posts'); + const post = await Post.create({ user_id: 1 }); + expect(post.sort).toBe(11); + expect(post.sort_in_status).toBe(6); + expect(post.sort_in_user).toBe(3); + }); + }); + describe('sort in whole table', () => { + it('init sort value', async () => { + const Post = db.getModel('posts'); + }); + it('move id=1 by offset=1', async () => { const Post = db.getModel('posts'); await agent @@ -165,10 +182,10 @@ describe('get', () => { expect(post1.get('sort')).toBe(0); const post2 = await Post.findByPk(2); - expect(post2.get('sort')).toBe(22); + expect(post2.get('sort')).toBe(10); - const post22 = await Post.findByPk(22); - expect(post22.get('sort')).toBe(21); + const post10 = await Post.findByPk(10); + expect(post10.get('sort')).toBe(9); }); it('move id=2 by offset=-Infinity', async () => { @@ -222,7 +239,7 @@ describe('get', () => { const Post = db.getModel('posts'); const post2 = await Post.findByPk(2); - expect(post2.get('sort')).toBe(22); + expect(post2.get('sort')).toBe(10); }); }); diff --git a/packages/actions/src/__tests__/tables/comments.ts b/packages/actions/src/__tests__/tables/comments.ts index 74637bebf7..9497b187a6 100644 --- a/packages/actions/src/__tests__/tables/comments.ts +++ b/packages/actions/src/__tests__/tables/comments.ts @@ -21,8 +21,18 @@ export default { name: 'user', }, { - type: 'integer', + type: 'sort', name: 'sort' + }, + { + type: 'sort', + name: 'sort_in_status', + scope: ['status'] + }, + { + type: 'sort', + name: 'sort_in_post', + scope: ['post'] } ], scopes: { @@ -31,6 +41,5 @@ export default { status: 'published' } } - }, - sortable: true + } } as TableOptions; diff --git a/packages/actions/src/__tests__/tables/posts.ts b/packages/actions/src/__tests__/tables/posts.ts index 70600f14e6..2f9462ca8c 100644 --- a/packages/actions/src/__tests__/tables/posts.ts +++ b/packages/actions/src/__tests__/tables/posts.ts @@ -30,18 +30,24 @@ export default { name: 'tags', }, { - type: 'integer', + type: 'sort', name: 'sort' }, + { + type: 'sort', + name: 'sort_in_status', + scope: ['status'] + }, + { + type: 'sort', + name: 'sort_in_user', + scope: ['user'] + }, { type: 'json', name: 'meta' } ], - hooks: { - beforeCreate(model, options) { - }, - }, scopes: { customTitle: (title, ctx) => { return { @@ -49,7 +55,6 @@ export default { title: title, }, } - }, - }, - sortable: true + } + } } as TableOptions; diff --git a/packages/database/src/__tests__/hooks.test.ts b/packages/database/src/__tests__/hooks.test.ts index 927b3b6ecf..c3adff7b52 100644 --- a/packages/database/src/__tests__/hooks.test.ts +++ b/packages/database/src/__tests__/hooks.test.ts @@ -158,7 +158,7 @@ describe('hooks', () => { expect(test.get('arr')).toEqual([ 1, 3, 2, 4 ]); }); - it.only('add hook in custom field', async () => { + it('add hook in custom field', async () => { const table = db.table({ name: 'test3', fields: [ diff --git a/packages/database/src/__tests__/model/getScopeWhere.test.ts b/packages/database/src/__tests__/model/getScopeWhere.test.ts new file mode 100644 index 0000000000..1a84b040e9 --- /dev/null +++ b/packages/database/src/__tests__/model/getScopeWhere.test.ts @@ -0,0 +1,38 @@ +import { getDatabase } from '..'; +import Database from '../../database'; + + + +describe('getScopeWhere', () => { + let db: Database; + + beforeEach(async () => { + db = getDatabase(); + db.table({ + name: 'posts', + fields: [ + { + type: 'string', + name: 'status' + } + ] + }); + await db.sync({ force: true }); + }); + + afterEach(() => db.close()); + + it('exist column', async () => { + const Post = db.getModel('posts'); + const post = await Post.create({ status: 'published' }); + const where = post.getScopeWhere(['status']); + expect(where).toEqual({ status: 'published' }); + }); + + it('non-exist column', async () => { + const Post = db.getModel('posts'); + const post = await Post.create({}); + const where = post.getScopeWhere(['whatever']); + expect(where).toEqual({}); + }); +}); diff --git a/packages/database/src/__tests__/model/model.test.ts b/packages/database/src/__tests__/model/model.test.ts index cd8280bf73..296f978e1d 100644 --- a/packages/database/src/__tests__/model/model.test.ts +++ b/packages/database/src/__tests__/model/model.test.ts @@ -367,7 +367,6 @@ describe('model', () => { })) }); const updatedComments = await Comment.findAll(); - console.log(updatedComments); const post1CommentsCount = await Comment.count({ where: { post_id: post.id } }); diff --git a/packages/database/src/fields/field-types.ts b/packages/database/src/fields/field-types.ts index e8b3f8ec8e..8ae69dfced 100644 --- a/packages/database/src/fields/field-types.ts +++ b/packages/database/src/fields/field-types.ts @@ -9,13 +9,15 @@ import { BelongsToManyOptions, ThroughOptions, } from 'sequelize'; +import { template, get, toNumber } from 'lodash'; +import bcrypt from 'bcrypt'; + import * as Options from './option-types'; import { getDataTypeKey } from '.'; import Table from '../table'; import Database from '../database'; import Model, { ModelCtor } from '../model'; -import { template, isArray, map, get, toNumber } from 'lodash'; -import bcrypt from 'bcrypt'; +import { whereCompare } from '../utils'; export interface IField { @@ -681,7 +683,7 @@ export class BELONGSTOMANY extends Relation { } public getAssociationOptions(): BelongsToManyOptions { - const { name, ...restOptions }= this.options; + const { name, ...restOptions } = this.options; return { as: name, through: this.getThroughModel(), @@ -689,3 +691,74 @@ export class BELONGSTOMANY extends Relation { } } } + +export class SORT extends NUMBER { + + public readonly options: Options.SortOptions; + + static async beforeCreateHook(this: SORT, model, options) { + const { transaction } = options; + const Model = model.constructor; + const { name, scope = [], next = 'max' } = this.options; + const where = model.getScopeWhere(scope); + const extremum: number = await Model[next](name, { where, transaction }) || 0; + model.set(name, extremum + (next === 'max' ? 1 : -1)); + } + + static async beforeBulkCreateHook(this: SORT, models, options) { + const { transaction } = options; + const table = this.context.sourceTable; + const Model = table.getModel(); + const { name, scope = [], next = 'max' } = this.options; + // 如果未配置范围限定,则可以进行性能优化处理(常用情况)。 + if (!scope.length) { + const extremum: number = await Model[next](name, { transaction }) || 0; + models.forEach((model, i: number) => { + model.setDataValue(name, extremum + (i + 1) * (next === 'max' ? 1 : -1)); + }); + return; + } + + // 用于存放 where 条件与计算极值 + const groups = new Map<{ [key: string]: any }, number>(); + await models.reduce((promise, model) => promise.then(async () => { + const where = model.getScopeWhere(scope); + + let extremum: number; + // 以 map 作为 key + let combo; + // 查找与 where 值相等的组合 + for (combo of groups.keys()) { + if (whereCompare(combo, where)) { + // 如果找到的话则以之前储存的值作为基础极值 + extremum = groups.get(combo); + break; + } + } + // 如未找到组合 + if (typeof extremum === 'undefined') { + // 则使用 where 条件查询极值 + extremum = await Model[next](name, { where, transaction }) || 0; + // 且使用 where 条件创建组合 + combo = where; + } + const nextValue = extremum + (next === 'max' ? 1 : -1); + // 设置数据行的排序值 + model.setDataValue(name, nextValue); + // 保存新的排序值为对应 where 组合的极值,以供下次计算 + groups.set(combo, nextValue); + }), Promise.resolve()); + } + + constructor(options: Options.SortOptions, context: FieldContext) { + super(options, context); + const Model = context.sourceTable.getModel(); + // TODO(feature): 可考虑策略模式,以在需要时对外提供接口 + Model.addHook('beforeCreate', SORT.beforeCreateHook.bind(this)); + Model.addHook('beforeBulkCreate', SORT.beforeBulkCreateHook.bind(this)); + } + + public getDataType(): Function { + return DataTypes.INTEGER; + } +} diff --git a/packages/database/src/fields/option-types.ts b/packages/database/src/fields/option-types.ts index ad3338a7fd..a41d7353a2 100644 --- a/packages/database/src/fields/option-types.ts +++ b/packages/database/src/fields/option-types.ts @@ -174,6 +174,25 @@ export interface BelongsToManyOptions extends Omit; + const table = this.database.getTable(this.constructor.name); + const associations = table.getAssociations(); + const where = {}; + scope.forEach(col => { + const association = associations.get(col); + const dataKey = association && association instanceof BELONGSTO + ? association.options.foreignKey + : col; + if (!Model.rawAttributes[dataKey]) { + return; + } + const value = this.getDataValue(dataKey); + if (typeof value !== 'undefined') { + where[dataKey] = value; + } + }); + return where; + } + async updateSingleAssociation(key: string, data: any, options: SaveOptions & { context?: any; } = {}) { const { fields, @@ -399,27 +420,29 @@ export abstract class Model extends SequelizeModel { for (const item of toUpsertObjects) { let target; if (typeof item[targetKey] === 'undefined') { - // TODO(optimize): 不确定 bulkCreate 的结果是否能保证顺序,能保证的话这里可以优化为批量处理 - target = await Target.create(item, opts); + target = await this[accessors.create](item, opts); } else { - let created: boolean; - [target, created] = await Target.findOrCreate({ + target = await Target.findOne({ + ...opts, where: { [targetKey]: item[targetKey] }, - defaults: item, - transaction }); - if (!created) { + if (!target) { + target = await this[accessors.create](item, opts); + } else { await target.update(item, opts); } } - + // TODO(optimize): 此处添加的对象其实已经创建了关联, + // 但考虑到单条 create 的 hook 要求带上关联键,且后面的 set, + // 所以仍然交给 set 再调用关联一次。 + toSetItems.add(target); + if (association instanceof BELONGSTOMANY) { belongsToManyList.push({ item, target }); } - toSetItems.add(target); await target.updateAssociations(item, opts); } diff --git a/packages/database/src/table.ts b/packages/database/src/table.ts index 92549ede1b..a7a37664ba 100644 --- a/packages/database/src/table.ts +++ b/packages/database/src/table.ts @@ -12,6 +12,7 @@ import { Relation, BELONGSTO, BELONGSTOMANY, + SORT } from './fields'; import Database from './database'; import { Model, ModelCtor } from './model'; @@ -127,6 +128,10 @@ export class Table { public relationTables = new Set(); + get sortable(): boolean { + return Array.from(this.fields.values()).some(field => field instanceof SORT); + } + constructor(options: TableOptions, context: TabelContext) { const { database } = context; const { diff --git a/packages/database/src/utils.ts b/packages/database/src/utils.ts index 46caa5f749..eddd1dbfc2 100644 --- a/packages/database/src/utils.ts +++ b/packages/database/src/utils.ts @@ -290,6 +290,10 @@ export function toInclude(options: any, context: ToIncludeContext = {}) { return data; } +export function whereCompare(a: any, b: any): boolean { + return _.isEqual(a, b); +} + export function requireModule(module: any) { if (typeof module === 'string') { module = require(module);