refactor: actions (#137)

* db test

* associated list action

* associated list action

* fix belongs to many repository test

* create action

* update action

* add update & destroy has one

* get action

* add action

* set action

* remove action

* toggle action

* chore: code import

* add sort field mutex

* change field mutex position

* feat: handle sort field scope change

* feat: sort actions

* fix: add action

* rename sort action to move action

* more actions params

* feat: repository destroy with filter and filterByPK

* feat: hasmany repository destroy with filter and filterByPK

* feat: belongsToMany repository destroy with filter and filterByPK

* fix: actions tests lock error

* feat: code cleanup

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
ChengLei Shao 2021-12-16 16:46:54 +08:00 committed by GitHub
parent 8f0a71a1cf
commit 79ba391aee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 2578 additions and 67 deletions

View File

@ -0,0 +1,112 @@
import { mockServer } from './index';
import { registerActions } from '@nocobase/actions';
describe('add action', () => {
let app;
let Post;
let Comment;
let Tag;
let PostTag;
beforeEach(async () => {
app = mockServer();
registerActions(app);
PostTag = app.collection({
name: 'posts_tags',
fields: [{ type: 'string', name: 'tagged_at' }],
});
Post = app.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'title' },
{ type: 'hasMany', name: 'comments' },
{ type: 'hasOne', name: 'profile' },
{ type: 'belongsToMany', name: 'tags', through: 'posts_tags' },
{ type: 'string', name: 'status', defaultValue: 'draft' },
],
});
Comment = app.collection({
name: 'comments',
fields: [
{ type: 'string', name: 'content' },
{ type: 'belongsTo', name: 'post' },
],
});
Tag = app.collection({
name: 'tags',
fields: [
{ type: 'string', name: 'name' },
{ type: 'belongsToMany', name: 'posts', through: 'posts_tags' },
],
});
await app.db.sync();
});
afterEach(async () => {
await app.destroy();
});
test('add belongs to many', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
},
});
const t1 = await Tag.repository.create({
values: {
name: 't1',
},
});
const t2 = await Tag.repository.create({
values: {
name: 't2',
},
});
const t3 = await Tag.repository.create({
values: {
name: 't3',
},
});
expect(await p1.countTags()).toEqual(0);
const response = await app
.agent()
.resource('posts.tags')
.add({
associatedIndex: p1.get('id'),
values: [t1.get('id'), t2.get('id')],
});
expect(await p1.countTags()).toEqual(2);
// add with through values
await app
.agent()
.resource('posts.tags')
.add({
associatedIndex: p1.get('id'),
values: [
[
t3.get('id'),
{
tagged_at: '123',
},
],
],
});
const tags = await p1.getTags();
expect(tags.length).toEqual(3);
const tag = tags.find((t) => t.id == t3.get('id'));
expect(tag.posts_tags.tagged_at).toEqual('123');
});
});

View File

@ -0,0 +1,112 @@
import { mockServer } from './index';
import { registerActions } from '@nocobase/actions';
describe('create action', () => {
let app;
let Post;
let Comment;
let Tag;
beforeEach(async () => {
app = mockServer();
registerActions(app);
Post = app.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'title' },
{ type: 'hasMany', name: 'comments' },
{ type: 'belongsToMany', name: 'tags' },
{ type: 'string', name: 'status', defaultValue: 'draft' },
],
});
Comment = app.collection({
name: 'comments',
fields: [
{ type: 'string', name: 'content' },
{ type: 'belongsTo', name: 'post' },
],
});
Tag = app.collection({
name: 'tags',
fields: [
{ type: 'string', name: 'name' },
{ type: 'belongsToMany', name: 'posts' },
],
});
await app.db.sync();
});
afterEach(async () => {
await app.destroy();
});
test('create resource', async () => {
expect(await Post.repository.findOne()).toBeNull();
const response = await app
.agent()
.resource('posts')
.create({
values: {
title: 't1',
},
});
expect(response.statusCode).toEqual(200);
const post = await Post.repository.findOne();
expect(post).not.toBeNull();
expect(post['title']).toEqual('t1');
});
test('create has many nested resource', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
},
});
expect(await Comment.repository.findOne()).toBeNull();
const response = await app
.agent()
.resource('posts.comments')
.create({
associatedIndex: p1.get('id'),
values: {
content: 'hello',
},
});
const comment = await Comment.repository.findOne();
expect(comment).not.toBeNull();
expect(comment.get('postId')).toEqual(p1.get('id'));
expect(comment.get('content')).toEqual('hello');
});
test('create belongs to many nested resource', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
},
});
expect(await Tag.repository.findOne()).toBeNull();
const response = await app
.agent()
.resource('posts.tags')
.create({
associatedIndex: p1.get('id'),
values: {
name: 'hello',
},
});
const tag = await Tag.repository.findOne();
expect(tag).not.toBeNull();
expect(await tag.hasPost(p1)).toBeTruthy();
expect(tag.get('name')).toEqual('hello');
});
});

View File

@ -0,0 +1,156 @@
import { mockServer } from './index';
import { registerActions } from '@nocobase/actions';
describe('destroy action', () => {
let app;
let Post;
let Comment;
let Tag;
let PostTag;
let Profile;
beforeEach(async () => {
app = mockServer();
registerActions(app);
PostTag = app.collection({
name: 'posts_tags',
fields: [{ type: 'string', name: 'tagged_at' }],
});
Post = app.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'title' },
{ type: 'hasMany', name: 'comments' },
{ type: 'hasOne', name: 'profile' },
{ type: 'belongsToMany', name: 'tags', through: 'posts_tags' },
{ type: 'string', name: 'status', defaultValue: 'draft' },
],
});
Profile = app.collection({
name: 'profiles',
fields: [
{ type: 'string', name: 'post_profile' },
{ type: 'belongsTo', name: 'post' },
],
});
Comment = app.collection({
name: 'comments',
fields: [
{ type: 'string', name: 'content' },
{ type: 'belongsTo', name: 'post' },
],
});
Tag = app.collection({
name: 'tags',
fields: [
{ type: 'string', name: 'name' },
{ type: 'belongsToMany', name: 'posts', through: 'posts_tags' },
],
});
await app.db.sync();
});
afterEach(async () => {
await app.destroy();
});
test('destroy resource', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
},
});
const response = await app
.agent()
.resource('posts')
.destroy({
resourceIndex: p1.get('id'),
});
expect(await Post.repository.count()).toEqual(0);
});
test('destroy has many resource', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
comments: [
{
content: 'c1',
},
],
},
});
const c1 = await Comment.repository.findOne();
const response = await app
.agent()
.resource('posts.comments')
.destroy({
resourceIndex: c1.get('id'),
associatedIndex: p1.get('id'),
});
expect(await Comment.repository.count()).toEqual(0);
});
test('destroy belongs to many', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
tags: [
{
name: 't1',
posts_tags: {
tagged_at: '123',
},
},
],
},
});
expect(await Tag.repository.count()).toEqual(1);
const p1t1 = (await p1.getTags())[0];
const response = await app
.agent()
.resource('posts.tags')
.destroy({
resourceIndex: p1.get('id'),
associatedIndex: p1t1.get('id'),
});
expect(await Tag.repository.count()).toEqual(0);
});
test('destroy has one', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
profile: {
post_profile: 'test',
},
},
});
const postProfile = await Profile.repository.findOne();
const response = await app
.agent()
.resource('posts.profile')
.destroy({
associatedIndex: p1.get('id'),
});
expect(await Profile.repository.count()).toEqual(0);
});
});

View File

