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', () => { describe('list', () => {
let db; let db;
let now: string;
let timestamps: { created_at: string; updated_at: string; };
beforeEach(async () => { beforeEach(async () => {
db = await initDatabase(); db = await initDatabase();
now = (new Date()).toISOString();
timestamps = { created_at: now, updated_at: now };
}); });
afterAll(() => db.close()); afterAll(() => db.close());
describe('common', () => { describe('common', () => {
beforeEach(async () => { 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 Post = db.getModel('posts');
const items = []; await Post.bulkCreate(Array(25).fill(null).map((_, index) => ({
for (let index = 0; index < 2; index++) {
items.push({
title: `title${index}`, title: `title${index}`,
status: index % 2 ? 'published' : 'draft', status: index % 2 ? 'published' : 'draft',
published_at: index % 2 ? new Date() : null published_at: index % 2 ? new Date(2020, 10, 30 - index, 0, 0, 0) : null,
}); user_id: users[index % users.length].id,
} ...timestamps
await Post.bulkCreate(items); })));
}); });
describe('filter', () => { describe('filter', () => {
@ -61,6 +71,17 @@ describe('list', () => {
}); });
describe('null', () => { 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 () => { it('filter[published_at.is]', async () => {
const Post = db.getModel('posts'); const Post = db.getModel('posts');
const expected = await Post.findAll({ const expected = await Post.findAll({
@ -104,70 +125,232 @@ describe('list', () => {
}); });
describe('page', () => { describe('page', () => {
it('page by default size(20) should be ok', async () => { it('default page and size(20) should be ok', async () => {
const response = await agent.get('/posts?fields=title&page=1'); const response = await agent.get('/posts?fields=title');
expect(response.body).toEqual({ expect(response.body).toEqual({
count: 2, count: 25,
page: 1, page: 1,
per_page: 20, 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 () => { 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({ expect(response.body).toEqual({
count: 2, count: 25,
page: 2, page: 1,
per_page: 1, per_page: 1,
rows: [ { title: 'title1' } ], rows: [ { title: 'title0' } ],
}); });
}); });
it('page 2 by size(1) should be ok', async () => { it('page 2 by size(1) should be ok', async () => {
const response = await agent.get('/posts?fields=title&page=2&per_page=1'); const response = await agent.get('/posts?fields=title&page=2&per_page=1');
expect(response.body).toEqual({ expect(response.body).toEqual({
count: 2, count: 25,
page: 2, page: 2,
per_page: 1, per_page: 1,
rows: [ { title: 'title1' } ], 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('list6', async () => { 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: [],
});
});
});
describe('fields', () => {
it('custom field', async () => {
const response = await agent.get('/posts?fields=title&filter[customTitle]=title0'); const response = await agent.get('/posts?fields=title&filter[customTitle]=title0');
expect(response.body).toEqual({ count: 1, rows: [ { title: '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', () => { 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 = db.getModel('posts');
const post = await Post.create(); const post = await Post.create({ user_id: users[0].id });
await post.updateAssociations({ await post.updateAssociations({
comments: [ comments: Array(6).fill(null).map((_, index) => ({
{content: 'content1', status: 'published'}, content: `content${index}`,
{content: 'content2', status: 'published'}, status: index % 2 ? 'published' : 'draft',
{content: 'content3', status: 'draft'}, user_id: users[index % users.length].id
{content: 'content4', status: 'published'}, }))
{content: 'content5', status: 'draft'},
{content: 'content6', status: 'published'},
],
}); });
});
it('get comments of a post', async () => {
const Post = db.getModel('posts');
const post = await Post.findByPk(1);
const response = await agent const response = await agent
.get(`/posts/${post.id}/comments?page=2&perPage=2&sort=content&fields=content&filter[published]=1`); .get(`/posts/${post.id}/comments?page=2&perPage=2&sort=content&fields=content&filter[published]=1`);
expect(response.body).toEqual({ expect(response.body).toEqual({
rows: [ { content: 'content4' }, { content: 'content6' } ], rows: [ { content: 'content5' } ],
count: 4, count: 3,
page: 2, page: 2,
per_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', () => { describe('belongsToMany', () => {
it('list1', async () => { beforeEach(async () => {
const Post = db.getModel('posts'); const Post = db.getModel('posts');
const post = await Post.create(); const post = await Post.create();
await post.updateAssociations({ await post.updateAssociations({
@ -181,15 +364,22 @@ describe('list', () => {
{name: 'tag7', status: 'published'}, {name: 'tag7', status: 'published'},
], ],
}); });
});
it('list1', async () => {
const Post = db.getModel('posts');
const post = await Post.findByPk(1);
const response = await agent 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({ expect(response.body).toEqual({
rows: [ { name: 'tag5' }, { name: 'tag7' } ], rows: [ { name: 'tag3' }, { name: 'tag1' } ],
count: 4, count: 4,
page: 2, page: 2,
per_page: 2 per_page: 2
}); });
}); });
}); });
}); });

View File

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

View File

@ -6,26 +6,37 @@ import Model, { ModelCtor } from '../../model';
let db: Database; let db: Database;
let Bar: ModelCtor<Model>; let Bar: ModelCtor<Model>;
let Baz: ModelCtor<Model>;
let Bay: ModelCtor<Model>;
let Foo: ModelCtor<Model>; let Foo: ModelCtor<Model>;
beforeAll(() => { beforeAll(() => {
db = getDatabase(); db = getDatabase();
db.table({ db.table({
name: 'bazs', name: 'bazs',
fields: [
{
type: 'belongsToMany',
name: 'bays'
},
]
}); });
db.table({ db.table({
name: 'bays', name: 'bays',
fields: [
{
type: 'belongsToMany',
name: 'bazs'
},
]
}); });
db.table({ db.table({
name: 'bars', name: 'bars',
fields: [ fields: [
{ {
type: 'belongsTo', type: 'belongsTo',
name: 'baz', name: 'foo',
},
{
type: 'belongsTo',
name: 'bay',
}, },
], ],
}); });
@ -53,6 +64,8 @@ beforeAll(() => {
name: 'coos', name: 'coos',
}); });
Bar = db.getModel('bars'); Bar = db.getModel('bars');
Baz = db.getModel('bazs');
Bay = db.getModel('bays');
Foo = db.getModel('foos'); Foo = db.getModel('foos');
}); });
@ -62,6 +75,10 @@ afterAll(() => db.close());
describe('parseApiJson', () => { describe('parseApiJson', () => {
describe('self table', () => { describe('self table', () => {
it('empty', () => {
expect(Foo.parseApiJson({})).toEqual({});
});
it('filter', () => { it('filter', () => {
const data = Foo.parseApiJson({ const data = Foo.parseApiJson({
filter: { filter: {
@ -78,6 +95,26 @@ describe('parseApiJson', () => {
expect(data).toEqual({ attributes: ['col1'] }); 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', () => { it('filter and fields', () => {
const data = Foo.parseApiJson({ const data = Foo.parseApiJson({
fields: ['col1'], fields: ['col1'],
@ -100,10 +137,29 @@ describe('parseApiJson', () => {
it('pagination: only page', () => { it('pagination: only page', () => {
expect(Foo.parseApiJson({ 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({ })).toEqual({
offset: 0, 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', () => { it('sort: multiple self fields', () => {
expect(Foo.parseApiJson({ expect(Foo.parseApiJson({
sort: 'a,-b' sort: 'a,-b'
@ -129,13 +184,22 @@ describe('parseApiJson', () => {
}); });
}); });
// TODO(feature): order by association should be ok it('sort: association field', () => {
it.skip('sort: association field', () => {
expect(Bar.parseApiJson({ expect(Bar.parseApiJson({
sort: '-foo.a' sort: '-foo.a'
})).toEqual({ })).toEqual({
order: [ 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' }, where: { col1: 'val1' },
} }
], ],
distinct: true,
}); });
}); });
@ -200,6 +265,7 @@ describe('parseApiJson', () => {
where: { col1: 'val1' }, where: { col1: 'val1' },
} }
], ],
distinct: true,
}); });
}); });
}); });

View File

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

View File

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

View File

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

View File

@ -1,6 +1,94 @@
import { parseRequest } from '..'; import { mergeFields, parseFields, parseQuery, parseRequest } from '..';
describe('utils', () => { 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', () => { describe('parseRequest', () => {
it('index action', () => { it('index action', () => {
const params = parseRequest({ const params = parseRequest({

View File

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

View File

@ -3,7 +3,7 @@ import glob from 'glob';
import compose from 'koa-compose'; import compose from 'koa-compose';
import Action, { ActionName } from './action'; import Action, { ActionName } from './action';
import Resource, { ResourceOptions } from './resource'; 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'; import { pathToRegexp } from 'path-to-regexp';
export interface ResourcerContext { export interface ResourcerContext {
@ -256,15 +256,7 @@ export class Resourcer {
// action 需要 clone 之后再赋给 ctx // action 需要 clone 之后再赋给 ctx
ctx.action = this.getAction(nameRule(params), params.actionName).clone(); ctx.action = this.getAction(nameRule(params), params.actionName).clone();
ctx.action.setContext(ctx); ctx.action.setContext(ctx);
// 自带 query 处理的不太给力,需要用 qs 转一下 const query = parseQuery(ctx.request.querystring);
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);
}
// 兼容 ctx.params 的处理,之后的版本里会去掉 // 兼容 ctx.params 的处理,之后的版本里会去掉
ctx[paramsKey] = { ctx[paramsKey] = {
table: params.resourceName, table: params.resourceName,

View File

@ -1,5 +1,6 @@
import _ from 'lodash'; import _ from 'lodash';
import { pathToRegexp } from 'path-to-regexp'; import { pathToRegexp } from 'path-to-regexp';
import qs from 'qs';
import { ResourceType } from './resource'; import { ResourceType } from './resource';
export interface ParseRequest { export interface ParseRequest {
@ -189,6 +190,20 @@ export function requireModule(module: any) {
return module.__esModule ? module.default : module; 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) { export function parseFields(fields: any) {
if (!fields) { if (!fields) {
return {} return {}
@ -197,10 +212,22 @@ export function parseFields(fields: any) {
fields = fields.split(',').map(field => field.trim()); fields = fields.split(',').map(field => field.trim());
} }
if (Array.isArray(fields)) { if (Array.isArray(fields)) {
return { const onlyFields = [];
only: fields, const output: any = {};
appends: [], 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') { if (fields.only && typeof fields.only === 'string') {
fields.only = fields.only.split(',').map(field => field.trim()); fields.only = fields.only.split(',').map(field => field.trim());