feat: add sort action (#22)
This commit is contained in:
parent
cd0b357887
commit
3da40bd35b
256
packages/actions/src/__tests__/sort.test.ts
Normal file
256
packages/actions/src/__tests__/sort.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -19,6 +19,10 @@ export default {
|
|||||||
{
|
{
|
||||||
type: 'belongsTo',
|
type: 'belongsTo',
|
||||||
name: 'user',
|
name: 'user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'integer',
|
||||||
|
name: 'sort'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
scopes: {
|
scopes: {
|
||||||
@ -28,4 +32,5 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
sortable: true
|
||||||
} as TableOptions;
|
} as TableOptions;
|
||||||
|
@ -29,6 +29,10 @@ export default {
|
|||||||
type: 'belongsToMany',
|
type: 'belongsToMany',
|
||||||
name: 'tags',
|
name: 'tags',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'integer',
|
||||||
|
name: 'sort'
|
||||||
|
}
|
||||||
],
|
],
|
||||||
hooks: {
|
hooks: {
|
||||||
beforeCreate(model, options) {
|
beforeCreate(model, options) {
|
||||||
@ -43,4 +47,5 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sortable: true
|
||||||
} as TableOptions;
|
} as TableOptions;
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { Context, Next } from '.';
|
import { Context, Next } from '.';
|
||||||
import { Relation, Model, Field, HasOne, HasMany, BelongsTo, BelongsToMany } from '@nocobase/database';
|
import { Relation, Model, Field, HasOne, HasMany, BelongsTo, BelongsToMany } from '@nocobase/database';
|
||||||
import { DEFAULT_PAGE, DEFAULT_PER_PAGE } from '@nocobase/resourcer';
|
import { DEFAULT_PAGE, DEFAULT_PER_PAGE } from '@nocobase/resourcer';
|
||||||
import { Utils, Op, Sequelize } from 'sequelize';
|
import { Utils, Op } from 'sequelize';
|
||||||
import { isEmpty } from 'lodash';
|
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -253,7 +252,7 @@ export async function update(ctx: Context, next: Next) {
|
|||||||
delete values[throughName];
|
delete values[throughName];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isEmpty(values)) {
|
if (!_.isEmpty(values)) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await model.update(values, { context: ctx });
|
await model.update(values, { context: ctx });
|
||||||
await model.updateAssociations(values, { context: ctx });
|
await model.updateAssociations(values, { context: ctx });
|
||||||
@ -350,10 +349,158 @@ export async function destroy(ctx: Context, next: Next) {
|
|||||||
await 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 {
|
export default {
|
||||||
list, // single、hasMany、belongsToMany
|
list, // single、hasMany、belongsToMany
|
||||||
create, // signle、hasMany
|
create, // signle、hasMany
|
||||||
get, // all
|
get, // all
|
||||||
update, // single、
|
update, // single、
|
||||||
destroy,
|
destroy,
|
||||||
|
sort
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user