@ -0,0 +1,133 @@
import { mockServer } from './index';
import { registerActions } from '@nocobase/actions';
describe('get action', () => {
let app;
let Post;
let Comment;
let Tag;
let PostTag;
let Profile;
beforeEach(async () => {
app = mockServer();
registerActions(app);
PostTag = app.collection({
name: 'posts_tags',
fields: [{ type: 'string', name: 'tagged_at' }],
});
Post = app.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'title' },
{ type: 'hasMany', name: 'comments' },
{ type: 'hasOne', name: 'profile' },
{ type: 'belongsToMany', name: 'tags', through: 'posts_tags' },
{ type: 'string', name: 'status', defaultValue: 'draft' },
],
});
Profile = app.collection({
name: 'profiles',
fields: [
{ type: 'string', name: 'post_profile' },
{ type: 'belongsTo', name: 'post' },
],
});
Comment = app.collection({
name: 'comments',
fields: [
{ type: 'string', name: 'content' },
{ type: 'belongsTo', name: 'post' },
],
});
Tag = app.collection({
name: 'tags',
fields: [
{ type: 'string', name: 'name' },
{ type: 'belongsToMany', name: 'posts', through: 'posts_tags' },
],
});
await app.db.sync();
});
afterEach(async () => {
await app.destroy();
});
test('get resource', async () => {
await Post.repository.create({
values: {
title: 'p0',
},
});
const p1 = await Post.repository.create({
values: {
title: 'p1',
},
});
const response = await app
.agent()
.resource('posts')
.get({
resourceIndex: p1.get('id'),
});
const body = response.body;
expect(body['id']).toEqual(p1.get('id'));
});
test('get has many resource', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
comments: [
{
content: 'c1',
},
],
},
});
const c1 = await Comment.repository.findOne();
const response = await app
.agent()
.resource('posts.comments')
.get({
resourceIndex: c1.get('id'),
associatedIndex: p1.get('id'),
});
expect(response.body['id']).toEqual(c1.get('id'));
});
test('get has one resource', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
profile: {
post_profile: 'test',
},
},
});
const postProfile = await Profile.repository.findOne();
const response = await app
.agent()
.resource('posts.profile')
.get({
associatedIndex: p1.get('id'),
});
expect(response.body['id']).toEqual(postProfile.get('id'));
});
});

View File

@ -1,19 +0,0 @@
import { mockServer } from '.';
async function test(ctx, next) {
ctx.body = ctx.action.params;
await next();
}
describe('hello', () => {
it('hello', async () => {
const app = mockServer();
app.collection({
name: 'tests',
});
app.actions({ test });
const response = await app.agent().resource('tests').test({ key1: 'val1' });
expect(response.body.key1).toBe('val1');
await app.destroy();
});
});

View File

