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 <chenlinxh@gmail.com>
This commit is contained in:
Junyi 2020-12-11 15:41:03 +08:00 committed by GitHub
parent 6b84446697
commit 841249f58c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 276 additions and 84 deletions

View File

@ -47,7 +47,7 @@ describe('create', () => {
{ content: 'comment2', status: 'draft' }, { content: 'comment2', status: 'draft' },
] ]
}); });
expect(response.body.sort).toBe(null); expect(response.body.sort).toBe(1);
expect(response.body.user_id).toBe(1); expect(response.body.user_id).toBe(1);
const postWithUser = await agent const postWithUser = await agent

View File

@ -8,7 +8,7 @@ describe('list', () => {
beforeAll(async () => { beforeAll(async () => {
resourcer.define({ resourcer.define({
name: 'posts', name: 'articles',
middlewares: [ middlewares: [
jsonReponse, jsonReponse,
], ],
@ -16,8 +16,8 @@ describe('list', () => {
}); });
db = await initDatabase(); db = await initDatabase();
db.table({ db.table({
name: 'posts', name: 'articles',
tableName: 'actions__m__posts', tableName: 'actions__articles',
fields: [ fields: [
{ {
type: 'string', type: 'string',
@ -48,7 +48,7 @@ describe('list', () => {
it('create', async () => { it('create', async () => {
const response = await agent const response = await agent
.post('/posts') .post('/articles')
.send({ .send({
title: 'title1', title: 'title1',
}); });
@ -56,7 +56,7 @@ describe('list', () => {
}); });
it('list', async () => { 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({ expect(response.body).toEqual({
data: [ { title: 'title1' } ], data: [ { title: 'title1' } ],
meta: { count: 1, page: 1, per_page: 20 } meta: { count: 1, page: 1, per_page: 20 }

View File

@ -6,13 +6,12 @@ describe('get', () => {
beforeEach(async () => { beforeEach(async () => {
db = await initDatabase(); db = await initDatabase();
const User = db.getModel('users'); 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 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}`, title: `title_${i}`,
status: i % 2 ? 'publish' : 'draft', status: i % 2 ? 'publish' : 'draft',
sort: i,
user_id: users[i % users.length].id user_id: users[i % users.length].id
}))); })));
@ -20,58 +19,76 @@ describe('get', () => {
comments: Array(post.sort % 5).fill(null).map((_, index) => ({ comments: Array(post.sort % 5).fill(null).map((_, index) => ({
content: `content_${index}`, content: `content_${index}`,
status: index % 2 ? 'published' : 'draft', status: index % 2 ? 'published' : 'draft',
user_id: users[index % users.length].id, user_id: users[index % users.length].id
sort: index
})) }))
})), Promise.resolve()); })), 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()); 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', () => { describe('sort in whole table', () => {
it('init sort value', async () => {
const Post = db.getModel('posts');
});
it('move id=1 by offset=1', async () => { it('move id=1 by offset=1', async () => {
const Post = db.getModel('posts'); const Post = db.getModel('posts');
await agent await agent
@ -165,10 +182,10 @@ describe('get', () => {
expect(post1.get('sort')).toBe(0); expect(post1.get('sort')).toBe(0);
const post2 = await Post.findByPk(2); const post2 = await Post.findByPk(2);
expect(post2.get('sort')).toBe(22); expect(post2.get('sort')).toBe(10);
const post22 = await Post.findByPk(22); const post10 = await Post.findByPk(10);
expect(post22.get('sort')).toBe(21); expect(post10.get('sort')).toBe(9);
}); });
it('move id=2 by offset=-Infinity', async () => { it('move id=2 by offset=-Infinity', async () => {
@ -222,7 +239,7 @@ describe('get', () => {
const Post = db.getModel('posts'); const Post = db.getModel('posts');
const post2 = await Post.findByPk(2); const post2 = await Post.findByPk(2);
expect(post2.get('sort')).toBe(22); expect(post2.get('sort')).toBe(10);
}); });
}); });

View File

@ -21,8 +21,18 @@ export default {
name: 'user', name: 'user',
}, },
{ {
type: 'integer', type: 'sort',
name: 'sort' name: 'sort'
},
{
type: 'sort',
name: 'sort_in_status',
scope: ['status']
},
{
type: 'sort',
name: 'sort_in_post',
scope: ['post']
} }
], ],
scopes: { scopes: {
@ -31,6 +41,5 @@ export default {
status: 'published' status: 'published'
} }
} }
}, }
sortable: true
} as TableOptions; } as TableOptions;

View File

@ -30,18 +30,24 @@ export default {
name: 'tags', name: 'tags',
}, },
{ {
type: 'integer', type: 'sort',
name: 'sort' name: 'sort'
}, },
{
type: 'sort',
name: 'sort_in_status',
scope: ['status']
},
{
type: 'sort',
name: 'sort_in_user',
scope: ['user']
},
{ {
type: 'json', type: 'json',
name: 'meta' name: 'meta'
} }
], ],
hooks: {
beforeCreate(model, options) {
},
},
scopes: { scopes: {
customTitle: (title, ctx) => { customTitle: (title, ctx) => {
return { return {
@ -49,7 +55,6 @@ export default {
title: title, title: title,
}, },
} }
}, }
}, }
sortable: true
} as TableOptions; } as TableOptions;

View File

@ -158,7 +158,7 @@ describe('hooks', () => {
expect(test.get('arr')).toEqual([ 1, 3, 2, 4 ]); 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({ const table = db.table({
name: 'test3', name: 'test3',
fields: [ fields: [

View File

@ -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({});
});
});

View File

@ -367,7 +367,6 @@ describe('model', () => {
})) }))
}); });
const updatedComments = await Comment.findAll(); const updatedComments = await Comment.findAll();
console.log(updatedComments);
const post1CommentsCount = await Comment.count({ const post1CommentsCount = await Comment.count({
where: { post_id: post.id } where: { post_id: post.id }
}); });

View File

@ -9,13 +9,15 @@ import {
BelongsToManyOptions, BelongsToManyOptions,
ThroughOptions, ThroughOptions,
} from 'sequelize'; } from 'sequelize';
import { template, get, toNumber } from 'lodash';
import bcrypt from 'bcrypt';
import * as Options from './option-types'; import * as Options from './option-types';
import { getDataTypeKey } from '.'; import { getDataTypeKey } from '.';
import Table from '../table'; import Table from '../table';
import Database from '../database'; import Database from '../database';
import Model, { ModelCtor } from '../model'; import Model, { ModelCtor } from '../model';
import { template, isArray, map, get, toNumber } from 'lodash'; import { whereCompare } from '../utils';
import bcrypt from 'bcrypt';
export interface IField { export interface IField {
@ -681,7 +683,7 @@ export class BELONGSTOMANY extends Relation {
} }
public getAssociationOptions(): BelongsToManyOptions { public getAssociationOptions(): BelongsToManyOptions {
const { name, ...restOptions }= this.options; const { name, ...restOptions } = this.options;
return { return {
as: name, as: name,
through: this.getThroughModel(), 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;
}
}

View File

@ -174,6 +174,25 @@ export interface BelongsToManyOptions extends Omit<SequelizeBelongsToManyOptions
otherKey?: string; otherKey?: string;
} }
export interface SortOptions extends NumberOptions {
type: 'sort';
/**
*
*
*
*/
scope?: string[];
/**
*
*
* max: 使用最大值
* min: 使用最小值
*
* Defaults to 'max'
*/
next?: 'min' | 'max';
}
export type ColumnOptions = AbstractFieldOptions export type ColumnOptions = AbstractFieldOptions
| BooleanOptions | BooleanOptions
| NumberOptions | NumberOptions

View File

@ -1,5 +1,5 @@
import { import {
Model as SequelizeModel, Op, Sequelize, ProjectionAlias, Utils, SaveOptions, Model as SequelizeModel, Op, Sequelize, ProjectionAlias, Utils, SaveOptions
} from 'sequelize'; } from 'sequelize';
import Database from './database'; import Database from './database';
import { import {
@ -259,6 +259,27 @@ export abstract class Model extends SequelizeModel {
return data; return data;
} }
getScopeWhere(scope: string[]) {
const Model = this.constructor as ModelCtor<Model>;
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<any> & { context?: any; } = {}) { async updateSingleAssociation(key: string, data: any, options: SaveOptions<any> & { context?: any; } = {}) {
const { const {
fields, fields,
@ -399,19 +420,22 @@ export abstract class Model extends SequelizeModel {
for (const item of toUpsertObjects) { for (const item of toUpsertObjects) {
let target; let target;
if (typeof item[targetKey] === 'undefined') { if (typeof item[targetKey] === 'undefined') {
// TODO(optimize): 不确定 bulkCreate 的结果是否能保证顺序,能保证的话这里可以优化为批量处理 target = await this[accessors.create](item, opts);
target = await Target.create(item, opts);
} else { } else {
let created: boolean; target = await Target.findOne({
[target, created] = await Target.findOrCreate({ ...opts,
where: { [targetKey]: item[targetKey] }, where: { [targetKey]: item[targetKey] },
defaults: item,
transaction
}); });
if (!created) { if (!target) {
target = await this[accessors.create](item, opts);
} else {
await target.update(item, opts); await target.update(item, opts);
} }
} }
// TODO(optimize): 此处添加的对象其实已经创建了关联,
// 但考虑到单条 create 的 hook 要求带上关联键,且后面的 set
// 所以仍然交给 set 再调用关联一次。
toSetItems.add(target);
if (association instanceof BELONGSTOMANY) { if (association instanceof BELONGSTOMANY) {
belongsToManyList.push({ belongsToManyList.push({
@ -419,7 +443,6 @@ export abstract class Model extends SequelizeModel {
target target
}); });
} }
toSetItems.add(target);
await target.updateAssociations(item, opts); await target.updateAssociations(item, opts);
} }

View File

@ -12,6 +12,7 @@ import {
Relation, Relation,
BELONGSTO, BELONGSTO,
BELONGSTOMANY, BELONGSTOMANY,
SORT
} from './fields'; } from './fields';
import Database from './database'; import Database from './database';
import { Model, ModelCtor } from './model'; import { Model, ModelCtor } from './model';
@ -127,6 +128,10 @@ export class Table {
public relationTables = new Set<string>(); public relationTables = new Set<string>();
get sortable(): boolean {
return Array.from(this.fields.values()).some(field => field instanceof SORT);
}
constructor(options: TableOptions, context: TabelContext) { constructor(options: TableOptions, context: TabelContext) {
const { database } = context; const { database } = context;
const { const {

View File

@ -290,6 +290,10 @@ export function toInclude(options: any, context: ToIncludeContext = {}) {
return data; return data;
} }
export function whereCompare(a: any, b: any): boolean {
return _.isEqual(a, b);
}
export function requireModule(module: any) { export function requireModule(module: any) {
if (typeof module === 'string') { if (typeof module === 'string') {
module = require(module); module = require(module);