refactor: change sort strategy from offset to targetId (#37)

* refactor: change sort strategy from offset to targetId

* fix: remove unnecessary query to optimize performance
This commit is contained in:
Junyi 2020-12-11 22:36:02 +08:00 committed by GitHub
parent 841249f58c
commit d1372e273a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 193 additions and 231 deletions

View File

@ -85,6 +85,11 @@ resourcer.define({
name: 'posts.comments', name: 'posts.comments',
actions: actions.associate, actions: actions.associate,
}); });
resourcer.define({
type: 'hasMany',
name: 'users.posts',
actions: actions.associate,
});
resourcer.define({ resourcer.define({
type: 'belongsTo', type: 'belongsTo',
name: 'posts.user', name: 'posts.user',

View File

@ -26,7 +26,7 @@ describe('get', () => {
afterAll(() => db.close()); afterAll(() => db.close());
describe.only('sort value initialization', () => { describe('sort value initialization', () => {
it('initialization by bulkCreate', async () => { it('initialization by bulkCreate', async () => {
const Post = db.getModel('posts'); const Post = db.getModel('posts');
const posts = await Post.findAll({ const posts = await Post.findAll({
@ -85,188 +85,165 @@ describe('get', () => {
}); });
describe('sort in whole table', () => { describe('sort in whole table', () => {
it('init sort value', async () => { it('move id=1 to position at id=2', async () => {
const Post = db.getModel('posts');
});
it('move id=1 by offset=1', async () => {
const Post = db.getModel('posts');
await agent await agent
.post('/posts:sort/1') .post('/posts:sort/1')
.send({ .send({
offset: 1, field: 'sort',
targetId: 2,
}); });
const post1 = await Post.findByPk(1); const Post = db.getModel('posts');
expect(post1.get('sort')).toBe(1); const posts = await Post.findAll({
attributes: ['id', 'sort'],
const post2 = await Post.findByPk(2); order: [['id', 'ASC']]
expect(post2.get('sort')).toBe(0); });
expect(posts.map(item => item.get())).toEqual([
{ id: 1, sort: 2 },
{ id: 2, sort: 1 },
{ id: 3, sort: 3 },
{ id: 4, sort: 4 },
{ id: 5, sort: 5 },
{ id: 6, sort: 6 },
{ id: 7, sort: 7 },
{ id: 8, sort: 8 },
{ id: 9, sort: 9 },
{ id: 10, sort: 10 }
]);
}); });
it('move id=1 by offset=9', async () => { it('move id=2 to position at id=1', async () => {
await agent
.post('/posts:sort/2')
.send({
field: 'sort',
targetId: 1,
});
const Post = db.getModel('posts'); const Post = db.getModel('posts');
const posts = await Post.findAll({
attributes: ['id', 'sort'],
order: [['id', 'ASC']]
});
expect(posts.map(item => item.get())).toEqual([
{ id: 1, sort: 2 },
{ id: 2, sort: 1 },
{ id: 3, sort: 3 },
{ id: 4, sort: 4 },
{ id: 5, sort: 5 },
{ id: 6, sort: 6 },
{ id: 7, sort: 7 },
{ id: 8, sort: 8 },
{ id: 9, sort: 9 },
{ id: 10, sort: 10 }
]);
});
it('move id=1 to position at id=10', async () => {
await agent await agent
.post('/posts:sort/1') .post('/posts:sort/1')
.send({ .send({
offset: 9, field: 'sort',
targetId: 10,
}); });
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'); const Post = db.getModel('posts');
await agent const posts = await Post.findAll({
.post('/posts:sort/1') attributes: ['id', 'sort'],
.send({ order: [['id', 'ASC']]
offset: -1, });
}); expect(posts.map(item => item.get())).toEqual([
{ id: 1, sort: 10 },
const post1 = await Post.findByPk(1); { id: 2, sort: 1 },
expect(post1.get('sort')).toBe(0); { id: 3, sort: 2 },
{ id: 4, sort: 3 },
const post2 = await Post.findByPk(2); { id: 5, sort: 4 },
expect(post2.get('sort')).toBe(1); { id: 6, sort: 5 },
}); { id: 7, sort: 6 },
{ id: 8, sort: 7 },
it('move id=2 by offset=8', async () => { { id: 9, sort: 8 },
const Post = db.getModel('posts'); { id: 10, sort: 9 }
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(10);
const post10 = await Post.findByPk(10);
expect(post10.get('sort')).toBe(9);
});
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', () => { describe('sort in filtered scope', () => {
it('move id=1 by offset=3 in scope filter[status]=publish', async () => { it('move id=2 to position at id=8 (same scope value)', 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 await agent
.post('/posts:sort/2?filter[status]=publish') .post('/posts:sort/2')
.send({ .send({
offset: 3, field: 'sort_in_status',
targetId: 8,
}); });
const post2 = await Post.findByPk(2); const Post = db.getModel('posts');
expect(post2.get('sort')).toBe(7); const posts = await Post.findAll({
where: {
status: 'publish'
},
attributes: ['id', 'sort_in_status'],
order: [['id', 'ASC']]
});
expect(posts.map(item => item.get())).toEqual([
{ id: 2, sort_in_status: 4 },
{ id: 4, sort_in_status: 1 },
{ id: 6, sort_in_status: 2 },
{ id: 8, sort_in_status: 3 },
{ id: 10, sort_in_status: 5 }
]);
}); });
it('move id=2 by offset=Infinity in scope filter[status]=publish', async () => { it('move id=1 to position at id=8 (different scope value)', async () => {
await agent await agent
.post('/posts:sort/2?filter[status]=publish') .post('/posts:sort/1')
.send({ .send({
offset: 'Infinity', field: 'sort_in_status',
targetId: 8,
}); });
const Post = db.getModel('posts'); const Post = db.getModel('posts');
const post2 = await Post.findByPk(2); const posts = await Post.findAll({
expect(post2.get('sort')).toBe(10); where: {
status: 'publish'
},
attributes: ['id', 'sort_in_status'],
order: [['id', 'ASC']]
});
expect(posts.map(item => item.get())).toEqual([
{ id: 1, sort_in_status: 4 },
{ id: 2, sort_in_status: 1 },
{ id: 4, sort_in_status: 2 },
{ id: 6, sort_in_status: 3 },
{ id: 8, sort_in_status: 5 },
{ id: 10, sort_in_status: 6 }
]);
}); });
}); });
describe('associations', () => { describe('associations', () => {
describe('hasMany', () => { describe('hasMany', () => {
it('sort only 1 item in group will never change', async () => { it('move id=1 to position at id=3 (different scope value)', async () => {
await agent await agent
.post('/posts/2/comments:sort/1') .post('/users/1/posts:sort/1')
.send({ .send({
offset: 1 field: 'sort_in_user',
targetId: 3,
}); });
const Comment = db.getModel('comments');
const comment1 = await Comment.findByPk(1);
expect(comment1.get('sort')).toBe(0);
});
it('/posts/5/comments:sort/7', async () => { const Post = db.getModel('posts');
await agent const posts = await Post.findAll({
.post('/posts/5/comments:sort/7') where: {
.send({ user_id: 3
offset: 1 },
}); attributes: ['id', 'sort_in_user'],
order: [['id', 'ASC']]
const Comment = db.getModel('comments'); });
const comment7 = await Comment.findByPk(7);
expect(comment7.get('sort')).toBe(1); expect(posts.map(item => item.get())).toEqual([
{ id: 1, sort_in_user: 1 },
{ id: 3, sort_in_user: 2 },
{ id: 10, sort_in_user: 3 },
]);
}); });
}); });
}); });

View File

@ -1,3 +1,5 @@
import { Utils, Op } from 'sequelize';
import _ from 'lodash';
import { Context, Next } from '.'; import { Context, Next } from '.';
import { import {
Model, Model,
@ -5,10 +7,9 @@ import {
HASMANY, HASMANY,
BELONGSTO, BELONGSTO,
BELONGSTOMANY, BELONGSTOMANY,
whereCompare
} from '@nocobase/database'; } from '@nocobase/database';
import { DEFAULT_PAGE, DEFAULT_PER_PAGE } from '@nocobase/resourcer'; import { DEFAULT_PAGE, DEFAULT_PER_PAGE } from '@nocobase/resourcer';
import { Utils, Op } from 'sequelize';
import _ from 'lodash';
import { filterByFields } from '../utils'; import { filterByFields } from '../utils';
/** /**
@ -373,7 +374,6 @@ export async function sort(ctx: Context, next: Next) {
associatedName, associatedName,
associatedKey, associatedKey,
associated, associated,
filter = {},
values values
} = ctx.action.params; } = ctx.action.params;
@ -390,114 +390,94 @@ export async function sort(ctx: Context, next: Next) {
const Model = ctx.db.getModel(resourceName); const Model = ctx.db.getModel(resourceName);
const table = ctx.db.getTable(resourceName); const table = ctx.db.getTable(resourceName);
if (!values.offset) { const { field, targetId } = values;
if (!values.field || typeof targetId === 'undefined') {
return next(); return next();
} }
const [primaryField] = Model.primaryKeyAttributes; const sortField = table.getField(field);
const sortField = values.field || table.getOptions().sortField || 'sort'; if (!sortField) {
return next();
// offset 的有效值为:整型 | 'Infinity' | '-Infinity' }
const offset = Number(values.offset); const { primaryKeyAttribute } = Model;
const sign = offset < 0 ? { const { name: sortAttr, scope = [] } = sortField.options;
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 transaction = await ctx.db.sequelize.transaction();
const { where = {} } = Model.parseApiJson({ filter }); const where = {};
if (associated && resourceField instanceof HASMANY) { if (associated && resourceField instanceof HASMANY) {
where[resourceField.options.foreignKey] = associatedKey; where[resourceField.options.foreignKey] = associatedKey;
} }
// 找到操作对象 // 找到操作对象
const operand = await Model.findOne({ const source = await Model.findOne({
// 这里增加 where 条件是要求如果有 filter 条件,就应该在同条件的组中排序,不是同条件组的报错处理。
where: { where: {
...where, ...where,
[primaryField]: resourceKey [primaryKeyAttribute]: resourceKey
}, },
transaction transaction
}); });
if (!source) {
if (!operand) {
await transaction.rollback(); await transaction.rollback();
// TODO: 错误需要后面统一处理 throw new Error(`resource(${resourceKey}) does not exist`);
throw new Error(`resource(${resourceKey}) with filter does not exist`);
} }
let target; const target = await Model.findByPk(targetId, { transaction });
// 如果是有限的变动值 if (!target) {
if (Number.isFinite(offset)) { await transaction.rollback();
const absChange = Math.abs(offset); throw new Error(`resource(${targetId}) does not exist`);
const group = await Model.findAll({ }
where: {
...where, const sourceScopeWhere = source.getScopeWhere(scope);
[primaryField]: { const targetScopeWhere = target.getScopeWhere(scope);
[Op.ne]: resourceKey const sameScope = whereCompare(sourceScopeWhere, targetScopeWhere);
},
[sortField]: { let increment;
[sign.op]: operand[sortField] const updateWhere = { ...targetScopeWhere };
} if (sameScope) {
}, const direction = source[sortAttr] < target[sortAttr] ? {
limit: absChange, sourceOp: Op.gt,
// offset: 0, targetOp: Op.lte,
attributes: [primaryField, sortField], increment: -1
order: [ } : {
[sortField, sign.order] sourceOp: Op.lt,
], targetOp: Op.gte,
transaction increment: 1
};
increment = direction.increment;
Object.assign(updateWhere, {
[sortAttr]: {
[direction.sourceOp]: source[sortAttr],
[direction.targetOp]: target[sortAttr]
}
});
} else {
increment = 1;
Object.assign(updateWhere, {
[sortAttr]: {
[Op.gte]: target[sortAttr]
}
}); });
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') { await Model.increment(sortAttr, {
target = await Model[sign.extremum](sortField, { by: increment,
where, where: updateWhere,
transaction transaction
}) - sign.direction; });
}
await operand.update({ await source.update({
[sortField]: target [sortAttr]: target[sortAttr],
...targetScopeWhere
}, { }, {
transaction transaction
}); });
await transaction.commit(); await transaction.commit();
ctx.body = operand; ctx.body = source;
await next(); await next();
} }

View File

@ -259,7 +259,7 @@ export abstract class Model extends SequelizeModel {
return data; return data;
} }
getScopeWhere(scope: string[]) { getScopeWhere(scope: string[] = []) {
const Model = this.constructor as ModelCtor<Model>; const Model = this.constructor as ModelCtor<Model>;
const table = this.database.getTable(this.constructor.name); const table = this.database.getTable(this.constructor.name);
const associations = table.getAssociations(); const associations = table.getAssociations();