@ -27,12 +27,10 @@ export function getConfig(config = {}, options?: any): DatabaseOptions {
host: process.env.DB_HOST, host: process.env.DB_HOST,
port: process.env.DB_PORT, port: process.env.DB_PORT,
dialect: process.env.DB_DIALECT, dialect: process.env.DB_DIALECT,
storage: process.env.DB_STORAGE,
logging: process.env.DB_LOG_SQL === 'on', logging: process.env.DB_LOG_SQL === 'on',
sync: { sync: {
force: true, force: true,
alter: {
drop: true,
},
}, },
hooks: { hooks: {
beforeDefine(model, options) { beforeDefine(model, options) {
@ -99,7 +97,11 @@ export class MockServer extends Koa {
constructor() { constructor() {
super(); super();
this.db = mockDatabase(); this.db = mockDatabase({
sync: {
force: true,
},
});
this.resourcer = new Resourcer({ this.resourcer = new Resourcer({
prefix: '/api', prefix: '/api',
}); });
@ -118,7 +120,7 @@ export class MockServer extends Koa {
} }
collection(options: CollectionOptions) { collection(options: CollectionOptions) {
this.db.collection(options); return this.db.collection(options);
} }
resource(options: ResourceOptions) { resource(options: ResourceOptions) {
@ -158,8 +160,6 @@ export class MockServer extends Koa {
url += `/${resourceIndex}`; url += `/${resourceIndex}`;
} }
console.log(url);
switch (method) { switch (method) {
case 'upload': case 'upload':
return agent return agent

View File

@ -0,0 +1,113 @@
import { mockServer } from './index';
import { registerActions } from '@nocobase/actions';
describe('list action', () => {
let app;
beforeEach(async () => {
app = mockServer();
registerActions(app);
const Post = app.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'title' },
{ type: 'hasMany', name: 'comments' },
{
type: 'belongsToMany',
name: 'tags',
},
{ type: 'string', name: 'status', defaultValue: 'draft' },
],
});
const Tag = app.collection({
name: 'tags',
fields: [
{ type: 'string', name: 'name' },
{ type: 'belongsToMany', name: 'posts' },
],
});
app.collection({
name: 'comments',
fields: [
{ type: 'string', name: 'content' },
{ type: 'string', name: 'status', defaultValue: 'draft' },
],
});
await app.db.sync();
const t1 = await Tag.repository.create({
values: {
name: 't1',
},
});
const t2 = await Tag.repository.create({
values: {
name: 't2',
},
});
const t3 = await Tag.repository.create({
values: {
name: 't3',
},
});
const p1 = await Post.repository.create({
values: {
title: 'pt1',
tags: [t1.get('id'), t2.get('id')],
},
});
await Post.repository.createMany({
records: [
{
title: 'pt2',
tags: [t2.get('id')],
},
{
title: 'pt3',
tags: [t3.get('id')],
},
],
});
});
afterEach(async () => {
await app.destroy();
});
test('list with pagination', async () => {
const response = await app
.agent()
.resource('posts')
.list({
fields: ['id'],
perPage: 1,
page: 2,
sort: ['id'],
});
const body = response.body;
expect(body.rows.length).toEqual(1);
expect(body.rows[0]['id']).toEqual(2);
expect(body.count).toEqual(3);
expect(body.totalPage).toEqual(3);
});
test('list by association', async () => {
// tags with posts id eq 1
const response = await app
.agent()
.resource('posts.tags')
.list({ associatedIndex: 1, fields: ['id', 'posts_tags.createdAt'], sort: ['id'] });
const body = response.body;
expect(body.count).toEqual(2);
expect(body.rows).toEqual([{ id: 1 }, { id: 2 }]);
});
});

View File

@ -0,0 +1,579 @@
import { mockServer, MockServer } from './index';
import { registerActions } from '@nocobase/actions';
describe('sort action', () => {
describe('same scope', () => {
let api: MockServer;
beforeEach(async () => {
api = mockServer();
registerActions(api);
api.db.collection({
name: 'tests',
fields: [
{ type: 'string', name: 'title' },
{ type: 'sort', name: 'sort' },
{ type: 'sort', name: 'sort2' },
],
});
await api.db.sync();
const Test = api.db.getCollection('tests');
for (let index = 1; index < 5; index++) {
await Test.repository.create({ values: { title: `t${index}` } });
}
});
afterEach(async () => {
return api.destroy();
});
it('targetId', async () => {
await api.agent().resource('tests').move({
sourceId: 1,
targetId: 3,
});
const response = await api
.agent()
.resource('tests')
.list({
sort: ['sort'],
});
expect(response.body).toMatchObject({
rows: [
{
title: 't2',
sort: 1,
},
{
title: 't3',
sort: 2,
},
{
title: 't1',
sort: 3,
},
{
title: 't4',
sort: 4,
},
],
});
});
it('targetId', async () => {
await api.agent().resource('tests').move({
sourceId: 3,
targetId: 1,
});
const response = await api
.agent()
.resource('tests')
.list({
sort: ['sort'],
});
expect(response.body).toMatchObject({
rows: [
{
title: 't3',
sort: 1,
},
{
title: 't1',
sort: 2,
},
{
title: 't2',
sort: 3,
},
{
title: 't4',
sort: 4,
},
],
});
});
it('sortField', async () => {
await api.agent().resource('tests').move({
sortField: 'sort2',
sourceId: 1,
targetId: 3,
});
const response = await api
.agent()
.resource('tests')
.list({
sort: ['sort2'],
});
expect(response.body).toMatchObject({
rows: [
{
title: 't2',
sort2: 1,
},
{
title: 't3',
sort2: 2,
},
{
title: 't1',
sort2: 3,
},
{
title: 't4',
sort2: 4,
},
],
});
});
it('sticky', async () => {
await api.agent().resource('tests').move({
sourceId: 3,
sticky: true,
});
const response = await api
.agent()
.resource('tests')
.list({
sort: ['sort'],
});
expect(response.body).toMatchObject({
rows: [
{
title: 't3',
sort: 0,
},
{
title: 't1',
sort: 1,
},
{
title: 't2',
sort: 2,
},
{
title: 't4',
sort: 4,
},
],
});
});
});
describe('different scope', () => {
let api: MockServer;
beforeEach(async () => {
api = mockServer();
registerActions(api);
api.db.collection({
name: 'tests',
fields: [
{ type: 'string', name: 'title' },
{ type: 'integer', name: 'state' },
{ type: 'sort', name: 'sort', scopeKey: 'state' },
],
});
await api.db.sync();
const Test = api.db.getCollection('tests');
for (let index = 1; index < 5; index++) {
await Test.repository.create({ values: { title: `t1${index}`, state: 1 } });
}
for (let index = 1; index < 5; index++) {
await Test.repository.create({ values: { title: `t2${index}`, state: 2 } });
}
});
afterEach(async () => {
return api.destroy();
});
it('targetId/1->6', async () => {
await api.agent().resource('tests').move({
sourceId: 1,
targetId: 6,
});
let response = await api
.agent()
.resource('tests')
.list({
sort: ['sort'],
filter: { state: 1 },
fields: ['title', 'sort'],
});
expect(response.body).toMatchObject({
rows: [
{
title: 't12',
sort: 2,
},
{
title: 't13',
sort: 3,
},
{
title: 't14',
sort: 4,
},
],
});
response = await api
.agent()
.resource('tests')
.list({
sort: ['sort'],
filter: { state: 2 },
});
expect(response.body).toMatchObject({
rows: [
{
title: 't21',
sort: 1,
},
{
title: 't11',
sort: 2,
},
{
title: 't22',
sort: 3,
},
{
title: 't23',
sort: 4,
},
{
title: 't24',
sort: 5,
},
],
});
});
it('targetId/1->6 - method=insertAfter', async () => {
await api.agent().resource('tests').move({
sourceId: 1,
targetId: 6,
method: 'insertAfter',
});
let response = await api
.agent()
.resource('tests')
.list({
sort: ['sort'],
filter: { state: 1 },
});
expect(response.body).toMatchObject({
rows: [
{
title: 't12',
sort: 2,
},
{
title: 't13',
sort: 3,
},
{
title: 't14',
sort: 4,
},
],
});
response = await api
.agent()
.resource('tests')
.list({
sort: ['sort'],
filter: { state: 2 },
});
expect(response.body).toMatchObject({
rows: [
{
title: 't21',
sort: 1,
},
{
title: 't22',
sort: 2,
},
{
title: 't11',
sort: 3,
},
{
title: 't23',
sort: 4,
},
{
title: 't24',
sort: 5,
},
],
});
});
it('targetId/6->2', async () => {
await api.agent().resource('tests').move({
sourceId: 6,
targetId: 2,
});
let response = await api
.agent()
.resource('tests')
.list({
sort: ['sort'],
filter: { state: 1 },
});
expect(response.body).toMatchObject({
rows: [
{
title: 't11',
sort: 1,
},
{
title: 't22',
sort: 2,
},
{
title: 't12',
sort: 3,
},
{
title: 't13',
sort: 4,
},
{
title: 't14',
sort: 5,
},
],
});
response = await api
.agent()
.resource('tests')
.list({
sort: ['sort'],
filter: { state: 2 },
});
expect(response.body).toMatchObject({
rows: [
{
title: 't21',
sort: 1,
},
{
title: 't23',
sort: 3,
},
{
title: 't24',
sort: 4,
},
],
});
});
it('targetId/6->2 - method=insertAfter', async () => {
await api.agent().resource('tests').move({
sourceId: 6,
targetId: 2,
method: 'insertAfter',
});
let response = await api
.agent()
.resource('tests')
.list({
sort: ['sort'],
filter: { state: 1 },
});
expect(response.body).toMatchObject({
rows: [
{
title: 't11',
sort: 1,
},
{
title: 't12',
sort: 2,
},
{
title: 't22',
sort: 3,
},
{
title: 't13',
sort: 4,
},
{
title: 't14',
sort: 5,
},
],
});
response = await api
.agent()
.resource('tests')
.list({
sort: ['sort'],
filter: { state: 2 },
});
expect(response.body).toMatchObject({
rows: [
{
title: 't21',
sort: 1,
},
{
title: 't23',
sort: 3,
},
{
title: 't24',
sort: 4,
},
],
});
});
it('targetScope', async () => {
await api
.agent()
.resource('tests')
.move({
sourceId: 1,
targetScope: {
state: 2,
},
});
let response = await api
.agent()
.resource('tests')
.list({
sort: ['sort'],
filter: { state: 1 },
});
expect(response.body).toMatchObject({
rows: [
{
title: 't12',
sort: 2,
},
{
title: 't13',
sort: 3,
},
{
title: 't14',
sort: 4,
},
],
});
response = await api
.agent()
.resource('tests')
.list({
sort: ['sort'],
filter: { state: 2 },
});
expect(response.body).toMatchObject({
rows: [
{
title: 't21',
sort: 1,
},
{
title: 't22',
sort: 2,
},
{
title: 't23',
sort: 3,
},
{
title: 't24',
sort: 4,
},
{
title: 't11',
sort: 5,
},
],
});
});
it('targetScope - method=prepend', async () => {
await api
.agent()
.resource('tests')
.move({
sourceId: 1,
targetScope: {
state: 2,
},
method: 'prepend',
});
let response = await api
.agent()
.resource('tests')
.list({
sort: ['sort'],
filter: { state: 1 },
});
expect(response.body).toMatchObject({
rows: [
{
title: 't12',
},
{
title: 't13',
},
{
title: 't14',
},
],
});
response = await api
.agent()
.resource('tests')
.list({
sort: ['sort'],
filter: { state: 2 },
});
expect(response.body).toMatchObject({
rows: [
{
title: 't11',
},
{
title: 't21',
},
{
title: 't22',
},
{
title: 't23',
},
{
title: 't24',
},
],
});
});
});
});

View File

@ -0,0 +1,126 @@
import { mockServer } from './index';
import { registerActions } from '@nocobase/actions';
describe('remove action', () => {
let app;
let Post;
let Comment;
let Tag;
let PostTag;
let Profile;
beforeEach(async () => {
app = mockServer();
registerActions(app);
PostTag = app.collection({
name: 'posts_tags',
fields: [{ type: 'string', name: 'tagged_at' }],
});
Post = app.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'title' },
{ type: 'hasMany', name: 'comments' },
{ type: 'hasOne', name: 'profile' },
{ type: 'belongsToMany', name: 'tags', through: 'posts_tags' },
{ type: 'string', name: 'status', defaultValue: 'draft' },
],
});
Profile = app.collection({
name: 'profiles',
fields: [
{ type: 'string', name: 'post_profile' },
{ type: 'belongsTo', name: 'post' },
],
});
Comment = app.collection({
name: 'comments',
fields: [
{ type: 'string', name: 'content' },
{ type: 'belongsTo', name: 'post' },
],
});
Tag = app.collection({
name: 'tags',
fields: [
{ type: 'string', name: 'name' },
{ type: 'belongsToMany', name: 'posts', through: 'posts_tags' },
],
});
await app.db.sync();
});
afterEach(async () => {
await app.destroy();
});
test('remove belongs to many', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
},
});
const t1 = await Tag.repository.create({
values: {
name: 't1',
},
});
const t2 = await Tag.repository.create({
values: {
name: 't2',
},
});
const t3 = await Tag.repository.create({
values: {
name: 't3',
},
});
await p1.setTags([t1, t2]);
expect(await p1.countTags()).toEqual(2);
const response = await app
.agent()
.resource('posts.tags')
.remove({
associatedIndex: p1.get('id'),
values: [t1.get('id')],
});
expect(await p1.countTags()).toEqual(1);
});
test('remove has one', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
profile: {
post_profile: 'test',
},
},
});
const postProfile = await Profile.repository.findOne();
expect(await postProfile.getPost()).not.toBeNull();
const response = await app
.agent()
.resource('posts.profile')
.remove({
associatedIndex: p1.get('id'),
});
await postProfile.reload();
expect(await postProfile.getPost()).toBeNull();
});
});

View File

