test: refactor test in database and add more (#17)

This commit is contained in:
Junyi 2020-11-19 23:30:30 +08:00 committed by GitHub
parent eb93f0734f
commit 85e42f95a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 388 additions and 179 deletions

View File

@ -36,7 +36,7 @@ const connection = {
}, },
}, },
}, },
logging: false, logging: process.env.DB_LOG_SQL === 'on' ? console.log : false,
}, },
database: null, database: null,
create() { create() {

View File

@ -18,7 +18,8 @@ describe('list', () => {
for (let index = 0; index < 2; index++) { for (let index = 0; index < 2; index++) {
items.push({ 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
}); });
} }
await Post.bulkCreate(items); await Post.bulkCreate(items);
@ -58,6 +59,48 @@ describe('list', () => {
expect(response.body.rows[0].title).toBe(drafts[0]); 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', () => { describe('page', () => {

View File

@ -13,6 +13,10 @@ export default {
name: 'status', name: 'status',
defaultValue: 'publish', defaultValue: 'publish',
}, },
{
type: 'date',
name: 'published_at'
},
{ {
type: 'belongsTo', type: 'belongsTo',
name: 'user', name: 'user',

View File

@ -37,10 +37,6 @@ export async function list(ctx: Context, next: Next) {
context: ctx, context: ctx,
}); });
let data = {}; let data = {};
if (page || perPage) {
options.limit = 1*(perPage||20);
options.offset = options.limit * (page > 0 ? page - 1 : 0);
}
if (associated && resourceField) { if (associated && resourceField) {
const AssociatedModel = ctx.db.getModel(associatedName); const AssociatedModel = ctx.db.getModel(associatedName);
if (!(associated instanceof AssociatedModel)) { if (!(associated instanceof AssociatedModel)) {

View File

@ -1,7 +1,14 @@
import Database from '../database'; import Database from '../database';
import { Dialect } from 'sequelize'; 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 = { const config = {
username: process.env.DB_USER, username: process.env.DB_USER,
@ -21,5 +28,12 @@ const config = {
}; };
export function getDatabase() { export function getDatabase() {
return new Database(config); return new Database({
...config,
hooks: {
beforeDefine(columns, model) {
model.tableName = `${getTestKey()}_${model.tableName || model.name.plural}`;
}
}
});
}; };

View File

@ -1,15 +1,13 @@
import Database from '..'; import Database from '../..';
import { getDatabase } from '.'; import { getDatabase } from '..';
import { Op, Sequelize } from 'sequelize'; import Model, { ModelCtor } from '../../model';
import Model, { ModelCtor } from '../model';
import { BelongsToMany } from '../fields';
import { Mode } from 'fs';
let db: Database; let db: Database;
beforeEach(async () => { beforeEach(async () => {
db = await getDatabase(); db = getDatabase();
}); });
afterEach(async () => { afterEach(async () => {
@ -269,137 +267,7 @@ describe('actions', () => {
}); });
}); });
describe('parseApiJson', () => { describe('query', () => {
let db: Database;
let Foo: ModelCtor<Model>;
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', () => {
it('q', async () => { it('q', async () => {
db.getModel('tags').addScope('scopeName', (name, ctx) => { db.getModel('tags').addScope('scopeName', (name, ctx) => {
expect(ctx.scopeName).toBe(name); expect(ctx.scopeName).toBe(name);
@ -485,9 +353,7 @@ describe('actions', () => {
// expect(count).toBe(1); // expect(count).toBe(1);
}); });
});
describe('query', () => {
it('to be explained', async () => { it('to be explained', async () => {
const [User, Post] = db.getModels(['users', 'posts']); const [User, Post] = db.getModels(['users', 'posts']);
const postData = []; const postData = [];

View File

@ -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<Model>;
let Foo: ModelCtor<Model>;
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' },
}
],
});
});
});
});

View File

@ -6,7 +6,7 @@ import { getDatabase } from './';
let db: Database; let db: Database;
beforeEach(async () => { beforeEach(async () => {
db = await getDatabase(); db = getDatabase();
}); });
afterEach(async () => { afterEach(async () => {

View File

@ -6,7 +6,7 @@ import Model from '../model';
let db: Database; let db: Database;
beforeEach(async () => { beforeEach(async () => {
db = await getDatabase(); db = getDatabase();
}); });
afterEach(async () => { afterEach(async () => {
@ -47,7 +47,8 @@ describe('tables', () => {
name: 'foos', name: 'foos',
}); });
expect(db.getModel('foos').name).toBe('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 () => { it('should be custom when tableName is defined', async () => {
@ -56,7 +57,8 @@ describe('tables', () => {
tableName: 'bar_v2' tableName: 'bar_v2'
}); });
expect(db.getModel('bar').name).toBe('bar'); 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 () => { it('should be custom when timestamps is defined', async () => {

View File

@ -87,6 +87,14 @@ describe('utils.toWhere', () => {
}); });
}); });
it('Op.lt', () => {
expect(toWhere({
'id.lt': 1
})).toEqual({
id: { [Op.lt]: 1 }
});
});
it('Op.between', () => { it('Op.between', () => {
expect(toWhere({ expect(toWhere({
'id.between': [1, 2] 'id.between': [1, 2]
@ -94,6 +102,14 @@ describe('utils.toWhere', () => {
id: { [Op.between]: [1, 2] } 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', () => { 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', () => { it('or in and', () => {
expect(toWhere({ expect(toWhere({
and: [{ a: 1 }, { 'b.or': [3, 4] }], 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 // TODO: bug
it.skip('field as or', () => { it.skip('field as "or"', () => {
expect(toWhere({ expect(toWhere({
or: 1, or: 1,
})).toEqual({ })).toEqual({
@ -175,7 +223,7 @@ describe('utils.toWhere', () => {
}); });
// TODO: bug // TODO: bug
it.skip('or for field as or', () => { it.skip('or for field as "or"', () => {
expect(toWhere({ expect(toWhere({
'or.or': [1, 2], 'or.or': [1, 2],
})).toEqual({ })).toEqual({
@ -186,60 +234,59 @@ describe('utils.toWhere', () => {
describe('association', () => { describe('association', () => {
let db: Database; let db: Database;
let Foo: ModelCtor<Model>;
beforeAll(() => { beforeAll(() => {
db = getDatabase(); db = getDatabase();
db.table({ db.table({
name: 'bazs', name: 'users',
}); });
db.table({ db.table({
name: 'bars', name: 'posts',
fields: [ fields: [
{ {
type: 'belongsTo', type: 'belongsTo',
name: 'baz', name: 'user',
} }
] ]
}); });
db.table({ db.table({
name: 'foos', name: 'categories',
fields: [ fields: [
{ {
type: 'hasMany', type: 'hasMany',
name: 'bars', name: 'posts',
} }
], ],
}); });
Foo = db.getModel('foos');
}); });
afterAll(() => db.close()); afterAll(() => db.close());
const toWhereExpect = (options, logging = false) => { const toWhereExpect = (options: any) => {
const Category = db.getModel('categories');
const where = toWhere(options, { const where = toWhere(options, {
associations: Foo.associations, associations: Category.associations,
}); });
return expect(where); return expect(where);
} }
it('association', () => { it('with included association where', () => {
toWhereExpect({ toWhereExpect({
col1: 'val1', col1: 'val1',
bars: { posts: {
name: { name: {
ilike: 'aa', ilike: 'aa',
}, },
col2: { col2: {
lt: 2, lt: 2,
}, },
baz: { user: {
col1: 12, col1: 12,
}, },
}, },
'bars.col3.ilike': 'aa', 'posts.col3.ilike': 'aa',
}).toEqual({ }).toEqual({
col1: 'val1', col1: 'val1',
$__include: { $__include: {
bars: { posts: {
name: { name: {
[Op.iLike]: 'aa', [Op.iLike]: 'aa',
}, },
@ -250,7 +297,7 @@ describe('utils.toWhere', () => {
[Op.iLike]: 'aa', [Op.iLike]: 'aa',
}, },
$__include: { $__include: {
baz: { user: {
col1: 12 col1: 12
}, },
}, },
@ -258,5 +305,19 @@ describe('utils.toWhere', () => {
}, },
}); });
}); });
// TODO: 是否要考虑跨关联表的逻辑分组这种情况
it.skip('logical across association', () => {
toWhereExpect({
or: {
status: 1,
posts: {
status: 1
}
}
}).toEqual({
})
});
}); });
}); });

View File

@ -82,6 +82,12 @@ export interface ApiJsonOptions {
*/ */
sort?: any; sort?: any;
/**
*
*/
page?: number;
perPage?: number;
context?: any; context?: any;
[key: string]: any; [key: string]: any;
@ -226,13 +232,17 @@ export abstract class Model extends SequelizeModel {
} }
static parseApiJson(options: ApiJsonOptions) { 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}, { 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) {
data.limit = perPage || 20;
data.offset = data.limit * (page > 0 ? page - 1 : 0);
}
if (data.attributes && data.attributes.length === 0) { if (data.attributes && data.attributes.length === 0) {
delete data.attributes; delete data.attributes;
} }

View File

@ -111,11 +111,12 @@ export function toInclude(options: any, context: ToIncludeContext = {}) {
if (Array.isArray(key)) { if (Array.isArray(key)) {
order.push(key); order.push(key);
} else { } else {
const direction = key.indexOf('-') === 0 ? 'DESC' : 'ASC'; const direction = key[0] === '-' ? 'DESC' : 'ASC';
const field = key.replace(/^-/, ''); const field = key.replace(/^-/, '');
items.appends.push(field); // TODO(verify): 理论上只是排序并不一定要输出相关字段
// TODO暂时只支持 postgresql // items.appends.push(field);
order.push([Sequelize.literal(dialect === 'mysql' ? `\`${field}\`` : `"${field}"`), direction]); // TODO: 暂时只支持主表排序,后续需要按`.`分隔符拆分 field 为关联排序
order.push([field, direction]);
} }
}); });
} }

View File

@ -257,7 +257,10 @@ export class Resourcer {
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 转一下 // 自带 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 // filter 支持 json string
if (typeof query.filter === 'string') { if (typeof query.filter === 'string') {
query.filter = JSON.parse(query.filter); query.filter = JSON.parse(query.filter);

View File

@ -87,7 +87,10 @@ export function middleware(options: MiddlewareOptions = {}) {
ctx.action = resourcer.getAction(resourceName, params.actionName).clone(); ctx.action = resourcer.getAction(resourceName, params.actionName).clone();
ctx.action.setContext(ctx); ctx.action.setContext(ctx);
// 自带 query 处理的不太给力,需要用 qs 转一下 // 自带 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 // filter 支持 json string
if (typeof query.filter === 'string') { if (typeof query.filter === 'string') {
query.filter = JSON.parse(query.filter); query.filter = JSON.parse(query.filter);