diff --git a/packages/actions/src/__tests__/index.ts b/packages/actions/src/__tests__/index.ts index 6c13e8aa7..4b8c225aa 100644 --- a/packages/actions/src/__tests__/index.ts +++ b/packages/actions/src/__tests__/index.ts @@ -36,7 +36,7 @@ const connection = { }, }, }, - logging: false, + logging: process.env.DB_LOG_SQL === 'on' ? console.log : false, }, database: null, create() { diff --git a/packages/actions/src/__tests__/list.test.ts b/packages/actions/src/__tests__/list.test.ts index 4c9a54946..2a77f134e 100644 --- a/packages/actions/src/__tests__/list.test.ts +++ b/packages/actions/src/__tests__/list.test.ts @@ -18,7 +18,8 @@ describe('list', () => { for (let index = 0; index < 2; index++) { items.push({ title: `title${index}`, - status: index % 2 ? 'published' : 'draft' + status: index % 2 ? 'published' : 'draft', + published_at: index % 2 ? new Date() : null }); } await Post.bulkCreate(items); @@ -58,6 +59,48 @@ describe('list', () => { expect(response.body.rows[0].title).toBe(drafts[0]); }); }); + + describe('null', () => { + it('filter[published_at.is]', async () => { + const Post = db.getModel('posts'); + const expected = await Post.findAll({ + where: { + published_at: { + [Op.is]: null + } + } + }); + const response = await agent.get('/posts?filter[published_at.is]'); + expect(response.body.count).toBe(expected.length); + }); + + it('filter[published_at.not]', async () => { + const Post = db.getModel('posts'); + const expected = await Post.findAll({ + where: { + published_at: { + [Op.not]: null + } + } + }); + const response = await agent.get('/posts?filter[published_at.not]'); + expect(response.body.count).toBe(expected.length); + }); + + // TODO(bug): should use `user.is` + it('filter[user_id.is]', async () => { + const Post = db.getModel('posts'); + const expected = await Post.findAll({ + where: { + user_id: { + [Op.is]: null + } + } + }); + const response = await agent.get('/posts?filter[user_id.is]'); + expect(response.body.count).toBe(expected.length); + }); + }); }); describe('page', () => { diff --git a/packages/actions/src/__tests__/tables/posts.ts b/packages/actions/src/__tests__/tables/posts.ts index a6b2f4d20..e6422c13b 100644 --- a/packages/actions/src/__tests__/tables/posts.ts +++ b/packages/actions/src/__tests__/tables/posts.ts @@ -13,6 +13,10 @@ export default { name: 'status', defaultValue: 'publish', }, + { + type: 'date', + name: 'published_at' + }, { type: 'belongsTo', name: 'user', diff --git a/packages/actions/src/actions/common.ts b/packages/actions/src/actions/common.ts index 185052648..1f54bc3d4 100644 --- a/packages/actions/src/actions/common.ts +++ b/packages/actions/src/actions/common.ts @@ -37,10 +37,6 @@ export async function list(ctx: Context, next: Next) { context: ctx, }); let data = {}; - if (page || perPage) { - options.limit = 1*(perPage||20); - options.offset = options.limit * (page > 0 ? page - 1 : 0); - } if (associated && resourceField) { const AssociatedModel = ctx.db.getModel(associatedName); if (!(associated instanceof AssociatedModel)) { diff --git a/packages/database/src/__tests__/index.ts b/packages/database/src/__tests__/index.ts index 0b2ea2ee4..7f9ec6e5f 100644 --- a/packages/database/src/__tests__/index.ts +++ b/packages/database/src/__tests__/index.ts @@ -1,7 +1,14 @@ import Database from '../database'; import { Dialect } from 'sequelize'; - +function getTestKey() { + const { id } = require.main; + const key = id + .replace(process.env.PWD, '') + .replace('.test.ts', '') + .replace(/[^\w]/g, '_'); + return key +} const config = { username: process.env.DB_USER, @@ -21,5 +28,12 @@ const config = { }; export function getDatabase() { - return new Database(config); + return new Database({ + ...config, + hooks: { + beforeDefine(columns, model) { + model.tableName = `${getTestKey()}_${model.tableName || model.name.plural}`; + } + } + }); }; diff --git a/packages/database/src/__tests__/model.test.ts b/packages/database/src/__tests__/model/model.test.ts similarity index 88% rename from packages/database/src/__tests__/model.test.ts rename to packages/database/src/__tests__/model/model.test.ts index bad72c70f..7275fac3e 100644 --- a/packages/database/src/__tests__/model.test.ts +++ b/packages/database/src/__tests__/model/model.test.ts @@ -1,15 +1,13 @@ -import Database from '..'; -import { getDatabase } from '.'; -import { Op, Sequelize } from 'sequelize'; -import Model, { ModelCtor } from '../model'; -import { BelongsToMany } from '../fields'; -import { Mode } from 'fs'; +import Database from '../..'; +import { getDatabase } from '..'; +import Model, { ModelCtor } from '../../model'; + let db: Database; beforeEach(async () => { - db = await getDatabase(); + db = getDatabase(); }); afterEach(async () => { @@ -269,137 +267,7 @@ describe('actions', () => { }); }); - describe('parseApiJson', () => { - let db: Database; - let Foo: ModelCtor; - beforeEach(() => { - db = getDatabase(); - db.table({ - name: 'bazs', - tableName: 'bazs_model' - }); - db.table({ - name: 'bays', - tableName: 'bays_model' - }); - db.table({ - name: 'bars', - tableName: 'bars_model', - fields: [ - { - type: 'belongsTo', - name: 'baz', - }, - { - type: 'belongsTo', - name: 'bay', - }, - ], - }); - db.table({ - name: 'foos', - tableName: 'foos_model', - fields: [ - { - type: 'hasMany', - name: 'bars', - }, - { - type: 'hasMany', - name: 'fozs', - }, - { - type: 'hasMany', - name: 'coos', - }, - ], - }); - db.table({ - name: 'fozs', - tableName: 'fozs_model', - }); - db.table({ - name: 'coos', - tableName: 'coos_model', - }); - Foo = db.getModel('foos'); - }); - - afterEach(() => db.close()); - - it('parseApiJson', () => { - const data = Foo.parseApiJson({ - filter: { - col1: 'co2', - } - }); - expect(data).toEqual({ where: { col1: 'co2' } }); - }); - - it('parseApiJson', () => { - const data = Foo.parseApiJson({ - fields: ['col1'], - }); - expect(data).toEqual({ attributes: ['col1'] }); - }); - - it('parseApiJson', () => { - const data = Foo.parseApiJson({ - fields: ['col1'], - filter: { - col1: 'co2', - }, - }); - expect(data).toEqual({ attributes: ['col1'], where: { col1: 'co2' } }); - }); - - it('parseApiJson', () => { - const data = Foo.parseApiJson({ - fields: ['col1'], - filter: { - col1: 'val1', - bars: { - col1: 'val1', - } - }, - }); - expect(data).toEqual({ - attributes: ['col1'], - where: { col1: 'val1' }, - include: [ - { - association: 'bars', - where: { col1: 'val1' }, - } - ], - }); - }); - - it('parseApiJson', () => { - const data = Foo.parseApiJson({ - fields: ['col1', 'bars.col1'], - filter: { - col1: 'val1', - bars: { - col1: 'val1', - } - }, - }); - expect(data).toEqual({ - attributes: ['col1'], - where: { col1: 'val1' }, - include: [ - { - association: 'bars', - attributes: ['col1'], - where: { col1: 'val1' }, - } - ], - }); - }); - }); - - describe('findByApiJson', () => { + describe('query', () => { it('q', async () => { db.getModel('tags').addScope('scopeName', (name, ctx) => { expect(ctx.scopeName).toBe(name); @@ -477,17 +345,15 @@ describe('actions', () => { expect(row.get('title')).toBe('title112233'); expect(row.user.get('name')).toBe('name112233'); }); - } catch(error) { - console.error(error); - } + } catch(error) { + console.error(error); + } // console.log(count); // expect(count).toBe(1); }); - }); - describe('query', () => { it('to be explained', async () => { const [User, Post] = db.getModels(['users', 'posts']); const postData = []; diff --git a/packages/database/src/__tests__/model/parseApiJson.test.ts b/packages/database/src/__tests__/model/parseApiJson.test.ts new file mode 100644 index 000000000..0b1484398 --- /dev/null +++ b/packages/database/src/__tests__/model/parseApiJson.test.ts @@ -0,0 +1,206 @@ +import { literal } from 'sequelize'; +import { getDatabase } from '..'; +import Database from '../../database'; +import Model, { ModelCtor } from '../../model'; + + +let db: Database; +let Bar: ModelCtor; +let Foo: ModelCtor; + +beforeAll(() => { + db = getDatabase(); + db.table({ + name: 'bazs', + }); + db.table({ + name: 'bays', + }); + db.table({ + name: 'bars', + fields: [ + { + type: 'belongsTo', + name: 'baz', + }, + { + type: 'belongsTo', + name: 'bay', + }, + ], + }); + db.table({ + name: 'foos', + fields: [ + { + type: 'hasMany', + name: 'bars', + }, + { + type: 'hasMany', + name: 'fozs', + }, + { + type: 'hasMany', + name: 'coos', + }, + ], + }); + db.table({ + name: 'fozs', + }); + db.table({ + name: 'coos', + }); + Bar = db.getModel('bars'); + Foo = db.getModel('foos'); +}); + +afterAll(() => db.close()); + + + +describe('parseApiJson', () => { + describe('self table', () => { + it('filter', () => { + const data = Foo.parseApiJson({ + filter: { + col1: 'co2', + } + }); + expect(data).toEqual({ where: { col1: 'co2' } }); + }); + + it('fields', () => { + const data = Foo.parseApiJson({ + fields: ['col1'], + }); + expect(data).toEqual({ attributes: ['col1'] }); + }); + + it('filter and fields', () => { + const data = Foo.parseApiJson({ + fields: ['col1'], + filter: { + col1: 'co2', + }, + }); + expect(data).toEqual({ attributes: ['col1'], where: { col1: 'co2' } }); + }); + + it('pagination: page and perPage', () => { + expect(Foo.parseApiJson({ + page: 1, + perPage: 10 + })).toEqual({ + offset: 0, + limit: 10, + }); + }); + + it('pagination: only page', () => { + expect(Foo.parseApiJson({ + page: 1 + })).toEqual({ + offset: 0, + limit: 20, + }); + }); + + it('sort: single self field', () => { + expect(Foo.parseApiJson({ + sort: 'a' + })).toEqual({ + order: [ + ['a', 'ASC'] + ] + }); + }); + + // TODO(bug): should not contain additional attributes + it('sort: multiple self fields', () => { + expect(Foo.parseApiJson({ + sort: 'a,-b' + })).toEqual({ + order: [ + ['a', 'ASC'], + ['b', 'DESC'], + ] + }); + }); + + // TODO(feature): order by association should be ok + it.skip('sort: association field', () => { + expect(Bar.parseApiJson({ + sort: '-foo.a' + })).toEqual({ + order: [ + [{ model: Foo, as: 'foo' }, 'a', 'DESC'], + ] + }); + }); + + it('page + sort + fields', () => { + expect(Foo.parseApiJson({ + fields: ['a', 'b'], + page: 2, + perPage: 10, + sort: 'a' + })).toEqual({ + attributes: ['a', 'b'], + offset: 10, + limit: 10, + order: [ + ['a', 'ASC'] + ] + }); + }); + }); + + describe('associations', () => { + it('filter on hasMany field', () => { + const data = Foo.parseApiJson({ + fields: ['col1'], + filter: { + col1: 'val1', + bars: { + col1: 'val1', + } + }, + }); + expect(data).toEqual({ + attributes: ['col1'], + where: { col1: 'val1' }, + include: [ + { + association: 'bars', + where: { col1: 'val1' }, + } + ], + }); + }); + + it('filter and fields to fetch hasMany field', () => { + const data = Foo.parseApiJson({ + fields: ['col1', 'bars.col1'], + filter: { + col1: 'val1', + bars: { + col1: 'val1', + } + }, + }); + expect(data).toEqual({ + attributes: ['col1'], + where: { col1: 'val1' }, + include: [ + { + association: 'bars', + attributes: ['col1'], + where: { col1: 'val1' }, + } + ], + }); + }); + }); +}); diff --git a/packages/database/src/__tests__/sync.test.ts b/packages/database/src/__tests__/sync.test.ts index 7b16eb755..bd4aa4cdd 100644 --- a/packages/database/src/__tests__/sync.test.ts +++ b/packages/database/src/__tests__/sync.test.ts @@ -6,7 +6,7 @@ import { getDatabase } from './'; let db: Database; beforeEach(async () => { - db = await getDatabase(); + db = getDatabase(); }); afterEach(async () => { diff --git a/packages/database/src/__tests__/tables.test.ts b/packages/database/src/__tests__/tables.test.ts index 590f13448..cdda3014c 100644 --- a/packages/database/src/__tests__/tables.test.ts +++ b/packages/database/src/__tests__/tables.test.ts @@ -6,7 +6,7 @@ import Model from '../model'; let db: Database; beforeEach(async () => { - db = await getDatabase(); + db = getDatabase(); }); afterEach(async () => { @@ -47,7 +47,8 @@ describe('tables', () => { name: 'foos', }); expect(db.getModel('foos').name).toBe('foos'); - expect(db.getModel('foos').getTableName()).toBe('foos'); + // hooked by beforeDefine in index.ts + expect(db.getModel('foos').getTableName()).toBe('_packages_database_src___tests___tables_foos'); }); it('should be custom when tableName is defined', async () => { @@ -56,7 +57,8 @@ describe('tables', () => { tableName: 'bar_v2' }); expect(db.getModel('bar').name).toBe('bar'); - expect(db.getModel('bar').getTableName()).toBe('bar_v2'); + // hooked by beforeDefine in index.ts + expect(db.getModel('bar').getTableName()).toBe('_packages_database_src___tests___tables_bar_v2'); }); it('should be custom when timestamps is defined', async () => { diff --git a/packages/database/src/__tests__/utils/toWhere.test.ts b/packages/database/src/__tests__/utils/toWhere.test.ts index b96ea1526..7114d1c54 100644 --- a/packages/database/src/__tests__/utils/toWhere.test.ts +++ b/packages/database/src/__tests__/utils/toWhere.test.ts @@ -86,6 +86,14 @@ describe('utils.toWhere', () => { }, }); }); + + it('Op.lt', () => { + expect(toWhere({ + 'id.lt': 1 + })).toEqual({ + id: { [Op.lt]: 1 } + }); + }); it('Op.between', () => { expect(toWhere({ @@ -94,6 +102,14 @@ describe('utils.toWhere', () => { id: { [Op.between]: [1, 2] } }); }); + + it('Op.between date', () => { + expect(toWhere({ + 'id.between': ['2020-11-01T00:00:00.000Z', '2020-12-01T00:00:00.000Z'] + })).toEqual({ + id: { [Op.between]: ['2020-11-01T00:00:00.000Z', '2020-12-01T00:00:00.000Z'] } + }); + }); }); describe('group by logical operator', () => { @@ -149,6 +165,30 @@ describe('utils.toWhere', () => { }); }); + it('or in or (not fully parsed)', () => { + expect(toWhere({ + or: [{ a: 1 }, { 'b.or': [3, 4] }], + })).toEqual({ + [Op.or]: [{ a: 1 }, { b: { [Op.or]: [3, 4] } }], + }); + }); + + it('or in or (parsed)', () => { + expect(toWhere({ + or: [{ a: 1 }, { b: { or: [3, 4] } }], + })).toEqual({ + [Op.or]: [{ a: 1 }, { b: { [Op.or]: [3, 4] } }], + }); + }); + + it('or in or in or', () => { + expect(toWhere({ + or: [{ a: 1 }, { or: { b: 3, c: { or: [5, 6] } } }], + })).toEqual({ + [Op.or]: [{ a: 1 }, { [Op.or]: { b: 3, c: { [Op.or]: [5, 6] } } }], + }); + }); + it('or in and', () => { expect(toWhere({ and: [{ a: 1 }, { 'b.or': [3, 4] }], @@ -165,8 +205,16 @@ describe('utils.toWhere', () => { }); }); + it('logical and other comparation', () => { + expect(toWhere({ + or: [{ a: 1 }, { b: { gt: 2 } }], + })).toEqual({ + [Op.or]: [{ a: 1 }, { b: { [Op.gt]: 2 } }], + }); + }); + // TODO: bug - it.skip('field as or', () => { + it.skip('field as "or"', () => { expect(toWhere({ or: 1, })).toEqual({ @@ -175,7 +223,7 @@ describe('utils.toWhere', () => { }); // TODO: bug - it.skip('or for field as or', () => { + it.skip('or for field as "or"', () => { expect(toWhere({ 'or.or': [1, 2], })).toEqual({ @@ -186,60 +234,59 @@ describe('utils.toWhere', () => { describe('association', () => { let db: Database; - let Foo: ModelCtor; beforeAll(() => { db = getDatabase(); db.table({ - name: 'bazs', + name: 'users', }); db.table({ - name: 'bars', + name: 'posts', fields: [ { type: 'belongsTo', - name: 'baz', + name: 'user', } ] }); db.table({ - name: 'foos', + name: 'categories', fields: [ { type: 'hasMany', - name: 'bars', + name: 'posts', } ], }); - Foo = db.getModel('foos'); }); afterAll(() => db.close()); - const toWhereExpect = (options, logging = false) => { + const toWhereExpect = (options: any) => { + const Category = db.getModel('categories'); const where = toWhere(options, { - associations: Foo.associations, + associations: Category.associations, }); return expect(where); } - it('association', () => { + it('with included association where', () => { toWhereExpect({ col1: 'val1', - bars: { + posts: { name: { ilike: 'aa', }, col2: { lt: 2, }, - baz: { + user: { col1: 12, }, }, - 'bars.col3.ilike': 'aa', + 'posts.col3.ilike': 'aa', }).toEqual({ col1: 'val1', $__include: { - bars: { + posts: { name: { [Op.iLike]: 'aa', }, @@ -250,7 +297,7 @@ describe('utils.toWhere', () => { [Op.iLike]: 'aa', }, $__include: { - baz: { + user: { col1: 12 }, }, @@ -258,5 +305,19 @@ describe('utils.toWhere', () => { }, }); }); + + // TODO: 是否要考虑跨关联表的逻辑分组这种情况 + it.skip('logical across association', () => { + toWhereExpect({ + or: { + status: 1, + posts: { + status: 1 + } + } + }).toEqual({ + + }) + }); }); }); diff --git a/packages/database/src/model.ts b/packages/database/src/model.ts index 5ef640651..ba75f0de8 100644 --- a/packages/database/src/model.ts +++ b/packages/database/src/model.ts @@ -82,6 +82,12 @@ export interface ApiJsonOptions { */ sort?: any; + /** + * 页码 + */ + page?: number; + perPage?: number; + context?: any; [key: string]: any; @@ -226,13 +232,17 @@ export abstract class Model extends SequelizeModel { } static parseApiJson(options: ApiJsonOptions) { - const { fields, filter, sort, context, ...restOptions } = options; + const { fields, filter, sort, context, page, perPage } = options; const data = toInclude({fields, filter, sort}, { 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); + } 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 919d42176..45b053028 100644 --- a/packages/database/src/utils.ts +++ b/packages/database/src/utils.ts @@ -111,11 +111,12 @@ export function toInclude(options: any, context: ToIncludeContext = {}) { if (Array.isArray(key)) { order.push(key); } else { - const direction = key.indexOf('-') === 0 ? 'DESC' : 'ASC'; + const direction = key[0] === '-' ? 'DESC' : 'ASC'; const field = key.replace(/^-/, ''); - items.appends.push(field); - // TODO:暂时只支持 postgresql - order.push([Sequelize.literal(dialect === 'mysql' ? `\`${field}\`` : `"${field}"`), direction]); + // TODO(verify): 理论上只是排序并不一定要输出相关字段 + // items.appends.push(field); + // TODO: 暂时只支持主表排序,后续需要按`.`分隔符拆分 field 为关联排序 + order.push([field, direction]); } }); } diff --git a/packages/resourcer/src/resourcer.ts b/packages/resourcer/src/resourcer.ts index 9cc33bc70..038898067 100644 --- a/packages/resourcer/src/resourcer.ts +++ b/packages/resourcer/src/resourcer.ts @@ -257,7 +257,10 @@ export class Resourcer { ctx.action = this.getAction(nameRule(params), params.actionName).clone(); ctx.action.setContext(ctx); // 自带 query 处理的不太给力,需要用 qs 转一下 - const query = qs.parse(qs.stringify(ctx.query)); + 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); diff --git a/packages/server/src/middleware.ts b/packages/server/src/middleware.ts index 7c1d62c09..0ac03563a 100644 --- a/packages/server/src/middleware.ts +++ b/packages/server/src/middleware.ts @@ -87,7 +87,10 @@ export function middleware(options: MiddlewareOptions = {}) { ctx.action = resourcer.getAction(resourceName, params.actionName).clone(); ctx.action.setContext(ctx); // 自带 query 处理的不太给力,需要用 qs 转一下 - const query = qs.parse(qs.stringify(ctx.query)); + 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);