@ -0,0 +1,112 @@
import { mockServer } from './index';
import { registerActions } from '@nocobase/actions';
describe('set action', () => {
let app;
let Post;
let Comment;
let Tag;
let PostTag;
beforeEach(async () => {
app = mockServer();
registerActions(app);
PostTag = app.collection({
name: 'posts_tags',
fields: [{ type: 'string', name: 'tagged_at' }],
});
Post = app.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'title' },
{ type: 'hasMany', name: 'comments' },
{ type: 'hasOne', name: 'profile' },
{ type: 'belongsToMany', name: 'tags', through: 'posts_tags' },
{ type: 'string', name: 'status', defaultValue: 'draft' },
],
});
Comment = app.collection({
name: 'comments',
fields: [
{ type: 'string', name: 'content' },
{ type: 'belongsTo', name: 'post' },
],
});
Tag = app.collection({
name: 'tags',
fields: [
{ type: 'string', name: 'name' },
{ type: 'belongsToMany', name: 'posts', through: 'posts_tags' },
],
});
await app.db.sync();
});
afterEach(async () => {
await app.destroy();
});
test('set belongs to many', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
},
});
const t1 = await Tag.repository.create({
values: {
name: 't1',
},
});
const t2 = await Tag.repository.create({
values: {
name: 't2',
},
});
const t3 = await Tag.repository.create({
values: {
name: 't3',
},
});
expect(await p1.countTags()).toEqual(0);
const response = await app
.agent()
.resource('posts.tags')
.set({
associatedIndex: p1.get('id'),
values: [t1.get('id'), t2.get('id')],
});
expect(await p1.countTags()).toEqual(2);
// add with through values
await app
.agent()
.resource('posts.tags')
.set({
associatedIndex: p1.get('id'),
values: [
[
t3.get('id'),
{
tagged_at: '123',
},
],
],
});
const tags = await p1.getTags();
expect(tags.length).toEqual(1);
const tag = tags.find((t) => t.id == t3.get('id'));
expect(tag.posts_tags.tagged_at).toEqual('123');
});
});

View File

@ -0,0 +1,264 @@
import { mockServer } from './index';
import { SortAbleCollection } from '../actions';
import lodash from 'lodash';
describe('sort collections', () => {
let app;
let Post;
beforeEach(async () => {
app = mockServer();
});
afterEach(async () => {
await app.destroy();
});
describe('sort collection', () => {
beforeEach(async () => {
Post = app.db.collection({
name: 'posts',
fields: [
{
type: 'string',
name: 'title',
},
{
type: 'sort',
name: 'sort',
},
],
});
await app.db.sync();
for (let i = 0; i < 5; i++) {
await Post.repository.create({
values: {
title: `t${i + 1}`,
},
});
}
});
test('forward insert', async () => {
const t2 = await Post.repository.findOne({
filter: {
title: 't2',
},
});
const t4 = await Post.repository.findOne({
filter: {
title: 't4',
},
});
const sortCollection = new SortAbleCollection(Post);
await sortCollection.move(t2.get('id'), t4.get('id'));
const results = (
await Post.repository.find({
sort: ['sort'],
})
).map((r) => {
return lodash.pick(r.toJSON(), ['title', 'sort']);
});
expect(results).toEqual([
{ title: 't1', sort: 1 },
{ title: 't3', sort: 2 },
{ title: 't4', sort: 3 },
{ title: 't2', sort: 4 },
{ title: 't5', sort: 5 },
]);
});
test('backward insert', async () => {
const t2 = await Post.repository.findOne({
filter: {
title: 't2',
},
});
const t4 = await Post.repository.findOne({
filter: {
title: 't4',
},
});
const sortCollection = new SortAbleCollection(Post);
await sortCollection.move(t4.get('id'), t2.get('id'));
const results = (
await Post.repository.find({
sort: ['sort'],
})
).map((r) => {
return lodash.pick(r.toJSON(), ['title', 'sort']);
});
expect(results).toEqual([
{ title: 't1', sort: 1 },
{ title: 't4', sort: 2 },
{ title: 't2', sort: 3 },
{ title: 't3', sort: 4 },
{ title: 't5', sort: 5 },
]);
});
});
describe('two scope move', () => {
beforeEach(async () => {
Post = app.db.collection({
name: 'posts',
fields: [
{
type: 'string',
name: 'title',
},
{
type: 'sort',
name: 'sort',
scopeKey: 'status',
},
{
type: 'string',
name: 'status',
},
],
});
await app.db.sync();
});
test('move in scope', async () => {
for (let i = 0; i < 5; i++) {
await Post.repository.create({
values: {
title: `s1:t${i + 1}`,
status: 'status1',
},
});
}
for (let i = 0; i < 5; i++) {
await Post.repository.create({
values: {
title: `s2:t${i + 1}`,
status: 'status2',
},
});
}
const s1t2 = await Post.repository.findOne({
filter: {
title: 's1:t2',
},
});
const s1t4 = await Post.repository.findOne({
filter: {
title: 's1:t4',
},
});
const sortCollection = new SortAbleCollection(Post);
await sortCollection.move(s1t2.get('id'), s1t4.get('id'));
const results = (
await Post.repository.find({
sort: ['sort'],
filter: {
status: 'status1',
},
})
).map((r) => {
return lodash.pick(r.toJSON(), ['title', 'sort']);
});
expect(results).toEqual([
{ title: 's1:t1', sort: 1 },
{ title: 's1:t3', sort: 2 },
{ title: 's1:t4', sort: 3 },
{ title: 's1:t2', sort: 4 },
{ title: 's1:t5', sort: 5 },
]);
const s2results = (
await Post.repository.find({
sort: ['sort'],
filter: {
status: 'status2',
},
})
).map((r) => {
return lodash.pick(r.toJSON(), ['title', 'sort']);
});
expect(s2results).toEqual([
{ title: 's2:t1', sort: 1 },
{ title: 's2:t2', sort: 2 },
{ title: 's2:t3', sort: 3 },
{ title: 's2:t4', sort: 4 },
{ title: 's2:t5', sort: 5 },
]);
});
test('move between scope', async () => {
for (let i = 0; i < 5; i++) {
await Post.repository.create({
values: {
title: `s1:t${i + 1}`,
status: 'status1',
},
});
}
for (let i = 0; i < 5; i++) {
await Post.repository.create({
values: {
title: `s2:t${i + 1}`,
status: 'status2',
},
});
}
const s1t1 = await Post.repository.findOne({
filter: {
title: 's1:t1',
},
});
const s2t3 = await Post.repository.findOne({
filter: {
title: 's2:t3',
},
});
const sortCollection = new SortAbleCollection(Post);
await sortCollection.move(s1t1.get('id'), s2t3.get('id'));
const results = (
await Post.repository.find({
sort: ['sort'],
filter: {
status: 'status2',
},
})
).map((r) => {
return lodash.pick(r.toJSON(), ['title', 'sort']);
});
expect(results).toEqual([
{ title: 's2:t1', sort: 1 },
{ title: 's2:t2', sort: 2 },
{ title: 's1:t1', sort: 3 },
{ title: 's2:t3', sort: 4 },
{ title: 's2:t4', sort: 5 },
{ title: 's2:t5', sort: 6 },
]);
});
});
});

View File

@ -0,0 +1,101 @@
import { mockServer } from './index';
import { registerActions } from '@nocobase/actions';
describe('toggle action', () => {
let app;
let Post;
let Comment;
let Tag;
let PostTag;
beforeEach(async () => {
app = mockServer();
registerActions(app);
PostTag = app.collection({
name: 'posts_tags',
fields: [{ type: 'string', name: 'tagged_at' }],
});
Post = app.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'title' },
{ type: 'hasMany', name: 'comments' },
{ type: 'hasOne', name: 'profile' },
{ type: 'belongsToMany', name: 'tags', through: 'posts_tags' },
{ type: 'string', name: 'status', defaultValue: 'draft' },
],
});
Comment = app.collection({
name: 'comments',
fields: [
{ type: 'string', name: 'content' },
{ type: 'belongsTo', name: 'post' },
],
});
Tag = app.collection({
name: 'tags',
fields: [
{ type: 'string', name: 'name' },
{ type: 'belongsToMany', name: 'posts', through: 'posts_tags' },
],
});
await app.db.sync();
});
afterEach(async () => {
await app.destroy();
});
test('toggle belongs to many', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
},
});
const t1 = await Tag.repository.create({
values: {
name: 't1',
},
});
const t2 = await Tag.repository.create({
values: {
name: 't2',
},
});
const t3 = await Tag.repository.create({
values: {
name: 't3',
},
});
expect(await p1.countTags()).toEqual(0);
const response = await app
.agent()
.resource('posts.tags')
.toggle({
associatedIndex: p1.get('id'),
values: [t1.get('id'), t2.get('id')],
});
expect(await p1.countTags()).toEqual(2);
await app
.agent()
.resource('posts.tags')
.toggle({
associatedIndex: p1.get('id'),
values: [t2.get('id')],
});
expect(await p1.countTags()).toEqual(1);
});
});

