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());
describe('common', () => {
it('create', async () => {
describe('single', () => {
it('create with hasMany items', async () => {
const response = await agent
.post('/posts')
.send({
title: 'title1',
comments: [
{ content: 'content1' },
{ content: 'content2' },
]
});
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.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 actions from '..';
import create1 from './actions/create1';
import create2 from './actions/create2';
import update1 from './actions/update1';
import update2 from './actions/update2';
function getTestKey() {
const { id } = require.main;
@ -52,9 +56,23 @@ const tableFiles = glob.sync(`${resolve(__dirname, './tables')}/*.ts`);
// resourcer 在内存中是单例,需要谨慎使用
export const resourcer = new Resourcer();
resourcer.use(associated);
resourcer.registerActionHandlers({...actions.associate, ...actions.common});
resourcer.registerActionHandlers({...actions.associate, ...actions.common });
resourcer.define({
name: 'posts',
actions: {
...actions.common,
create1,
create2,
update1,
update2
},
});
resourcer.define({
name: 'comments',
actions: actions.common,
});
resourcer.define({
name: 'users',
actions: actions.common,
});
resourcer.define({

View File

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

View File

@ -9,24 +9,130 @@ describe('update', () => {
afterAll(() => db.close());
it('common1', async () => {
const Post = db.getModel('posts');
const post = await Post.create();
const response = await agent
.put(`/posts/${post.id}`).send({
title: 'title11112222'
describe('common', () => {
it('basic', async () => {
const Post = db.getModel('posts');
const post = await Post.create();
const response = await agent
.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 = await User.create();
const response = await agent
.put(`/users/${user.id}/profile`).send({
email: 'email1122',
});
expect(response.body.email).toEqual('email1122');
expect(response.body).toEqual({});
});
it('hasMany1', async () => {

View File

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

View File

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