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,
create() {

View File

@ -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', () => {

View File

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

View File

@ -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)) {

View File

@ -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}`;
}
}
});
};

View File

@ -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<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', () => {
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 = [];

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;
beforeEach(async () => {
db = await getDatabase();
db = getDatabase();
});
afterEach(async () => {

View File

@ -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 () => {

View File

@ -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<Model>;
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({
})
});
});
});

View File

@ -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;
}

View File

@ -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]);
}
});
}

View File

@ -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);

View File

@ -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);