View File

@ -0,0 +1,172 @@
import { mockServer } from './index';
import { registerActions } from '@nocobase/actions';
describe('update action', () => {
let app;
let Post;
let Comment;
let Tag;
let PostTag;
let Profile;
beforeEach(async () => {
app = mockServer();
registerActions(app);
PostTag = app.collection({
name: 'posts_tags',
fields: [{ type: 'string', name: 'tagged_at' }],
});
Post = app.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'title' },
{ type: 'hasMany', name: 'comments' },
{ type: 'hasOne', name: 'profile' },
{ type: 'belongsToMany', name: 'tags', through: 'posts_tags' },
{ type: 'string', name: 'status', defaultValue: 'draft' },
],
});
Profile = app.collection({
name: 'profiles',
fields: [
{ type: 'string', name: 'post_profile' },
{ type: 'belongsTo', name: 'post' },
],
});
Comment = app.collection({
name: 'comments',
fields: [
{ type: 'string', name: 'content' },
{ type: 'belongsTo', name: 'post' },
],
});
Tag = app.collection({
name: 'tags',
fields: [
{ type: 'string', name: 'name' },
{ type: 'belongsToMany', name: 'posts', through: 'posts_tags' },
],
});
await app.db.sync();
});
afterEach(async () => {
await app.destroy();
});
test('update resource', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
},
});
const response = await app
.agent()
.resource('posts')
.update({
resourceIndex: p1.get('id'),
values: {
title: 'p0',
},
});
await p1.reload();
expect(p1.get('title')).toEqual('p0');
});
test('update has many resource', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
comments: [
{
content: 'c1',
},
],
},
});
const c1 = await Comment.repository.findOne();
const response = await app
.agent()
.resource('posts.comments')
.update({
resourceIndex: c1.get('id'),
associatedIndex: p1.get('id'),
values: {
content: 'c0',
},
});
await c1.reload();
expect(c1.get('content')).toEqual('c0');
});
test('update belongs to many through value', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
tags: [
{
name: 't1',
posts_tags: {
tagged_at: '123',
},
},
],
},
});
const p1t1 = (await p1.getTags())[0];
const response = await app
.agent()
.resource('posts.tags')
.update({
resourceIndex: p1.get('id'),
associatedIndex: p1t1.get('id'),
values: {
posts_tags: {
tagged_at: 'test',
},
},
});
await p1t1.reload();
expect(p1t1.posts_tags.tagged_at).toEqual('test');
});
test('update has one', async () => {
const p1 = await Post.repository.create({
values: {
title: 'p1',
profile: {
post_profile: 'test',
},
},
});
const postProfile = await Profile.repository.findOne();
const response = await app
.agent()
.resource('posts.profile')
.update({
associatedIndex: p1.get('id'),
values: {
post_profile: 'test0',
},
});
await postProfile.reload();
expect(postProfile.get('post_profile')).toEqual('test0');
});
});

View File

@ -0,0 +1,14 @@
import { Context } from '..';
import { getRepositoryFromParams } from './utils';
import { BelongsToManyRepository, MultipleRelationRepository, HasManyRepository } from '@nocobase/database';
export async function add(ctx: Context, next) {
const repository = getRepositoryFromParams(ctx);
if (!(repository instanceof MultipleRelationRepository || repository instanceof HasManyRepository)) {
return await next();
}
await (<HasManyRepository | BelongsToManyRepository>repository).add(ctx.action.params.values);
await next();
}

View File

@ -0,0 +1,19 @@
import { Context } from '..';
import { getRepositoryFromParams } from './utils';
export async function create(ctx: Context, next) {
const repository = getRepositoryFromParams(ctx);
const { whitelist, blacklist, updateAssociationValues, values } = ctx.action.params;
const instance = await repository.create({
values,
whitelist,
blacklist,
updateAssociationValues,
context: ctx,
});
ctx.body = instance;
await next();
}

View File

@ -0,0 +1,17 @@
import { Context } from '..';
import { getRepositoryFromParams } from './utils';
export async function destroy(ctx: Context, next) {
const repository = getRepositoryFromParams(ctx);
const { resourceIndex, filter } = ctx.action.params;
const instance = await repository.destroy({
filter,
filterByPk: resourceIndex,
context: ctx,
});
ctx.body = instance;
await next();
}

View File

@ -0,0 +1,20 @@
import { Context } from '..';
import { getRepositoryFromParams } from './utils';
import { SingleRelationRepository } from '@nocobase/database';
export async function get(ctx: Context, next) {
const repository = getRepositoryFromParams(ctx);
const { resourceIndex, fields, appends, except, filter } = ctx.action.params;
const instance = await repository.findOne({
filterByPk: resourceIndex,
fields,
appends,
except,
filter,
});
ctx.body = instance;
await next();
}

View File

@ -0,0 +1,10 @@
export * from './list';
export * from './create';
export * from './update';
export * from './destroy';
export * from './get';
export * from './add';
export * from './set';
export * from './remove';
export * from './toggle';
export * from './move';

View File

@ -0,0 +1,47 @@
import { Context } from '..';
import { getRepositoryFromParams } from './utils';
export const DEFAULT_PAGE = 1;
export const DEFAULT_PER_PAGE = 20;
function pageArgsToLimitArgs(
page: number,
perPage: number,
): {
offset: number;
limit: number;
} {
return {
offset: (page - 1) * perPage,
limit: perPage,
};
}
function totalPage(total, perPage): number {
return Math.ceil(total / perPage);
}
export async function list(ctx: Context, next) {
const { page = DEFAULT_PAGE, perPage = DEFAULT_PER_PAGE, fields, filter, appends, except, sort } = ctx.action.params;
const repository = getRepositoryFromParams(ctx);
const [rows, count] = await repository.findAndCount({
filter,
fields,
appends,
except,
sort,
...pageArgsToLimitArgs(page, perPage),
});
ctx.body = {
count,
rows,
page,
perPage,
totalPage: totalPage(count, perPage),
};
await next();
}

View File

