feat: add createdBy/updatedBy field config for table managed by collections (#43)

* feat: add createdBy/updatedBy field config for table managed by collections

* fix: update by different users and field initialization

* fix: function argument

* add: test cases

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Junyi 2020-12-18 09:04:40 +08:00 committed by GitHub
parent 59db7a34dc
commit 110d5bcc9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 634 additions and 46 deletions

View File

@ -117,6 +117,7 @@ export async function create(ctx: Context, next: Next) {
} = ctx.action.params;
const values = filterByFields(data, fields);
const transaction = await ctx.db.sequelize.transaction();
const options = { transaction, context: ctx };
let model: Model;
if (associated && resourceField) {
const AssociatedModel = ctx.db.getModel(associatedName);
@ -125,15 +126,15 @@ export async function create(ctx: Context, next: Next) {
}
const { create } = resourceField.getAccessors();
// @ts-ignore
model = await associated[create](values, { transaction, context: ctx });
await model.updateAssociations(values, { transaction, context: ctx });
model = await associated[create](values, options);
await model.updateAssociations(values, options);
ctx.body = model;
} else {
const ResourceModel = ctx.db.getModel(resourceName);
// @ts-ignore
model = await ResourceModel.create(values, { transaction, context: ctx });
model = await ResourceModel.create(values, options);
// @ts-ignore
await model.updateAssociations(values, { transaction, context: ctx });
await model.updateAssociations(values, options);
ctx.body = model;
}
await transaction.commit();
@ -219,6 +220,7 @@ export async function update(ctx: Context, next: Next) {
} = ctx.action.params;
const values = filterByFields(data, fields);
const transaction = await ctx.db.sequelize.transaction();
const options = { transaction, context: ctx };
if (associated && resourceField) {
const AssociatedModel = ctx.db.getModel(associatedName);
if (!(associated instanceof AssociatedModel)) {
@ -227,21 +229,20 @@ export async function update(ctx: Context, next: Next) {
}
const { get: getAccessor } = resourceField.getAccessors();
if (resourceField instanceof HASONE || resourceField instanceof BELONGSTO) {
let model: Model = await associated[getAccessor]({ transaction, context: ctx });
let model: Model = await associated[getAccessor](options);
if (model) {
// @ts-ignore
await model.update(values, { transaction, context: ctx });
await model.updateAssociations(values, { transaction, context: ctx });
await model.update(values, options);
await model.updateAssociations(values, options);
ctx.body = model;
}
} else if (resourceField instanceof HASMANY || resourceField instanceof BELONGSTOMANY) {
const TargetModel = ctx.db.getModel(resourceField.getTarget());
const [model]: Model[] = await associated[getAccessor]({
...options,
where: {
[resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey,
},
transaction,
context: ctx,
}
});
if (resourceField instanceof BELONGSTOMANY) {
@ -257,32 +258,30 @@ export async function update(ctx: Context, next: Next) {
},
transaction
});
await through.updateAssociations(throughValues, { transaction, context: ctx });
await through.update(throughValues, { transaction, context: ctx });
await through.updateAssociations(throughValues, options);
await through.update(throughValues, options);
delete values[throughName];
}
}
if (!_.isEmpty(values)) {
// @ts-ignore
await model.update(values, { transaction, context: ctx });
await model.updateAssociations(values, { transaction, context: ctx });
await model.update(values, options);
await model.updateAssociations(values, options);
}
ctx.body = model;
}
} else {
const Model = ctx.db.getModel(resourceName);
const model = await Model.findOne({
...options,
where: {
[resourceKeyAttribute || Model.primaryKeyAttribute]: resourceKey,
},
// @ts-ignore hooks 里添加 context
context: ctx,
transaction
}
});
// @ts-ignore
await model.update(values, { transaction, context: ctx });
await model.update(values, options);
// @ts-ignore
await model.updateAssociations(values, { transaction, context: ctx });
await model.updateAssociations(values, options);
ctx.body = model;
}
await transaction.commit();

View File

