fix: field filter logic for create/update (#34)

* fix: field filter logic for create/update

* fix: add test cases
This commit is contained in:
Junyi 2020-12-08 14:27:51 +08:00 committed by GitHub
parent caa98f6d08
commit 7467441276
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 249 additions and 37 deletions

View File

@ -9,7 +9,7 @@ export default {
}, },
fields: { fields: {
except: ['sort'] except: ['sort', 'user.profile', 'comments.status']
}, },
handler: create handler: create

View File

@ -40,9 +40,31 @@ describe('create', () => {
.post('/posts:create1') .post('/posts:create1')
.send({ .send({
title: 'title1', title: 'title1',
sort: 100 sort: 100,
user: { name: 'aaa', profile: { email: 'email' } },
comments: [
{ content: 'comment1', status: 'published' },
{ content: 'comment2', status: 'draft' },
]
}); });
expect(response.body.sort).toBe(null); expect(response.body.sort).toBe(null);
expect(response.body.user_id).toBe(1);
const postWithUser = await agent
.get(`/posts/${response.body.id}?fields=user`);
expect(postWithUser.body.user.id).toBe(1);
const user = await agent
.get(`/users/${postWithUser.body.user.id}?fields=profile`);
expect(user.body.profile).toBe(null);
const postWithComments = await agent
.get(`/posts/${response.body.id}?fields=comments`);
const comments = postWithComments.body.comments.map(({ content, status }) => ({ content, status }));
expect(comments).toEqual([
{ content: 'comment1', status: null },
{ content: 'comment2', status: null },
]);
}); });
it('create with options.fields.only by custom action', async () => { it('create with options.fields.only by custom action', async () => {

View File

@ -0,0 +1,87 @@
import { initDatabase, agent } from './index';
import { filterByFields } from '../utils';
describe('utils', () => {
describe('filterByFields', () => {
it('only fields', async () => {
const values = filterByFields({
title: 'title1',
sort: 100,
user: { name: 'aaa' }
}, ['title'])
expect(values).toEqual({
title: 'title1'
});
});
it('except fields', async () => {
const values = filterByFields({
title: 'title1',
sort: 100,
user: { name: 'aaa', profile: { email: 'email' } }
}, {
except: ['sort', 'user.profile']
})
expect(values).toEqual({
title: 'title1',
user: { name: 'aaa' }
});
});
it('only and except fields', async () => {
const values = filterByFields({
title: 'title1',
sort: 100,
user: { name: 'aaa', profile: { email: 'email' } }
}, {
only: ['user'],
except: ['sort', 'user.profile']
})
expect(values).toEqual({
user: { name: 'aaa' }
});
});
it('only and except fields with array', async () => {
const values = filterByFields({
title: 'title1',
comments: [
{ content: 'comment1', status: 'published', sort: 1 },
{ content: 'comment2', status: 'draft', sort: 2 }
]
}, {
only: ['comments'],
except: ['comments.status', 'comments.sort']
});
expect(values).toEqual({
comments: [
{ content: 'comment1' },
{ content: 'comment2' }
]
});
});
it('only and except fields with array', async () => {
const values = filterByFields({
title: 'title1',
user: { name: 'aaa', profile: { email: 'email' } },
comments: [
{ content: 'comment1', status: 'published', sort: 1 },
{ content: 'comment2', status: 'draft', sort: 2 }
]
}, {
only: ['comments.content'],
except: ['user.name']
});
expect(values).toEqual({
user: {
profile: { email: 'email' }
},
comments: [
{ content: 'comment1' },
{ content: 'comment2' }
]
});
});
});
});

View File

@ -1,5 +1,6 @@
import * as actions from './actions'; import * as actions from './actions';
export * as utils from './utils';
export * as actions from './actions'; export * as actions from './actions';
export * as middlewares from './middlewares'; export * as middlewares from './middlewares';
export default actions; export default actions;

View File

@ -6,5 +6,68 @@ export function filterByFields(data: any, fields: any = {}): any {
except = [] except = []
} = fields; } = fields;
return _.omit(only ? _.pick(data, only): data, except); // ['user.profile.age', 'user.status', 'user', 'title', 'status']
// to
// {
// user: {
// profile: { age: true },
// status: true
// },
// title: true,
// status: true
// }
//
function makeMap(array: string[]) {
const map = {};
array.forEach(key => {
_.set(map, key, true);
});
return map;
}
const onlyMap = only ? makeMap(only) : null;
const exceptMap = makeMap(except);
function filter(value, { onlyMap, exceptMap }: { onlyMap?: any, exceptMap?: any }) {
const isArray = Array.isArray(value);
const values = isArray ? value : [value];
const results = values.map(v => {
const result = {};
Object.keys(v).forEach(key => {
// 未定义 except 时继续判断 only
if (!exceptMap || typeof exceptMap[key] === 'undefined') {
if (onlyMap) {
if (typeof onlyMap[key] !== 'undefined') {
// 防止 fields 参数和传入类型不匹配时的报错
if (onlyMap[key] === true || typeof v[key] !== 'object') {
result[key] = v[key];
} else {
result[key] = filter(v[key], { onlyMap: onlyMap[key] });
}
}
} else {
result[key] = v[key];
}
} else {
// 定义了 except 子级
if (typeof exceptMap[key] === 'object' && typeof v[key] === 'object') {
result[key] = filter(v[key], {
// onlyMap[key] === true 或不存在时,对子级只考虑 except
onlyMap: onlyMap && typeof onlyMap[key] === 'object' ? onlyMap[key] : null,
exceptMap: exceptMap[key]
});
}
// 其他情况直接跳过
}
});
return result;
});
return isArray ? results : results[0];
}
return filter(data, { onlyMap, exceptMap });
} }

View File

@ -30,7 +30,15 @@ beforeAll(() => {
}, },
] ]
}); });
db.table({
name: 'bobs',
fields: [
{
type: 'belongsTo',
name: 'bar'
}
]
});
db.table({ db.table({
name: 'bars', name: 'bars',
fields: [ fields: [
@ -38,6 +46,10 @@ beforeAll(() => {
type: 'belongsTo', type: 'belongsTo',
name: 'foo', name: 'foo',
}, },
{
type: 'hasOne',
name: 'bob'
}
], ],
}); });
db.table({ db.table({
@ -114,6 +126,25 @@ describe('parseApiJson', () => {
include: ['col'] include: ['col']
}}); }});
}); });
// TODO(bug): 当遇到多层关联时attributes 控制不正确
it.skip('assciation fields', () => {
expect(Foo.parseApiJson({
fields: ['bars.bob', 'bars'],
})).toEqual({
include: [
{
association: 'bars',
include: [
{
association: 'bob',
}
]
}
],
distinct: true
});
});
it('filter and fields', () => { it('filter and fields', () => {
const data = Foo.parseApiJson({ const data = Foo.parseApiJson({

View File

@ -66,13 +66,47 @@ interface ToIncludeContext {
ctx?: any ctx?: any
} }
export function toOrder(sort: string | string[], model: any): string[][] {
if (sort && typeof sort === 'string') {
sort = sort.split(',');
}
const order = [];
if (Array.isArray(sort) && sort.length > 0) {
sort.forEach(key => {
if (Array.isArray(key)) {
order.push(key);
} else {
const direction = key[0] === '-' ? 'DESC' : 'ASC';
const keys = key.replace(/^-/, '').split('.');
const field = keys.pop();
const by = [];
let associationModel = model;
for (let i = 0; i < keys.length; i++) {
const association = model.associations[keys[i]];
if (association && association.target) {
associationModel = association.target;
by.push(associationModel);
}
}
order.push([...by, field, direction]);
}
});
}
return order;
}
export function toInclude(options: any, context: ToIncludeContext = {}) { export function toInclude(options: any, context: ToIncludeContext = {}) {
function makeFields(key) { function makeFields(key) {
if (!Array.isArray(items[key])) { if (!Array.isArray(items[key])) {
return; return;
} }
items[key].forEach(field => { items[key].forEach(field => {
// 按点分隔转化为数组
const arr: Array<string> = Array.isArray(field) ? Utils.cloneDeep(field) : field.split('.'); const arr: Array<string> = Array.isArray(field) ? Utils.cloneDeep(field) : field.split('.');
// 当前列
const col = arr.shift(); const col = arr.shift();
// 内嵌的情况 // 内嵌的情况
if (arr.length > 0) { if (arr.length > 0) {
@ -111,13 +145,13 @@ export function toInclude(options: any, context: ToIncludeContext = {}) {
}); });
} }
const { fields = [] } = options; const { fields = [], filter } = options;
const { model, sourceAlias, associations = {}, ctx, dialect } = context; const { model, sourceAlias, associations = {}, ctx, dialect } = context;
let where = options.where || {}; let where = options.where || {};
if (options.filter) { if (filter) {
where = toWhere(options.filter, { where = toWhere(filter, {
model, model,
associations, associations,
ctx, ctx,
@ -141,36 +175,6 @@ export function toInclude(options: any, context: ToIncludeContext = {}) {
const items = Array.isArray(fields) ? { only: fields } : fields; const items = Array.isArray(fields) ? { only: fields } : fields;
items.appends = items.appends || []; items.appends = items.appends || [];
let sort = options.sort;
if (sort && typeof sort === 'string') {
sort = sort.split(',');
}
const order = [];
if (Array.isArray(sort) && sort.length > 0) {
sort.forEach(key => {
if (Array.isArray(key)) {
order.push(key);
} else {
const direction = key[0] === '-' ? 'DESC' : 'ASC';
const keys = key.replace(/^-/, '').split('.');
const field = keys.pop();
const by = [];
let associationModel = model;
for (let i = 0; i < keys.length; i++) {
const association = model.associations[keys[i]];
if (association && association.target) {
associationModel = association.target;
by.push(associationModel);
}
}
order.push([...by, field, direction]);
}
});
}
makeFields('only'); makeFields('only');
makeFields('appends'); makeFields('appends');
@ -260,6 +264,8 @@ export function toInclude(options: any, context: ToIncludeContext = {}) {
} }
if (include.size > 0) { if (include.size > 0) {
// TODO(bug): 当遇到多层关联时attributes 控制不正确
// ['user.profile.age', 'user.status', 'user', 'title', 'status']
if (!data.attributes) { if (!data.attributes) {
data.attributes = []; data.attributes = [];
} }
@ -275,6 +281,8 @@ export function toInclude(options: any, context: ToIncludeContext = {}) {
data.scopes = scopes; data.scopes = scopes;
} }
const order = toOrder(options.sort, model);
if (order.length > 0) { if (order.length > 0) {
data.order = order; data.order = order;
} }