Feature field for set default (#49)
* feat: add AsDefault field type * fix: association definition in test case * fix: logic of field as default in bulkCreate * fix: change to asDefault field * refactor: rename to radio and move unit test cases back to database package * change to radio Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
parent
b2fe087fc2
commit
3e75bbe6c3
174
packages/database/src/__tests__/fields/radio.test.ts
Normal file
174
packages/database/src/__tests__/fields/radio.test.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import { getDatabase } from '..';
|
||||
|
||||
describe('radio', () => {
|
||||
let db;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = getDatabase();
|
||||
|
||||
db.table({
|
||||
name: 'users',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'name'
|
||||
},
|
||||
{
|
||||
type: 'hasMany',
|
||||
name: 'posts'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
db.table({
|
||||
name: 'posts',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
},
|
||||
{
|
||||
type: 'belongsTo',
|
||||
name: 'user',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'status',
|
||||
defaultValue: 'published',
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
name: 'pinned'
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
name: 'latest',
|
||||
defaultValue: true
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
name: 'pinned_in_status',
|
||||
scope: ['status']
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
name: 'pinned_in_user',
|
||||
scope: ['user'],
|
||||
defaultValue: true
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await db.sync({ force: true });
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
describe('create', () => {
|
||||
it('undefined value as defaultValue', async () => {
|
||||
const Post = db.getModel('posts');
|
||||
const created1 = await Post.create({ title: 'title1', pinned: true });
|
||||
expect(created1.pinned).toBe(true);
|
||||
expect(created1.latest).toBe(true);
|
||||
|
||||
const created2 = await Post.create({ title: 'title2' });
|
||||
expect(created2.pinned).toBe(false);
|
||||
expect(created2.latest).toBe(true);
|
||||
|
||||
const posts = await Post.findAll({ order: [['id', 'ASC']] });
|
||||
|
||||
expect(posts.map(({ pinned, latest }) => ({ pinned, latest }))).toEqual([
|
||||
{ pinned: true, latest: false },
|
||||
{ pinned: false, latest: true }
|
||||
]);
|
||||
});
|
||||
|
||||
it('true value set', async () => {
|
||||
const Post = db.getModel('posts');
|
||||
const created1 = await Post.create({ title: 'title1', pinned: true });
|
||||
expect(created1.pinned).toBe(true);
|
||||
|
||||
const created2 = await Post.create({ title: 'title2', pinned: true });
|
||||
expect(created2.pinned).toBe(true);
|
||||
|
||||
const posts = await Post.findAll({ order: [['id', 'ASC']] });
|
||||
|
||||
expect(posts.map(({ pinned }) => pinned)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('bulkCreate', async () => {
|
||||
const Post = db.getModel('posts');
|
||||
const posts = await Post.bulkCreate([
|
||||
{ title: 'title1' },
|
||||
{ title: 'title2', pinned: true },
|
||||
{ title: 'title3' },
|
||||
]);
|
||||
|
||||
expect(posts.map(({ pinned, latest }) => ({ pinned, latest }))).toEqual([
|
||||
{ pinned: false, latest: false },
|
||||
{ pinned: true, latest: false },
|
||||
{ pinned: false, latest: true }
|
||||
]);
|
||||
});
|
||||
|
||||
it('create with scopes', async () => {
|
||||
const User = db.getModel('users');
|
||||
const users = await User.bulkCreate([{}, {}]);
|
||||
const Post = db.getModel('posts');
|
||||
const bulkCreated = await Post.bulkCreate([
|
||||
{ title: 'title1', status: 'published', user_id: 1},
|
||||
{ title: 'title2', status: 'published', user_id: 2, pinned_in_status: true },
|
||||
{ title: 'title3', status: 'draft', user_id: 1, pinned_in_status: true },
|
||||
]);
|
||||
expect(bulkCreated.map(({ pinned_in_status, pinned_in_user }) => ({ pinned_in_status, pinned_in_user }))).toEqual([
|
||||
{ pinned_in_status: false, pinned_in_user: false },
|
||||
{ pinned_in_status: true, pinned_in_user: true },
|
||||
{ pinned_in_status: true, pinned_in_user: true }
|
||||
]);
|
||||
|
||||
const user1Post = await users[1].createPost({ title: 'title4', status: 'draft', pinned_in_status: true });
|
||||
expect(user1Post.pinned_in_status).toBe(true);
|
||||
expect(user1Post.pinned_in_user).toBe(true);
|
||||
|
||||
const posts = await Post.findAll({ order: [['id', 'ASC']] });
|
||||
expect(posts.map(({ title, pinned_in_status, pinned_in_user }) => ({ title, pinned_in_status, pinned_in_user }))).toMatchObject([
|
||||
{ title: 'title1', pinned_in_status: false, pinned_in_user: false },
|
||||
{ title: 'title2', pinned_in_status: true, pinned_in_user: false },
|
||||
{ title: 'title3', pinned_in_status: false, pinned_in_user: true },
|
||||
{ title: 'title4', pinned_in_status: true, pinned_in_user: true }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('update one to false effect nothing else', async () => {
|
||||
const Post = db.getModel('posts');
|
||||
await Post.bulkCreate([
|
||||
{ title: 'title1', pinned: true },
|
||||
{ title: 'title2' }
|
||||
]);
|
||||
|
||||
const created = await Post.create({ pinned: false });
|
||||
expect(created.pinned).toBe(false);
|
||||
|
||||
const posts = await Post.findAll({ order: [['title', 'ASC']] });
|
||||
|
||||
expect(posts.map(({ pinned }) => pinned)).toEqual([true, false, false]);
|
||||
});
|
||||
|
||||
it('update one to true makes others to false', async () => {
|
||||
const Post = db.getModel('posts');
|
||||
await Post.bulkCreate([
|
||||
{ title: 'title1', pinned: true },
|
||||
{ title: 'title2' }
|
||||
]);
|
||||
|
||||
const created = await Post.create({ pinned: true });
|
||||
expect(created.pinned).toBe(true);
|
||||
|
||||
const posts = await Post.findAll({ order: [['title', 'ASC']] });
|
||||
|
||||
expect(posts.map(({ pinned }) => pinned)).toEqual([false, false, true]);
|
||||
});
|
||||
});
|
||||
});
|
@ -20,11 +20,11 @@ import {
|
||||
JSON as Json,
|
||||
JSONB as Jsonb,
|
||||
PASSWORD as Password,
|
||||
} from '../fields';
|
||||
} from '../../fields';
|
||||
import { DataTypes } from 'sequelize';
|
||||
import { ABSTRACT } from 'sequelize/lib/data-types';
|
||||
import { getDatabase } from '.';
|
||||
import Database from '..';
|
||||
import { getDatabase } from '..';
|
||||
import Database from '../..';
|
||||
|
||||
describe('field types', () => {
|
||||
const assertTypeInstanceOf = (expected, actual) => {
|
@ -788,3 +788,102 @@ export class SORT extends NUMBER {
|
||||
return extremum + (next === 'max' ? 1 : -1);
|
||||
}
|
||||
}
|
||||
|
||||
export class Radio extends BOOLEAN {
|
||||
|
||||
public readonly options: Options.RadioOptions;
|
||||
|
||||
static async beforeSaveHook(this: Radio, model, options) {
|
||||
const { name, defaultValue = false, scope = [] } = this.options;
|
||||
const { transaction } = options;
|
||||
const value = model.get(name) || defaultValue;
|
||||
model.set(name, value);
|
||||
if (value) {
|
||||
const where = model.getValuesByFieldNames(scope);
|
||||
await this.setOthers({ where, transaction });
|
||||
}
|
||||
}
|
||||
|
||||
static async beforeBulkCreateHook(this: Radio, models, options) {
|
||||
const { name, defaultValue = false, scope = [] } = this.options;
|
||||
const { transaction } = options;
|
||||
|
||||
// 如果未配置范围限定,则可以进行性能优化处理(常用情况)。
|
||||
if (!scope.length) {
|
||||
await this.makeGroup(models, { transaction });
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = new Map<{ [key: string]: any }, any[]>();
|
||||
// 按 scope 先分组
|
||||
models.forEach(model => {
|
||||
const where = model.getValuesByFieldNames(scope);
|
||||
// 以 map 作为 key
|
||||
let combo;
|
||||
let group;
|
||||
// 查找与 where 值相等的组合
|
||||
for (combo of groups.keys()) {
|
||||
if (whereCompare(combo, where)) {
|
||||
group = groups.get(combo);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!group) {
|
||||
group = [];
|
||||
groups.set(where, group);
|
||||
}
|
||||
group.push(model);
|
||||
});
|
||||
|
||||
for (const [where, group] of groups) {
|
||||
await this.makeGroup(group, { where, transaction });
|
||||
}
|
||||
}
|
||||
|
||||
constructor({ type, ...options }: Options.RadioOptions, context: FieldContext) {
|
||||
super({ ...options, type: 'boolean' }, context);
|
||||
const Model = context.sourceTable.getModel();
|
||||
// TODO(feature): 可考虑策略模式,以在需要时对外提供接口
|
||||
const beforeSaveHook = Radio.beforeSaveHook.bind(this);
|
||||
Model.addHook('beforeCreate', beforeSaveHook);
|
||||
Model.addHook('beforeUpdate', beforeSaveHook);
|
||||
// Model.addHook('beforeUpsert', beforeSaveHook);
|
||||
Model.addHook('beforeBulkCreate', Radio.beforeBulkCreateHook.bind(this));
|
||||
// TODO(optimize): bulkUpdate 的 hooks 参数不一样,没有对象列表,考虑到很少用,暂时不实现
|
||||
// Model.addHook('beforeBulkUpdate', beforeBulkCreateHook);
|
||||
}
|
||||
|
||||
public getDataType() {
|
||||
return DataTypes.BOOLEAN;
|
||||
}
|
||||
|
||||
public async setOthers(this: Radio, { where = {}, transaction }) {
|
||||
const { name } = this.options;
|
||||
const table = this.context.sourceTable;
|
||||
const Model = table.getModel();
|
||||
// 防止 beforeBulkUpdate hook 死循环,因外层 bulkUpdate 并不禁用,正常更新无影响。
|
||||
await Model.update({ [name]: false }, { where, transaction, hooks: false });
|
||||
}
|
||||
|
||||
async makeGroup(this: Radio, models, { where = {}, transaction }) {
|
||||
const { name, defaultValue = false } = this.options;
|
||||
let lastTrue;
|
||||
let lastNull;
|
||||
models.forEach(model => {
|
||||
const value = model.get(name);
|
||||
if (value) {
|
||||
lastTrue = model;
|
||||
} else if (value == null) {
|
||||
lastNull = model;
|
||||
}
|
||||
model.set(name, false);
|
||||
});
|
||||
if (lastTrue) {
|
||||
lastTrue.set(name, true);
|
||||
} else if (defaultValue && lastNull) {
|
||||
lastNull.set(name, true);
|
||||
}
|
||||
|
||||
await this.setOthers({ where, transaction });
|
||||
}
|
||||
}
|
||||
|
@ -193,6 +193,16 @@ export interface SortOptions extends NumberOptions {
|
||||
next?: 'min' | 'max';
|
||||
}
|
||||
|
||||
export interface RadioOptions extends Omit<BooleanOptions, 'type'> {
|
||||
type: 'radio';
|
||||
/**
|
||||
* 默认限定范围
|
||||
*
|
||||
* 在同表的限定范围内的字段值相等的数据行中设置默认
|
||||
*/
|
||||
scope?: string[];
|
||||
}
|
||||
|
||||
export type ColumnOptions = AbstractFieldOptions
|
||||
| BooleanOptions
|
||||
| NumberOptions
|
||||
|
@ -176,10 +176,11 @@ export default {
|
||||
},
|
||||
{
|
||||
interface: 'boolean',
|
||||
type: 'boolean',
|
||||
type: 'radio',
|
||||
name: 'default',
|
||||
title: '作为默认标签页',
|
||||
defaultValue: false,
|
||||
scope: ['collection'],
|
||||
component: {
|
||||
type: 'checkbox',
|
||||
showInTable: true,
|
||||
|
@ -139,10 +139,11 @@ export default {
|
||||
},
|
||||
{
|
||||
interface: 'boolean',
|
||||
type: 'boolean',
|
||||
type: 'radio',
|
||||
name: 'default',
|
||||
title: '作为默认试图',
|
||||
defaultValue: false,
|
||||
scope: ['collection'],
|
||||
component: {
|
||||
type: 'checkbox',
|
||||
showInTable: true,
|
||||
|
Loading…
Reference in New Issue
Block a user