@ -0,0 +1,138 @@
import { Op, Model } from 'sequelize';
import { Context } from '..';
import { Collection, PrimaryKey, Repository, SortField } from '@nocobase/database';
import { getRepositoryFromParams } from './utils';
export async function move(ctx: Context, next) {
const repository = getRepositoryFromParams(ctx);
const { sourceId, targetId, sortField, targetScope, sticky, method } = ctx.action.params;
if (repository instanceof Repository) {
const sortAbleCollection = new SortAbleCollection(repository.collection, sortField);
if (sourceId && targetId) {
await sortAbleCollection.move(sourceId, targetId, {
insertAfter: method === 'insertAfter',
});
}
// change scope
if (sourceId && targetScope) {
await sortAbleCollection.changeScope(sourceId, targetScope, method);
}
if (sourceId && sticky) {
await sortAbleCollection.sticky(sourceId);
}
}
await next();
}
interface SortPosition {
scope?: string;
id: PrimaryKey;
}
interface MoveOptions {
insertAfter?: boolean;
}
export class SortAbleCollection {
collection: Collection;
field: SortField;
scopeKey: string;
constructor(collection: Collection, fieldName: string = 'sort') {
this.collection = collection;
this.field = collection.getField(fieldName);
if (!(this.field instanceof SortField)) {
throw new Error(`${fieldName} is not a sort field`);
}
this.scopeKey = this.field.get('scopeKey');
}
// insert source position to target position
async move(sourceInstanceId: PrimaryKey, targetInstanceId: PrimaryKey, options: MoveOptions = {}) {
const sourceInstance = await this.collection.repository.findById(sourceInstanceId);
const targetInstance = await this.collection.repository.findById(targetInstanceId);
if (this.scopeKey && sourceInstance.get(this.scopeKey) !== targetInstance.get(this.scopeKey)) {
await sourceInstance.set(this.scopeKey, targetInstance.get(this.scopeKey));
await sourceInstance.save();
}
await this.sameScopeMove(sourceInstance, targetInstance, options);
}
async changeScope(sourceInstanceId: PrimaryKey, targetScope: any, method?: string) {
const sourceInstance = await this.collection.repository.findById(sourceInstanceId);
const targetScopeValue = targetScope[this.scopeKey];
if (targetScopeValue && sourceInstance.get(this.scopeKey) !== targetScopeValue) {
await sourceInstance.set(this.scopeKey, targetScopeValue);
await sourceInstance.save();
if (method === 'prepend') {
await this.sticky(sourceInstanceId);
}
}
}
async sticky(sourceInstanceId: PrimaryKey) {
const sourceInstance = await this.collection.repository.findById(sourceInstanceId);
sourceInstance.set(this.field.get('name'), 0);
await sourceInstance.save();
}
async sameScopeMove(sourceInstance: Model, targetInstance: Model, options: MoveOptions) {
const fieldName = this.field.get('name');
const sourceSort = sourceInstance.get(fieldName);
let targetSort = targetInstance.get(fieldName);
if (options.insertAfter) {
targetSort = targetSort + 1;
}
let scopeValue = this.scopeKey ? sourceInstance.get(this.scopeKey) : null;
let updateCondition;
let change;
if (targetSort > sourceSort) {
updateCondition = {
[Op.gt]: sourceSort,
[Op.lte]: targetSort,
};
change = -1;
} else {
updateCondition = {
[Op.lt]: sourceSort,
[Op.gte]: targetSort,
};
change = 1;
}
const where = {
[fieldName]: updateCondition,
};
if (scopeValue) {
where[this.scopeKey] = {
[Op.eq]: scopeValue,
};
}
await this.collection.model.increment(fieldName, {
where,
by: change,
});
await sourceInstance.update({
[fieldName]: targetSort,
});
}
}

View File

@ -0,0 +1,2 @@
import { RelationRepositoryActionBuilder } from './utils';
export const remove = RelationRepositoryActionBuilder('remove');

View File

@ -0,0 +1,2 @@
import { RelationRepositoryActionBuilder } from './utils';
export const set = RelationRepositoryActionBuilder('set');

View File

@ -0,0 +1,14 @@
import { Context } from '..';
import { getRepositoryFromParams } from './utils';
import { BelongsToManyRepository } from '@nocobase/database';
export async function toggle(ctx: Context, next) {
const repository = getRepositoryFromParams(ctx);
if (!(repository instanceof BelongsToManyRepository)) {
return await next();
}
await (<BelongsToManyRepository>repository).toggle(ctx.action.params.values);
await next();
}

View File

@ -0,0 +1,20 @@
import { Context } from '..';
import { getRepositoryFromParams } from './utils';
export async function update(ctx: Context, next) {
const repository = getRepositoryFromParams(ctx);
const { resourceIndex, values, whitelist, blacklist, filter, updateAssociationValues } = ctx.action.params;
const instance = await repository.update({
filterByPk: resourceIndex,
values,
whitelist,
blacklist,
filter,
updateAssociationValues,
context: ctx,
});
ctx.body = instance;
await next();
}

View File

@ -0,0 +1,27 @@
import { MultipleRelationRepository, Repository } from '@nocobase/database';
import { Context } from '..';
export function getRepositoryFromParams(ctx: Context) {
const { resourceName, associatedName, associatedIndex } = ctx.action.params;
let repository: MultipleRelationRepository | Repository;
if (associatedName) {
repository = <MultipleRelationRepository>(
ctx.db.getCollection(associatedName).repository.relation(resourceName).of(associatedIndex)
);
} else {
repository = <Repository>ctx.db.getCollection(resourceName).repository;
}
return repository;
}
export function RelationRepositoryActionBuilder(method: 'remove' | 'set') {
return async function (ctx: Context, next) {
const repository = getRepositoryFromParams(ctx);
await repository[method](ctx.action.params.values);
await next();
};
}

View File

@ -1,6 +1,8 @@
import Koa from 'koa'; import Koa from 'koa';
import { Database } from '@nocobase/database'; import { Database } from '@nocobase/database';
import { Action } from '@nocobase/resourcer'; import { Action } from '@nocobase/resourcer';
import lodash from 'lodash';
import * as actions from './actions';
export type Next = () => Promise<any>; export type Next = () => Promise<any>;
@ -11,6 +13,10 @@ export interface Context extends Koa.Context {
[key: string]: any; [key: string]: any;
} }
export function registerActions(api: any) {} export function registerActions(api: any) {
api.actions(
lodash.pick(actions, ['add', 'create', 'destroy', 'get', 'list', 'remove', 'set', 'toggle', 'update', 'move']),
);
}
export default {}; export default {};

View File

@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@nocobase/utils": "^0.6.0-alpha.0", "@nocobase/utils": "^0.6.0-alpha.0",
"async-mutex": "^0.3.2",
"bcrypt": "^5.0.0", "bcrypt": "^5.0.0",
"deepmerge": "^4.2.2", "deepmerge": "^4.2.2",
"flat": "^5.0.2", "flat": "^5.0.2",

View File

@ -22,6 +22,7 @@ describe('string field', () => {
fields: [{ type: 'sort', name: 'sort' }], fields: [{ type: 'sort', name: 'sort' }],
}); });
await db.sync(); await db.sync();
const test1 = await Test.model.create<any>(); const test1 = await Test.model.create<any>();
expect(test1.sort).toBe(1); expect(test1.sort).toBe(1);
const test2 = await Test.model.create<any>(); const test2 = await Test.model.create<any>();
@ -30,6 +31,24 @@ describe('string field', () => {
expect(test3.sort).toBe(3); expect(test3.sort).toBe(3);
}); });
test('simultaneously create ', async () => {
const Test = db.collection({
name: 'tests',
fields: [{ type: 'sort', name: 'sort' }],
});
await db.sync();
const promise = [];
for (let i = 0; i < 3; i++) {
promise.push(Test.model.create());
}
await Promise.all(promise);
const tests = await Test.model.findAll();
const sortValues = tests.map((t) => t.get('sort')).sort();
expect(sortValues).toEqual([1, 2, 3]);
});
it('skip if sort value not empty', async () => { it('skip if sort value not empty', async () => {
const Test = db.collection({ const Test = db.collection({
name: 'tests', name: 'tests',
@ -53,13 +72,21 @@ describe('string field', () => {
], ],
}); });
await db.sync(); await db.sync();
const t1 = await Test.model.create({ status: 'publish' }); const t1 = await Test.model.create({ status: 'publish' });
const t2 = await Test.model.create({ status: 'publish' }); const t2 = await Test.model.create({ status: 'publish' });
const t3 = await Test.model.create({ status: 'draft' }); const t3 = await Test.model.create({ status: 'draft' });
const t4 = await Test.model.create({ status: 'draft' }); const t4 = await Test.model.create({ status: 'draft' });
expect(t1.get('sort')).toBe(1); expect(t1.get('sort')).toBe(1);
expect(t2.get('sort')).toBe(2); expect(t2.get('sort')).toBe(2);
expect(t3.get('sort')).toBe(1); expect(t3.get('sort')).toBe(1);
expect(t4.get('sort')).toBe(2); expect(t4.get('sort')).toBe(2);
t1.set('status', 'draft');
await t1.save();
await t1.reload();
expect(t1.get('sort')).toBe(3);
}); });
}); });

View File

