Test/list (#19)

* test: add more case for list

* feat: allow order by associations in sorting

* fix: add more utils test in resourcer and fix except usage

* test: fix cases

* fix: page default value and max page size

* fix: page params and cases

* fix: list params

* fix: constants in list action

* fix: count when include, attributes when except and cases

* test: add case for hasMany.
This commit is contained in:
Junyi 2020-11-23 16:49:46 +08:00 committed by GitHub
parent 578454d07f
commit cd0b357887
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 566 additions and 186 deletions

View File

@ -4,25 +4,35 @@ import { initDatabase, agent } from './index';
describe('list', () => {
let db;
let now: string;
let timestamps: { created_at: string; updated_at: string; };
beforeEach(async () => {
db = await initDatabase();
now = (new Date()).toISOString();
timestamps = { created_at: now, updated_at: now };
});
afterAll(() => db.close());
describe('common', () => {
beforeEach(async () => {
const User = db.getModel('users');
await User.bulkCreate([
{ name: 'a', ...timestamps },
{ name: 'b', ...timestamps },
{ name: 'c', ...timestamps }
]);
const users = await User.findAll();
const Post = db.getModel('posts');
const items = [];
for (let index = 0; index < 2; index++) {
items.push({
title: `title${index}`,
status: index % 2 ? 'published' : 'draft',
published_at: index % 2 ? new Date() : null
});
}
await Post.bulkCreate(items);
await Post.bulkCreate(Array(25).fill(null).map((_, index) => ({
title: `title${index}`,
status: index % 2 ? 'published' : 'draft',
published_at: index % 2 ? new Date(2020, 10, 30 - index, 0, 0, 0) : null,
user_id: users[index % users.length].id,
...timestamps
})));
});
describe('filter', () => {
@ -61,6 +71,17 @@ describe('list', () => {
});
describe('null', () => {
it('filter[published_at]', async () => {
const Post = db.getModel('posts');
const expected = await Post.findAll({
where: {
published_at: null
}
});
const response = await agent.get('/posts?filter[published_at]');
expect(response.body.count).toBe(expected.length);
});
it('filter[published_at.is]', async () => {
const Post = db.getModel('posts');
const expected = await Post.findAll({
@ -104,70 +125,232 @@ describe('list', () => {
});
describe('page', () => {
it('page by default size(20) should be ok', async () => {
const response = await agent.get('/posts?fields=title&page=1');
it('default page and size(20) should be ok', async () => {
const response = await agent.get('/posts?fields=title');
expect(response.body).toEqual({
count: 2,
count: 25,
page: 1,
per_page: 20,
rows: [ { title: 'title0' }, { title: 'title1' } ],
rows: Array(20).fill(null).map((_, index) => ({ title: `title${index}` })),
});
});
it('page 1 by size(1) should be ok', async () => {
const response = await agent.get('/posts?fields=title&page=2&perPage=1');
const response = await agent.get('/posts?fields=title&page=1&perPage=1');
expect(response.body).toEqual({
count: 2,
page: 2,
count: 25,
page: 1,
per_page: 1,
rows: [ { title: 'title1' } ],
rows: [ { title: 'title0' } ],
});
});
it('page 2 by size(1) should be ok', async () => {
const response = await agent.get('/posts?fields=title&page=2&per_page=1');
expect(response.body).toEqual({
count: 2,
count: 25,
page: 2,
per_page: 1,
rows: [ { title: 'title1' } ],
});
});
it('page 1 by size(101) should be change to 100', async () => {
const response = await agent.get('/posts?fields=title&page=1&per_page=101');
expect(response.body).toEqual({
count: 25,
page: 1,
per_page: 100,
rows: Array(25).fill(null).map((_, index) => ({ title: `title${index}` })),
});
});
it('page 2 by size(101) should be change to 100 and result is empty', async () => {
const response = await agent.get('/posts?fields=title&page=2&per_page=101');
expect(response.body).toEqual({
count: 25,
page: 2,
per_page: 100,
rows: [],
});
});
it('default page by size(-1) should be change to 100 and result will be 25 items', async () => {
const response = await agent.get('/posts?fields=title&per_page=-1');
expect(response.body).toEqual({
count: 25,
page: 1,
per_page: 100,
rows: Array(25).fill(null).map((_, index) => ({ title: `title${index}` })),
});
});
it('page 2 by size(-1) should be change to 100 and result is empty', async () => {
const response = await agent.get('/posts?fields=title&page=2&per_page=-1');
expect(response.body).toEqual({
count: 25,
page: 2,
per_page: 100,
rows: [],
});
});
});
it('list6', async () => {
const response = await agent.get('/posts?fields=title&filter[customTitle]=title0');
expect(response.body).toEqual({ count: 1, rows: [ { title: 'title0' } ] });
describe('fields', () => {
it('custom field', async () => {
const response = await agent.get('/posts?fields=title&filter[customTitle]=title0');
expect(response.body).toEqual({
count: 1,
page: 1,
per_page: 20,
rows: [ { title: 'title0' } ]
});
});
it('self field and belongs to field', async () => {
const response = await agent.get('/posts?fields=title,user.name&filter[title]=title0');
expect(response.body).toEqual({
count: 1,
page: 1,
per_page: 20,
rows: [
{
title: 'title0',
user: {
name: 'a'
}
}
]
});
});
// TODO(question): 当 fields 只填写了关联字段时,当前表的其他字段是否需要输出?
it.skip('only belongs to', async () => {
const response = await agent.get('/posts?fields=user&filter[title]=title0');
expect(response.body).toEqual({
count: 1,
rows: [
{
title: 'title0',
user: { name: 'a' }
}
]
});
});
it('except fields', async () => {
const response = await agent.get('/posts?fields[except]=status&filter[title]=title0');
expect(response.body.rows[0].status).toBeUndefined();
});
it('only and except fields', async () => {
const response = await agent.get('/posts?fields=title&fields[except]=status&filter[title]=title0');
expect(response.body.rows[0].status).toBeUndefined();
expect(response.body.rows).toEqual([{ title: 'title0' }]);
});
it('only with belongs to fields', async () => {
const response = await agent.get('/posts?fields[only]=title&fields[only]=user.name&filter[title]=title0');
expect(response.body.rows[0].user.name).toEqual('a');
expect(response.body.rows).toEqual([{ title: 'title0', user: { name: 'a' } }]);
});
it('appends fields', async () => {
const response = await agent.get('/posts?fields[only]=title&fields[appends]=user.name&filter[title]=title0');
expect(response.body.rows[0].user.name).toEqual('a');
expect(response.body.rows).toEqual([{ title: 'title0', user: { id: 1, name: 'a', ...timestamps } }]);
});
});
});
describe('hasMany', () => {
it('list1', async () => {
beforeEach(async () => {
const User = db.getModel('users');
await User.bulkCreate([
{ name: 'a' },
{ name: 'b' },
{ name: 'c' }
]);
const users = await User.findAll();
const Post = db.getModel('posts');
const post = await Post.create();
const post = await Post.create({ user_id: users[0].id });
await post.updateAssociations({
comments: [
{content: 'content1', status: 'published'},
{content: 'content2', status: 'published'},
{content: 'content3', status: 'draft'},
{content: 'content4', status: 'published'},
{content: 'content5', status: 'draft'},
{content: 'content6', status: 'published'},
],
comments: Array(6).fill(null).map((_, index) => ({
content: `content${index}`,
status: index % 2 ? 'published' : 'draft',
user_id: users[index % users.length].id
}))
});
});
it('get comments of a post', async () => {
const Post = db.getModel('posts');
const post = await Post.findByPk(1);
const response = await agent
.get(`/posts/${post.id}/comments?page=2&perPage=2&sort=content&fields=content&filter[published]=1`);
expect(response.body).toEqual({
rows: [ { content: 'content4' }, { content: 'content6' } ],
count: 4,
rows: [ { content: 'content5' } ],
count: 3,
page: 2,
per_page: 2
});
});
it('get comments within a post, order by comments.content', async () => {
const response = await agent
.get('/posts?fields=title,comments.content&filter[comments.status]=draft&page=1&perPage=2&sort=-comments.content');
expect(response.body).toEqual({
rows: [{
title: null,
comments: [{ content: 'content4' }, { content: 'content2' }, { content: 'content0'}]
}],
count: 1,
page: 1,
per_page: 2
});
});
it('get comments of a post, and user of each comment', async () => {
const Post = db.getModel('posts');
const post = await Post.findByPk(1);
const response = await agent
.get(`/posts/${post.id}/comments?fields=content,user.name&filter[status]=draft&sort=-content&page=1&perPage=2`);
expect(response.body).toEqual({
count: 3,
page: 1,
per_page: 2,
rows: [
{ content: 'content4', user: { name: 'b' } },
{ content: 'content2', user: { name: 'c' } }
]
});
});
// TODO(bug)
it.skip('get posts of user with comments', async () => {
const response = await agent
.get(`/users/1/posts?fields=comments.content,user.name&filter[comments.status]=draft&sort=-content&page=1&perPage=2`);
expect(response.body).toEqual({
count: 1,
page: 1,
per_page: 2,
rows: [
{
comments: [
{ content: 'content4' },
{ content: 'content2' }
],
user: { name: 'a' }
}
]
});
});
});
describe('belongsToMany', () => {
it('list1', async () => {
beforeEach(async () => {
const Post = db.getModel('posts');
const post = await Post.create();
await post.updateAssociations({
@ -181,15 +364,22 @@ describe('list', () => {
{name: 'tag7', status: 'published'},
],
});
});
it('list1', async () => {
const Post = db.getModel('posts');
const post = await Post.findByPk(1);
const response = await agent
.get(`/posts/${post.id}/tags?page=2&perPage=2&sort=name&fields=name&filter[published]=1`);
.get(`/posts/${post.id}/tags?page=2&perPage=2&sort=-name&fields=name&filter[status]=published`);
expect(response.body).toEqual({
rows: [ { name: 'tag5' }, { name: 'tag7' } ],
rows: [ { name: 'tag3' }, { name: 'tag1' } ],
count: 4,
page: 2,
per_page: 2
});
});
});
});

View File

@ -1,7 +1,9 @@
import { Context, Next } from '.';
import { Relation, Model, Field, HasOne, HasMany, BelongsTo, BelongsToMany } from '@nocobase/database';
import { DEFAULT_PAGE, DEFAULT_PER_PAGE } from '@nocobase/resourcer';
import { Utils, Op, Sequelize } from 'sequelize';
import { isEmpty } from 'lodash';
import _ from 'lodash';
/**
*
@ -17,8 +19,8 @@ import { isEmpty } from 'lodash';
*/
export async function list(ctx: Context, next: Next) {
const {
page,
perPage,
page = DEFAULT_PAGE,
perPage = DEFAULT_PER_PAGE,
sort = [],
fields = [],
filter = {},
@ -50,17 +52,19 @@ export async function list(ctx: Context, next: Next) {
...options,
context: ctx,
});
delete options.attributes;
delete options.limit;
delete options.offset;
delete options.order;
if (options.include) {
options.include = options.include.map(includeOptions => {
const associatedOptions = _.omit(options, [
'attributes',
'limit',
'offset',
'order'
]);
if (associatedOptions.include) {
associatedOptions.include = associatedOptions.include.map(includeOptions => {
includeOptions.attributes = [];
return includeOptions;
});
}
const count = await associated[countAccessor]({ ...options, context: ctx });
const count = await associated[countAccessor]({ ...associatedOptions, context: ctx });
data = {
rows,
count,
@ -72,9 +76,10 @@ export async function list(ctx: Context, next: Next) {
context: ctx,
});
}
if (page || perPage) {
data['page'] = 1*(page||1);
data[Utils.underscoredIf('perPage', Model.options.underscored)] = 1*(perPage||20);
if (options.limit || typeof options.offset !== 'undefined') {
// Math.round 避免精度问题
data['page'] = Math.round((options.offset || 0) / options.limit + 1);
data[Utils.underscoredIf('perPage', Model.options.underscored)] = options.limit;
}
ctx.body = data;
await next();

View File

@ -6,26 +6,37 @@ import Model, { ModelCtor } from '../../model';
let db: Database;
let Bar: ModelCtor<Model>;
let Baz: ModelCtor<Model>;
let Bay: ModelCtor<Model>;
let Foo: ModelCtor<Model>;
beforeAll(() => {
db = getDatabase();
db.table({
name: 'bazs',
fields: [
{
type: 'belongsToMany',
name: 'bays'
},
]
});
db.table({
name: 'bays',
fields: [
{
type: 'belongsToMany',
name: 'bazs'
},
]
});
db.table({
name: 'bars',
fields: [
{
type: 'belongsTo',
name: 'baz',
},
{
type: 'belongsTo',
name: 'bay',
name: 'foo',
},
],
});
@ -53,6 +64,8 @@ beforeAll(() => {
name: 'coos',
});
Bar = db.getModel('bars');
Baz = db.getModel('bazs');
Bay = db.getModel('bays');
Foo = db.getModel('foos');
});
@ -62,6 +75,10 @@ afterAll(() => db.close());
describe('parseApiJson', () => {
describe('self table', () => {
it('empty', () => {
expect(Foo.parseApiJson({})).toEqual({});
});
it('filter', () => {
const data = Foo.parseApiJson({
filter: {
@ -77,6 +94,26 @@ describe('parseApiJson', () => {
});
expect(data).toEqual({ attributes: ['col1'] });
});
it('fields.except', () => {
expect(Foo.parseApiJson({
fields: {
except: ['col']
},
})).toEqual({ attributes: {
exclude: ['col']
}});
});
it('fields.appends', () => {
expect(Foo.parseApiJson({
fields: {
appends: ['col']
},
})).toEqual({ attributes: {
include: ['col']
}});
});
it('filter and fields', () => {
const data = Foo.parseApiJson({
@ -100,10 +137,29 @@ describe('parseApiJson', () => {
it('pagination: only page', () => {
expect(Foo.parseApiJson({
page: 1
page: 2
})).toEqual({
offset: 100,
limit: 100,
});
});
it('pagination: only perPage and max limit', () => {
expect(Foo.parseApiJson({
perPage: 1000
})).toEqual({
offset: 0,
limit: 20,
limit: 500,
});
});
it('pagination: perPage=-1 stand for max limit', () => {
expect(Foo.parseApiJson({
page: 2,
perPage: -1
})).toEqual({
offset: 500,
limit: 500,
});
});
@ -117,7 +173,6 @@ describe('parseApiJson', () => {
});
});
// TODO(bug): should not contain additional attributes
it('sort: multiple self fields', () => {
expect(Foo.parseApiJson({
sort: 'a,-b'
@ -129,13 +184,22 @@ describe('parseApiJson', () => {
});
});
// TODO(feature): order by association should be ok
it.skip('sort: association field', () => {
it('sort: association field', () => {
expect(Bar.parseApiJson({
sort: '-foo.a'
})).toEqual({
order: [
[{ model: Foo, as: 'foo' }, 'a', 'DESC'],
[Foo, 'a', 'DESC'],
]
});
});
it('sort: many to many association field', () => {
expect(Baz.parseApiJson({
sort: '-bays.a'
})).toEqual({
order: [
[Bay, 'a', 'DESC'],
]
});
});
@ -177,6 +241,7 @@ describe('parseApiJson', () => {
where: { col1: 'val1' },
}
],
distinct: true,
});
});
@ -200,6 +265,7 @@ describe('parseApiJson', () => {
where: { col1: 'val1' },
}
],
distinct: true,
});
});
});

View File

@ -59,7 +59,7 @@ describe('toInclude', () => {
const toIncludeExpect = (options: any, logging = false) => {
const include = toInclude(options, {
Model: Foo,
model: Foo,
associations: Foo.associations,
});
if (logging) {

View File

@ -127,6 +127,10 @@ export interface WithCountAttributeOptions {
[key: string]: any;
}
export const DEFAULT_OFFSET = 0;
export const DEFAULT_LIMIT = 100;
export const MAX_LIMIT = 500;
/**
* Model
*
@ -234,14 +238,14 @@ export abstract class Model extends SequelizeModel {
static parseApiJson(options: ApiJsonOptions) {
const { fields, filter, sort, context, page, perPage } = options;
const data = toInclude({fields, filter, sort}, {
Model: this,
model: this,
associations: this.associations,
dialect: this.sequelize.getDialect(),
ctx: context,
});
if (page || perPage) {
data.limit = perPage || 20;
data.offset = data.limit * (page > 0 ? page - 1 : 0);
data.limit = perPage === -1 ? MAX_LIMIT : Math.min(perPage || DEFAULT_LIMIT, MAX_LIMIT);
data.offset = data.limit * (page > 0 ? page - 1 : DEFAULT_OFFSET);
}
if (data.attributes && data.attributes.length === 0) {
delete data.attributes;

View File

@ -12,7 +12,7 @@ for (const key in Op) {
}
interface ToWhereContext {
Model?: ModelCtor<Model> | Model | typeof Model;
model?: ModelCtor<Model> | Model | typeof Model;
associations?: any;
dialect?: string;
ctx?: any;
@ -25,7 +25,7 @@ export function toWhere(options: any, context: ToWhereContext = {}) {
if (Array.isArray(options)) {
return options.map((item) => toWhere(item, context));
}
const { Model, associations = {}, ctx, dialect } = context;
const { model, associations = {}, ctx, dialect } = context;
const items = {};
// 先处理「点号」的问题
for (const key in options) {
@ -37,13 +37,13 @@ export function toWhere(options: any, context: ToWhereContext = {}) {
values['$__include'] = values['$__include'] || {}
values['$__include'][key] = toWhere(items[key], {
...context,
Model: associations[key].target,
model: associations[key].target,
associations: associations[key].target.associations,
});
}
else if (Model && Model.options.scopes && Model.options.scopes[key]) {
else if (model && model.options.scopes && model.options.scopes[key]) {
values['$__scopes'] = values['$__scopes'] || [];
const scope = Model.options.scopes[key];
const scope = model.options.scopes[key];
if (typeof scope === 'function') {
values['$__scopes'].push({ method: [key, items[key], ctx] });
} else {
@ -59,7 +59,7 @@ export function toWhere(options: any, context: ToWhereContext = {}) {
}
interface ToIncludeContext {
Model?: ModelCtor<Model> | Model | typeof Model;
model?: ModelCtor<Model> | Model | typeof Model;
sourceAlias?: string;
associations?: any;
dialect?: string;
@ -67,14 +67,58 @@ interface ToIncludeContext {
}
export function toInclude(options: any, context: ToIncludeContext = {}) {
function makeFields(key) {
if (!Array.isArray(items[key])) {
return;
}
items[key].forEach(field => {
const arr: Array<string> = Array.isArray(field) ? Utils.cloneDeep(field) : field.split('.');
const col = arr.shift();
// 内嵌的情况
if (arr.length > 0) {
if (!children.has(col)) {
children.set(col, {
fields: {
only: [],
except: [],
appends: [],
},
});
}
children.get(col).fields[key].push(arr);
return;
}
// 关系字段
if (associations[col]) {
const includeItem: any = {
association: col,
};
if (includeWhere[col]) {
includeItem.where = includeWhere[col];
}
include.set(col, includeItem);
return;
}
const matches: Array<any> = /(.+)_count$/.exec(col);
if (matches && associations[matches[1]]) {
attributes[key].push(model.withCountAttribute({
association: matches[1],
sourceAlias: sourceAlias
}));
} else {
attributes[key].push(col);
}
});
}
const { fields = [] } = options;
const { Model, sourceAlias, associations = {}, ctx, dialect } = context;
const { model, sourceAlias, associations = {}, ctx, dialect } = context;
let where = options.where || {};
if (options.filter) {
where = toWhere(options.filter, {
Model,
model,
associations,
ctx,
}) || {};
@ -96,6 +140,7 @@ export function toInclude(options: any, context: ToIncludeContext = {}) {
const children = new Map();
const items = Array.isArray(fields) ? { only: fields } : fields;
items.appends = items.appends || [];
let sort = options.sort;
@ -106,104 +151,29 @@ export function toInclude(options: any, context: ToIncludeContext = {}) {
const order = [];
if (Array.isArray(sort) && sort.length > 0) {
items.appends = items.appends || [];
sort.forEach(key => {
if (Array.isArray(key)) {
order.push(key);
} else {
const direction = key[0] === '-' ? 'DESC' : 'ASC';
const field = key.replace(/^-/, '');
// TODO(verify): 理论上只是排序并不一定要输出相关字段
// items.appends.push(field);
// TODO: 暂时只支持主表排序,后续需要按`.`分隔符拆分 field 为关联排序
order.push([field, direction]);
}
});
}
if (Array.isArray(items.only) && items.only.length > 0) {
items.only.forEach(field => {
const arr: Array<string> = Array.isArray(field) ? Utils.cloneDeep(field) : field.split('.');
const col = arr.shift();
// 内嵌的情况
if (arr.length > 0) {
if (!children.has(col)) {
children.set(col, {
fields: {
only: [arr],
except: [],
appends: [],
},
});
} else {
children.get(col).fields.only.push(arr);
}
return;
}
// 关系字段
if (associations[col]) {
const includeItem: any = {
association: col,
};
if (includeWhere[col]) {
includeItem.where = includeWhere[col];
}
include.set(col, includeItem);
return;
}
const matches: Array<any> = /(.+)\_count$/.exec(col);
if (matches && associations[matches[1]]) {
attributes.only.push(Model.withCountAttribute({
association: matches[1],
sourceAlias: sourceAlias
}));
} else {
attributes.only.push(col);
}
});
}
if (Array.isArray(items.appends) && items.appends.length > 0) {
items.appends.forEach(field => {
const arr: Array<string> = Array.isArray(field) ? Utils.cloneDeep(field) : field.split('.');
const col = arr.shift();
// 内嵌的情况
if (arr.length > 0) {
if (!children.has(col)) {
children.set(col, {
fields: {
only: [],
except: [],
appends: [arr],
},
});
} else {
children.get(col).fields.appends.push(arr);
}
return;
}
// 关系字段
if (associations[col]) {
const includeItem: any = {
association: col,
};
if (includeWhere[col]) {
includeItem.where = includeWhere[col];
}
include.set(col, includeItem);
return;
}
const matches: Array<any> = /(.+)\_count$/.exec(col);
if (matches && associations[matches[1]]) {
attributes.appends.push(Model.withCountAttribute({
association: matches[1],
sourceAlias: sourceAlias
}));
} else {
attributes.appends.push(col);
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('appends');
if (Array.isArray(items.except) && items.except.length > 0) {
items.except.forEach(field => {
@ -245,7 +215,7 @@ export function toInclude(options: any, context: ToIncludeContext = {}) {
for (const [key, child] of children) {
const result = toInclude(child, {
...context,
Model: associations[key].target,
model: associations[key].target,
sourceAlias: key,
associations: associations[key].target.associations,
});
@ -267,14 +237,16 @@ export function toInclude(options: any, context: ToIncludeContext = {}) {
include.set(key, item);
}
const data:any = {};
const data: any = {};
// 存在黑名单时
if (attributes.except.length > 0) {
data.attributes = {
include: attributes.appends,
exclude: attributes.except,
};
if (attributes.appends.length) {
data.attributes.include = attributes.appends;
}
}
// 存在白名单时
else if (attributes.only.length > 0) {
@ -292,6 +264,7 @@ export function toInclude(options: any, context: ToIncludeContext = {}) {
data.attributes = [];
}
data.include = Array.from(include.values());
data.distinct = true;
}
if (Object.keys(where).length > 0) {

View File

@ -1,6 +1,94 @@
import { parseRequest } from '..';
import { mergeFields, parseFields, parseQuery, parseRequest } from '..';
describe('utils', () => {
describe('parseQuery', () => {
it('filter support normal json type', () => {
const object = {
number: -1.1,
string: 'str=a',
boolean: true,
null: null,
array: [5],
object: {
member: {}
},
undefined: undefined
};
const json = JSON.stringify(object);
expect(parseQuery(`filter=${encodeURIComponent(json)}&sort=-col`)).toEqual({
filter: object,
sort: '-col'
});
});
});
describe('parseFields', () => {
it('plain string fields equal to only', () => {
expect(parseFields('name,age')).toEqual({
only: ['name', 'age']
});
});
it('plain array fields equal to only', () => {
expect(parseFields(['name', 'age'])).toEqual({
only: ['name', 'age']
});
});
it('only string fields equal to only', () => {
expect(parseFields({ only: 'name,age' })).toEqual({
only: ['name', 'age']
});
});
it('only array fields equal to only', () => {
expect(parseFields({ only: ['name', 'age'] })).toEqual({
only: ['name', 'age']
});
});
it('plain only and expect fields', () => {
// input as "fields=title&fields[only]=content&fields[except]=status&fields[except]=created_at"
const result = parseFields([ 'title', { only: 'content' }, { except: ['status', 'created_at'] } ]);
expect(result).toEqual({
only: ['title', 'content'],
except: ['status', 'created_at']
});
});
});
describe('mergeFields', () => {
describe('empty default', () => {
it('always contains "appends"', async () => {
expect(mergeFields({}, { only: ['col'] }))
.toEqual({ appends: [], only: ['col'] });
});
it('appends', async () => {
expect(mergeFields({}, { only: ['col1'], appends: ['col2'] }))
.toEqual({ only: ['col1'], appends: ['col2'] });
});
});
describe('options provided', () => {
it('defaults provided: only, except, appends', () => {
expect(mergeFields({
only: ['col1', 'col2'],
except: ['col3'],
appends: ['col4']
}, {
only: ['col1', 'col3', 'col4'],
except: ['col5'],
appends: ['col6']
}))
.toEqual({
only: ['col1'],
appends: ['col6', 'col4']
});
});
});
});
describe('parseRequest', () => {
it('index action', () => {
const params = parseRequest({
@ -192,4 +280,4 @@ describe('utils', () => {
expect(params).toEqual({ associatedName: 'user', resourceName: 'posts', actionName: 'list' });
});
});
});
});

View File

@ -77,6 +77,10 @@ export interface ActionOptions {
*
*/
perPage?: number;
/**
*
*/
maxPerPage?: number;
/**
*
*/
@ -167,6 +171,10 @@ export interface ActionParams {
[key: string]: any;
}
export const DEFAULT_PAGE = 1;
export const DEFAULT_PER_PAGE = 20;
export const MAX_PER_PAGE = 100;
export class Action {
protected handler: any;
@ -215,7 +223,7 @@ export class Action {
setParam(key: string, value: any) {
if (/\[\]$/.test(key)) {
key = key.substr(0, key.length-2);
key = key.substr(0, key.length - 2);
let values = _.get(this.parameters, key);
if (_.isArray(values)) {
values.push(value);
@ -229,10 +237,32 @@ export class Action {
}
async mergeParams(params: ActionParams) {
const { filter, fields, values, ...restPrams } = params;
let { filter: optionsFilter, fields: optionsFields } = this.options;
const {
filter,
fields,
values,
page: paramPage,
perPage: paramPerPage,
per_page,
...restPrams
} = params;
const {
filter: optionsFilter,
fields: optionsFields,
page = DEFAULT_PAGE,
perPage = DEFAULT_PER_PAGE,
maxPerPage = MAX_PER_PAGE
} = this.options;
const options = _.omit(this.options, [
'defaultValues', 'filter', 'fields', 'handler', 'middlewares', 'middleware',
'defaultValues',
'filter',
'fields',
'maxPerPage',
'page',
'perPage',
'handler',
'middlewares',
'middleware',
]);
const data: ActionParams = {
...options,
@ -241,8 +271,13 @@ export class Action {
if (!_.isEmpty(this.options.defaultValues) || !_.isEmpty(values)) {
data.values = _.merge(_.cloneDeep(this.options.defaultValues), values);
}
if (data.per_page) {
data.perPage = data.per_page;
// TODO: to be unified by style funciton
if (per_page || paramPerPage) {
data.perPage = per_page || paramPerPage;
}
if (paramPage || data.perPage) {
data.page = paramPage || page;
data.perPage = data.perPage == -1 ? maxPerPage : Math.min(data.perPage || perPage, maxPerPage);
}
// if (typeof optionsFilter === 'function') {
// this.parameters = _.cloneDeep(data);
@ -321,4 +356,4 @@ export class Action {
}
}
export default Action;
export default Action;

View File

@ -3,7 +3,7 @@ import glob from 'glob';
import compose from 'koa-compose';
import Action, { ActionName } from './action';
import Resource, { ResourceOptions } from './resource';
import { parseRequest, getNameByParams, ParsedParams, requireModule } from './utils';
import { parseRequest, getNameByParams, ParsedParams, requireModule, parseQuery } from './utils';
import { pathToRegexp } from 'path-to-regexp';
export interface ResourcerContext {
@ -256,15 +256,7 @@ export class Resourcer {
// action 需要 clone 之后再赋给 ctx
ctx.action = this.getAction(nameRule(params), params.actionName).clone();
ctx.action.setContext(ctx);
// 自带 query 处理的不太给力,需要用 qs 转一下
const query = qs.parse(ctx.request.querystring, {
// 原始 query string 中如果一个键连等号“=”都没有可以被认为是 null 类型
strictNullHandling: true
});
// filter 支持 json string
if (typeof query.filter === 'string') {
query.filter = JSON.parse(query.filter);
}
const query = parseQuery(ctx.request.querystring);
// 兼容 ctx.params 的处理,之后的版本里会去掉
ctx[paramsKey] = {
table: params.resourceName,

View File

@ -1,5 +1,6 @@
import _ from 'lodash';
import { pathToRegexp } from 'path-to-regexp';
import qs from 'qs';
import { ResourceType } from './resource';
export interface ParseRequest {
@ -189,6 +190,20 @@ export function requireModule(module: any) {
return module.__esModule ? module.default : module;
}
export function parseQuery(input: string): any {
// 自带 query 处理的不太给力,需要用 qs 转一下
const query = qs.parse(input, {
// 原始 query string 中如果一个键连等号“=”都没有可以被认为是 null 类型
strictNullHandling: true
});
// filter 支持 json string
if (typeof query.filter === 'string') {
query.filter = JSON.parse(query.filter);
}
return query;
}
export function parseFields(fields: any) {
if (!fields) {
return {}
@ -197,10 +212,22 @@ export function parseFields(fields: any) {
fields = fields.split(',').map(field => field.trim());
}
if (Array.isArray(fields)) {
return {
only: fields,
appends: [],
const onlyFields = [];
const output: any = {};
fields.forEach(item => {
if (typeof item === 'string') {
onlyFields.push(item);
} else if (typeof item === 'object') {
if (item.only) {
onlyFields.push(...item.only.toString().split(','));
}
Object.assign(output, parseFields(item));
}
});
if (onlyFields.length) {
output.only = onlyFields;
}
return output;
}
if (fields.only && typeof fields.only === 'string') {
fields.only = fields.only.split(',').map(field => field.trim());