diff --git a/packages/database/src/__tests__/fields/radio.test.ts b/packages/database/src/__tests__/fields/radio.test.ts new file mode 100644 index 000000000..ef6e241af --- /dev/null +++ b/packages/database/src/__tests__/fields/radio.test.ts @@ -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]); + }); + }); +}); diff --git a/packages/database/src/__tests__/types.test.ts b/packages/database/src/__tests__/fields/types.test.ts similarity index 99% rename from packages/database/src/__tests__/types.test.ts rename to packages/database/src/__tests__/fields/types.test.ts index 32855e03e..443b687b4 100644 --- a/packages/database/src/__tests__/types.test.ts +++ b/packages/database/src/__tests__/fields/types.test.ts @@ -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) => { diff --git a/packages/database/src/fields/field-types.ts b/packages/database/src/fields/field-types.ts index 3a173c8ce..c8613bbdd 100644 --- a/packages/database/src/fields/field-types.ts +++ b/packages/database/src/fields/field-types.ts @@ -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 }); + } +} diff --git a/packages/database/src/fields/option-types.ts b/packages/database/src/fields/option-types.ts index a41d7353a..8c946163e 100644 --- a/packages/database/src/fields/option-types.ts +++ b/packages/database/src/fields/option-types.ts @@ -193,6 +193,16 @@ export interface SortOptions extends NumberOptions { next?: 'min' | 'max'; } +export interface RadioOptions extends Omit { + type: 'radio'; + /** + * 默认限定范围 + * + * 在同表的限定范围内的字段值相等的数据行中设置默认 + */ + scope?: string[]; +} + export type ColumnOptions = AbstractFieldOptions | BooleanOptions | NumberOptions diff --git a/packages/plugin-collections/src/collections/tabs.ts b/packages/plugin-collections/src/collections/tabs.ts index 510a5f0ad..b12f345e1 100644 --- a/packages/plugin-collections/src/collections/tabs.ts +++ b/packages/plugin-collections/src/collections/tabs.ts @@ -176,10 +176,11 @@ export default { }, { interface: 'boolean', - type: 'boolean', + type: 'radio', name: 'default', title: '作为默认标签页', defaultValue: false, + scope: ['collection'], component: { type: 'checkbox', showInTable: true, diff --git a/packages/plugin-collections/src/collections/views.ts b/packages/plugin-collections/src/collections/views.ts index 2eef9d3c8..691e21a41 100644 --- a/packages/plugin-collections/src/collections/views.ts +++ b/packages/plugin-collections/src/collections/views.ts @@ -139,10 +139,11 @@ export default { }, { interface: 'boolean', - type: 'boolean', + type: 'radio', name: 'default', title: '作为默认试图', defaultValue: false, + scope: ['collection'], component: { type: 'checkbox', showInTable: true,