@ -47,6 +47,7 @@ describe('belongs to many', () => {
fields: [ fields: [
{ type: 'belongsToMany', name: 'posts', through: 'posts_tags' }, { type: 'belongsToMany', name: 'posts', through: 'posts_tags' },
{ type: 'string', name: 'name' }, { type: 'string', name: 'name' },
{ type: 'string', name: 'status' },
], ],
}); });
@ -124,7 +125,9 @@ describe('belongs to many', () => {
}); });
const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id);
let [findResult, count] = await PostTagRepository.findAndCount(); let [findResult, count] = await PostTagRepository.findAndCount({
fields: ['id'],
});
expect(count).toEqual(2); expect(count).toEqual(2);
@ -450,6 +453,43 @@ describe('belongs to many', () => {
expect(count).toEqual(0); expect(count).toEqual(0);
}); });
test('destroy by id and filter', async () => {
let t1 = await Tag.repository.create({
values: {
name: 't1',
status: 'published',
},
});
const t2 = await Tag.repository.create({
values: {
name: 't2',
status: 'draft',
},
});
const p1 = await Post.repository.create({
values: { title: 'p1' },
});
const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id);
await PostTagRepository.set([t1.id, t2.id]);
let [_, count] = await PostTagRepository.findAndCount();
expect(count).toEqual(2);
await PostTagRepository.destroy({
filterByPk: t1.get('id') as number,
filter: {
status: 'draft',
},
});
[_, count] = await PostTagRepository.findAndCount();
expect(count).toEqual(2);
});
test('destroy with id', async () => { test('destroy with id', async () => {
let t1 = await Tag.repository.create({ let t1 = await Tag.repository.create({
values: { values: {

View File

@ -29,6 +29,7 @@ describe('has many repository', () => {
{ type: 'string', name: 'title' }, { type: 'string', name: 'title' },
{ type: 'belongsToMany', name: 'tags', through: 'posts_tags' }, { type: 'belongsToMany', name: 'tags', through: 'posts_tags' },
{ type: 'hasMany', name: 'comments' }, { type: 'hasMany', name: 'comments' },
{ type: 'string', name: 'status' },
], ],
}); });
@ -167,6 +168,56 @@ describe('has many repository', () => {
expect(findAndCount[1]).toEqual(6); expect(findAndCount[1]).toEqual(6);
}); });
test('destroy by pk and filter', async () => {
const u1 = await User.repository.create({
values: { name: 'u1' },
});
const UserPostRepository = new HasManyRepository(User, 'posts', u1.id);
const p1 = await UserPostRepository.create({
values: {
title: 't1',
status: 'published',
},
});
const p2 = await UserPostRepository.create({
values: {
title: 't2',
status: 'draft',
},
});
await UserPostRepository.destroy({
filterByPk: p1.id,
filter: {
status: 'draft',
},
});
expect(await UserPostRepository.count()).toEqual(2);
await UserPostRepository.destroy({
filterByPk: p1.id,
filter: {
status: 'published',
},
});
expect(
await UserPostRepository.findOne({
filterByPk: p1.id,
}),
).toBeNull();
expect(
await UserPostRepository.findOne({
filterByPk: p2.id,
}),
).not.toBeNull();
});
test('destroy by pk', async () => { test('destroy by pk', async () => {
const u1 = await User.repository.create({ const u1 = await User.repository.create({
values: { name: 'u1' }, values: { name: 'u1' },

View File

@ -20,12 +20,31 @@ describe('destroy', () => {
name: 'posts', name: 'posts',
fields: [ fields: [
{ type: 'string', name: 'title' }, { type: 'string', name: 'title' },
{ type: 'string', name: 'status' },
{ type: 'belongsTo', name: 'user' }, { type: 'belongsTo', name: 'user' },
], ],
}); });
await db.sync(); await db.sync();
}); });
test('destroy with filter and filterByPk', async () => {
const p1 = await Post.repository.create({
values: {
name: 'u1',
status: 'published',
},
});
await Post.repository.destroy({
filterByPk: p1.get('id') as number,
filter: {
status: 'draft',
},
});
expect(await Post.repository.count()).toEqual(1);
});
test('destroy all', async () => { test('destroy all', async () => {
await User.repository.create({ await User.repository.create({
values: { values: {

View File

@ -1,27 +1,54 @@
import { Mutex } from 'async-mutex';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { DataTypes } from 'sequelize'; import { DataTypes } from 'sequelize';
import { BaseColumnFieldOptions, Field } from './field'; import { BaseColumnFieldOptions, Field } from './field';
const sortFieldMutex = new Mutex();
export class SortField extends Field { export class SortField extends Field {
get dataType() { get dataType() {
return DataTypes.INTEGER; return DataTypes.INTEGER;
} }
init() { async setSortValue(instance, options) {
const { name, scopeKey } = this.options; const { name, scopeKey } = this.options;
const { model } = this.context.collection; const { model } = this.context.collection;
model.beforeCreate(async (instance, options) => {
if (isNumber(instance.get(name))) { if (isNumber(instance.get(name)) && instance._previousDataValues[scopeKey] == instance[scopeKey]) {
return; return;
} }
const where = {}; const where = {};
if (scopeKey) { if (scopeKey) {
where[scopeKey] = instance.get(scopeKey); where[scopeKey] = instance.get(scopeKey);
} }
await sortFieldMutex.runExclusive(async () => {
const max = await model.max<number, any>(name, { ...options, where }); const max = await model.max<number, any>(name, { ...options, where });
instance.set(name, (max || 0) + 1); const newValue = (max || 0) + 1;
instance.set(name, newValue);
}); });
} }
async onScopeChange(instance, options) {
const { scopeKey } = this.options;
if (scopeKey && !instance.isNewRecord && instance._previousDataValues[scopeKey] != instance[scopeKey]) {
await this.setSortValue(instance, options);
}
}
bind() {
super.bind();
this.on('beforeUpdate', this.onScopeChange.bind(this));
this.on('beforeCreate', this.setSortValue.bind(this));
}
unbind() {
super.unbind();
this.off('beforeUpdate', this.onScopeChange.bind(this));
this.off('beforeCreate', this.setSortValue.bind(this));
}
} }
export interface SortFieldOptions extends BaseColumnFieldOptions { export interface SortFieldOptions extends BaseColumnFieldOptions {

View File

@ -1,9 +1,14 @@
export * from './database'; export * from './database';
export * from './collection'; export * from './collection';
export * from './repository';
export * from './utils'; export * from './utils';
export { Database as default } from './database'; export { Database as default } from './database';
export * from './relation-repository/belongs-to-many-repository'; export * from './relation-repository/belongs-to-many-repository';
export * from './relation-repository/belongs-to-repository'; export * from './relation-repository/belongs-to-repository';
export * from './relation-repository/hasmany-repository';
export * from './relation-repository/single-relation-repository';
export * from './relation-repository/multiple-relation-repository';
export { Model, ModelCtor } from 'sequelize'; export { Model, ModelCtor } from 'sequelize';
export * from './fields'; export * from './fields';
export * from './update-associations'; export * from './update-associations';

View File

@ -76,12 +76,14 @@ export class BelongsToManyRepository extends MultipleRelationRepository implemen
}); });
ids = instancesToIds(instances); ids = instancesToIds(instances);
} else if (options && options['filterByPk']) { }
options = options['filterByPk'];
const instances = (<any>this.association).toInstanceArray(options); if (options && options['filterByPk']) {
ids = instancesToIds(instances); const instances = (<any>this.association).toInstanceArray(options['filterByPk']);
} else if (options && !options['filterByPk']) { ids = ids ? lodash.intersection(ids, instancesToIds(instances)) : instancesToIds(instances);
}
if (options && !options['filterByPk'] && !options['filter']) {
const sourceModel = await this.getSourceModel(transaction); const sourceModel = await this.getSourceModel(transaction);
const instances = await sourceModel[this.accessors().get]({ const instances = await sourceModel[this.accessors().get]({
@ -228,6 +230,18 @@ export class BelongsToManyRepository extends MultipleRelationRepository implemen
return; return;
} }
extendFindOptions(findOptions) {
let joinTableAttributes;
if (lodash.get(findOptions, 'fields')) {
joinTableAttributes = [];
}
return {
...findOptions,
joinTableAttributes,
};
}
throughName() { throughName() {
return this.throughModel().name; return this.throughModel().name;
} }

View File

@ -8,6 +8,7 @@ interface BelongsToFindOptions extends SingleRelationFindOption {}
interface IBelongsToRepository<M extends Model> { interface IBelongsToRepository<M extends Model> {
// 不需要 findOnefind 就是 findOne // 不需要 findOnefind 就是 findOne
find(options?: BelongsToFindOptions): Promise<M>; find(options?: BelongsToFindOptions): Promise<M>;
findOne(options?: BelongsToFindOptions): Promise<M>;
// 新增并关联,如果存在关联,解除之后,与新数据建立关联 // 新增并关联,如果存在关联,解除之后,与新数据建立关联
create(options?: CreateOptions): Promise<M>; create(options?: CreateOptions): Promise<M>;
// 更新 // 更新

View File

@ -53,7 +53,8 @@ export class HasManyRepository extends MultipleRelationRepository implements IHa
} }
where.push(filterResult.where); where.push(filterResult.where);
} else if (options && options['filterByPk']) { }
if (options && options['filterByPk']) {
if (typeof options === 'object' && options['filterByPk']) { if (typeof options === 'object' && options['filterByPk']) {
options = options['filterByPk']; options = options['filterByPk'];
} }

View File

@ -6,7 +6,8 @@ interface HasOneFindOptions extends SingleRelationFindOption {}
interface IHasOneRepository<M extends Model> { interface IHasOneRepository<M extends Model> {
// 不需要 findOnefind 就是 findOne // 不需要 findOnefind 就是 findOne
find(options?: HasOneFindOptions): Promise<Model<any>>; find(options?: HasOneFindOptions): Promise<M>;
findOne(options?: HasOneFindOptions): Promise<M>;
// 新增并关联,如果存在关联,解除之后,与新数据建立关联 // 新增并关联,如果存在关联,解除之后,与新数据建立关联
create(options?: CreateOptions): Promise<M>; create(options?: CreateOptions): Promise<M>;
// 更新 // 更新
@ -19,4 +20,4 @@ interface IHasOneRepository<M extends Model> {
remove(): Promise<void>; remove(): Promise<void>;
} }
export class HasOneRepository<M extends Model> extends SingleRelationRepository implements IHasOneRepository<M> {} export class HasOneRepository extends SingleRelationRepository implements IHasOneRepository<any> {}

View File

@ -25,12 +25,18 @@ export interface AssociatedOptions extends TransactionAble {
} }
export abstract class MultipleRelationRepository extends RelationRepository { export abstract class MultipleRelationRepository extends RelationRepository {
extendFindOptions(findOptions) {
return findOptions;
}
async find(options?: FindOptions): Promise<any> { async find(options?: FindOptions): Promise<any> {
const transaction = await this.getTransaction(options); const transaction = await this.getTransaction(options);
const findOptions = this.buildQueryOptions({ const findOptions = this.extendFindOptions(
this.buildQueryOptions({
...options, ...options,
}); }),
);
const getAccessor = this.accessors().get; const getAccessor = this.accessors().get;
const sourceModel = await this.getSourceModel(transaction); const sourceModel = await this.getSourceModel(transaction);
@ -77,7 +83,7 @@ export abstract class MultipleRelationRepository extends RelationRepository {
]; ];
} }
async count(options: CountOptions) { async count(options?: CountOptions) {
const transaction = await this.getTransaction(options); const transaction = await this.getTransaction(options);
const sourceModel = await this.getSourceModel(transaction); const sourceModel = await this.getSourceModel(transaction);
@ -101,7 +107,7 @@ export abstract class MultipleRelationRepository extends RelationRepository {
transaction, transaction,
}); });
return count.count; return parseInt(count.count);
} }
async findOne(options?: FindOneOptions): Promise<any> { async findOne(options?: FindOneOptions): Promise<any> {

View File

@ -2,12 +2,13 @@ import { RelationRepository, transaction } from './relation-repository';
import { Model, SingleAssociationAccessors } from 'sequelize'; import { Model, SingleAssociationAccessors } from 'sequelize';
import { updateModelByValues } from '../update-associations'; import { updateModelByValues } from '../update-associations';
import lodash from 'lodash'; import lodash from 'lodash';
import { Appends, Except, Fields, PrimaryKey, TransactionAble, UpdateOptions } from '../repository'; import { Appends, Except, Fields, Filter, PrimaryKey, TransactionAble, UpdateOptions } from '../repository';
export interface SingleRelationFindOption extends TransactionAble { export interface SingleRelationFindOption extends TransactionAble {
fields?: Fields; fields?: Fields;
except?: Except; except?: Except;
appends?: Appends; appends?: Appends;
filter?: Filter;
} }
interface SetOption extends TransactionAble { interface SetOption extends TransactionAble {
@ -56,6 +57,10 @@ export abstract class SingleRelationRepository extends RelationRepository {
}); });
} }
async findOne(options?: SingleRelationFindOption): Promise<Model<any>> {
return this.find(options);
}
@transaction() @transaction()
async destroy(options?: TransactionAble): Promise<Boolean> { async destroy(options?: TransactionAble): Promise<Boolean> {
const transaction = await this.getTransaction(options); const transaction = await this.getTransaction(options);

View File

@ -83,6 +83,7 @@ export interface DestroyOptions extends SequelizeDestroyOptions {
filter?: Filter; filter?: Filter;
filterByPk?: PrimaryKey | PrimaryKey[]; filterByPk?: PrimaryKey | PrimaryKey[];
truncate?: boolean; truncate?: boolean;
context?: any;
} }
interface FindAndCountOptions extends Omit<SequelizeAndCountOptions, 'where' | 'include' | 'order'> { interface FindAndCountOptions extends Omit<SequelizeAndCountOptions, 'where' | 'include' | 'order'> {
@ -197,7 +198,7 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
transaction, transaction,
}); });
return count as any; return count;
} }
/** /**
@ -370,14 +371,14 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
options = <DestroyOptions>options; options = <DestroyOptions>options;
let filterByPk = options.filterByPk; const filterByPk: PrimaryKey[] | undefined =
options.filterByPk && !lodash.isArray(options.filterByPk)
if (filterByPk) { ? [options.filterByPk]
if (!Array.isArray(filterByPk)) { : (options.filterByPk as PrimaryKey[] | undefined);
filterByPk = [filterByPk];
}
if (filterByPk && !options.filter) {
return await this.model.destroy({ return await this.model.destroy({
...options,
where: { where: {
[this.model.primaryKeyAttribute]: { [this.model.primaryKeyAttribute]: {
[Op.in]: filterByPk, [Op.in]: filterByPk,
@ -388,19 +389,27 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
} }
if (options.filter) { if (options.filter) {
const instances = await this.find({ let pks = (
await this.find({
filter: options.filter, filter: options.filter,
transaction, transaction,
}); })
).map((instance) => instance[this.model.primaryKeyAttribute]);
if (filterByPk) {
pks = lodash.intersection(pks, filterByPk);
}
return await this.destroy({ return await this.destroy({
filterByPk: instances.map((instance) => instance[this.model.primaryKeyAttribute]), context: options.context,
filterByPk: pks,
transaction, transaction,
}); });
} }
if (options.truncate) { if (options.truncate) {
return await this.model.destroy({ return await this.model.destroy({
...options,
truncate: true, truncate: true,
transaction, transaction,
}); });

View File

@ -1,4 +1,3 @@
import qs from 'qs';
import glob from 'glob'; import glob from 'glob';
import compose from 'koa-compose'; import compose from 'koa-compose';
import Action, { ActionName } from './action'; import Action, { ActionName } from './action';
@ -280,6 +279,7 @@ export class Resourcer {
accessors: this.options.accessors || accessors, accessors: this.options.accessors || accessors,
}, },
); );
if (!params) { if (!params) {
return next(); return next();
} }

View File

@ -3368,6 +3368,13 @@ astral-regex@^2.0.0:
resolved "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" resolved "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==
async-mutex@^0.3.2:
version "0.3.2"
resolved "https://registry.nlark.com/async-mutex/download/async-mutex-0.3.2.tgz#1485eda5bda1b0ec7c8df1ac2e815757ad1831df"
integrity sha1-FIXtpb2hsOx8jfGsLoFXV60YMd8=
dependencies:
tslib "^2.3.1"
asynckit@^0.4.0: asynckit@^0.4.0:
version "0.4.0" version "0.4.0"
resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
@ -13249,7 +13256,7 @@ tslib@^1.8.1, tslib@^1.9.0:
resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.1: tslib@^2.0.1, tslib@^2.3.1:
version "2.3.1" version "2.3.1"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==