@ -430,7 +430,7 @@ export abstract class Relation extends Field {
return {
as: name,
...restOptions,
}
};
}
public getAssociationArguments() {
@ -438,8 +438,8 @@ export abstract class Relation extends Field {
target: this.getTarget(),
type: this.getAssociationType(),
options: this.getAssociationOptions(),
}
};
};
}
}
class HasOneOrMany extends Relation {
@ -495,7 +495,7 @@ export class HASONE extends HasOneOrMany {
}
public getAssociationOptions(): HasOneOptions {
const { name, ...restOptions }= this.options;
const { name, ...restOptions } = this.options;
return {
as: name,
...restOptions,
@ -514,17 +514,16 @@ export class HASMANY extends HasOneOrMany {
target = name;
}
super({target, ...options}, context);
super({ target, ...options }, context);
this.targetTableInit();
}
public getAssociationOptions(): HasManyOptions {
const { name, ...restOptions }= this.options;
const { name, ...restOptions } = this.options;
return {
as: name,
...restOptions,
}
};
}
}
@ -539,7 +538,7 @@ export class BELONGSTO extends Relation {
target = Utils.pluralize(name);
}
super({target, ...options}, context);
super({ target, ...options }, context);
this.targetTableInit();
this.updateOptionsAfterTargetModelBeDefined();

View File

