diff --git a/packages/actions/src/__tests__/list.test.ts b/packages/actions/src/__tests__/list.test.ts index 2a77f134e..f204da686 100644 --- a/packages/actions/src/__tests__/list.test.ts +++ b/packages/actions/src/__tests__/list.test.ts @@ -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 }); }); + + }); }); diff --git a/packages/actions/src/actions/common.ts b/packages/actions/src/actions/common.ts index 1f54bc3d4..f1753d391 100644 --- a/packages/actions/src/actions/common.ts +++ b/packages/actions/src/actions/common.ts @@ -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(); diff --git a/packages/database/src/__tests__/model/parseApiJson.test.ts b/packages/database/src/__tests__/model/parseApiJson.test.ts index 0b1484398..da5db5e91 100644 --- a/packages/database/src/__tests__/model/parseApiJson.test.ts +++ b/packages/database/src/__tests__/model/parseApiJson.test.ts @@ -6,26 +6,37 @@ import Model, { ModelCtor } from '../../model'; let db: Database; let Bar: ModelCtor; +let Baz: ModelCtor; +let Bay: ModelCtor; let Foo: ModelCtor; 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, }); }); }); diff --git a/packages/database/src/__tests__/utils/toInclude.test.ts b/packages/database/src/__tests__/utils/toInclude.test.ts index 6e33cfe54..c715b7e04 100644 --- a/packages/database/src/__tests__/utils/toInclude.test.ts +++ b/packages/database/src/__tests__/utils/toInclude.test.ts @@ -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) { diff --git a/packages/database/src/model.ts b/packages/database/src/model.ts index ba75f0de8..51a357fb9 100644 --- a/packages/database/src/model.ts +++ b/packages/database/src/model.ts @@ -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; diff --git a/packages/database/src/utils.ts b/packages/database/src/utils.ts index 45b053028..1e9eeadf7 100644 --- a/packages/database/src/utils.ts +++ b/packages/database/src/utils.ts @@ -12,7 +12,7 @@ for (const key in Op) { } interface ToWhereContext { - Model?: ModelCtor | Model | typeof Model; + model?: ModelCtor | 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 | typeof Model; + model?: ModelCtor | 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 = 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 = /(.+)_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 = 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 = /(.+)\_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 = 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 = /(.+)\_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) { diff --git a/packages/resourcer/src/__tests__/utils.test.ts b/packages/resourcer/src/__tests__/utils.test.ts index 1eff2b202..c85b8a602 100644 --- a/packages/resourcer/src/__tests__/utils.test.ts +++ b/packages/resourcer/src/__tests__/utils.test.ts @@ -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' }); }); }); -}); \ No newline at end of file +}); diff --git a/packages/resourcer/src/action.ts b/packages/resourcer/src/action.ts index f4e43b564..536d27a84 100644 --- a/packages/resourcer/src/action.ts +++ b/packages/resourcer/src/action.ts @@ -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; \ No newline at end of file +export default Action; diff --git a/packages/resourcer/src/resourcer.ts b/packages/resourcer/src/resourcer.ts index 038898067..408bf945e 100644 --- a/packages/resourcer/src/resourcer.ts +++ b/packages/resourcer/src/resourcer.ts @@ -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, diff --git a/packages/resourcer/src/utils.ts b/packages/resourcer/src/utils.ts index 63c03a547..dc9547671 100644 --- a/packages/resourcer/src/utils.ts +++ b/packages/resourcer/src/utils.ts @@ -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());