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:
parent
578454d07f
commit
cd0b357887
@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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());
|
||||
|
Loading…
Reference in New Issue
Block a user