diff --git a/packages/actions/src/__tests__/sort.test.ts b/packages/actions/src/__tests__/sort.test.ts new file mode 100644 index 000000000..b3f806b1d --- /dev/null +++ b/packages/actions/src/__tests__/sort.test.ts @@ -0,0 +1,256 @@ +import { initDatabase, agent } from './index'; + +describe('get', () => { + let db; + + beforeEach(async () => { + db = await initDatabase(); + const User = db.getModel('users'); + const users = await User.bulkCreate('abcdefg'.split('').map(name => ({ name }))); + + const Post = db.getModel('posts'); + const posts = await Post.bulkCreate(Array(22).fill(null).map((_, i) => ({ + title: `title_${i}`, + status: i % 2 ? 'publish' : 'draft', + sort: i, + user_id: users[i % users.length].id + }))); + + await posts.reduce((promise, post) => promise.then(() => post.updateAssociations({ + 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 + })) + })), 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('sort in whole table', () => { + it('move id=1 by offset=1', async () => { + const Post = db.getModel('posts'); + await agent + .post('/posts:sort/1') + .send({ + offset: 1, + }); + + const post1 = await Post.findByPk(1); + expect(post1.get('sort')).toBe(1); + + const post2 = await Post.findByPk(2); + expect(post2.get('sort')).toBe(0); + }); + + it('move id=1 by offset=9', async () => { + const Post = db.getModel('posts'); + await agent + .post('/posts:sort/1') + .send({ + offset: 9, + }); + + const post1 = await Post.findByPk(1); + expect(post1.get('sort')).toBe(9); + + const post2 = await Post.findByPk(2); + expect(post2.get('sort')).toBe(0); + + const post10 = await Post.findByPk(10); + expect(post10.get('sort')).toBe(8); + + const post11 = await Post.findByPk(11); + expect(post11.get('sort')).toBe(10); + }); + + it('move id=1 by offset=-1', async () => { + const Post = db.getModel('posts'); + await agent + .post('/posts:sort/1') + .send({ + offset: -1, + }); + + const post1 = await Post.findByPk(1); + expect(post1.get('sort')).toBe(0); + + const post2 = await Post.findByPk(2); + expect(post2.get('sort')).toBe(1); + }); + + it('move id=2 by offset=8', async () => { + const Post = db.getModel('posts'); + await agent + .post('/posts:sort/2') + .send({ + offset: 8, + }); + + const post2 = await Post.findByPk(2); + expect(post2.get('sort')).toBe(9); + + const post10 = await Post.findByPk(10); + expect(post10.get('sort')).toBe(8); + }); + + it('move id=2 by offset=-1', async () => { + const Post = db.getModel('posts'); + await agent + .post('/posts:sort/2') + .send({ + offset: -1, + }); + + const post2 = await Post.findByPk(2); + expect(post2.get('sort')).toBe(0); + + const post1 = await Post.findByPk(1); + expect(post1.get('sort')).toBe(1); + }); + + it('move id=2 by offset=Infinity', async () => { + const Post = db.getModel('posts'); + await agent + .post('/posts:sort/2') + .send({ + offset: 'Infinity', + }); + + const post1 = await Post.findByPk(1); + expect(post1.get('sort')).toBe(0); + + const post2 = await Post.findByPk(2); + expect(post2.get('sort')).toBe(22); + + const post22 = await Post.findByPk(22); + expect(post22.get('sort')).toBe(21); + }); + + it('move id=2 by offset=-Infinity', async () => { + const Post = db.getModel('posts'); + await agent + .post('/posts:sort/2') + .send({ + offset: '-Infinity', + }); + + const post1 = await Post.findByPk(1); + expect(post1.get('sort')).toBe(0); + + const post2 = await Post.findByPk(2); + expect(post2.get('sort')).toBe(-1); + }); + }); + + describe('sort in filtered scope', () => { + it('move id=1 by offset=3 in scope filter[status]=publish', async () => { + try { + await agent + .post('/posts:sort/1?filter[status]=publish') + .send({ + offset: 3, + }); + } catch (error) { + expect(error).toBeDefined(); + } + }); + + // 在 scope 中的排序无所谓值是否与其他不在 scope 中的重复。 + it('move id=2 by offset=3 in scope filter[status]=publish', async () => { + const Post = db.getModel('posts'); + await agent + .post('/posts:sort/2?filter[status]=publish') + .send({ + offset: 3, + }); + + const post2 = await Post.findByPk(2); + expect(post2.get('sort')).toBe(7); + }); + + it('move id=2 by offset=Infinity in scope filter[status]=publish', async () => { + await agent + .post('/posts:sort/2?filter[status]=publish') + .send({ + offset: 'Infinity', + }); + + const Post = db.getModel('posts'); + const post2 = await Post.findByPk(2); + expect(post2.get('sort')).toBe(22); + }); + }); + + describe('associations', () => { + describe('hasMany', () => { + it('sort only 1 item in group will never change', async () => { + await agent + .post('/posts/2/comments:sort/1') + .send({ + offset: 1 + }); + + const Comment = db.getModel('comments'); + const comment1 = await Comment.findByPk(1); + expect(comment1.get('sort')).toBe(0); + }); + + it('/posts/5/comments:sort/7', async () => { + await agent + .post('/posts/5/comments:sort/7') + .send({ + offset: 1 + }); + + const Comment = db.getModel('comments'); + const comment7 = await Comment.findByPk(7); + expect(comment7.get('sort')).toBe(1); + }); + }); + }); +}); diff --git a/packages/actions/src/__tests__/tables/comments.ts b/packages/actions/src/__tests__/tables/comments.ts index a19023006..74637bebf 100644 --- a/packages/actions/src/__tests__/tables/comments.ts +++ b/packages/actions/src/__tests__/tables/comments.ts @@ -19,6 +19,10 @@ export default { { type: 'belongsTo', name: 'user', + }, + { + type: 'integer', + name: 'sort' } ], scopes: { @@ -28,4 +32,5 @@ export default { } } }, + sortable: true } as TableOptions; diff --git a/packages/actions/src/__tests__/tables/posts.ts b/packages/actions/src/__tests__/tables/posts.ts index e6422c13b..f16abd3b1 100644 --- a/packages/actions/src/__tests__/tables/posts.ts +++ b/packages/actions/src/__tests__/tables/posts.ts @@ -29,6 +29,10 @@ export default { type: 'belongsToMany', name: 'tags', }, + { + type: 'integer', + name: 'sort' + } ], hooks: { beforeCreate(model, options) { @@ -43,4 +47,5 @@ export default { } }, }, + sortable: true } as TableOptions; diff --git a/packages/actions/src/actions/common.ts b/packages/actions/src/actions/common.ts index f1753d391..73ab611a1 100644 --- a/packages/actions/src/actions/common.ts +++ b/packages/actions/src/actions/common.ts @@ -1,8 +1,7 @@ import { Context, Next } from '.'; import { Relation, Model, Field, HasOne, HasMany, BelongsTo, BelongsToMany } from '@nocobase/database'; import { DEFAULT_PAGE, DEFAULT_PER_PAGE } from '@nocobase/resourcer'; -import { Utils, Op, Sequelize } from 'sequelize'; -import { isEmpty } from 'lodash'; +import { Utils, Op } from 'sequelize'; import _ from 'lodash'; /** @@ -253,7 +252,7 @@ export async function update(ctx: Context, next: Next) { delete values[throughName]; } } - if (!isEmpty(values)) { + if (!_.isEmpty(values)) { // @ts-ignore await model.update(values, { context: ctx }); await model.updateAssociations(values, { context: ctx }); @@ -350,10 +349,158 @@ export async function destroy(ctx: Context, next: Next) { await next(); } +/** + * 人工排序 + * + * 基于偏移量策略实现的排序方法 + * + * TODO 字段验证 + * + * @param ctx + * @param next + */ +export async function sort(ctx: Context, next: Next) { + const { + resourceName, + resourceKey, + resourceField, + associatedName, + associatedKey, + associated, + filter = {}, + values + } = ctx.action.params; + + if (associated && resourceField) { + if (resourceField instanceof HasOne || resourceField instanceof BelongsTo) { + throw new Error(`the association (${resourceName} belongs to ${associatedName}) cannot be sorted`); + } + // TODO(feature) + if (resourceField instanceof BelongsToMany) { + throw new Error('sorting for belongs to many association has not been implemented'); + } + } + + const Model = ctx.db.getModel(resourceName); + const table = ctx.db.getTable(resourceName); + + if (!table.getOptions().sortable || !values.offset) { + return next(); + } + const [primaryField] = Model.primaryKeyAttributes; + const sortField = values.field || table.getOptions().sortField || 'sort'; + + // offset 的有效值为:整型 | 'Infinity' | '-Infinity' + const offset = Number(values.offset); + const sign = offset < 0 ? { + op: Op.lte, + order: 'DESC', + direction: 1, + extremum: 'min' + } : { + op: Op.gte, + order: 'ASC', + direction: -1, + extremum: 'max' + }; + + const transaction = await ctx.db.sequelize.transaction(); + + const { where = {} } = Model.parseApiJson({ filter }); + if (associated && resourceField instanceof HasMany) { + where[resourceField.options.foreignKey] = associatedKey; + } + + // 找到操作对象 + const operand = await Model.findOne({ + // 这里增加 where 条件是要求如果有 filter 条件,就应该在同条件的组中排序,不是同条件组的报错处理。 + where: { + ...where, + [primaryField]: resourceKey + }, + transaction + }); + + if (!operand) { + await transaction.rollback(); + // TODO: 错误需要后面统一处理 + throw new Error(`resource(${resourceKey}) with filter does not exist`); + } + + let target; + + // 如果是有限的变动值 + if (Number.isFinite(offset)) { + const absChange = Math.abs(offset); + const group = await Model.findAll({ + where: { + ...where, + [primaryField]: { + [Op.ne]: resourceKey + }, + [sortField]: { + [sign.op]: operand[sortField] + } + }, + limit: absChange, + // offset: 0, + attributes: [primaryField, sortField], + order: [ + [sortField, sign.order] + ], + transaction + }); + + if (!group.length) { + // 如果变动范围内的元素数比范围小 + // 说明全部数据不足一页 + // target = group[0][priorityKey] - sign.direction; + // 没有元素无需变动 + await transaction.commit(); + ctx.body = operand; + return next(); + } + + // 如果变动范围内都有元素(可能出现 limit 范围内元素不足的情况) + if (group.length === absChange) { + target = group[group.length - 1][sortField]; + + await Model.increment(sortField, { + by: sign.direction, + where: { + [primaryField]: { + [Op.in]: group.map(item => item[primaryField]) + } + }, + transaction + }); + } + } + // 如果要求置顶或沉底(未在上一过程中计算出目标值) + if (typeof target === 'undefined') { + target = await Model[sign.extremum](sortField, { + where, + transaction + }) - sign.direction; + } + await operand.update({ + [sortField]: target + }, { + transaction + }); + + await transaction.commit(); + + ctx.body = operand; + + await next(); +} + export default { list, // single、hasMany、belongsToMany create, // signle、hasMany get, // all update, // single、 destroy, + sort };