Feature: action fields options for create/update (#32)

* feat: add fields options for create/update actions

* test: add test case for json

* fix: minor update for reviews

* fix: test case

* fix: change fields filter strategy for create/update

* feat: add transaction for create/update
This commit is contained in:
Junyi 2020-12-07 11:54:23 +08:00 committed by GitHub
parent 312571fba8
commit 4e41e630ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 319 additions and 83 deletions

View File

@ -0,0 +1,16 @@
import { ActionOptions } from '@nocobase/resourcer';
import { create } from '../../actions/common';
export default {
defaultValues: {
meta: {
location: 'Kunming'
}
},
fields: {
except: ['sort']
},
handler: create
} as ActionOptions;

View File

@ -0,0 +1,10 @@
import { ActionOptions } from '@nocobase/resourcer';
import { create } from '../../actions/common';
export default {
fields: {
only: ['title']
},
handler: create
} as ActionOptions;

View File

@ -0,0 +1,16 @@
import { ActionOptions } from '@nocobase/resourcer';
import { update } from '../../actions/common';
export default {
defaultValues: {
meta: {
location: 'Kunming'
}
},
fields: {
except: ['title']
},
handler: update
} as ActionOptions;

View File

@ -0,0 +1,10 @@
import { ActionOptions } from '@nocobase/resourcer';
import { update } from '../../actions/common';
export default {
fields: {
only: ['title']
},
handler: update
} as ActionOptions;

View File

@ -9,14 +9,57 @@ describe('create', () => {
afterAll(() => db.close()); afterAll(() => db.close());
describe('common', () => { describe('single', () => {
it('create', async () => { it('create with hasMany items', async () => {
const response = await agent const response = await agent
.post('/posts') .post('/posts')
.send({ .send({
title: 'title1', title: 'title1',
comments: [
{ content: 'content1' },
{ content: 'content2' },
]
}); });
expect(response.body.title).toBe('title1'); expect(response.body.title).toBe('title1');
const createdPost = await agent.get(`/posts/${response.body.id}?fields=comments`);
expect(createdPost.body.comments.length).toBe(2);
});
it('create with defaultValues by custom action', async () => {
const response = await agent
.post('/posts:create1')
.send({
title: 'title1',
});
expect(response.body.meta).toEqual({ location: 'Kunming' });
});
it('create with options.fields.except by custom action', async () => {
const response = await agent
.post('/posts:create1')
.send({
title: 'title1',
sort: 100
});
expect(response.body.sort).toBe(null);
});
it('create with options.fields.only by custom action', async () => {
const response = await agent
.post('/posts:create2')
.send({
title: 'title1',
meta: { a: 1 }
});
expect(response.body.title).toBe('title1');
expect(response.body.meta).toBe(null);
const result = await agent
.get(`/posts/${response.body.id}`);
expect(result.body.title).toBe('title1');
expect(result.body.meta).toBe(null);
}); });
}); });
@ -31,6 +74,14 @@ describe('create', () => {
}); });
expect(response.body.post_id).toBe(post.id); expect(response.body.post_id).toBe(post.id);
expect(response.body.content).toBe('content1'); expect(response.body.content).toBe('content1');
const comments = await agent
.get('/comments?fields=id,content');
expect(comments.body.count).toBe(1);
expect(comments.body.rows).toEqual([{
id: 1,
content: 'content1'
}]);
}); });
}); });
}); });

View File

