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 <chenlinxh@gmail.com>
This commit is contained in:
Junyi 2020-12-28 21:08:13 +08:00 committed by GitHub
parent 0754c5979a
commit b2fe087fc2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 799 additions and 120 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<any>(dataSource.list || [
{
type: 'item',
@ -47,7 +49,7 @@ export function FilterGroup(props: any) {
{<Component
fields={fields}
dataSource={item}
showDeleteButton={list.length > 1}
// showDeleteButton={list.length > 1}
onChange={(value) => {
replace(index, value);
const newList = [...list];
@ -101,7 +103,7 @@ export function FilterGroup(props: any) {
>
<PlusCircleOutlined />
</Button>
{showDeleteButton && <Button style={{padding: 0, position: 'absolute', top: 0, right: 0, width: 32}} type={'link'} onClick={(e) => {
{showDeleteButton && <Button className={'filter-remove-link filter-group'} style={{padding: 0, position: 'absolute', top: 0, right: 0, width: 32}} type={'link'} onClick={(e) => {
onDelete && onDelete(e);
}}>
<CloseCircleOutlined />
@ -126,59 +128,82 @@ interface FilterItemProps {
const OP_MAP = {
string: [
{label: '等于', value: 'eq'},
{label: '不等于', value: 'neq'},
{label: '包含', value: 'cont'},
{label: '不包含', value: 'ncont'},
{label: '非空', value: 'notnull'},
{label: '为空', value: 'null'},
{label: '等于', value: 'eq', selected: true},
{label: '不等于', value: 'ne'},
{label: '包含', value: '$includes'},
{label: '不包含', value: '$notIncludes'},
{label: '非空', value: '$notNull'},
{label: '为空', value: '$null'},
],
number: [
{label: '等于', value: 'eq'},
{label: '不等于', value: 'neq'},
{label: '等于', value: 'eq', selected: true},
{label: '不等于', value: 'ne'},
{label: '大于', value: 'gt'},
{label: '大于等于', value: 'gte'},
{label: '小于', value: 'lt'},
{label: '小于等于', value: 'lte'},
{label: '介于', value: 'between'},
{label: '非空', value: 'notnull'},
{label: '为空', value: 'null'},
{label: '非空', value: '$notNull'},
{label: '为空', value: '$null'},
],
file: [
{label: '非空', value: 'notnull'},
{label: '为空', value: 'null'},
{label: '存在', value: 'id.gt'},
{label: '不存在', value: 'id.$null'},
],
boolean: [
{label: '等于', value: 'eq'},
{label: '是', value: '$isTruly', selected: true},
{label: '否', value: '$isFalsy'},
],
choices: [
{label: '等于', value: 'eq'},
{label: '不等于', value: 'neq'},
{label: '包含', value: 'cont'},
{label: '不包含', value: 'ncont'},
{label: '非空', value: 'notnull'},
{label: '为空', value: 'null'},
select: [
{label: '等于', value: 'eq', selected: true},
{label: '不等于', value: 'ne'},
{label: '包含', value: '$anyOf'},
{label: '不包含', value: '$noneOf'},
{label: '非空', value: '$notNull'},
{label: '为空', value: '$null'},
],
multipleSelect: [
{label: '等于', value: 'eq', selected: true},
{label: '不等于', value: 'ne'},
{label: '包含', value: '$anyOf'},
{label: '不包含', value: '$noneOf'},
{label: '非空', value: '$notNull'},
{label: '为空', value: '$null'},
],
datetime: [
{label: '等于', value: 'eq'},
{label: '等于', value: 'eq', selected: true},
{label: '不等于', value: 'neq'},
{label: '大于', value: 'gt'},
{label: '大于等于', value: 'gte'},
{label: '小于', value: 'lt'},
{label: '小于等于', value: 'lte'},
{label: '介于', value: 'between'},
{label: '非空', value: 'notnull'},
{label: '为空', value: 'null'},
{label: '是今天', value: 'now'},
{label: '在今天之前', value: 'before_today'},
{label: '在今天之后', value: 'after_today'},
{label: '非空', value: '$notNull'},
{label: '为空', value: '$null'},
// {label: '是今天', value: 'now'},
// {label: '在今天之前', value: 'before_today'},
// {label: '在今天之后', value: 'after_today'},
],
linkTo: [
{label: '包含', value: 'cont'},
{label: '不包含', value: 'ncont'},
{label: '非空', value: 'notnull'},
{label: '为空', value: 'null'},
time: [
{label: '等于', value: 'eq', selected: true},
{label: '不等于', value: 'neq'},
{label: '大于', value: 'gt'},
{label: '大于等于', value: 'gte'},
{label: '小于', value: 'lt'},
{label: '小于等于', value: 'lte'},
{label: '介于', value: 'between'},
{label: '非空', value: '$notNull'},
{label: '为空', value: '$null'},
// {label: '是今天', value: 'now'},
// {label: '在今天之前', value: 'before_today'},
// {label: '在今天之后', value: 'after_today'},
],
// linkTo: [
// {label: '包含', value: 'cont'},
// {label: '不包含', value: 'ncont'},
// {label: '非空', value: '$notNull'},
// {label: '为空', value: '$null'},
// ],
};
const op = {
@ -188,6 +213,15 @@ const op = {
percent: OP_MAP.number,
datetime: OP_MAP.datetime,
date: OP_MAP.datetime,
time: OP_MAP.time,
checkbox: OP_MAP.boolean,
boolean: OP_MAP.boolean,
select: OP_MAP.select,
multipleSelect: OP_MAP.select,
checkboxes: OP_MAP.select,
radio: OP_MAP.select,
upload: OP_MAP.file,
attachment: OP_MAP.file,
};
const StringInput = (props) => {
@ -203,41 +237,101 @@ const controls = {
string: StringInput,
textarea: StringInput,
number: InputNumber,
// datetime: DatePicker,
date: (props) => {
const { value, onChange, ...restProps } = props;
const m = moment(value, 'YYYY-MM-DD HH:mm:ss');
return (
<DatePicker value={m.isValid() ? m : null} onChange={(value) => {
onChange(value ? value.format('YYYY-MM-DD HH:mm:ss') : null)
console.log(value.format('YYYY-MM-DD HH:mm:ss'));
}}/>
);
},
percent: InputNumber,
boolean: BooleanControl,
checkbox: BooleanControl,
select: OptionControl,
radio: OptionControl,
checkboxes: OptionControl,
multipleSelect: OptionControl,
time: TimePicker,
date: DateControl,
};
function DateControl(props: any) {
const { value, onChange, ...restProps } = props;
const m = moment(value, 'YYYY-MM-DD HH:mm:ss');
return (
<DatePicker value={m.isValid() ? m : null} onChange={(value) => {
onChange(value ? value.format('YYYY-MM-DD HH:mm:ss') : null)
console.log(value.format('YYYY-MM-DD HH:mm:ss'));
}}/>
);
}
function TimeControl(props: any) {
return
}
function OptionControl(props) {
const { multiple = true, op, options, value, onChange, ...restProps } = props;
let mode: any = 'multiple';
if (!multiple && ['eq', 'ne'].indexOf(op) !== -1) {
mode = undefined;
}
return (
<Select style={{ minWidth: 120 }} mode={mode} value={value} onChange={(value) => {
onChange(value);
}} options={options}>
</Select>
);
}
function BooleanControl(props) {
const { value, onChange, ...restProps } = props;
return (
<Radio.Group value={value} onChange={(e) => {
onChange(e.target.value);
}}>
<Radio value={true}></Radio>
<Radio value={false}></Radio>
</Radio.Group>
);
}
function NullControl(props) {
return null;
}
export function FilterItem(props: FilterItemProps) {
const { index, fields = [], showDeleteButton = false, onDelete, onChange, dataSource = {} } = props;
const { index, fields = [], showDeleteButton = true, onDelete, onChange } = props;
const [type, setType] = useState('string');
const [field, setField] = useState<any>({});
const [dataSource, setDataSource] = useState(props.dataSource||{});
useEffect(() => {
const field = fields.find(field => field.name === dataSource.column);
const field = fields.find(field => field.name === props.dataSource.column);
if (field) {
setField(field);
setType(field.component.type);
// console.log(dataSource);
}
setDataSource({...props.dataSource});
// if (['boolean', 'checkbox'].indexOf(type) !== -1) {
// onChange({...dataSource, op: undefined});
// }
}, [
dataSource,
props.dataSource, type,
]);
console.log({type});
const ValueControl = controls[type]||controls.string;
let ValueControl = controls[type]||controls.string;
if (['$null', '$notNull', '$isTruly', '$isFalsy'].indexOf(dataSource.op) !== -1) {
ValueControl = NullControl;
}
if (['boolean', 'checkbox'].indexOf(type) !== -1) {
ValueControl = NullControl;
}
// let multiple = true;
// if ()
const opOptions = op[type]||op.string;
console.log({field, dataSource, type, ValueControl});
return (
<Space>
<Select value={dataSource.column}
onChange={(value) => {
const field = fields.find(field => field.name === value);
if (field) {
setType(field.interface);
setType(field.component.type);
}
onChange({...dataSource, column: value});
onChange({...dataSource, column: value, op: get(op, [field.component.type, 0, 'value']), value: undefined});
}}
style={{ width: 120 }}
placeholder={'选择字段'}>
@ -249,18 +343,20 @@ export function FilterItem(props: FilterItemProps) {
onChange={(value) => {
onChange({...dataSource, op: value});
}}
defaultValue={get(opOptions, [0, 'value'])}
options={opOptions}
>
{(op[type]||op.string).map(option => (
{/* {(op[type]||op.string).map(option => (
<Select.Option value={option.value}>{option.label}</Select.Option>
))}
))} */}
</Select>
<ValueControl value={dataSource.value} onChange={(value) => {
<ValueControl multiple={type === 'checkboxes' || !!field.multiple} op={dataSource.op} options={field.dataSource} value={dataSource.value} onChange={(value) => {
onChange({...dataSource, value: value});
}}
style={{ width: 180 }}
/>
{showDeleteButton && (
<Button type={'link'} style={{padding: 0}} onClick={(e) => {
<Button className={'filter-remove-link filter-item'} type={'link'} style={{padding: 0}} onClick={(e) => {
onDelete && onDelete(e);
}}><CloseCircleOutlined /></Button>
)}
@ -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 <FilterGroup dataSource={value ? toValues(value) : dataSource} onChange={(values) => {
return <FilterGroup showDeleteButton={false} dataSource={value ? toValues(value) : dataSource} onChange={(values) => {
console.log(values);
onChange(toFilter(values));
}} {...restProps}/>

View File

@ -0,0 +1,3 @@
.filter-remove-link {
color: inherit;
}

View File

@ -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],

View File

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

View File

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

View File

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

View File

@ -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<string, typeof Op | Function>();
// 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;

View File

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

View File

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

View File

@ -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,