@ -344,6 +344,7 @@ export const createdBy = {
options: {
interface: 'createdBy',
type: 'belongsTo',
name: 'createdBy',
filterable: true,
component: {
type: 'drawerSelect',
@ -374,6 +375,7 @@ export const updatedBy = {
disabled: true,
options: {
interface: 'updatedBy',
name: 'updatedBy',
type: 'belongsTo',
filterable: true,
component: {

View File

@ -11,6 +11,7 @@
},
"devDependencies": {
"@nocobase/actions": "^0.3.0-alpha.0",
"@nocobase/server": "^0.3.0-alpha.0",
"crypto-random-string": "^3.3.0",
"umi": "^3.2.23"
},

View File

@ -0,0 +1,277 @@
import { getApp, getAgent } from '.';
describe('user fields', () => {
let app;
let agent;
let db;
beforeEach(async () => {
app = await getApp();
agent = getAgent(app);
db = app.database;
await db.sync({ force: true });
});
afterEach(async () => {
await db.close();
});
describe('model definition', () => {
it('add model without createdBy/updatedBy field', async () => {
const Collection = db.getModel('collections');
await Collection.create({ name: 'posts' });
const Post = db.getModel('posts');
const post = await Post.create();
expect(post.created_by_id).toBeUndefined();
expect(post.updated_by_id).toBeUndefined();
});
it('add model with named createdBy/updatedBy field', async () => {
const Collection = db.getModel('collections');
await Collection.create({ name: 'posts', createdBy: 'author', updatedBy: 'editor' });
const Post = db.getModel('posts');
const post = await Post.create();
expect(post.author_id).toBeDefined();
expect(post.editor_id).toBeDefined();
});
it('add model with named createdBy/updatedBy field and target', async () => {
const Collection = db.getModel('collections');
await Collection.create({
name: 'posts',
createdBy: { name: 'author', target: 'users' },
updatedBy: { name: 'editor', target: 'users' }
});
const Post = db.getModel('posts');
const post = await Post.create();
expect(post.author_id).toBeDefined();
expect(post.editor_id).toBeDefined();
});
it('add model with boolean createdBy/updatedBy field', async () => {
const Collection = db.getModel('collections');
await Collection.create({ name: 'posts', createdBy: true, updatedBy: true });
const Post = db.getModel('posts');
const post = await Post.create();
expect(post.created_by_id).toBeDefined();
expect(post.updated_by_id).toBeDefined();
});
// TODO(bug): 重复添加字段不能与 fields 表同步,应做到同步
it.only('add model and then add createdBy/updatedBy field', async () => {
const Collection = db.getModel('collections');
const collection = await Collection.import({
name: 'posts',
createdBy: true,
updatedBy: true,
fields: [
{
interface: 'createdBy',
title: '创建人1',
type: 'createdBy',
name: 'createdBy1',
target: 'users',
foreignKey: 'created_by_id',
},
{
interface: 'updatedBy',
title: '更新人1',
type: 'updatedBy',
name: 'updatedBy1',
target: 'users',
foreignKey: 'updated_by_id',
},
{
interface: 'createdBy',
title: '创建人2',
type: 'createdBy',
name: 'createdBy2',
target: 'users',
foreignKey: 'created_by_id',
},
{
interface: 'updatedBy',
title: '更新人2',
type: 'updatedBy',
name: 'updatedBy2',
target: 'users',
foreignKey: 'updated_by_id',
},
]
});
const table = db.getTable('posts');
// console.log(table.getFields());
const User = db.getModel('users');
const Post = db.getModel('posts');
// 用户1 操作
const user1 = await User.create();
const postWithUser = await Post.create({}, { context: { state: { currentUser: user1 } } });
const post = await Post.findOne(Post.parseApiJson({
filter: {
id: postWithUser.id,
},
fields: ['createdBy1', 'updatedBy1', 'createdBy2', 'updatedBy2'],
}));
expect(post.createdBy1.id).toBe(user1.id);
expect(post.updatedBy1.id).toBe(user1.id);
expect(post.createdBy2.id).toBe(user1.id);
expect(post.updatedBy2.id).toBe(user1.id);
// 换个用户
const user2 = await User.create();
await postWithUser.update({title: 'title1'}, { context: { state: { currentUser: user2 } } });
const post2 = await Post.findOne(Post.parseApiJson({
filter: {
id: postWithUser.id,
},
fields: ['createdBy1', 'updatedBy1', 'createdBy2', 'updatedBy2'],
}));
expect(post2.createdBy1.id).toBe(user1.id);
expect(post2.createdBy2.id).toBe(user1.id);
expect(post2.updatedBy1.id).toBe(user2.id);
expect(post2.updatedBy2.id).toBe(user2.id);
// const Collection = db.getModel('collections');
// const collection = await Collection.create({
// name: 'posts'
// });
// const createdByField = await collection.createField({ type: 'createdBy', name: 'author', target: 'users' });
// const updatedByField = await collection.createField({ type: 'updatedBy', name: 'editor', target: 'users' });
// const postTable = db.getTable('posts');
// const Post = db.getModel('posts');
// // create data should contain added fields
// const post = await Post.create();
// expect(post[postTable.getField(createdByField.get('name')).options.foreignKey]).toBeDefined();
// expect(post[postTable.getField(updatedByField.get('name')).options.foreignKey]).toBeDefined();
// // add same type field twice should get same field
// const createdByField2 = await collection.createField({ type: 'createdBy', target: 'users' });
// expect(createdByField2.get('name')).toBe(createdByField.get('name'));
// // add same type field twice with a new name should get same field name as before
// const updatedByField2 = await collection.createField({ type: 'updatedBy', name: 'proofreader', target: 'users' });
// expect(updatedByField2.get('name')).toBe(updatedByField.get('name'));
// // delete field data should not really remove the column in table
// await createdByField2.destroy();
// expect(postTable.getField('author')).toBeDefined();
});
});
describe('createdBy field', () => {
it('create data with createdBy/updatedBy field', async () => {
const Collection = db.getModel('collections');
await Collection.create({ name: 'posts', createdBy: true, updatedBy: true });
const User = db.getModel('users');
const currentUser = await User.create();
const user2 = await User.create();
const Post = db.getModel('posts');
const postWithoutUser = await Post.create();
expect(postWithoutUser.created_by_id).toBe(null);
expect(postWithoutUser.updated_by_id).toBe(null);
const postWithUser = await Post.create({}, { context: { state: { currentUser } } });
expect(postWithUser.created_by_id).toBe(currentUser.id);
expect(postWithUser.updated_by_id).toBe(currentUser.id);
// 更新数据 createdBy 数据不变
await postWithUser.update({title: 'title1'}, { context: { state: { currentUser: user2 } } });
expect(postWithUser.created_by_id).toBe(currentUser.id);
expect(postWithUser.updated_by_id).toBe(user2.id);
});
it('create data with value of createdBy/updatedBy field', async () => {
const Collection = db.getModel('collections');
await Collection.create({ name: 'posts', createdBy: true, updatedBy: true });
const User = db.getModel('users');
const user1 = await User.create();
const user2 = await User.create();
const Post = db.getModel('posts');
const post = await Post.create({
created_by_id: user1.id,
updated_by_id: user1.id,
}, { context: { state: { currentUser: user2 } } });
expect(post.created_by_id).toBe(user1.id);
expect(post.updated_by_id).toBe(user1.id);
});
});
describe('updatedBy field', () => {
it('update data ', async () => {
const Collection = db.getModel('collections');
await Collection.create({
name: 'posts',
updatedBy: true,
fields: [
{
type: 'string',
name: 'title'
}
]
});
const User = db.getModel('users');
const currentUser = await User.create();
const Post = db.getModel('posts');
const post = await Post.create();
expect(post.updated_by_id).toBe(null);
await post.update({ title: 'title' }, { context: { state: { currentUser } } })
expect(post.updated_by_id).toBe(currentUser.id);
});
it('update data by different user', async () => {
const Collection = db.getModel('collections');
await Collection.create({
name: 'posts',
updatedBy: true,
fields: [
{
type: 'string',
name: 'title'
}
]
});
const User = db.getModel('users');
const user1 = await User.create();
const user2 = await User.create();
const Post = db.getModel('posts');
let context = { state: { currentUser: user2 } };
const post = await Post.create({
updated_by_id: user1.id,
}, { context });
expect(post.updated_by_id).toBe(user1.id);
await post.update({ title: 'title' }, { context });
expect(post.updated_by_id).toBe(user2.id);
await post.update({ title: 'title', updated_by_id: user1.id }, { context });
expect(post.updated_by_id).toBe(user1.id);
// 不同用户更新数据
context = { state: { currentUser: user1 } };
await post.update({ title: 'title234' }, { context });
expect(post.updated_by_id).toBe(user1.id);
// 重新查询
const post2 = await Post.findByPk(post.id);
expect(post2.updated_by_id).toBe(user1.id);
});
});
});

View File

@ -0,0 +1,142 @@
import path from 'path';
import qs from 'qs';
import supertest from 'supertest';
import bodyParser from 'koa-bodyparser';
import { Dialect } from 'sequelize';
import Database from '@nocobase/database';
import { actions, middlewares } from '@nocobase/actions';
import { Application, middleware } from '@nocobase/server';
import plugin from '../server';
function getTestKey() {
const { id } = require.main;
const key = id
.replace(`${process.env.PWD}/packages`, '')
.replace(/src\/__tests__/g, '')
.replace('.test.ts', '')
.replace(/[^\w]/g, '_')
.replace(/_+/g, '_');
return key
}
const config = {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
host: process.env.DB_HOST,
port: Number.parseInt(process.env.DB_PORT, 10),
dialect: process.env.DB_DIALECT as Dialect,
define: {
hooks: {
beforeCreate(model, options) {
},
},
},
logging: process.env.DB_LOG_SQL === 'on',
sync: {
force: true,
alter: {
drop: true,
},
},
};
export async function getApp() {
const app = new Application({
database: {
...config,
hooks: {
beforeDefine(columns, model) {
model.tableName = `${getTestKey()}_${model.tableName || model.name.plural}`;
}
},
},
resourcer: {
prefix: '/api',
},
});
app.resourcer.use(middlewares.associated);
app.resourcer.registerActionHandlers({...actions.associate, ...actions.common});
await app.plugins([
[path.resolve(__dirname, '../../../plugin-collections')],
[plugin]
]);
await app.database.sync({
force: true,
});
app.use(async (ctx, next) => {
ctx.db = app.database;
await next();
});
app.use(bodyParser());
app.use(middleware({
prefix: '/api',
resourcer: app.resourcer,
database: app.database,
}));
return app;
}
interface ActionParams {
resourceKey?: string | number;
// resourceName?: string;
// associatedName?: string;
associatedKey?: string | number;
fields?: any;
filter?: any;
values?: any;
[key: string]: any;
}
interface Handler {
get: (params?: ActionParams) => Promise<supertest.Response>;
list: (params?: ActionParams) => Promise<supertest.Response>;
create: (params?: ActionParams) => Promise<supertest.Response>;
update: (params?: ActionParams) => Promise<supertest.Response>;
destroy: (params?: ActionParams) => Promise<supertest.Response>;
[name: string]: (params?: ActionParams) => Promise<supertest.Response>;
}
export interface Agent {
resource: (name: string) => Handler;
}
export function getAgent(app: Application): Agent {
const agent = supertest.agent(app.callback());
return {
resource(name: string): any {
return new Proxy({}, {
get(target, method, receiver) {
return (params: ActionParams = {}) => {
const { associatedKey, resourceKey, values = {}, ...restParams } = params;
let url = `/api/${name}`;
if (associatedKey) {
url = `/api/${name.split('.').join(`/${associatedKey}/`)}`;
}
url += `:${method as string}`;
if (resourceKey) {
url += `/${resourceKey}`;
}
if (['list', 'get'].indexOf(method as string) !== -1) {
return agent.get(`${url}?${qs.stringify(restParams)}`);
} else {
return agent.post(`${url}?${qs.stringify(restParams)}`).send(values);
}
}
}
});
}
};
}
export function getDatabase() {
return new Database({
...config,
hooks: {
beforeDefine(columns, model) {
model.tableName = `${getTestKey()}_${model.tableName || model.name.plural}`;
}
}
});
};

View File

@ -0,0 +1,27 @@
import { BelongsToOptions, BELONGSTO, FieldContext } from '@nocobase/database';
import { setUserValue } from './utils';
export interface CreatedByOptions extends Omit<BelongsToOptions, 'type'> {
type: 'createdBy' | 'createdby'
}
export default class CreatedBy extends BELONGSTO {
static beforeBulkCreateHook(this: CreatedBy, models, { context }) {
models.forEach(model => {
setUserValue.call(this, model, { context });
});
}
constructor({ type, ...options }: CreatedByOptions, context: FieldContext) {
super({ ...options, type: 'belongsTo' } as BelongsToOptions, context);
const Model = context.sourceTable.getModel();
// TODO(feature): 可考虑策略模式,以在需要时对外提供接口
Model.addHook('beforeCreate', setUserValue.bind(this));
Model.addHook('beforeBulkCreate', CreatedBy.beforeBulkCreateHook.bind(this));
}
public getDataType(): Function {
return BELONGSTO;
}
}

View File

@ -0,0 +1,36 @@
import { BelongsToOptions, BELONGSTO, FieldContext } from '@nocobase/database';
import { setUserValue } from './utils';
export interface UpdatedByOptions extends Omit<BelongsToOptions, 'type'> {
type: 'updatedBy' | 'updatedby'
}
export default class UpdatedBy extends BELONGSTO {
static beforeBulkCreateHook(this: UpdatedBy, models, { context }) {
models.forEach(model => {
setUserValue.call(this, model, { context });
});
}
static beforeBulkUpdateHook(this: UpdatedBy, models, { context }) {
models.forEach(model => {
setUserValue.call(this, model, { context });
});
}
constructor({ type, ...options }: UpdatedByOptions, context: FieldContext) {
super({ ...options, type: 'belongsTo' } as BelongsToOptions, context);
const Model = context.sourceTable.getModel();
// TODO(feature): 可考虑策略模式,以在需要时对外提供接口
Model.addHook('beforeCreate', setUserValue.bind(this));
Model.addHook('beforeBulkCreate', UpdatedBy.beforeBulkCreateHook.bind(this));
Model.addHook('beforeUpdate', setUserValue.bind(this));
Model.addHook('beforeBulkUpdate', UpdatedBy.beforeBulkUpdateHook.bind(this));
}
public getDataType(): Function {
return BELONGSTO;
}
}

View File

@ -0,0 +1,2 @@
export { default as CreatedBy } from './CreatedBy';
export { default as UpdatedBy } from './UpdatedBy';

View File

@ -0,0 +1,23 @@
import { CreatedBy, UpdatedBy } from ".";
export function setUserValue(this: CreatedBy | UpdatedBy, model, { context }) {
const { foreignKey } = this.options;
// 已有外键数据(只在创建时生效)
if (model.getDataValue(foreignKey)) {
if (model.isNewRecord) {
return;
}
const changed = model.changed();
if (Array.isArray(changed) && changed.find(key => key === foreignKey)) {
return;
}
}
if (!context) {
return;
}
const { currentUser } = context.state;
if (!currentUser) {
return;
}
model.set(foreignKey, currentUser.get(this.options.targetKey));
}

View File

@ -0,0 +1,42 @@
function makeOptions(type: string, options: any) {
if (!options) {
return;
}
let name = type;
let target = 'users';
switch (typeof options) {
case 'string':
name = options;
break;
// 今后支持多账号体系时可以扩展配置
case 'object':
name = options.name || name;
target = options.target || target;
break;
}
return {
type,
name,
target
};
}
export default async function(model, options) {
const { database } = model;
const tableName = model.get('name') as string;
const table = database.getTable(tableName);
const { createdBy, updatedBy } = table.getOptions();
const fieldsToMake = { createdBy, updatedBy };
const addedFields = Object.keys(fieldsToMake)
.filter(type => Boolean(fieldsToMake[type]))
.map(type => table.addField(makeOptions(type, fieldsToMake[type])));
if (addedFields.length) {
await table.sync({
force: false,
alter: {
drop: false,
}
});
}
}

View File

@ -0,0 +1,18 @@
import { Model, getDataTypeKey, getField } from '@nocobase/database';
export default async function(model: Model, options) {
// const { database } = model;
// const { type, target, collection_name } = model.get();
// const table = database.getTable(collection_name);
// const Type = getField(getDataTypeKey(type));
// let Exist;
// for (const Field of table.getFields().values()) {
// if (Field instanceof Type && Field.options.target === target) {
// Exist = Field;
// break;
// }
// }
// if (Exist) {
// model.set('name', Exist.options.name);
// }
}

View File

@ -0,0 +1,12 @@
import Database from '@nocobase/database';
import collectionsAfterCreate from './collection-after-create';
import fieldsBeforeCreate from './fields-before-create';
export default function () {
const database: Database = this.database;
// TODO(feature): 应该通过新的插件机制暴露接口,而不是直接访问其他插件的底层代码
database.getModel('collections').addHook('afterCreate', collectionsAfterCreate);
// 由于创建字段不是同时完成的,可能要在不同的 hook 里处理才行
database.getModel('fields').addHook('beforeCreate', fieldsBeforeCreate);
}

View File

@ -1,19 +1,29 @@
import path from 'path';
import Database from '@nocobase/database';
import Database, { registerFields } from '@nocobase/database';
import Resourcer from '@nocobase/resourcer';
import * as fields from './fields';
import hooks from './hooks';
import login from './actions/login';
import register from './actions/register';
import logout from './actions/logout';
import check from './actions/check';
export default async function (options = {}) {
const database: Database = this.database;
const resourcer: Resourcer = this.resourcer;
registerFields(fields);
database.import({
directory: path.resolve(__dirname, 'collections'),
});
hooks.call(this);
resourcer.registerActionHandlers({
'users:login': login,
'users:register': register,

View File

@ -20,25 +20,23 @@ export class Application extends Koa {
}
async plugins(plugins: any[]) {
for (const pluginOption of plugins) {
let plugin: Function;
let options = {};
if (Array.isArray(pluginOption)) {
plugin = pluginOption.shift();
options = pluginOption.shift()||{};
if (typeof plugin === 'function') {
plugin = plugin.bind(this);
} else if (typeof plugin === 'string') {
const libDir = __filename.endsWith('.ts') ? 'src' : 'lib';
plugin = require(`${plugin}/${libDir}/server`).default;
plugin = plugin.bind(this);
}
} else if (typeof pluginOption === 'function') {
plugin = pluginOption.bind(this);
for (const pluginOptions of plugins) {
if (Array.isArray(pluginOptions)) {
const [entry, options = {}] = pluginOptions;
await this.plugin(entry, options);
} else {
await this.plugin(pluginOptions);
}
await plugin(options);
}
}
async plugin(entry: string | Function, options: any = {}) {
const main = typeof entry === 'function'
? entry
: require(`${entry}/${__filename.endsWith('.ts') ? 'src' : 'lib'}/server`).default;
await main.call(this, options);
}
}
export default Application;