@ -11,6 +11,10 @@ import Resourcer from '@nocobase/resourcer';
import associated from '../middlewares/associated'; import associated from '../middlewares/associated';
import actions from '..'; import actions from '..';
import create1 from './actions/create1';
import create2 from './actions/create2';
import update1 from './actions/update1';
import update2 from './actions/update2';
function getTestKey() { function getTestKey() {
const { id } = require.main; const { id } = require.main;
@ -52,9 +56,23 @@ const tableFiles = glob.sync(`${resolve(__dirname, './tables')}/*.ts`);
// resourcer 在内存中是单例,需要谨慎使用 // resourcer 在内存中是单例,需要谨慎使用
export const resourcer = new Resourcer(); export const resourcer = new Resourcer();
resourcer.use(associated); resourcer.use(associated);
resourcer.registerActionHandlers({...actions.associate, ...actions.common}); resourcer.registerActionHandlers({...actions.associate, ...actions.common });
resourcer.define({ resourcer.define({
name: 'posts', name: 'posts',
actions: {
...actions.common,
create1,
create2,
update1,
update2
},
});
resourcer.define({
name: 'comments',
actions: actions.common,
});
resourcer.define({
name: 'users',
actions: actions.common, actions: actions.common,
}); });
resourcer.define({ resourcer.define({

View File

@ -22,7 +22,7 @@ export default {
name: 'user', name: 'user',
}, },
{ {
type: 'hasmany', type: 'hasMany',
name: 'comments', name: 'comments',
}, },
{ {
@ -32,6 +32,10 @@ export default {
{ {
type: 'integer', type: 'integer',
name: 'sort' name: 'sort'
},
{
type: 'json',
name: 'meta'
} }
], ],
hooks: { hooks: {

View File

@ -9,24 +9,130 @@ describe('update', () => {
afterAll(() => db.close()); afterAll(() => db.close());
it('common1', async () => { describe('common', () => {
const Post = db.getModel('posts'); it('basic', async () => {
const post = await Post.create(); const Post = db.getModel('posts');
const response = await agent const post = await Post.create();
.put(`/posts/${post.id}`).send({ const response = await agent
title: 'title11112222' .put(`/posts/${post.id}`).send({
title: 'title11112222'
});
expect(response.body.title).toBe('title11112222');
});
it('update json field by replacing', async () => {
const Post = db.getModel('posts');
const post = await Post.create({ meta: { a: 1, b: 'c', c: { d: false } } });
const updated = await agent
.put(`/posts/${post.id}`).send({
meta: {}
});
expect(updated.body.meta).toEqual({});
});
it.skip('update json field by path based update', async () => {
const Post = db.getModel('posts');
const post = await Post.create({ meta: { a: 1, b: 'c', c: { d: false } } });
const updated = await agent
.put(`/posts/${post.id}?options[json]=merge`).send({
meta: {
b: 'b',
c: { d: true }
}
});
// console.log(updated.body);
});
// TODO(question): json 字段的覆盖/合并策略
it.skip('update with fields overwrite default values', async () => {
const Post = db.getModel('posts');
const post = await Post.create();
const response = await agent
.put(`/posts:update1/${post.id}`).send({
meta: { a: 1 },
});
expect(response.body.meta).toEqual({ a: 1 });
const result = await agent
.get(`/posts/${post.id}`);
expect(result.body.meta).toEqual({ a: 1 });
});
// TODO(bug): action 的默认值处理时机不对
it.skip('update with different fields to default values', async () => {
const Post = db.getModel('posts');
const post = await Post.create({
meta: { location: 'Beijing' }
}); });
expect(response.body.title).toBe('title11112222'); const response = await agent
.put(`/posts:update1/${post.id}`).send({
meta: { a: 1 },
});
expect(response.body.meta).toEqual({ location: 'Beijing', a: 1 });
});
it('update with options.fields.expect in action', async () => {
const Post = db.getModel('posts');
const post = await Post.create();
const response = await agent
.put(`/posts:update1/${post.id}`).send({
title: 'title11112222',
});
expect(response.body.title).toBe(null);
expect(response.body.meta).toEqual({
location: 'Kunming'
});
const result = await agent
.get(`/posts/${post.id}`);
expect(result.body.title).toBe(null);
expect(result.body.meta).toEqual({
location: 'Kunming'
});
});
it('update with options.fields.only in action', async () => {
const Post = db.getModel('posts');
const post = await Post.create();
const response = await agent
.put(`/posts:update2/${post.id}`).send({
title: 'title11112222',
meta: { a: 1 }
});
expect(response.body.title).toBe('title11112222');
expect(response.body.meta).toBe(null);
const result = await agent
.get(`/posts/${post.id}`);
expect(result.body.title).toBe('title11112222');
expect(result.body.meta).toBe(null);
});
}); });
it('hasOne1', async () => { it('hasOne', async () => {
const User = db.getModel('users');
const user = await User.create();
await user.updateAssociations({
profile: { email: 'email1122' }
});
const response = await agent
.put(`/users/${user.id}/profile`).send({
email: 'email1111',
});
expect(response.body.email).toEqual('email1111');
});
it('hasOne without exist target', async () => {
const User = db.getModel('users'); const User = db.getModel('users');
const user = await User.create(); const user = await User.create();
const response = await agent const response = await agent
.put(`/users/${user.id}/profile`).send({ .put(`/users/${user.id}/profile`).send({
email: 'email1122', email: 'email1122',
}); });
expect(response.body.email).toEqual('email1122'); expect(response.body).toEqual({});
}); });
it('hasMany1', async () => { it('hasMany1', async () => {

View File

@ -1,8 +1,9 @@
import { Context, Next } from '.'; import { Context, Next } from '.';
import { Relation, Model, Field, HasOne, HasMany, BelongsTo, BelongsToMany } from '@nocobase/database'; import { Relation, Model, HasOne, HasMany, BelongsTo, BelongsToMany } 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 { Utils, Op } from 'sequelize';
import _ from 'lodash'; import _ from 'lodash';
import { filterByFields } from '../utils';
/** /**
* *
@ -103,26 +104,32 @@ export async function create(ctx: Context, next: Next) {
associated, associated,
resourceField, resourceField,
associatedName, associatedName,
values, resourceName,
} = ctx.action.params as { associated: Model, associatedName: string, resourceField: Relation, values: any }; values: data,
fields
} = ctx.action.params;
const values = filterByFields(data, fields);
const transaction = await ctx.db.sequelize.transaction();
let model: Model;
if (associated && resourceField) { if (associated && resourceField) {
const AssociatedModel = ctx.db.getModel(associatedName); const AssociatedModel = ctx.db.getModel(associatedName);
if (!(associated instanceof AssociatedModel)) { if (!(associated instanceof AssociatedModel)) {
throw new Error(`${associatedName} associated model invalid`); throw new Error(`${associatedName} associated model invalid`);
} }
const create = resourceField.getAccessors().create; const { create } = resourceField.getAccessors();
const model: Model = await associated[create](values, { context: ctx }); // @ts-ignore
await model.updateAssociations(values, { context: ctx }); model = await associated[create](values, { transaction, context: ctx });
await model.updateAssociations(values, { transaction, context: ctx });
ctx.body = model; ctx.body = model;
} else { } else {
const { resourceName } = ctx.action.params; const ResourceModel = ctx.db.getModel(resourceName);
const Model = ctx.db.getModel(resourceName);
// @ts-ignore // @ts-ignore
const model = await Model.create(values, { context: ctx }); model = await ResourceModel.create(values, { transaction, context: ctx });
// @ts-ignore // @ts-ignore
await model.updateAssociations(values, { context: ctx }); await model.updateAssociations(values, { transaction, context: ctx });
ctx.body = model; ctx.body = model;
} }
await transaction.commit();
await next(); await next();
} }
@ -197,41 +204,39 @@ export async function get(ctx: Context, next: Next) {
export async function update(ctx: Context, next: Next) { export async function update(ctx: Context, next: Next) {
const { const {
associated, associated,
resourceField,
associatedName, associatedName,
} = ctx.action.params as { resourceField,
associated: Model, resourceName,
associatedName: string, resourceKey,
resourceField: Relation, // TODO(question): 这个属性从哪设置的?
values: any, resourceKeyAttribute,
}; fields,
values: data
} = ctx.action.params;
const values = filterByFields(data, fields);
const transaction = await ctx.db.sequelize.transaction();
if (associated && resourceField) { if (associated && resourceField) {
const AssociatedModel = ctx.db.getModel(associatedName); const AssociatedModel = ctx.db.getModel(associatedName);
if (!(associated instanceof AssociatedModel)) { if (!(associated instanceof AssociatedModel)) {
await transaction.rollback();
throw new Error(`${associatedName} associated model invalid`); throw new Error(`${associatedName} associated model invalid`);
} }
const {get: getAccessor, create: createAccessor, add: addAccessor} = resourceField.getAccessors(); const { get: getAccessor } = resourceField.getAccessors();
const { resourceKey, resourceKeyAttribute, fields = [], values } = ctx.action.params;
const TargetModel = ctx.db.getModel(resourceField.getTarget());
const options = TargetModel.parseApiJson({
fields,
});
if (resourceField instanceof HasOne || resourceField instanceof BelongsTo) { if (resourceField instanceof HasOne || resourceField instanceof BelongsTo) {
let model: Model = await associated[getAccessor]({ ...options, context: ctx }); let model: Model = await associated[getAccessor]({ transaction, context: ctx });
if (model) { if (model) {
// @ts-ignore // @ts-ignore
await model.update(values, { context: ctx }); await model.update(values, { transaction, context: ctx });
} else if (!model && resourceField instanceof HasOne) { await model.updateAssociations(values, { transaction, context: ctx });
model = await associated[createAccessor](values, { context: ctx }); ctx.body = model;
} }
await model.updateAssociations(values, { context: ctx });
ctx.body = model;
} else if (resourceField instanceof HasMany || resourceField instanceof BelongsToMany) { } else if (resourceField instanceof HasMany || resourceField instanceof BelongsToMany) {
const TargetModel = ctx.db.getModel(resourceField.getTarget());
const [model]: Model[] = await associated[getAccessor]({ const [model]: Model[] = await associated[getAccessor]({
...options,
where: { where: {
[resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey,
}, },
transaction,
context: ctx, context: ctx,
}); });
@ -246,39 +251,37 @@ export async function update(ctx: Context, next: Next) {
[foreignKey]: associated[sourceKey], [foreignKey]: associated[sourceKey],
[otherKey]: resourceKey, [otherKey]: resourceKey,
}, },
transaction
}); });
await through.update(throughValues); await through.updateAssociations(throughValues, { transaction, context: ctx });
await through.updateAssociations(throughValues); await through.update(throughValues, { transaction, context: ctx });
delete values[throughName]; delete values[throughName];
} }
} }
if (!_.isEmpty(values)) { if (!_.isEmpty(values)) {
// @ts-ignore // @ts-ignore
await model.update(values, { context: ctx }); await model.update(values, { transaction, context: ctx });
await model.updateAssociations(values, { context: ctx }); await model.updateAssociations(values, { transaction, context: ctx });
} }
ctx.body = model; ctx.body = model;
} }
} else { } else {
const { resourceName, resourceKey, resourceKeyAttribute, fields = [], values } = ctx.action.params;
const Model = ctx.db.getModel(resourceName); const Model = ctx.db.getModel(resourceName);
const options = Model.parseApiJson({
fields,
});
const model = await Model.findOne({ const model = await Model.findOne({
...options,
where: { where: {
[resourceKeyAttribute || Model.primaryKeyAttribute]: resourceKey, [resourceKeyAttribute || Model.primaryKeyAttribute]: resourceKey,
}, },
// @ts-ignore hooks 里添加 context // @ts-ignore hooks 里添加 context
context: ctx, context: ctx,
transaction
}); });
// @ts-ignore // @ts-ignore
await model.update(values, { context: ctx }); await model.update(values, { transaction, context: ctx });
// @ts-ignore // @ts-ignore
await model.updateAssociations(values, { context: ctx }); await model.updateAssociations(values, { transaction, context: ctx });
ctx.body = model; ctx.body = model;
} }
await transaction.commit();
await next(); await next();
} }

View File

@ -20,37 +20,29 @@ export async function associated(ctx: Context, next: Next) {
const Model = ctx.db.getModel(associatedName); const Model = ctx.db.getModel(associatedName);
const field = ctx.db.getTable(associatedName).getField(resourceName); const field = ctx.db.getTable(associatedName).getField(resourceName);
let model: Model; let key: string;
if (field instanceof HasOne) { switch (true) {
model = await Model.findOne({ case field instanceof BelongsTo:
where: { key = field.options.targetKey || Model.primaryKeyAttribute;
[field.options.sourceKey]: associatedKey, break;
} case field instanceof HasOne:
}); case field instanceof HasMany:
} else if (field instanceof HasMany) { case field instanceof BelongsToMany:
model = await Model.findOne({ key = field.options.sourceKey;
where: { break;
[field.options.sourceKey]: associatedKey,
}
});
} else if (field instanceof BelongsTo) {
model = await Model.findOne({
where: {
[Model.primaryKeyAttribute]: associatedKey,
}
});
} else if (field instanceof BelongsToMany) {
model = await Model.findOne({
where: {
[field.options.sourceKey]: associatedKey,
}
});
} }
if (model) { if (key) {
ctx.action.setParam('associated', model); const model = await Model.findOne({
ctx.action.setParam('resourceField', field); where: {
[key]: associatedKey,
}
});
if (model) {
ctx.action.setParam('associated', model);
ctx.action.setParam('resourceField', field);
}
} }
await next(); await next();

View File

@ -0,0 +1,10 @@
import _ from 'lodash';
export function filterByFields(data: any, fields: any = {}): any {
const {
only = Array.isArray(fields) ? fields : null,
except = []
} = fields;
return _.omit(only ? _.pick(data, only): data, except);
}