From b2fe087fc2e8885a79c67150bac088e945862887 Mon Sep 17 00:00:00 2001 From: Junyi Date: Mon, 28 Dec 2020 21:08:13 +0800 Subject: [PATCH] Feature: custom operators for querying (#48) * feat: add some custom operators for querying * feat: add some custom operators for querying * test: fix cases * improve custom operator function * improve filter field component Co-authored-by: chenos --- packages/actions/src/__tests__/index.ts | 13 +- packages/actions/src/__tests__/list.test.ts | 259 ++++++++++++++++-- .../actions/src/__tests__/tables/profiles.ts | 24 ++ .../actions/src/__tests__/tables/users.ts | 11 +- packages/actions/src/actions/common.ts | 9 +- .../components/form.fields/filter/index.tsx | 220 +++++++++++---- .../components/form.fields/filter/style.less | 3 + .../src/__tests__/model/model.test.ts | 7 - packages/database/src/__tests__/op.test.ts | 62 +++++ .../src/__tests__/utils/toWhere.test.ts | 150 +++++++++- packages/database/src/model.ts | 1 + packages/database/src/op.ts | 86 ++++++ packages/database/src/utils.ts | 59 +++- packages/resourcer/src/utils.ts | 4 +- packages/server/src/middleware.ts | 11 +- 15 files changed, 799 insertions(+), 120 deletions(-) create mode 100644 packages/app/src/components/form.fields/filter/style.less create mode 100644 packages/database/src/__tests__/op.test.ts create mode 100644 packages/database/src/op.ts diff --git a/packages/actions/src/__tests__/index.ts b/packages/actions/src/__tests__/index.ts index 3f0582e99..06422e4ea 100644 --- a/packages/actions/src/__tests__/index.ts +++ b/packages/actions/src/__tests__/index.ts @@ -20,9 +20,12 @@ import update2 from './actions/update2'; function getTestKey() { const { id } = require.main; const key = id - .replace(__dirname, '') + .replace(`${process.env.PWD}/packages`, '') + .replace(/src\/__tests__/g, '') .replace('.test.ts', '') - .replace(/[^\w]/g, '_'); + .replace(/[^\w]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, ''); return key } @@ -77,6 +80,10 @@ resourcer.define({ name: 'users', actions: actions.common, }); +resourcer.define({ + name: 'profiles', + actions: actions.common, +}); resourcer.define({ type: 'hasOne', name: 'users.profile', @@ -128,7 +135,7 @@ export async function initDatabase() { const options = requireModule(file); database.table(typeof options === 'function' ? options(database) : { ...options, - tableName: `${options.tableName}_${key}` + tableName: `${key}_${options.tableName}` }); }); await database.sync({ diff --git a/packages/actions/src/__tests__/list.test.ts b/packages/actions/src/__tests__/list.test.ts index 3faedbfe4..367de4ffe 100644 --- a/packages/actions/src/__tests__/list.test.ts +++ b/packages/actions/src/__tests__/list.test.ts @@ -1,4 +1,4 @@ -import { Op } from 'sequelize'; +import { literal, Op } from 'sequelize'; import { initDatabase, agent } from './index'; @@ -23,11 +23,14 @@ describe('list', () => { beforeEach(async () => { const User = db.getModel('users'); await User.bulkCreate([ - { name: 'a', ...timestamps }, - { name: 'b', ...timestamps }, + { name: 'a', ...timestamps, nicknames: ['aa', 'aaa'] }, + { name: 'b', ...timestamps, nicknames: [] }, { name: 'c', ...timestamps } ]); const users = await User.findAll(); + users[0].updateSingleAssociation('profile', { city: '1101', interest: [1] }); + users[1].updateSingleAssociation('profile', { city: '3710', interest: [1, 2] }); + users[2].updateSingleAssociation('profile', { city: '5301', interest: [] }); const Post = db.getModel('posts'); await Post.bulkCreate(Array(25).fill(null).map((_, index) => ({ @@ -140,7 +143,184 @@ describe('list', () => { expect(response.body.count).toBe(1); expect(response.body.rows[0].id).toBe(2); }); - }) + }); + + describe('custom ops', () => { + it('$null', async () => { + const Post = db.getModel('posts'); + const expected = await Post.findAll({ + where: { + published_at: null + } + }); + const response = await agent.get('/posts?filter[published_at.$null]='); + expect(response.body.count).toBe(expected.length); + }); + + describe('$anyOf', () => { + describe('single', () => { + // TODO(question): 是否应该用 in/notIn 来处理单项? + // 或者单项存值也使用 JSON 类型也可以。 + it.skip('$anyOf', async () => { + // const Profile = db.getModel('profiles'); + // const profiles = await Profile.findAll(); + const response = await agent.get('/profiles?filter[city.$anyOf]=Beijing,Weihai'); + console.log(response.body); + // expect(response.body.count).toBe(2); + }); + }); + + describe('multiple', () => { + it('$anyOf for 1 element in definition', async () => { + const User = db.getModel('users'); + const expected = await User.findOne({ + where: { + nicknames: { [Op.contains]: 'aa' } + } + }); + const response = await agent.get('/users?filter[nicknames.$anyOf][]=aa'); + expect(response.body.count).toBe(1); + expect(response.body.rows[0].name).toBe(expected.name); + }); + + it('$anyOf for all elements in definition', async () => { + const User = db.getModel('users'); + const expected = await User.findOne({ + where: { + nicknames: { [Op.or]: [ + { [Op.contains]: 'aaa' }, + { [Op.contains]: 'aa' } + ] } + } + }); + const response = await agent.get('/users?filter[nicknames.$anyOf]=aaa,aa'); + expect(response.body.count).toBe(1); + expect(response.body.rows[0].name).toBe(expected.name); + }); + + it('$anyOf for some element not in definition', async () => { + const User = db.getModel('users'); + const expected = await User.findOne({ + where: { + nicknames: { [Op.or]: [{ [Op.contains]: ['aaa'] }, { [Op.contains]: ['a'] }] } + } + }); + const response = await agent.get('/users?filter[nicknames.$anyOf]=aaa,a'); + expect(response.body.count).toBe(1); + expect(response.body.rows[0].name).toBe(expected.name); + }); + + it('$anyOf for no element', async () => { + const User = db.getModel('users'); + const expected = await User.findAll(); + const response = await agent.get('/users?filter={"nicknames.$anyOf":[]}'); + expect(response.body.count).toBe(expected.length); + }); + }); + }); + + describe('$allOf', () => { + it('$allOf for no element', async () => { + const response = await agent.get('/users?filter={"nicknames.$allOf":[]}'); + expect(response.body.count).toBe(3); + }); + + it('$allOf for different element', async () => { + const response = await agent.get('/users?filter[nicknames.$allOf]=a,aa'); + expect(response.body.count).toBe(0); + }); + + it('$allOf for less element', async () => { + const response = await agent.get('/users?filter[nicknames.$allOf][]=aa&fields=name,nicknames'); + expect(response.body.count).toBe(1); + expect(response.body.rows).toEqual([ + { name: 'a', nicknames: ['aa', 'aaa'] } + ]); + }); + + it('$allOf for same element', async () => { + const response = await agent.get('/users?filter[nicknames.$allOf]=aa,aaa&fields=name,nicknames'); + expect(response.body.count).toBe(1); + expect(response.body.rows).toEqual([ + { name: 'a', nicknames: ['aa', 'aaa'] } + ]); + }); + + it('$allOf for more element', async () => { + const response = await agent.get('/users?filter[nicknames.$allOf]=a,aa,aaa'); + expect(response.body.count).toBe(0); + }); + }); + + // TODO(bug): 需要 toWhere 重构和操作符函数修改 + describe.skip('$noneOf', () => { + it('$noneOf for no element', async () => { + const response = await agent.get('/users?filter={"nicknames.$noneOf":[]}'); + expect(response.body.count).toBe(3); + }); + + it('$noneOf for different element', async () => { + const User = db.getModel('users'); + const users = await User.findAll({ + where: { + [Op.not]: { + // 不使用 or 包装两个同一个 col 的条件会被转化成 and,与官方文档不符 + // WHERE NOT ("users"."nicknames" @> '"aa"' AND "users"."nicknames" @> '"a"') + [Op.or]: [ + { nicknames: { [Op.contains]: 'aa' } }, + { nicknames: { [Op.contains]: 'a' } }, + ] + } + } + }); + console.log(users); + // const response = await agent.get('/users?filter[nicknames.$noneOf]=a,aa'); + // expect(response.body.count).toBe(2); + }); + + it('$noneOf for less element', async () => { + const response = await agent.get('/users?filter[nicknames.$noneOf][]=aa&fields=name,nicknames'); + expect(response.body.count).toBe(2); + }); + + it('$noneOf for same element', async () => { + const response = await agent.get('/users?filter[nicknames.$noneOf]=aa,aaa&fields=name,nicknames'); + expect(response.body.count).toBe(2); + }); + + it('$noneOf for more element', async () => { + const response = await agent.get('/users?filter[nicknames.$noneOf]=a,aa,aaa'); + expect(response.body.count).toBe(2); + }); + }); + + describe('$match', () => { + it('$match for no element', async () => { + const response = await agent.get('/users?filter={"nicknames.$match":[]}'); + expect(response.body.count).toBe(2); + }); + + it('$match for different element', async () => { + const response = await agent.get('/users?filter[nicknames.$match]=a,aa'); + expect(response.body.count).toBe(0); + }); + + it('$match for less element', async () => { + const response = await agent.get('/users?filter[nicknames.$match][]=aa&fields=name,nicknames'); + expect(response.body.count).toBe(0); + }); + + it('$match for same element', async () => { + const response = await agent.get('/users?filter[nicknames.$match]=aa,aaa&fields=name,nicknames'); + expect(response.body.count).toBe(1); + }); + + it('$match for more element', async () => { + const response = await agent.get('/users?filter[nicknames.$match]=a,aa,aaa'); + expect(response.body.count).toBe(0); + }); + }); + }); }); describe('page', () => { @@ -277,7 +457,8 @@ describe('list', () => { 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', ...timestampsStrings } }]); + expect(response.body.rows).toEqual([{ + title: 'title0', user: { id: 1, nicknames: ['aa', 'aaa'], name: 'a', ...timestampsStrings } }]); }); }); }); @@ -349,8 +530,8 @@ describe('list', () => { // 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`); - + .get(`/users/1/posts?fields=comments.content,user.name&filter[comments.status]=draft&sort=-comments.content&page=1&perPage=2`); + expect(response.body).toEqual({ count: 1, page: 1, @@ -366,22 +547,55 @@ describe('list', () => { ] }); }); + + it('count field in hasMany', async () => { + try { + const response = await agent + .get(`/users/1?fields=name,posts_count`); + console.log(response.body); + } catch (err) { + console.error(err); + } + }); + + it('count field in hasMany', async () => { + try { + const response = await agent + .get(`/users/1/posts?fields=title,comments_count`); + console.log(response.body); + } catch (err) { + console.error(err); + } + }); }); describe('belongsToMany', () => { beforeEach(async () => { + const Tag = db.getModel('tags'); + const tags = await Tag.bulkCreate([ + {name: 'tag1', status: 'published'}, + {name: 'tag2', status: 'draft'}, + {name: 'tag3', status: 'published'}, + {name: 'tag4', status: 'draft'}, + {name: 'tag5', status: 'published'}, + {name: 'tag6', status: 'draft'}, + {name: 'tag7', status: 'published'}, + {name: 'tag8', status: 'published'}, + {name: 'tag9', status: 'draft'}, + {name: 'tag10', status: 'published'}, + ]); const Post = db.getModel('posts'); - const post = await Post.create(); - await post.updateAssociations({ - tags: [ - {name: 'tag1', status: 'published'}, - {name: 'tag2', status: 'draft'}, - {name: 'tag3', status: 'published'}, - {name: 'tag4', status: 'draft'}, - {name: 'tag5', status: 'published'}, - {name: 'tag6', status: 'draft'}, - {name: 'tag7', status: 'published'}, - ], + const [post1, post2] = await Post.bulkCreate([{}, {}]); + await post1.updateAssociations({ + tags: [1,2,3,4,5,6,7] + }); + await post2.updateAssociations({ + tags: [2,5,8] + }); + const User = db.getModel('users'); + const user = await User.create(); + await user.updateAssociations({ + posts: [post1] }); }); @@ -396,6 +610,13 @@ describe('list', () => { page: 2, per_page: 2 }); - }); + }); + + // TODO(bug): SQL 报错 + it.skip('list2', async () => { + const response = await agent + .get(`/users/1/posts?fields=tags`); + console.log(response.body); + }); }); }); diff --git a/packages/actions/src/__tests__/tables/profiles.ts b/packages/actions/src/__tests__/tables/profiles.ts index 992fdb885..be1e6e616 100644 --- a/packages/actions/src/__tests__/tables/profiles.ts +++ b/packages/actions/src/__tests__/tables/profiles.ts @@ -4,9 +4,33 @@ export default { name: 'profiles', tableName: 'actions__profiles', fields: [ + { + type: 'belongsTo', + name: 'user' + }, { type: 'string', name: 'email', }, + { + type: 'string', + name: 'city', + dataSource: [ + { value: '1101', title: 'Beijing' }, + { value: '3710', title: 'Weihai' }, + { value: '5301', title: 'Kunming' } + ] + }, + { + type: 'jsonb', + name: 'interest', + defaultValue: [], + multiple: true, + dataSource: [ + { value: 1, title: 'running' }, + { value: 2, title: 'climbing' }, + { value: 3, title: 'fishing' }, + ] + } ], } as TableOptions; diff --git a/packages/actions/src/__tests__/tables/users.ts b/packages/actions/src/__tests__/tables/users.ts index 224ddf7f7..5ded5bc7b 100644 --- a/packages/actions/src/__tests__/tables/users.ts +++ b/packages/actions/src/__tests__/tables/users.ts @@ -9,8 +9,17 @@ export default { name: 'name', }, { - type: 'hasone', + type: 'jsonb', + name: 'nicknames', + defaultValue: [] + }, + { + type: 'hasOne', name: 'profile', }, + { + type: 'hasMany', + name: 'posts' + } ], } as TableOptions; diff --git a/packages/actions/src/actions/common.ts b/packages/actions/src/actions/common.ts index e9fc85154..a46418398 100644 --- a/packages/actions/src/actions/common.ts +++ b/packages/actions/src/actions/common.ts @@ -259,19 +259,14 @@ export async function create(ctx: Context, next: Next) { throw new Error(`${associatedName} associated model invalid`); } const { create } = resourceField.getAccessors(); - // @ts-ignore model = await associated[create](values, options); - await model.updateAssociations(values, options); - ctx.body = model; } else { const ResourceModel = ctx.db.getModel(resourceName); - // @ts-ignore model = await ResourceModel.create(values, options); - // @ts-ignore - await model.updateAssociations(values, options); - ctx.body = model; } + await model.updateAssociations(values, options); await transaction.commit(); + ctx.body = model; await next(); } diff --git a/packages/app/src/components/form.fields/filter/index.tsx b/packages/app/src/components/form.fields/filter/index.tsx index 4c25213d4..cd6f1d7ee 100644 --- a/packages/app/src/components/form.fields/filter/index.tsx +++ b/packages/app/src/components/form.fields/filter/index.tsx @@ -1,13 +1,15 @@ import React, { useEffect, useState } from 'react'; -import { Button, Select, Input, Space, Form, InputNumber, DatePicker } from 'antd'; +import { Button, Select, Input, Space, Form, InputNumber, DatePicker, TimePicker, Radio } from 'antd'; import { PlusCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; import useDynamicList from './useDynamicList'; import { connect } from '@formily/react-schema-renderer' import { mapStyledProps } from '../shared' +import get from 'lodash/get'; import moment from 'moment'; +import './style.less'; export function FilterGroup(props: any) { - const { showDeleteButton = false, fields = [], onDelete, onChange, onAdd, dataSource = {} } = props; + const { showDeleteButton = true, fields = [], onDelete, onChange, onAdd, dataSource = {} } = props; const { list, getKey, push, remove, replace } = useDynamicList(dataSource.list || [ { type: 'item', @@ -47,7 +49,7 @@ export function FilterGroup(props: any) { { 1} + // showDeleteButton={list.length > 1} onChange={(value) => { replace(index, value); const newList = [...list]; @@ -101,7 +103,7 @@ export function FilterGroup(props: any) { > 添加条件组 - {showDeleteButton && )} @@ -270,12 +366,18 @@ export function FilterItem(props: FilterItemProps) { function toFilter(values: any) { let filter: any; - const { type, andor = 'and', list = [], column, op, value } = values; + let { type, andor = 'and', list = [], column, op, value } = values; if (type === 'group') { filter = { [andor]: list.map(value => toFilter(value)).filter(Boolean) } } else if (type === 'item' && column && op) { + if (['id.$null', 'id.$notNull', '$null', '$notNull', '$isTruly', '$isFalsy'].indexOf(op) !== -1) { + value = true; + } + // if (op === 'id.gt') { + // value = 0; + // } filter = { [`${column}`]: {[op]: value}, } @@ -314,7 +416,7 @@ export const Filter = connect({ }; const { value, onChange, ...restProps } = props; console.log('valuevaluevaluevaluevaluevalue', value); - return { + return { console.log(values); onChange(toFilter(values)); }} {...restProps}/> diff --git a/packages/app/src/components/form.fields/filter/style.less b/packages/app/src/components/form.fields/filter/style.less new file mode 100644 index 000000000..b08b1135b --- /dev/null +++ b/packages/app/src/components/form.fields/filter/style.less @@ -0,0 +1,3 @@ +.filter-remove-link { + color: inherit; +} \ No newline at end of file diff --git a/packages/database/src/__tests__/model/model.test.ts b/packages/database/src/__tests__/model/model.test.ts index 296f978e1..d5037f7df 100644 --- a/packages/database/src/__tests__/model/model.test.ts +++ b/packages/database/src/__tests__/model/model.test.ts @@ -1222,13 +1222,6 @@ describe('belongsToMany', () => { }); expect(await post.countTags()).toBe(1); }); - // TODO(question) - it.skip('update with primaryKey (defined targetKey)', async () => { - await post.updateAssociations({ - tags: tag2.id, - }); - expect(await post.countTags()).toBe(1); - }); it('update with model', async () => { await post.updateAssociations({ tags: [tag1, tag2], diff --git a/packages/database/src/__tests__/op.test.ts b/packages/database/src/__tests__/op.test.ts new file mode 100644 index 000000000..1fa703829 --- /dev/null +++ b/packages/database/src/__tests__/op.test.ts @@ -0,0 +1,62 @@ +import { getDatabase } from '.'; +import Database, { Field } from '../'; +import Table from '../table'; + +let db: Database; + +beforeEach(async () => { + db = getDatabase(); + db.table({ + name: 'tests', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'jsonb', + name: 'arr', + defaultValue: [], + }, + ], + }); + await db.sync(); +}); + +afterEach(async () => { + await db.close(); +}); + +describe('op', () => { + it('test', async () => { + const Test = db.getModel('tests'); + await Test.bulkCreate([ + { + arr: ['aa', 'bb'], + }, + { + arr: ['bb', 'dd'], + }, + { + arr: ['cc', 'bb'], + }, + { + arr: ['dd'], + } + ]); + const options = Test.parseApiJson({ + filter: { + and: [ + { + 'arr.$anyOf': ['bb'], + }, + { + 'arr.$noneOf': ['aa', 'cc'], + }, + ], + }, + }); + const test = await Test.findOne(options); + expect(test.get('arr')).toEqual(['bb', 'dd']); + }); +}); diff --git a/packages/database/src/__tests__/utils/toWhere.test.ts b/packages/database/src/__tests__/utils/toWhere.test.ts index ccb3f76e5..7af79b3d7 100644 --- a/packages/database/src/__tests__/utils/toWhere.test.ts +++ b/packages/database/src/__tests__/utils/toWhere.test.ts @@ -140,6 +140,119 @@ describe('utils.toWhere', () => { id: { [Op.between]: ['2020-11-01T00:00:00.000Z', '2020-12-01T00:00:00.000Z'] } }); }); + + it('Op.$null', () => { + expect(toWhere({ + 'id.$null': true + })).toEqual({ + id: { [Op.is]: null } + }); + }); + + it('Op.$null', () => { + expect(toWhere({ + 'id.$null': false + })).toEqual({ + id: { [Op.is]: null } + }); + }); + + it('Op.$null', () => { + expect(toWhere({ + 'id.$null': null + })).toEqual({ + id: { [Op.is]: null } + }); + }); + + it('Op.$notNull', () => { + expect(toWhere({ + 'id.$notNull': true + })).toEqual({ + id: { [Op.not]: null } + }); + }); + + it('Op.$notNull', () => { + expect(toWhere({ + 'id.$notNull': false + })).toEqual({ + id: { [Op.not]: null } + }); + }); + + it('Op.$notNull', () => { + expect(toWhere({ + 'id.$notNull': null + })).toEqual({ + id: { [Op.not]: null } + }); + }); + + it('Op.$includes', () => { + expect(toWhere({ + 'string.$includes': 'a' + })).toEqual({ + string: { [Op.iLike]: '%a%' } + }); + }); + + it('Op.$notIncludes', () => { + expect(toWhere({ + 'string.$notIncludes': 'a' + })).toEqual({ + string: { [Op.notILike]: '%a%' } + }); + }); + + it('Op.$startsWith', () => { + expect(toWhere({ + 'string.$startsWith': 'a' + })).toEqual({ + string: { [Op.iLike]: 'a%' } + }); + }); + + it('Op.$notStartsWith', () => { + expect(toWhere({ + 'string.$notStartsWith': 'a' + })).toEqual({ + string: { [Op.notILike]: 'a%' } + }); + }); + + it('Op.$endsWith', () => { + expect(toWhere({ + 'string.$endsWith': 'a' + })).toEqual({ + string: { [Op.iLike]: '%a' } + }); + }); + + it('Op.$notEndsWith', () => { + expect(toWhere({ + 'string.$notEndsWith': 'a' + })).toEqual({ + string: { [Op.notILike]: '%a' } + }); + }); + + it('Op.$anyOf', () => { + expect(toWhere({ + 'array.$anyOf': ['a', 'b'] + })).toEqual({ + array: { [Op.or]: [{ [Op.contains]: 'a' }, { [Op.contains]: 'b' }] } + }); + }); + + // TODO(bug) + it.skip('Op.$noneOf', () => { + expect(toWhere({ + 'array.$noneOf': ['a', 'b'] + })).toEqual({ + array: { [Op.not]: [{ [Op.contains]: 'a' }, { [Op.contains]: 'b' }] } + }); + }); }); describe('group by logical operator', () => { @@ -283,6 +396,37 @@ describe('utils.toWhere', () => { return expect(where); } + it('logical and other comparation', () => { + toWhereExpect({ + or: [ + { a: 1 }, + { b: { gt: 2 } }, + { and: [ + { + 'c.and': [ + {gt: 3, lt: 6} + ], + }, + ] }, + ], + }) + .toEqual({ + [Op.or]: [ + { a: 1 }, + { b: { [Op.gt]: 2 } }, + {[Op.and]: [ + { + c: { + [Op.and]: [ + { [Op.gt]: 3, [Op.lt]: 6 } + ] + }, + } + ]}, + ], + }); + }); + it('with included association where', () => { toWhereExpect({ col1: 'val1', @@ -295,6 +439,7 @@ describe('utils.toWhere', () => { }, user: { col1: 12, + 'col2.lt': 2, }, }, 'posts.col3.ilike': 'aa', @@ -313,7 +458,10 @@ describe('utils.toWhere', () => { }, $__include: { user: { - col1: 12 + col1: 12, + col2: { + [Op.lt]: 2, + }, }, }, }, diff --git a/packages/database/src/model.ts b/packages/database/src/model.ts index 2732ca62a..afdbda12c 100644 --- a/packages/database/src/model.ts +++ b/packages/database/src/model.ts @@ -248,6 +248,7 @@ export abstract class Model extends SequelizeModel { associations: this.associations, dialect: this.sequelize.getDialect(), ctx: context, + database: this.database, }); if (page || perPage) { data.limit = perPage === -1 ? MAX_LIMIT : Math.min(perPage || DEFAULT_LIMIT, MAX_LIMIT); diff --git a/packages/database/src/op.ts b/packages/database/src/op.ts new file mode 100644 index 000000000..02d6da66d --- /dev/null +++ b/packages/database/src/op.ts @@ -0,0 +1,86 @@ +import { Op, Utils, Sequelize } from 'sequelize'; + +function toArray(value: any): any[] { + if (value == null) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +const op = new Map(); + +// Sequelize 内置 +for (const key in Op) { + op.set(key, Op[key]); + const val = Utils.underscoredIf(key, true); + op.set(val, Op[key]); + op.set(val.replace(/_/g, ''), Op[key]); +} + +// 通用 + +// 是否为空:数据库意义的 null +op.set('$null', () => ({ [Op.is]: null })); +op.set('$notNull', () => ({ [Op.not]: null })); + +op.set('$isTruly', () => ({ + [Op.eq]: true, +})); +op.set('$isFalsy', () => ({ + [Op.or]: [ + { + [Op.eq]: false, + }, + { + [Op.is]: null, + }, + ], +})); + +// 字符串 + +// 包含:指对应字段的值包含某个子串 +op.set('$includes', (value: string) => ({ [Op.iLike]: `%${value}%` })); +// 不包含:指对应字段的值不包含某个子串(慎用:性能问题) +op.set('$notIncludes', (value: string) => ({ [Op.notILike]: `%${value}%` })); +// 以之起始 +op.set('$startsWith', (value: string) => ({ [Op.iLike]: `${value}%` })); +// 不以之起始 +op.set('$notStartsWith', (value: string) => ({ [Op.notILike]: `${value}%` })); +// 以之结束 +op.set('$endsWith', (value: string) => ({ [Op.iLike]: `%${value}` })); +// 不以之结束 +op.set('$notEndsWith', (value: string) => ({ [Op.notILike]: `%${value}` })); + +// 多选(JSON)类型 + +// 包含组中任意值(命名来源:`Array.prototype.some`) +op.set('$anyOf', (values: any[]) => ({ + [Op.or]: toArray(values).map(value => ({ [Op.contains]: value })) +})); +// 包含组中所有值 +op.set('$allOf', (values: any) => ({ [Op.contains]: toArray(values) })); +// TODO(bug): 不包含组中任意值 +op.set('$noneOf', (values: any[], options) => { + if (!values) { + return Sequelize.literal(''); + } + values = Array.isArray(values) ? values : [values]; + const { field, fieldPath } = options; + const column = fieldPath.split('.').map(name => `"${name}"`).join('.'); + const sql = values.map(value => `(${column})::jsonb @> '${JSON.stringify(value)}'`).join(' OR '); + console.log(sql); + return Sequelize.literal(`not (${sql})`); +}); +// 与组中值匹配 +op.set('$match', (values: any[]) => { + const array = toArray(values); + return { + [Op.contains]: array, + [Op.contained]: array + }; +}); + + + +export default op; diff --git a/packages/database/src/utils.ts b/packages/database/src/utils.ts index 63eb6ea11..91e5db8ff 100644 --- a/packages/database/src/utils.ts +++ b/packages/database/src/utils.ts @@ -1,21 +1,16 @@ -import { Op, Utils, Sequelize } from 'sequelize'; +import { Utils, Sequelize, Op } from 'sequelize'; import Model, { ModelCtor } from './model'; import _ from 'lodash'; - -const op = new Map(); - -for (const key in Op) { - op.set(key, Op[key]); - const val = Utils.underscoredIf(key, true); - op.set(val, Op[key]); - op.set(val.replace(/_/g, ''), Op[key]); -} +import op from './op'; +import Database from './database'; interface ToWhereContext { model?: ModelCtor | Model | typeof Model; associations?: any; dialect?: string; + database?: Database; ctx?: any; + prefix?: any; } export function toWhere(options: any, context: ToWhereContext = {}) { @@ -25,18 +20,20 @@ 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 { prefix, model, associations = {}, ctx, dialect } = context; const items = {}; // 先处理「点号」的问题 for (const key in options) { _.set(items, key, options[key]); } - const values = {}; + let values = {}; for (const key in items) { + const childPreifx = prefix ? `${prefix}.${key}` : key; if (associations[key]) { values['$__include'] = values['$__include'] || {} values['$__include'][key] = toWhere(items[key], { ...context, + prefix: childPreifx, model: associations[key].target, associations: associations[key].target.associations, }); @@ -52,7 +49,42 @@ export function toWhere(options: any, context: ToWhereContext = {}) { } else { // TODO: to fix same op key as field name - values[op.has(key) ? op.get(key) : key] = toWhere(items[key], context); + const opKey = op.get(key); + let k; + switch (typeof opKey) { + case 'function': + const name = model ? model.options.name.plural : ''; + const result = opKey(items[key], { model, fieldPath: name ? `${name}.${prefix}` : prefix }); + if (result.constructor.name === 'Literal') { + values['$__literals'] = values['$__literals'] || []; + values['$__literals'].push(result); + } else { + Object.assign(values, result); + } + // console.log(result.constructor.name === 'Literal'); + continue; + case 'undefined': + k = key; + break; + default: + k = opKey; + break; + } + values[k] = toWhere(items[key], { + ...context, + prefix: op.has(key) ? prefix : childPreifx, + }); + } + } + if (values['$__literals']) { + const $__literals = _.cloneDeep(values['$__literals']); + delete values['$__literals']; + console.log(Object.keys(values)); + return { + [Op.and]: [ + ...$__literals, + values, + ], } } return values; @@ -63,6 +95,7 @@ interface ToIncludeContext { sourceAlias?: string; associations?: any; dialect?: string; + database?: Database; ctx?: any } diff --git a/packages/resourcer/src/utils.ts b/packages/resourcer/src/utils.ts index 30190abf3..8ce5d3002 100644 --- a/packages/resourcer/src/utils.ts +++ b/packages/resourcer/src/utils.ts @@ -197,7 +197,9 @@ export function parseQuery(input: string): any { // 自带 query 处理的不太给力,需要用 qs 转一下 const query = qs.parse(input, { // 原始 query string 中如果一个键连等号“=”都没有可以被认为是 null 类型 - strictNullHandling: true + strictNullHandling: true, + // 逗号分隔转换为数组 + comma: true }); // filter 支持 json string if (typeof query.filter === 'string') { diff --git a/packages/server/src/middleware.ts b/packages/server/src/middleware.ts index 3a7bbc1da..3bf2f183b 100644 --- a/packages/server/src/middleware.ts +++ b/packages/server/src/middleware.ts @@ -1,7 +1,7 @@ import qs from 'qs'; import compose from 'koa-compose'; import { pathToRegexp } from 'path-to-regexp'; -import Resourcer, { getNameByParams, KoaMiddlewareOptions, parseRequest, ResourcerContext } from '@nocobase/resourcer'; +import Resourcer, { getNameByParams, KoaMiddlewareOptions, parseRequest, parseQuery, ResourcerContext } from '@nocobase/resourcer'; import Database, { BELONGSTO, BELONGSTOMANY, HASMANY, HASONE } from '@nocobase/database'; interface MiddlewareOptions extends KoaMiddlewareOptions { @@ -95,14 +95,7 @@ export function middleware(options: MiddlewareOptions = {}) { ctx.action = resourcer.getAction(resourceName, 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,