From 99d33a0241e8e268004da33a9be3de1e1c89bf9a Mon Sep 17 00:00:00 2001 From: chenos Date: Thu, 9 Sep 2021 22:38:39 +0800 Subject: [PATCH] refactor: improve action unit test cases --- packages/actions/package.json | 3 +- .../actions/src/__tests__/actions/create1.ts | 16 - .../actions/src/__tests__/actions/create2.ts | 10 - .../actions/src/__tests__/actions/list1.ts | 17 - .../actions/src/__tests__/actions/update1.ts | 16 - .../actions/src/__tests__/actions/update2.ts | 10 - packages/actions/src/__tests__/add.test.ts | 26 +- packages/actions/src/__tests__/create.test.ts | 152 ++-- .../actions/src/__tests__/destroy.test.ts | 180 ++-- packages/actions/src/__tests__/get.test.ts | 126 +-- packages/actions/src/__tests__/index.ts | 146 ---- packages/actions/src/__tests__/list.test.ts | 703 +++------------- .../actions/src/__tests__/middleware.test.ts | 65 -- packages/actions/src/__tests__/remove.test.ts | 77 +- packages/actions/src/__tests__/set.test.ts | 47 +- packages/actions/src/__tests__/sort.test.ts | 783 +++++++++++------- .../actions/src/__tests__/tables/comments.ts | 45 - .../actions/src/__tests__/tables/posts.ts | 60 -- .../src/__tests__/tables/posts_tags.ts | 12 - .../actions/src/__tests__/tables/profiles.ts | 36 - packages/actions/src/__tests__/tables/tags.ts | 27 - .../actions/src/__tests__/tables/users.ts | 25 - packages/actions/src/__tests__/toggle.test.ts | 21 + packages/actions/src/__tests__/update.test.ts | 236 ++---- packages/actions/src/__tests__/utils.test.ts | 1 - packages/actions/src/actions/add.ts | 48 ++ packages/actions/src/actions/associate.ts | 211 ----- packages/actions/src/actions/common.ts | 662 --------------- packages/actions/src/actions/create.ts | 55 ++ packages/actions/src/actions/destroy.ts | 95 +++ packages/actions/src/actions/get.ts | 77 ++ packages/actions/src/actions/index.ts | 24 +- packages/actions/src/actions/list.ts | 229 +++++ packages/actions/src/actions/remove.ts | 57 ++ packages/actions/src/actions/set.ts | 52 ++ packages/actions/src/actions/sort.ts | 170 ++++ packages/actions/src/actions/toggle.ts | 72 ++ packages/actions/src/actions/update.ts | 104 +++ packages/actions/src/index.ts | 21 + .../actions/src/middlewares/associated.ts | 2 +- .../actions/src/middlewares/data-wrapping.ts | 31 - packages/actions/src/middlewares/index.ts | 1 - packages/test/src/mockServer.ts | 44 +- 43 files changed, 1894 insertions(+), 2901 deletions(-) delete mode 100644 packages/actions/src/__tests__/actions/create1.ts delete mode 100644 packages/actions/src/__tests__/actions/create2.ts delete mode 100644 packages/actions/src/__tests__/actions/list1.ts delete mode 100644 packages/actions/src/__tests__/actions/update1.ts delete mode 100644 packages/actions/src/__tests__/actions/update2.ts delete mode 100644 packages/actions/src/__tests__/index.ts delete mode 100644 packages/actions/src/__tests__/middleware.test.ts delete mode 100644 packages/actions/src/__tests__/tables/comments.ts delete mode 100644 packages/actions/src/__tests__/tables/posts.ts delete mode 100644 packages/actions/src/__tests__/tables/posts_tags.ts delete mode 100644 packages/actions/src/__tests__/tables/profiles.ts delete mode 100644 packages/actions/src/__tests__/tables/tags.ts delete mode 100644 packages/actions/src/__tests__/tables/users.ts create mode 100644 packages/actions/src/__tests__/toggle.test.ts create mode 100644 packages/actions/src/actions/add.ts delete mode 100644 packages/actions/src/actions/associate.ts delete mode 100644 packages/actions/src/actions/common.ts create mode 100644 packages/actions/src/actions/create.ts create mode 100644 packages/actions/src/actions/destroy.ts create mode 100644 packages/actions/src/actions/get.ts create mode 100644 packages/actions/src/actions/list.ts create mode 100644 packages/actions/src/actions/remove.ts create mode 100644 packages/actions/src/actions/set.ts create mode 100644 packages/actions/src/actions/sort.ts create mode 100644 packages/actions/src/actions/toggle.ts create mode 100644 packages/actions/src/actions/update.ts delete mode 100644 packages/actions/src/middlewares/data-wrapping.ts diff --git a/packages/actions/package.json b/packages/actions/package.json index 112b6c30e..852a32f40 100644 --- a/packages/actions/package.json +++ b/packages/actions/package.json @@ -11,8 +11,7 @@ "@nocobase/resourcer": "^0.4.0-alpha.7" }, "devDependencies": { - "koa": "^2.13.0", - "sequelize": "^6.3.4" + "@nocobase/test": "^0.4.0-alpha.7" }, "repository": { "type": "git", diff --git a/packages/actions/src/__tests__/actions/create1.ts b/packages/actions/src/__tests__/actions/create1.ts deleted file mode 100644 index 2ed6af4e5..000000000 --- a/packages/actions/src/__tests__/actions/create1.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ActionOptions } from '@nocobase/resourcer'; -import { create } from '../../actions/common'; - -export default { - values: { - meta: { - location: 'Kunming' - } - }, - - fields: { - except: ['sort', 'user.profile', 'comments.status'] - }, - - handler: create -} as unknown as ActionOptions; diff --git a/packages/actions/src/__tests__/actions/create2.ts b/packages/actions/src/__tests__/actions/create2.ts deleted file mode 100644 index 43a40300b..000000000 --- a/packages/actions/src/__tests__/actions/create2.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ActionOptions } from '@nocobase/resourcer'; -import { create } from '../../actions/common'; - -export default { - fields: { - only: ['title'] - }, - - handler: create -} as unknown as ActionOptions; diff --git a/packages/actions/src/__tests__/actions/list1.ts b/packages/actions/src/__tests__/actions/list1.ts deleted file mode 100644 index a5d283b28..000000000 --- a/packages/actions/src/__tests__/actions/list1.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ActionOptions } from '@nocobase/resourcer'; -import { list } from '../../actions/common'; - -const now = new Date(); -const before7Days = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7); - -export default { - filter: { - status: 'published', - published_at: { - gte: before7Days.toISOString(), - lt: now.toISOString() - } - }, - - handler: list -} as unknown as ActionOptions; diff --git a/packages/actions/src/__tests__/actions/update1.ts b/packages/actions/src/__tests__/actions/update1.ts deleted file mode 100644 index 449978ee1..000000000 --- a/packages/actions/src/__tests__/actions/update1.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ActionOptions } from '@nocobase/resourcer'; -import { update } from '../../actions/common'; - -export default { - values: { - meta: { - location: 'Kunming' - } - }, - - fields: { - except: ['title'] - }, - - handler: update -} as unknown as ActionOptions; diff --git a/packages/actions/src/__tests__/actions/update2.ts b/packages/actions/src/__tests__/actions/update2.ts deleted file mode 100644 index 6f3d708cc..000000000 --- a/packages/actions/src/__tests__/actions/update2.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ActionOptions } from '@nocobase/resourcer'; -import { update } from '../../actions/common'; - -export default { - fields: { - only: ['title'] - }, - - handler: update -} as unknown as ActionOptions; diff --git a/packages/actions/src/__tests__/add.test.ts b/packages/actions/src/__tests__/add.test.ts index 063453f09..01eb5bbf2 100644 --- a/packages/actions/src/__tests__/add.test.ts +++ b/packages/actions/src/__tests__/add.test.ts @@ -1,23 +1,21 @@ -import { initDatabase, agent } from './index'; +import { mockServer, MockServer } from '@nocobase/test'; +import { registerActions } from '..'; describe('add', () => { - let db; + let api: MockServer; beforeEach(async () => { - db = await initDatabase(); + api = mockServer({ + dataWrapping: false, + }); + registerActions(api); }); - afterAll(() => db.close()); + afterEach(async () => { + return api.destroy(); + }); - it('belongsToMany1', async () => { - const [Post, Tag] = db.getModels(['posts', 'tags']); - let post = await Post.create(); - let tag1 = await Tag.create({ name: 'tag1' }); - let tag2 = await Tag.create({ name: 'tag2' }); - await agent.post(`/posts/${post.id}/tags:add/${tag1.id}`); - await agent.post(`/posts/${post.id}/tags:add/${tag2.id}`); - let [tag01, tag02] = await post.getTags(); - expect(tag01.id).toBe(tag1.id); - expect(tag02.id).toBe(tag2.id); + it('add', async () => { + }); }); diff --git a/packages/actions/src/__tests__/create.test.ts b/packages/actions/src/__tests__/create.test.ts index c8365b144..39b7e03e6 100644 --- a/packages/actions/src/__tests__/create.test.ts +++ b/packages/actions/src/__tests__/create.test.ts @@ -1,109 +1,65 @@ -import { initDatabase, agent } from './index'; +import { mockServer, MockServer } from '@nocobase/test'; +import { registerActions } from '..'; describe('create', () => { - let db; + let api: MockServer; beforeEach(async () => { - db = await initDatabase(); + api = mockServer({ + dataWrapping: false, + }); + registerActions(api); }); - afterAll(() => db.close()); - - describe('single', () => { - it('create with hasMany items', async () => { - const response = await agent - .post('/posts') - .send({ - title: 'title1', - comments: [ - { content: 'content1' }, - { content: 'content2' }, - ] - }); - expect(response.body.title).toBe('title1'); - - const createdPost = await agent.get(`/posts/${response.body.id}?fields=comments`); - expect(createdPost.body.comments.length).toBe(2); - }); - - it('create with defaultValues by custom action', async () => { - const response = await agent - .post('/posts:create1') - .send({ - title: 'title1', - }); - expect(response.body.meta).toEqual({ location: 'Kunming' }); - }); - - it('create with options.fields.except by custom action', async () => { - const response = await agent - .post('/posts:create1') - .send({ - title: 'title1', - sort: 100, - user: { name: 'aaa', profile: { email: 'email' } }, - comments: [ - { content: 'comment1', status: 'published' }, - { content: 'comment2', status: 'draft' }, - ] - }); - expect(response.body.sort).toBe(1); - expect(response.body.user_id).toBe(1); - - const postWithUser = await agent - .get(`/posts/${response.body.id}?fields=user`); - expect(postWithUser.body.user.id).toBe(1); - - const user = await agent - .get(`/users/${postWithUser.body.user.id}?fields=profile`); - expect(user.body.profile).toBe(null); - - const postWithComments = await agent - .get(`/posts/${response.body.id}?fields=comments`); - const comments = postWithComments.body.comments.map(({ content, status }) => ({ content, status })); - expect(comments).toEqual([ - { content: 'comment1', status: null }, - { content: 'comment2', status: null }, - ]); - }); - - it('create with options.fields.only by custom action', async () => { - const response = await agent - .post('/posts:create2') - .send({ - title: 'title1', - meta: { a: 1 } - }); - expect(response.body.title).toBe('title1'); - expect(response.body.meta).toBe(null); - - const result = await agent - .get(`/posts/${response.body.id}`); - - expect(result.body.title).toBe('title1'); - expect(result.body.meta).toBe(null); - }); + afterEach(async () => { + return api.destroy(); }); - describe('hasMany', () => { - it('create', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - const response = await agent - .post(`/posts/${post.id}/comments`) - .send({ - content: 'content1', - }); - expect(response.body.post_id).toBe(post.id); - expect(response.body.content).toBe('content1'); - - const comments = await agent - .get('/comments?fields=id,content'); - expect(comments.body.count).toBe(1); - expect(comments.body.rows).toEqual([{ - id: 1, - content: 'content1' - }]); + it('create', async () => { + api.database.table({ + name: 'tests', + fields: [ + { type: 'string', name: 'name' }, + ], }); + await api.database.sync(); + const response = await api.resource('tests').create({ + values: { name: 'n1' }, + }); + expect(response.body.name).toBe('n1'); + }); + + it('associations', async () => { + api.database.table({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + ], + }); + api.database.table({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'hasMany', name: 'comments' }, + ], + }); + await api.database.sync(); + const [Post, Comment] = api.database.getModels(['posts', 'comments']); + const response = await api.resource('posts').create({ + values: { + title: 't1', + comments: [ + { content: 'c1' }, + { content: 'c2' }, + ] + }, + }); + expect(await Post.count()).toBe(1); + expect(await Comment.count()).toBe(2); + await api.resource('posts.comments').create({ + associatedKey: response.body.id, + values: { content: 'c1' }, + }); + expect(await Comment.count()).toBe(3); }); }); diff --git a/packages/actions/src/__tests__/destroy.test.ts b/packages/actions/src/__tests__/destroy.test.ts index af85406b1..8d9f4d1aa 100644 --- a/packages/actions/src/__tests__/destroy.test.ts +++ b/packages/actions/src/__tests__/destroy.test.ts @@ -1,148 +1,62 @@ -import { initDatabase, agent } from './index'; +import { mockServer, MockServer } from '@nocobase/test'; +import { registerActions } from '..'; describe('destroy', () => { - let db; + let api: MockServer; beforeEach(async () => { - db = await initDatabase(); + api = mockServer({ + dataWrapping: false, + }); + registerActions(api); + api.database.table({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'hasMany', name: 'comments' }, + ], + }); + api.database.table({ + name: 'comments', + fields: [{ type: 'string', name: 'content' }], + }); + await api.database.sync(); }); - afterAll(() => db.close()); - - describe('single', () => { - it('common1', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - const response = await agent - .delete(`/posts/${post.id}`); - // console.log(response.body); - expect(response.body.count).toBe(1); - }); - - it('batch delete by filter', async () => { - const Post = db.getModel('posts'); - const posts = await Post.bulkCreate([ - { title: 'title1', status: 'published' }, - { title: 'title2', status: 'draft' }, - { title: 'title3', status: 'published' }, - { title: 'title4', status: 'draft' }, - ]); - - await agent - .delete('/posts?filter[status]=draft'); - - const published = await Post.findAll(); - expect(published.length).toBe(2); - expect(published.map(({ title, status }) => ({ title, status }))).toEqual([ - { title: 'title1', status: 'published' }, - { title: 'title3', status: 'published' } - ]); - }); + afterEach(async () => { + return api.destroy(); }); - describe('hasOne', () => { - it('delete has-one item', async () => { - const User = db.getModel('users'); - const user = await User.create(); - await user.updateAssociations({ - profile: { - email: 'email1122', - } - }); - const response = await agent - .delete(`/users/${user.id}/profile`); - const profile = await user.getProfile(); - expect(profile).toBeNull(); + it('destroy', async () => { + const Post = api.database.getModel('posts'); + const post = await Post.create(); + expect( + await Post.count({ + where: { id: post.id }, + }), + ).toBe(1); + await api.resource('posts').destroy({ + resourceKey: post.id, }); + expect( + await Post.count({ + where: { id: post.id }, + }), + ).toBe(0); }); - describe('hasMany', () => { - it('delete single item in has-many list', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - await post.updateAssociations({ - comments: [ - { content: 'content111222' }, - ], - }); - const [comment] = await post.getComments(); - await agent - .delete(`/posts/${post.id}/comments/${comment.id}`); - const count = await post.countComments(); - expect(count).toBe(0); + it('destroy associations', async () => { + const [Post, Comment] = api.database.getModels(['posts', 'comments']); + const post = await Post.create(); + const comment = await Comment.create(); + await post.updateAssociations({ + comments: [comment], }); - - it('delete batch items in has-many list', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - await post.updateAssociations({ - comments: [ - { content: 'content1', status: 'published' }, - { content: 'content2', status: 'draft' }, - { content: 'content3', status: 'published' }, - { content: 'content4', status: 'draft' }, - ], - }); - await agent - .delete(`/posts/${post.id}/comments?filter[status]=draft`); - const comments = await post.getComments(); - expect(comments.length).toBe(2); - expect(comments.map(({ content }) => content)).toEqual(['content1', 'content3']); - }); - }); - - describe('belongsTo', () => { - it('delete belongs-to item', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - await post.updateAssociations({ - user: { name: 'name121234' }, - }); - await agent.delete(`/posts/${post.id}/user:destroy`); - const user = await post.getUser(); - expect(user).toBeNull(); - }); - }); - - describe('belongsToMany', () => { - it('delete single target item', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - await post.updateAssociations({ - tags: [ - { name: 'tag112233' }, - ], - }); - const [tag] = await post.getTags(); - await agent - .delete(`/posts/${post.id}/tags:destroy/${tag.id}`); - const tags = await post.getTags(); - expect(tags.length).toBe(0); - - const PostsTags = db.getModel('posts_tags'); - const postsTags = await PostsTags.findAll(); - expect(postsTags.length).toBe(0); - }); - - it('delete batch target item by filter', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - await post.updateAssociations({ - tags: [ - { name: 'tag1', status: 'enabled' }, - { name: 'tag2', status: 'disabled' }, - { name: 'tag3', status: 'enabled' }, - { name: 'tag4', status: 'disabled' }, - ], - }); - await agent - .delete(`/posts/${post.id}/tags:destroy?filter[status]=disabled`); - const tags = await post.getTags(); - expect(tags.length).toBe(2); - - const PostsTags = db.getModel('posts_tags'); - const postsTags = await PostsTags.findAll(); - expect(postsTags.length).toBe(2); + await api.resource('posts.comments').destroy({ + resourceKey: comment.id, + associatedKey: post.id, }); + const comment2 = await Comment.findByPk(comment.id); + expect(comment2).toBeNull(); }); }); diff --git a/packages/actions/src/__tests__/get.test.ts b/packages/actions/src/__tests__/get.test.ts index a1bbceffb..bfda34225 100644 --- a/packages/actions/src/__tests__/get.test.ts +++ b/packages/actions/src/__tests__/get.test.ts @@ -1,91 +1,57 @@ -import { initDatabase, agent } from './index'; +import { mockServer, MockServer } from '@nocobase/test'; +import { registerActions } from '..'; describe('get', () => { - let db; + let api: MockServer; beforeEach(async () => { - db = await initDatabase(); - }); - - afterAll(() => db.close()); - - it('common1', async () => { - const Post = db.getModel('posts'); - const post = await Post.create({ - title: 'title11112222' + api = mockServer({ + dataWrapping: false, }); - const response = await agent - .get(`/posts/${post.id}`); - expect(response.body.title).toBe('title11112222'); - }); - - it('hasOne1', async () => { - const User = db.getModel('users'); - const user = await User.create(); - const response = await agent - .get(`/users/${user.id}/profile?fields=email`); - expect(response.body).toEqual({}); - }); - - it('hasOne2', async () => { - const User = db.getModel('users'); - const user = await User.create(); - await user.updateAssociations({ - profile: { - email: 'email1', - }, - }); - const response = await agent - .get(`/users/${user.id}/profile?fields=email`); - expect(response.body).toEqual({ email: 'email1' }); - }); - - it('hasMany1', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - await post.updateAssociations({ - comments: [ - { content: 'content111222' }, + registerActions(api); + api.database.table({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'hasMany', name: 'comments' }, ], }); - const [comment] = await post.getComments(); - const response = await agent - .get(`/posts/${post.id}/comments/${comment.id}`); - expect(response.body.post_id).toBe(post.id); - expect(response.body.content).toBe('content111222'); - }); - - it('belongsTo1', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - const response = await agent - .get(`/posts/${post.id}/user?fields=name`); - expect(response.body).toEqual({}); - }); - - it('belongsTo2', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - await post.updateAssociations({ - user: { name: 'name121234' }, - }); - const response = await agent - .get(`/posts/${post.id}/user?fields=name`); - expect(response.body).toEqual({ name: 'name121234' }); - }); - - it('belongsToMany', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - await post.updateAssociations({ - tags: [ - { name: 'tag112233' }, + api.database.table({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, ], }); - const [tag] = await post.getTags(); - const response = await agent - .get(`/posts/${post.id}/tags/${tag.id}?fields=name,posts.id`); - expect(response.body.posts[0].id).toBe(post.id); - expect(response.body.name).toBe('tag112233'); + await api.database.sync(); + }); + + afterEach(async () => { + return api.destroy(); + }); + + it('get', async () => { + const Post = api.database.getModel('posts'); + const post = await Post.create({ title: 't1' }); + const response = await api.resource('posts').get({ + resourceKey: post.id, + fields: ['id', 'title'] + }); + expect(post.toJSON()).toMatchObject(response.body); + }); + + it('get associations', async () => { + const [Post, Comment] = api.database.getModels(['posts', 'comments']); + const post = await Post.create(); + const comment = await Comment.create({ content: 'c2' }); + await post.updateAssociations({ + comments: [comment] + }); + const response = await api.resource('posts.comments').get({ + resourceKey: comment.id, + associatedKey: post.id, + fields: ['id', 'post_id', 'content'] + }); + const comment2 = await Comment.findByPk(comment.id); + expect(comment2.toJSON()).toMatchObject(response.body); }); }); diff --git a/packages/actions/src/__tests__/index.ts b/packages/actions/src/__tests__/index.ts deleted file mode 100644 index 1607855d0..000000000 --- a/packages/actions/src/__tests__/index.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { resolve } from 'path'; - -import glob from 'glob'; -import Koa from 'koa'; -import { Dialect } from 'sequelize'; -import bodyParser from 'koa-bodyparser'; -import supertest from 'supertest'; - -import Database, { requireModule } from '@nocobase/database'; -import Resourcer from '@nocobase/resourcer'; - -import associated from '../middlewares/associated'; -import actions from '..'; -import list1 from './actions/list1'; -import create1 from './actions/create1'; -import create2 from './actions/create2'; -import update1 from './actions/update1'; -import update2 from './actions/update2'; - -function getTestKey() { - const { id } = require.main; - const key = id - .replace(`${process.env.PWD}/packages`, '') - .replace(/src\/__tests__/g, '') - .replace('.test.ts', '') - .replace(/[^\w]/g, '_') - .replace(/_+/g, '_') - .replace(/^_|_$/g, ''); - return key -} - -const connection = { - config: { - username: process.env.DB_USER, - password: process.env.DB_PASSWORD, - database: process.env.DB_DATABASE, - host: process.env.DB_HOST, - port: Number.parseInt(process.env.DB_PORT, 10), - dialect: process.env.DB_DIALECT as Dialect, - define: { - hooks: { - beforeCreate(model, options) { - - }, - }, - }, - logging: process.env.DB_LOG_SQL === 'on' ? console.log : false, - }, - database: null, - create() { - this.database = new Database(this.config); - }, - get() { - return this.database; - } -}; - -const tableFiles = glob.sync(`${resolve(__dirname, './tables')}/*.ts`); - -// resourcer 在内存中是单例,需要谨慎使用 -export const resourcer = new Resourcer(); -resourcer.use(associated); -resourcer.registerActionHandlers({ ...actions.associate, ...actions.common }); -resourcer.define({ - name: 'posts', - actions: { - ...actions.common, - list1, - create1, - create2, - update1, - update2 - }, -}); -resourcer.define({ - name: 'comments', - actions: actions.common, -}); -resourcer.define({ - name: 'users', - actions: actions.common, -}); -resourcer.define({ - name: 'profiles', - actions: actions.common, -}); -resourcer.define({ - type: 'hasOne', - name: 'users.profile', - actions: actions.associate, -}); -resourcer.define({ - type: 'hasMany', - name: 'posts.comments', - actions: actions.associate, -}); -resourcer.define({ - type: 'hasMany', - name: 'users.posts', - actions: actions.associate, -}); -resourcer.define({ - type: 'belongsTo', - name: 'posts.user', - actions: actions.associate, -}); -resourcer.define({ - type: 'belongsToMany', - name: 'posts.tags', - actions: actions.associate, -}); - -const app = new Koa(); -app.use(async (ctx, next) => { - ctx.db = connection.get(); - await next(); -}); -app.use(bodyParser()); -app.use(resourcer.middleware()); - -// 使用 agent 可以减少部分模板代码 -export const agent = supertest.agent(app.callback()); - -export async function initDatabase() { - if (!connection.get()) { - connection.create(); - } - - const database = connection.get(); - - // 由于 jest 每个测试文件是独立 worker 进程的机制,各个进程使用的是不同的数据库连接实例,但又对应到同一个数据库。 - // 所以不同测试文件会存在表结构冲突,这里使用了基于文件唯一的 key 作为后缀区分不同进程使用的数据库表,以满足并行测试。 - const key = getTestKey(); - tableFiles.forEach(file => { - const options = requireModule(file); - database.table(typeof options === 'function' ? options(database) : { - ...options, - tableName: `${key}_${options.tableName}` - }); - }); - await database.sync({ - force: true, - }); - - return database; -} diff --git a/packages/actions/src/__tests__/list.test.ts b/packages/actions/src/__tests__/list.test.ts index 05bb99b54..3a96c5964 100644 --- a/packages/actions/src/__tests__/list.test.ts +++ b/packages/actions/src/__tests__/list.test.ts @@ -1,626 +1,135 @@ -import { literal, Op } from 'sequelize'; - -import { initDatabase, agent } from './index'; +import { mockServer, MockServer } from '@nocobase/test'; +import { registerActions } from '..'; describe('list', () => { - let db; - let now: Date; - let nowString: string; - let timestamps: { created_at: Date; updated_at: Date; }; - let timestampsStrings; + let api: MockServer; beforeEach(async () => { - db = await initDatabase(); - now = new Date(); - nowString = now.toISOString() - timestamps = { created_at: now, updated_at: now }; - timestampsStrings = { created_at: nowString, updated_at: nowString }; - }); - - afterAll(() => db.close()); - - describe('common', () => { - beforeEach(async () => { - const User = db.getModel('users'); - await User.bulkCreate([ - { 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) => ({ - title: `title${index}`, - status: index % 2 ? 'published' : 'draft', - published_at: index % 2 ? new Date(now.getFullYear(), now.getMonth(), now.getDate() - index, 0, 0, 0) : null, - user_id: users[index % users.length].id, - ...timestamps - }))); + api = mockServer({ + dataWrapping: false, }); - - describe('filter', () => { - describe('equal', () => { - it('should be filtered by `status` equal to `published`', async () => { - const Post = db.getModel('posts'); - const response = await agent.get('/posts?filter[status]=published'); - expect(response.body.count).toBe(await Post.count({ where: { status: 'published' } })); - }); - - it('should be filtered by `title` equal to `title1`', async () => { - const Post = db.getModel('posts'); - const response = await agent.get('/posts?filter[title]=title1'); - expect(response.body.count).toBe(await Post.count({ - where: { - title: 'title1', - }, - })); - }); - }); - - describe('not equal', () => { - it('filter[status][ne]=published', async () => { - const Post = db.getModel('posts'); - const drafts = (await Post.findAll({ - where: { - status: { - [Op.ne]: 'published' - } - } - })).map(item => item.get('title')); - const response = await agent.get('/posts?filter[status][ne]=published'); - expect(response.body.count).toBe(drafts.length); - expect(response.body.rows[0].title).toBe(drafts[0]); - }); - }); - - describe('null', () => { - it('filter[published_at]', async () => { - const Post = db.getModel('posts'); - const expected = await Post.findAll({ - where: { - published_at: null - } - }); - const response = await agent.get('/posts?filter[published_at]'); - expect(response.body.count).toBe(expected.length); - }); - - it('filter[published_at.is]', async () => { - const Post = db.getModel('posts'); - const expected = await Post.findAll({ - where: { - published_at: { - [Op.is]: null - } - } - }); - const response = await agent.get('/posts?filter[published_at.is]'); - expect(response.body.count).toBe(expected.length); - }); - - it('filter[published_at.not]', async () => { - const Post = db.getModel('posts'); - const expected = await Post.findAll({ - where: { - published_at: { - [Op.not]: null - } - } - }); - const response = await agent.get('/posts?filter[published_at.not]'); - expect(response.body.count).toBe(expected.length); - }); - - // TODO(bug): should use `user.is` - it('filter[user_id.is]', async () => { - const Post = db.getModel('posts'); - const expected = await Post.findAll({ - where: { - user_id: { - [Op.is]: null - } - } - }); - const response = await agent.get('/posts?filter[user_id.is]'); - expect(response.body.count).toBe(expected.length); - }); - }); - - describe('merge params with action options', () => { - it('plain key-value filter', async () => { - const response = await agent.get('/posts:list1?filter[status]=draft'); - expect(response.body.count).toBe(0); - }); - - it('date filter', async () => { - // const before1Days = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); - const before3Days = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 3); - const response = await agent.get(`/posts:list1?filter[published_at.gt]=${before3Days.toISOString()}`); - 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', () => { - // TODO(bug) - it.skip('$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); - }); - }); - }); + registerActions(api); + api.database.table({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + { type: 'string', name: 'status', defaultValue: 'draft' }, + ], }); - - describe('page', () => { - it('default page and size(20) should be ok', async () => { - const response = await agent.get('/posts?fields=title'); - expect(response.body).toEqual({ - count: 25, - page: 1, - per_page: 20, - rows: Array(20).fill(null).map((_, index) => ({ title: `title${index}` })), - }); - }); - - it('page 1 by size(1) should be ok', async () => { - const response = await agent.get('/posts?fields=title&page=1&perPage=1'); - expect(response.body).toEqual({ - count: 25, - page: 1, - per_page: 1, - rows: [{ title: 'title0' }], - }); - }); - - it('page 2 by size(1) should be ok', async () => { - const response = await agent.get('/posts?fields=title&page=2&per_page=1'); - expect(response.body).toEqual({ - count: 25, - page: 2, - per_page: 1, - rows: [{ title: 'title1' }], - }); - }); - - it('page 1 by size(101) should be change to 100', async () => { - const response = await agent.get('/posts?fields=title&page=1&per_page=101'); - expect(response.body).toEqual({ - count: 25, - page: 1, - per_page: 100, - rows: Array(25).fill(null).map((_, index) => ({ title: `title${index}` })), - }); - }); - - it('page 2 by size(101) should be change to 100 and result is empty', async () => { - const response = await agent.get('/posts?fields=title&page=2&per_page=101'); - expect(response.body).toEqual({ - count: 25, - page: 2, - per_page: 100, - rows: [], - }); - }); - - it('default page by size(-1) should be change to 100 and result will be 25 items', async () => { - const response = await agent.get('/posts?fields=title&per_page=-1'); - expect(response.body).toEqual({ - count: 25, - page: 1, - per_page: 100, - rows: Array(25).fill(null).map((_, index) => ({ title: `title${index}` })), - }); - }); - - it('page 2 by size(-1) should be change to 100 and result is empty', async () => { - const response = await agent.get('/posts?fields=title&page=2&per_page=-1'); - expect(response.body).toEqual({ - count: 25, - page: 2, - per_page: 100, - rows: [], - }); - }); + api.database.table({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'hasMany', name: 'comments' }, + { type: 'string', name: 'status', defaultValue: 'draft' }, + ], }); - - describe('fields', () => { - it('custom field', async () => { - const response = await agent.get('/posts?fields=title&filter[customTitle]=title0'); - expect(response.body).toEqual({ - count: 1, - page: 1, - per_page: 20, - rows: [{ title: 'title0' }] - }); - }); - - it('self field and belongs to field', async () => { - const response = await agent.get('/posts?fields=title,user.name&filter[title]=title0'); - expect(response.body).toEqual({ - count: 1, - page: 1, - per_page: 20, - rows: [ - { - title: 'title0', - user: { - name: 'a' - } - } - ] - }); - }); - - // TODO(question): 当 fields 只填写了关联字段时,当前表的其他字段是否需要输出? - it.skip('only belongs to', async () => { - const response = await agent.get('/posts?fields=user&filter[title]=title0'); - expect(response.body).toEqual({ - count: 1, - rows: [ - { - title: 'title0', - user: { name: 'a' } - } - ] - }); - }); - - it('except fields', async () => { - const response = await agent.get('/posts?fields[except]=status&filter[title]=title0'); - expect(response.body.rows[0].status).toBeUndefined(); - }); - - it('only and except fields', async () => { - const response = await agent.get('/posts?fields=title&fields[except]=status&filter[title]=title0'); - expect(response.body.rows[0].status).toBeUndefined(); - expect(response.body.rows).toEqual([{ title: 'title0' }]); - }); - - it('only with belongs to fields', async () => { - const response = await agent.get('/posts?fields[only]=title&fields[only]=user.name&filter[title]=title0'); - expect(response.body.rows[0].user.name).toEqual('a'); - expect(response.body.rows).toEqual([{ title: 'title0', user: { name: 'a' } }]); - }); - - 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, nicknames: ['aa', 'aaa'], name: 'a', ...timestampsStrings } - }]); - }); - }); - }); - - describe('hasMany', () => { - beforeEach(async () => { - const User = db.getModel('users'); - await User.bulkCreate([ - { name: 'a' }, - { name: 'b' }, - { name: 'c' } - ]); - const users = await User.findAll(); - const Post = db.getModel('posts'); - const post = await Post.create({ user_id: users[0].id }); + await api.database.sync(); + const [Post, Comment] = api.database.getModels(['posts', 'comments']); + for (let index = 1; index < 4; index++) { + const post = await Post.create({ title: `t${index}` }); await post.updateAssociations({ - comments: Array(6).fill(null).map((_, index) => ({ - content: `content${index}`, - status: index % 2 ? 'published' : 'draft', - user_id: users[index % users.length].id - })) + comments: [{ content: 'c1', status: 'publish' }, { content: 'c2' }, { content: 'c3' }], }); - }); + } + }); - it('get comments of a post', async () => { - const Post = db.getModel('posts'); - const post = await Post.findByPk(1); - const response = await agent - .get(`/posts/${post.id}/comments?page=2&perPage=2&sort=content&fields=content&filter[published]=1`); - expect(response.body).toEqual({ - rows: [{ content: 'content5' }], - count: 3, - page: 2, - per_page: 2 + afterEach(async () => { + return api.destroy(); + }); + + describe('fields', () => { + it('fields', async () => { + const response = await api.resource('posts').list({ + fields: ['title'], + filter: { + title: 't1', + }, }); - }); - - it('get comments within a post, order by comments.content', async () => { - const response = await agent - .get('/posts?fields=title,comments.content&filter[comments.status]=draft&page=1&perPage=2&sort=-comments.content'); - expect(response.body).toEqual({ - rows: [{ - title: null, - comments: [{ content: 'content4' }, { content: 'content2' }, { content: 'content0' }] - }], - count: 1, - page: 1, - per_page: 2 - }); - }); - - it('get comments of a post, and user of each comment', async () => { - const Post = db.getModel('posts'); - const post = await Post.findByPk(1); - const response = await agent - .get(`/posts/${post.id}/comments?fields=content,user.name&filter[status]=draft&sort=-content&page=1&perPage=2`); - - expect(response.body).toEqual({ - count: 3, - page: 1, - per_page: 2, - rows: [ - { content: 'content4', user: { name: 'b' } }, - { content: 'content2', user: { name: 'c' } } - ] - }); - }); - - // 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=-comments.content&page=1&perPage=2`); - expect(response.body).toEqual({ count: 1, + rows: [{ title: 't1' }], page: 1, - per_page: 2, + per_page: 20, + }); + }); + + it('fields#appends', async () => { + const response = await api.resource('posts').list({ + fields: { + appends: ['comments'], + }, + filter: { + title: 't1', + }, + }); + expect(response.body).toMatchObject({ + count: 1, rows: [ { + title: 't1', comments: [ - { content: 'content4' }, - { content: 'content2' } + { + content: 'c1', + }, + { + content: 'c2', + }, + { + content: 'c3', + }, ], - user: { name: 'a' } - } - ] + }, + ], + page: 1, + per_page: 20, }); }); - - 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 [post1, post2] = await Post.bulkCreate([{}, {}]); - await post1.updateAssociations({ - tags: [1, 2, 3, 4, 5, 6, 7] + describe('filter', () => { + it('and', async () => { + const response = await api.resource('posts').list({ + filter: { + and: [ + { title: 't1' }, + { status: 'draft' }, + ], + }, }); - await post2.updateAssociations({ - tags: [2, 5, 8] - }); - const User = db.getModel('users'); - const user = await User.create(); - await user.updateAssociations({ - posts: [post1] + expect(response.body).toMatchObject({ + count: 1, + rows: [ + { + title: 't1', + }, + ], + page: 1, + per_page: 20 }); }); - - it('list1', async () => { - const Post = db.getModel('posts'); - const post = await Post.findByPk(1); - const response = await agent - .get(`/posts/${post.id}/tags?page=2&perPage=2&sort=-name&fields=name&filter[status]=published`); - expect(response.body).toEqual({ - rows: [{ name: 'tag3' }, { name: 'tag1' }], - count: 4, - page: 2, - per_page: 2 + it('or', async () => { + const response = await api.resource('posts').list({ + filter: { + or: [ + { title: 't1' }, + { title: 't2' }, + ], + }, + }); + expect(response.body).toMatchObject({ + count: 2, + rows: [ + { + title: 't1', + }, + { + title: 't2', + } + ], + page: 1, + per_page: 20 }); - }); - - // TODO(bug): SQL 报错 - it.skip('list2', async () => { - const response = await agent - .get(`/users/1/posts?fields=tags`); - console.log(response.body); }); }); }); diff --git a/packages/actions/src/__tests__/middleware.test.ts b/packages/actions/src/__tests__/middleware.test.ts deleted file mode 100644 index b61998220..000000000 --- a/packages/actions/src/__tests__/middleware.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import actions from '..'; -import { Context } from '../actions'; -import { dataWrapping } from '../middlewares'; -import { initDatabase, agent, resourcer } from './index'; - -describe('list', () => { - let db; - - beforeAll(async () => { - resourcer.define({ - name: 'articles', - middlewares: [ - dataWrapping, - ], - actions: actions.common, - }); - db = await initDatabase(); - db.table({ - name: 'articles', - tableName: 'actions__articles', - fields: [ - { - type: 'string', - name: 'title', - }, - { - type: 'string', - name: 'status', - defaultValue: 'publish', - } - ], - scopes: { - customTitle: (title, ctx: Context) => { - return { - where: { - title: title, - }, - } - }, - } - }); - await db.sync({ - force: true, - }); - }); - - afterAll(() => db.close()); - - it('create', async () => { - const response = await agent - .post('/articles') - .send({ - title: 'title1', - }); - expect(response.body.data.title).toBe('title1'); - }); - - it('list', async () => { - const response = await agent.get('/articles?fields=title&page=1'); - expect(response.body).toEqual({ - data: [{ title: 'title1' }], - meta: { count: 1, page: 1, per_page: 20 } - }); - }); -}); diff --git a/packages/actions/src/__tests__/remove.test.ts b/packages/actions/src/__tests__/remove.test.ts index 2721e143d..6278b833a 100644 --- a/packages/actions/src/__tests__/remove.test.ts +++ b/packages/actions/src/__tests__/remove.test.ts @@ -1,76 +1,21 @@ -import { initDatabase, agent } from './index'; +import { mockServer, MockServer } from '@nocobase/test'; +import { registerActions } from '..'; describe('remove', () => { - let db; + let api: MockServer; beforeEach(async () => { - db = await initDatabase(); + api = mockServer({ + dataWrapping: false, + }); + registerActions(api); }); - afterAll(() => db.close()); - - it('hasOne1', async () => { - const User = db.getModel('users'); - const user = await User.create(); - await user.updateAssociations({ - profile: { - email: 'email1122', - } - }); - const response = await agent - .post(`/users/${user.id}/profile:remove`); - const profile = await user.getProfile(); - expect(profile).toBeNull(); + afterEach(async () => { + return api.destroy(); }); - it('hasMany1', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - await post.updateAssociations({ - comments: [ - { content: 'content111222' }, - ], - }); - let [comment] = await post.getComments(); - await agent - .post(`/posts/${post.id}/comments:remove/${comment.id}`); - const count = await post.countComments(); - expect(count).toBe(0); - }); - - it('belongsTo1', async () => { - const Post = db.getModel('posts'); - let post = await Post.create(); - await post.updateAssociations({ - user: { name: 'name121234' }, - }); - await agent.post(`/posts/${post.id}/user:remove`); - post = await Post.findOne({ - where: { - id: post.id, - } - }); - const user = await post.getUser(); - expect(user).toBeNull(); - }); - - it('belongsToMany', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - await post.updateAssociations({ - tags: [ - { - name: 'tag112233', - posts_tags: { - test: 'test1', - } - }, - ], - }); - const [tag] = await post.getTags(); - await agent - .delete(`/posts/${post.id}/tags:remove/${tag.id}`); - const tags = await post.getTags(); - expect(tags.length).toBe(0); + it('remove', async () => { + }); }); diff --git a/packages/actions/src/__tests__/set.test.ts b/packages/actions/src/__tests__/set.test.ts index 695e47eea..edeedf2dc 100644 --- a/packages/actions/src/__tests__/set.test.ts +++ b/packages/actions/src/__tests__/set.test.ts @@ -1,44 +1,21 @@ -import { initDatabase, agent } from './index'; +import { mockServer, MockServer } from '@nocobase/test'; +import { registerActions } from '..'; describe('set', () => { - let db; + let api: MockServer; beforeEach(async () => { - db = await initDatabase(); - }); - - afterAll(() => db.close()); - - it('belongsTo1', async () => { - const Post = db.getModel('posts'); - const User = db.getModel('users'); - let post = await Post.create(); - let user = await User.create(); - await agent.post(`/posts/${post.id}/user:set/${user.id}`); - post = await Post.findOne({ - where: { - id: post.id, - } + api = mockServer({ + dataWrapping: false, }); - const postUser = await post.getUser(); - expect(user.id).toBe(postUser.id); + registerActions(api); }); - // TODO: 关系暂不关注,先注释了 - it.skip('belongsToMany1', async () => { - const [Post, Tag] = db.getModels(['posts', 'tags']); - let post = await Post.create(); - let tag1 = await Tag.create({ name: 'tag1' }); - let tag2 = await Tag.create({ name: 'tag2' }); - await agent.post(`/posts/${post.id}/tags:set/${tag1.id}`); - // 单独跑 ok,和上面的 it 一起跑就无法获取到 - const tags = await post.getTags(); - console.log(post, tags); - expect(tag1.id).toBe(tags[0].id); - expect(await post.countTags()).toBe(1); - await agent.post(`/posts/${post.id}/tags:set/${tag2.id}`); - const [tag02] = await post.getTags(); - expect(tag2.id).toBe(tag02.id); - expect(await post.countTags()).toBe(1); + afterEach(async () => { + return api.destroy(); + }); + + it('set', async () => { + }); }); diff --git a/packages/actions/src/__tests__/sort.test.ts b/packages/actions/src/__tests__/sort.test.ts index 058e304ee..b5307bd44 100644 --- a/packages/actions/src/__tests__/sort.test.ts +++ b/packages/actions/src/__tests__/sort.test.ts @@ -1,302 +1,513 @@ -import { initDatabase, agent } from './index'; +import { mockServer, MockServer } from '@nocobase/test'; +import { registerActions } from '..'; -describe('get', () => { - let db; +describe('sort', () => { - beforeEach(async () => { - db = await initDatabase(); - const User = db.getModel('users'); - const users = await User.bulkCreate(Array.from('abcdefg').map(name => ({ name }))); + describe('same scope', () => { + let api: MockServer; - const Post = db.getModel('posts'); - const posts = await Post.bulkCreate(Array(10).fill(null).map((_, i) => ({ - title: `title_${i}`, - status: i % 2 ? 'publish' : 'draft', - user_id: users[i % users.length].id - }))); - - await posts.reduce((promise, post) => promise.then(() => post.updateAssociations({ - comments: Array(post.sort % 5).fill(null).map((_, index) => ({ - content: `content_${index}`, - status: index % 2 ? 'published' : 'draft', - user_id: users[index % users.length].id - })) - })), Promise.resolve()); - }); - - afterAll(() => db.close()); - - describe('sort value initialization', () => { - it('initialization by bulkCreate', async () => { - const Post = db.getModel('posts'); - const posts = await Post.findAll({ - order: [['id', 'ASC']] + beforeEach(async () => { + api = mockServer({ + dataWrapping: false, }); - expect(posts.map(({ id, sort, sort_in_status, sort_in_user }) => ({ id, sort, sort_in_status, sort_in_user }))).toEqual([ - { id: 1, sort: 1, sort_in_status: 1, sort_in_user: 1 }, - { id: 2, sort: 2, sort_in_status: 1, sort_in_user: 1 }, - { id: 3, sort: 3, sort_in_status: 2, sort_in_user: 1 }, - { id: 4, sort: 4, sort_in_status: 2, sort_in_user: 1 }, - { id: 5, sort: 5, sort_in_status: 3, sort_in_user: 1 }, - { id: 6, sort: 6, sort_in_status: 3, sort_in_user: 1 }, - { id: 7, sort: 7, sort_in_status: 4, sort_in_user: 1 }, - { id: 8, sort: 8, sort_in_status: 4, sort_in_user: 2 }, - { id: 9, sort: 9, sort_in_status: 5, sort_in_user: 2 }, - { id: 10, sort: 10, sort_in_status: 5, sort_in_user: 2 } - ]); - }); - - it('initialization by updateAssociations', async () => { - const Comment = db.getModel('comments'); - const comments = await Comment.findAll({ - order: [['id', 'ASC']] + registerActions(api); + api.database.table({ + name: 'tests', + fields: [ + { type: 'string', name: 'title' }, + { type: 'sort', name: 'sort' }, + { type: 'sort', name: 'sort2' }, + ], }); - expect(comments.map(({ id, sort, sort_in_status, sort_in_post }) => ({ id, sort, sort_in_status, sort_in_post }))).toEqual([ - { id: 1, sort: 1, sort_in_status: 1, sort_in_post: 1 }, - { id: 2, sort: 2, sort_in_status: 2, sort_in_post: 1 }, - { id: 3, sort: 3, sort_in_status: 1, sort_in_post: 2 }, - { id: 4, sort: 4, sort_in_status: 3, sort_in_post: 1 }, - { id: 5, sort: 5, sort_in_status: 2, sort_in_post: 2 }, - { id: 6, sort: 6, sort_in_status: 4, sort_in_post: 3 }, - { id: 7, sort: 7, sort_in_status: 5, sort_in_post: 1 }, - { id: 8, sort: 8, sort_in_status: 3, sort_in_post: 2 }, - { id: 9, sort: 9, sort_in_status: 6, sort_in_post: 3 }, - { id: 10, sort: 10, sort_in_status: 4, sort_in_post: 4 }, - { id: 11, sort: 11, sort_in_status: 7, sort_in_post: 1 }, - { id: 12, sort: 12, sort_in_status: 8, sort_in_post: 1 }, - { id: 13, sort: 13, sort_in_status: 5, sort_in_post: 2 }, - { id: 14, sort: 14, sort_in_status: 9, sort_in_post: 1 }, - { id: 15, sort: 15, sort_in_status: 6, sort_in_post: 2 }, - { id: 16, sort: 16, sort_in_status: 10, sort_in_post: 3 }, - { id: 17, sort: 17, sort_in_status: 11, sort_in_post: 1 }, - { id: 18, sort: 18, sort_in_status: 7, sort_in_post: 2 }, - { id: 19, sort: 19, sort_in_status: 12, sort_in_post: 3 }, - { id: 20, sort: 20, sort_in_status: 8, sort_in_post: 4 } - ]); + await api.database.sync(); + const Test = api.database.getModel('tests'); + for (let index = 1; index < 5; index++) { + await Test.create({ title: `t${index}` }); + } }); - it('sort value of append item', async () => { - const Post = db.getModel('posts'); - const post = await Post.create({ user_id: 1 }); - expect(post.sort).toBe(11); - expect(post.sort_in_status).toBe(6); - expect(post.sort_in_user).toBe(3); + afterEach(async () => { + return api.destroy(); }); - }); - describe('sort in whole table', () => { - it('move id=1 to position at id=2', async () => { - await agent - .post('/posts:sort/1') - .send({ - field: 'sort', - target: { id: 2 }, - }); - - const Post = db.getModel('posts'); - const posts = await Post.findAll({ - attributes: ['id', 'sort'], - order: [['id', 'ASC']] + it('targetId', async () => { + await api.resource('tests').sort({ + sourceId: 1, + targetId: 3, }); - expect(posts.map(item => item.get())).toEqual([ - { id: 1, sort: 2 }, - { id: 2, sort: 1 }, - { id: 3, sort: 3 }, - { id: 4, sort: 4 }, - { id: 5, sort: 5 }, - { id: 6, sort: 6 }, - { id: 7, sort: 7 }, - { id: 8, sort: 8 }, - { id: 9, sort: 9 }, - { id: 10, sort: 10 } - ]); - }); - - it('move id=2 to position at id=1', async () => { - await agent - .post('/posts:sort/2') - .send({ - field: 'sort', - target: { id: 1 }, - }); - - const Post = db.getModel('posts'); - const posts = await Post.findAll({ - attributes: ['id', 'sort'], - order: [['id', 'ASC']] + const response = await api.resource('tests').list({ + sort: ['sort'], }); - expect(posts.map(item => item.get())).toEqual([ - { id: 1, sort: 2 }, - { id: 2, sort: 1 }, - { id: 3, sort: 3 }, - { id: 4, sort: 4 }, - { id: 5, sort: 5 }, - { id: 6, sort: 6 }, - { id: 7, sort: 7 }, - { id: 8, sort: 8 }, - { id: 9, sort: 9 }, - { id: 10, sort: 10 } - ]); - }); - - it('move id=1 to position at id=10', async () => { - await agent - .post('/posts:sort/1') - .send({ - field: 'sort', - target: { id: 10 }, - }); - - const Post = db.getModel('posts'); - const posts = await Post.findAll({ - attributes: ['id', 'sort'], - order: [['id', 'ASC']] - }); - expect(posts.map(item => item.get())).toEqual([ - { id: 1, sort: 10 }, - { id: 2, sort: 1 }, - { id: 3, sort: 2 }, - { id: 4, sort: 3 }, - { id: 5, sort: 4 }, - { id: 6, sort: 5 }, - { id: 7, sort: 6 }, - { id: 8, sort: 7 }, - { id: 9, sort: 8 }, - { id: 10, sort: 9 } - ]); - }); - }); - - describe('sort in filtered scope', () => { - it('move id=2 to position at id=8 (same scope value)', async () => { - await agent - .post('/posts:sort/2') - .send({ - field: 'sort_in_status', - target: { id: 8 }, - }); - - const Post = db.getModel('posts'); - const posts = await Post.findAll({ - where: { - status: 'publish' - }, - attributes: ['id', 'sort_in_status'], - order: [['id', 'ASC']] - }); - expect(posts.map(item => item.get())).toEqual([ - { id: 2, sort_in_status: 4 }, - { id: 4, sort_in_status: 1 }, - { id: 6, sort_in_status: 2 }, - { id: 8, sort_in_status: 3 }, - { id: 10, sort_in_status: 5 } - ]); - }); - - it('move id=1 to position at id=8 (different scope value)', async () => { - await agent - .post('/posts:sort/1') - .send({ - field: 'sort_in_status', - target: { id: 8 }, - }); - - const Post = db.getModel('posts'); - const posts = await Post.findAll({ - where: { - status: 'publish' - }, - attributes: ['id', 'sort_in_status'], - order: [['id', 'ASC']] - }); - expect(posts.map(item => item.get())).toEqual([ - { id: 1, sort_in_status: 4 }, - { id: 2, sort_in_status: 1 }, - { id: 4, sort_in_status: 2 }, - { id: 6, sort_in_status: 3 }, - { id: 8, sort_in_status: 5 }, - { id: 10, sort_in_status: 6 } - ]); - }); - - it('move id=1 to new empty list of scope', async () => { - await agent - .post('/posts:sort/1') - .send({ - field: 'sort_in_status', - target: { status: 'archived' }, - }); - - const Post = db.getModel('posts'); - const posts = await Post.findAll({ - attributes: ['id', 'sort_in_status'], - order: [['id', 'ASC']] - }); - expect(posts.map(item => item.get())).toEqual([ - { id: 1, sort_in_status: 1 }, - { id: 2, sort_in_status: 1 }, - { id: 3, sort_in_status: 2 }, - { id: 4, sort_in_status: 2 }, - { id: 5, sort_in_status: 3 }, - { id: 6, sort_in_status: 3 }, - { id: 7, sort_in_status: 4 }, - { id: 8, sort_in_status: 4 }, - { id: 9, sort_in_status: 5 }, - { id: 10, sort_in_status: 5 } - ]); - }); - - it('move id=1 to scope without target primary key', async () => { - await agent - .post('/posts:sort/1') - .send({ - field: 'sort_in_status', - target: { status: 'publish' }, - }); - - const Post = db.getModel('posts'); - const posts = await Post.findAll({ - where: { - status: 'publish' - }, - attributes: ['id', 'sort_in_status'], - order: [['id', 'ASC']] - }); - expect(posts.map(item => item.get())).toEqual([ - { id: 1, sort_in_status: 6 }, - { id: 2, sort_in_status: 1 }, - { id: 4, sort_in_status: 2 }, - { id: 6, sort_in_status: 3 }, - { id: 8, sort_in_status: 4 }, - { id: 10, sort_in_status: 5 } - ]); - }); - }); - - describe('associations', () => { - describe('hasMany', () => { - it('move id=1 to position at id=3 (different scope value)', async () => { - await agent - .post('/users/1/posts:sort/1') - .send({ - field: 'sort_in_user', - target: { id: 3 }, - }); - - const Post = db.getModel('posts'); - const posts = await Post.findAll({ - where: { - user_id: 3 + expect(response.body).toMatchObject({ + rows: [ + { + title: 't2', + sort: 1, }, - attributes: ['id', 'sort_in_user'], - order: [['id', 'ASC']] - }); + { + title: 't3', + sort: 2, + }, + { + title: 't1', + sort: 3, + }, + { + title: 't4', + sort: 4, + } + ], + }); + }); - expect(posts.map(item => item.get())).toEqual([ - { id: 1, sort_in_user: 1 }, - { id: 3, sort_in_user: 2 }, - { id: 10, sort_in_user: 3 }, - ]); + it('targetId', async () => { + await api.resource('tests').sort({ + sourceId: 3, + targetId: 1, + }); + const response = await api.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.resource('tests').sort({ + sortField: 'sort2', + sourceId: 1, + targetId: 3, + }); + const response = await api.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.resource('tests').sort({ + sourceId: 3, + sticky: true, + }); + const response = await api.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({ + dataWrapping: false, + }); + registerActions(api); + api.database.table({ + name: 'tests', + fields: [ + { type: 'string', name: 'title' }, + { type: 'integer', name: 'state' }, + { type: 'sort', name: 'sort', scope: ['state'] }, + ], + }); + await api.database.sync(); + const Test = api.database.getModel('tests'); + for (let index = 1; index < 5; index++) { + await Test.create({ title: `t1${index}`, state: 1 }); + } + for (let index = 1; index < 5; index++) { + await Test.create({ title: `t2${index}`, state: 2 }); + } + }); + + afterEach(async () => { + return api.destroy(); + }); + + it('targetId/1->6', async () => { + await api.resource('tests').sort({ + sourceId: 1, + targetId: 6, + }); + let response = await api.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.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.resource('tests').sort({ + sourceId: 1, + targetId: 6, + method: 'insertAfter', + }); + let response = await api.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.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.resource('tests').sort({ + sourceId: 6, + targetId: 2, + }); + let response = await api.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.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.resource('tests').sort({ + sourceId: 6, + targetId: 2, + method: 'insertAfter', + }); + let response = await api.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.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.resource('tests').sort({ + sourceId: 1, + targetScope: { + state: 2, + }, + }); + let response = await api.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.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.resource('tests').sort({ + sourceId: 1, + targetScope: { + state: 2, + }, + method: 'prepend', + }); + let response = await api.resource('tests').list({ + sort: ['sort'], + filter: { state: 1 }, + }); + expect(response.body).toMatchObject({ + rows: [ + { + title: 't12', + }, + { + title: 't13', + }, + { + title: 't14', + }, + ], + }); + response = await api.resource('tests').list({ + sort: ['sort'], + filter: { state: 2 }, + }); + expect(response.body).toMatchObject({ + rows: [ + { + title: 't11', + }, + { + title: 't21', + }, + { + title: 't22', + }, + { + title: 't23', + }, + { + title: 't24', + }, + ], }); }); }); diff --git a/packages/actions/src/__tests__/tables/comments.ts b/packages/actions/src/__tests__/tables/comments.ts deleted file mode 100644 index 9497b187a..000000000 --- a/packages/actions/src/__tests__/tables/comments.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { TableOptions } from "@nocobase/database"; - -export default { - name: 'comments', - tableName: 'actions__comments', - fields: [ - { - type: 'string', - name: 'content', - }, - { - type: 'string', - name: 'status', - }, - { - type: 'belongsTo', - name: 'post', - }, - { - type: 'belongsTo', - name: 'user', - }, - { - type: 'sort', - name: 'sort' - }, - { - type: 'sort', - name: 'sort_in_status', - scope: ['status'] - }, - { - type: 'sort', - name: 'sort_in_post', - scope: ['post'] - } - ], - scopes: { - published: { - where: { - status: 'published' - } - } - } -} as TableOptions; diff --git a/packages/actions/src/__tests__/tables/posts.ts b/packages/actions/src/__tests__/tables/posts.ts deleted file mode 100644 index 2f9462ca8..000000000 --- a/packages/actions/src/__tests__/tables/posts.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { TableOptions } from "@nocobase/database"; - -export default { - name: 'posts', - tableName: 'actions__posts', - fields: [ - { - type: 'string', - name: 'title', - }, - { - type: 'string', - name: 'status', - defaultValue: 'publish', - }, - { - type: 'date', - name: 'published_at' - }, - { - type: 'belongsTo', - name: 'user', - }, - { - type: 'hasMany', - name: 'comments', - }, - { - type: 'belongsToMany', - name: 'tags', - }, - { - type: 'sort', - name: 'sort' - }, - { - type: 'sort', - name: 'sort_in_status', - scope: ['status'] - }, - { - type: 'sort', - name: 'sort_in_user', - scope: ['user'] - }, - { - type: 'json', - name: 'meta' - } - ], - scopes: { - customTitle: (title, ctx) => { - return { - where: { - title: title, - }, - } - } - } -} as TableOptions; diff --git a/packages/actions/src/__tests__/tables/posts_tags.ts b/packages/actions/src/__tests__/tables/posts_tags.ts deleted file mode 100644 index 039ddcda1..000000000 --- a/packages/actions/src/__tests__/tables/posts_tags.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { TableOptions } from "@nocobase/database"; - -export default { - name: 'posts_tags', - tableName: 'actions__posts_tags', - fields: [ - { - type: 'string', - name: 'test', - }, - ], -} as TableOptions; diff --git a/packages/actions/src/__tests__/tables/profiles.ts b/packages/actions/src/__tests__/tables/profiles.ts deleted file mode 100644 index be1e6e616..000000000 --- a/packages/actions/src/__tests__/tables/profiles.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { TableOptions } from "@nocobase/database"; - -export default { - name: 'profiles', - tableName: 'actions__profiles', - fields: [ - { - type: 'belongsTo', - name: 'user' - }, - { - type: 'string', - name: 'email', - }, - { - type: 'string', - name: 'city', - dataSource: [ - { value: '1101', title: 'Beijing' }, - { value: '3710', title: 'Weihai' }, - { value: '5301', title: 'Kunming' } - ] - }, - { - type: 'jsonb', - name: 'interest', - defaultValue: [], - multiple: true, - dataSource: [ - { value: 1, title: 'running' }, - { value: 2, title: 'climbing' }, - { value: 3, title: 'fishing' }, - ] - } - ], -} as TableOptions; diff --git a/packages/actions/src/__tests__/tables/tags.ts b/packages/actions/src/__tests__/tables/tags.ts deleted file mode 100644 index 63368e711..000000000 --- a/packages/actions/src/__tests__/tables/tags.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { TableOptions } from "@nocobase/database"; - -export default { - name: 'tags', - tableName: 'actions__tags', - fields: [ - { - type: 'string', - name: 'name', - }, - { - type: 'string', - name: 'status', - }, - { - type: 'belongsToMany', - name: 'posts', - }, - ], - scopes: { - published: { - where: { - status: 'published' - } - } - } -} as TableOptions; diff --git a/packages/actions/src/__tests__/tables/users.ts b/packages/actions/src/__tests__/tables/users.ts deleted file mode 100644 index 5ded5bc7b..000000000 --- a/packages/actions/src/__tests__/tables/users.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { TableOptions } from "@nocobase/database"; - -export default { - name: 'users', - tableName: 'actions__users', - fields: [ - { - type: 'string', - name: 'name', - }, - { - type: 'jsonb', - name: 'nicknames', - defaultValue: [] - }, - { - type: 'hasOne', - name: 'profile', - }, - { - type: 'hasMany', - name: 'posts' - } - ], -} as TableOptions; diff --git a/packages/actions/src/__tests__/toggle.test.ts b/packages/actions/src/__tests__/toggle.test.ts new file mode 100644 index 000000000..aca90f665 --- /dev/null +++ b/packages/actions/src/__tests__/toggle.test.ts @@ -0,0 +1,21 @@ +import { mockServer, MockServer } from '@nocobase/test'; +import { registerActions } from '..'; + +describe('toggle', () => { + let api: MockServer; + + beforeEach(async () => { + api = mockServer({ + dataWrapping: false, + }); + registerActions(api); + }); + + afterEach(async () => { + return api.destroy(); + }); + + it('toggle', async () => { + + }); +}); diff --git a/packages/actions/src/__tests__/update.test.ts b/packages/actions/src/__tests__/update.test.ts index aa03874ca..92242b499 100644 --- a/packages/actions/src/__tests__/update.test.ts +++ b/packages/actions/src/__tests__/update.test.ts @@ -1,192 +1,66 @@ -import { initDatabase, agent } from './index'; +import { mockServer, MockServer } from '@nocobase/test'; +import { registerActions } from '..'; describe('update', () => { - let db; + let api: MockServer; beforeEach(async () => { - db = await initDatabase(); - }); - - afterAll(() => db.close()); - - describe('common', () => { - it('basic', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - const response = await agent - .put(`/posts/${post.id}`).send({ - title: 'title11112222' - }); - expect(response.body.title).toBe('title11112222'); + api = mockServer({ + dataWrapping: false, }); - - it('update json field by replacing', async () => { - const Post = db.getModel('posts'); - const post = await Post.create({ meta: { a: 1, b: 'c', c: { d: false } } }); - const updated = await agent - .put(`/posts/${post.id}`).send({ - meta: {} - }); - expect(updated.body.meta).toEqual({}); - }); - - it.skip('update json field by path based update', async () => { - const Post = db.getModel('posts'); - const post = await Post.create({ meta: { a: 1, b: 'c', c: { d: false } } }); - const updated = await agent - .put(`/posts/${post.id}?options[json]=merge`).send({ - meta: { - b: 'b', - c: { d: true } - } - }); - // console.log(updated.body); - }); - - // TODO(question): json 字段的覆盖/合并策略 - it.skip('update with fields overwrite default values', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - const response = await agent - .put(`/posts:update1/${post.id}`).send({ - meta: { a: 1 }, - }); - expect(response.body.meta).toEqual({ a: 1 }); - - const result = await agent - .get(`/posts/${post.id}`); - - expect(result.body.meta).toEqual({ a: 1 }); - }); - - // TODO(bug): action 的默认值处理时机不对 - it.skip('update with different fields to default values', async () => { - const Post = db.getModel('posts'); - const post = await Post.create({ - meta: { location: 'Beijing' } - }); - const response = await agent - .put(`/posts:update1/${post.id}`).send({ - meta: { a: 1 }, - }); - expect(response.body.meta).toEqual({ location: 'Beijing', a: 1 }); - }); - - it('update with options.fields.expect in action', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - const response = await agent - .put(`/posts:update1/${post.id}`).send({ - title: 'title11112222', - }); - expect(response.body.title).toBe(null); - expect(response.body.meta).toEqual({ - location: 'Kunming' - }); - - const result = await agent - .get(`/posts/${post.id}`); - - expect(result.body.title).toBe(null); - expect(result.body.meta).toEqual({ - location: 'Kunming' - }); - }); - - it('update with options.fields.only in action', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - const response = await agent - .put(`/posts:update2/${post.id}`).send({ - title: 'title11112222', - meta: { a: 1 } - }); - expect(response.body.title).toBe('title11112222'); - expect(response.body.meta).toBe(null); - - const result = await agent - .get(`/posts/${post.id}`); - - expect(result.body.title).toBe('title11112222'); - expect(result.body.meta).toBe(null); - }); - }); - - it('hasOne', async () => { - const User = db.getModel('users'); - const user = await User.create(); - await user.updateAssociations({ - profile: { email: 'email1122' } - }); - const response = await agent - .put(`/users/${user.id}/profile`).send({ - email: 'email1111', - }); - expect(response.body.email).toEqual('email1111'); - }); - - it('hasOne without exist target', async () => { - const User = db.getModel('users'); - const user = await User.create(); - const response = await agent - .put(`/users/${user.id}/profile`).send({ - email: 'email1122', - }); - expect(response.body).toEqual({}); - }); - - it('hasMany1', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - await post.updateAssociations({ - comments: [ - { content: 'content111222' }, + registerActions(api); + api.database.table({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'hasMany', name: 'comments' }, ], }); - const [comment] = await post.getComments(); - const response = await agent - .put(`/posts/${post.id}/comments/${comment.id}`).send({ content: 'content111222333' }); - expect(response.body.post_id).toBe(post.id); - expect(response.body.content).toBe('content111222333'); - }); - - it('belongsTo1', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - await post.updateAssociations({ - user: { name: 'name121234' }, - }); - const response = await agent - .post(`/posts/${post.id}/user:update`).send({ name: 'name1212345' }); - expect(response.body.name).toEqual('name1212345'); - }); - - it('belongsToMany', async () => { - const Post = db.getModel('posts'); - const post = await Post.create(); - await post.updateAssociations({ - tags: [ - { name: 'tag112233' }, + api.database.table({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, ], }); - const [tag] = await post.getTags(); - let response = await agent - .post(`/posts/${post.id}/tags:update/${tag.id}`).send({ - name: 'tag11223344', - posts_tags: { - test: 'test1', - }, - }); - const [tag1] = await post.getTags(); - expect(tag1.posts_tags.test).toBe('test1'); - expect(response.body.name).toBe('tag11223344'); - response = await agent - .post(`/posts/${post.id}/tags:update/${tag.id}`).send({ - posts_tags: { - test: 'test112233', - }, - }); - const [tag2] = await post.getTags(); - expect(tag2.posts_tags.test).toBe('test112233'); + await api.database.sync(); + }); + + afterEach(async () => { + return api.destroy(); + }); + + it('update', async () => { + const Post = api.database.getModel('posts'); + const post = await Post.create(); + await api.resource('posts').update({ + resourceKey: post.id, + values: { + title: 't1', + }, + }); + const post2 = await Post.findByPk(post.id); + expect(post2.toJSON()).toMatchObject({ + title: 't1', + }); + }); + + it('update associations', async () => { + const [Post, Comment] = api.database.getModels(['posts', 'comments']); + const post = await Post.create(); + const comment = await Comment.create(); + await post.updateAssociations({ + comments: [comment] + }); + await api.resource('posts.comments').update({ + resourceKey: comment.id, + associatedKey: post.id, + values: { + content: 'c2', + }, + }); + const comment2 = await Comment.findByPk(comment.id); + expect(comment2.toJSON()).toMatchObject({ + content: 'c2', + }); }); }); diff --git a/packages/actions/src/__tests__/utils.test.ts b/packages/actions/src/__tests__/utils.test.ts index 298fe7441..b001b6317 100644 --- a/packages/actions/src/__tests__/utils.test.ts +++ b/packages/actions/src/__tests__/utils.test.ts @@ -1,4 +1,3 @@ -import { initDatabase, agent } from './index'; import { filterByFields } from '../utils'; describe('utils', () => { diff --git a/packages/actions/src/actions/add.ts b/packages/actions/src/actions/add.ts new file mode 100644 index 000000000..4debccece --- /dev/null +++ b/packages/actions/src/actions/add.ts @@ -0,0 +1,48 @@ +import { Context, Next } from '..'; +import { + Model, + Relation, +} from '@nocobase/database'; + +/** + * 附加关联 + * + * BlongsToMany + * + * @param ctx + * @param next + */ +export async function add(ctx: Context, next: Next) { + const { + associated, + resourceField, + associatedName, + } = ctx.action.params as { + associated: Model, + associatedName: string, + resourceField: Relation, + values: any, + }; + const AssociatedModel = ctx.db.getModel(associatedName); + if (!(associated instanceof AssociatedModel)) { + throw new Error(`${associatedName} associated model invalid`); + } + const { add: addAccessor } = resourceField.getAccessors(); + const { resourceKey, resourceKeyAttribute, fields = [] } = ctx.action.params; + const TargetModel = ctx.db.getModel(resourceField.getTarget()); + // const options = TargetModel.parseApiJson({ + // fields, + // }); + const model = await TargetModel.findOne({ + // ...options, + where: { + [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, + }, + // @ts-ignore + context: ctx, + }); + ctx.body = await associated[addAccessor](model); + await next(); +} + +export default add; diff --git a/packages/actions/src/actions/associate.ts b/packages/actions/src/actions/associate.ts deleted file mode 100644 index 1bbdf6ad8..000000000 --- a/packages/actions/src/actions/associate.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { Context, Next } from '.'; -import { list, get, create, update, destroy } from './common'; -import { - Model, - Relation, - HASONE, - BELONGSTO, - BELONGSTOMANY, - HASMANY, -} from '@nocobase/database'; - -/** - * 建立关联 - * - * BlongsTo - * BlongsToMany - * - * @param ctx - * @param next - */ -export async function set(ctx: Context, next: Next) { - const { - associated, - resourceField, - associatedName, - } = ctx.action.params as { - associated: Model, - associatedName: string, - resourceField: Relation, - values: any, - }; - const AssociatedModel = ctx.db.getModel(associatedName); - if (!(associated instanceof AssociatedModel)) { - throw new Error(`${associatedName} associated model invalid`); - } - const { set: setAccessor } = resourceField.getAccessors(); - const { resourceKey, resourceKeyAttribute, fields = [] } = ctx.action.params; - const TargetModel = ctx.db.getModel(resourceField.getTarget()); - // const options = TargetModel.parseApiJson({ - // fields, - // }); - const model = await TargetModel.findOne({ - where: { - [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, - }, - // @ts-ignore - context: ctx, - }); - ctx.body = await associated[setAccessor](model); - await next(); -} - -/** - * 附加关联 - * - * BlongsToMany - * - * @param ctx - * @param next - */ -export async function add(ctx: Context, next: Next) { - const { - associated, - resourceField, - associatedName, - } = ctx.action.params as { - associated: Model, - associatedName: string, - resourceField: Relation, - values: any, - }; - const AssociatedModel = ctx.db.getModel(associatedName); - if (!(associated instanceof AssociatedModel)) { - throw new Error(`${associatedName} associated model invalid`); - } - const { add: addAccessor } = resourceField.getAccessors(); - const { resourceKey, resourceKeyAttribute, fields = [] } = ctx.action.params; - const TargetModel = ctx.db.getModel(resourceField.getTarget()); - // const options = TargetModel.parseApiJson({ - // fields, - // }); - const model = await TargetModel.findOne({ - // ...options, - where: { - [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, - }, - // @ts-ignore - context: ctx, - }); - ctx.body = await associated[addAccessor](model); - await next(); -} - -/** - * 删除关联 - * - * BlongsTo - * BlongsToMany - * - * @param ctx - * @param next - */ -export async function remove(ctx: Context, next: Next) { - const { - associated, - resourceField, - associatedName, - } = ctx.action.params as { - associated: Model, - associatedName: string, - resourceField: Relation, - values: any, - }; - const AssociatedModel = ctx.db.getModel(associatedName); - if (!(associated instanceof AssociatedModel)) { - throw new Error(`${associatedName} associated model invalid`); - } - const { get: getAccessor, remove: removeAccessor, set: setAccessor } = resourceField.getAccessors(); - const { resourceKey, resourceKeyAttribute, fields = [] } = ctx.action.params; - const TargetModel = ctx.db.getModel(resourceField.getTarget()); - const options = TargetModel.parseApiJson({ - fields, - }); - if (resourceField instanceof HASONE || resourceField instanceof BELONGSTO) { - ctx.body = await associated[setAccessor](null); - } else if (resourceField instanceof HASMANY || resourceField instanceof BELONGSTOMANY) { - const [model]: Model[] = await associated[getAccessor]({ - ...options, - where: { - [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, - }, - context: ctx, - }); - await associated[removeAccessor](model); - ctx.body = { id: model.id }; - } - await next(); -} - -export async function toggle(ctx: Context, next: Next) { - const { - associated, - resourceField, - associatedName, - } = ctx.action.params as { - associated: Model, - associatedName: string, - resourceField: Relation, - values: any, - }; - const AssociatedModel = ctx.db.getModel(associatedName); - if (!(associated instanceof AssociatedModel)) { - throw new Error(`${associatedName} associated model invalid`); - } - const { get: getAccessor, remove: removeAccessor, set: setAccessor, add: addAccessor } = resourceField.getAccessors(); - const { resourceKey, resourceKeyAttribute, fields = [] } = ctx.action.params; - const TargetModel = ctx.db.getModel(resourceField.getTarget()); - const options = TargetModel.parseApiJson({ - fields, - }); - if (resourceField instanceof HASONE || resourceField instanceof BELONGSTO) { - const m1 = await associated[getAccessor](); - if (m1 && m1[resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute] == resourceKey) { - ctx.body = await associated[setAccessor](null); - } else { - const m2 = await TargetModel.findOne({ - // ...options, - where: { - [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, - }, - // @ts-ignore - context: ctx, - }); - ctx.body = await associated[setAccessor](m2); - } - } else if (resourceField instanceof HASMANY || resourceField instanceof BELONGSTOMANY) { - const [model]: Model[] = await associated[getAccessor]({ - ...options, - where: { - [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, - }, - context: ctx, - }); - if (model) { - ctx.body = await associated[removeAccessor](model); - } else { - const m2 = await TargetModel.findOne({ - // ...options, - where: { - [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, - }, - // @ts-ignore - context: ctx, - }); - ctx.body = await associated[addAccessor](m2); - } - } - await next(); -} - -export default { - list, // hasMany、belongsToMany - get, // 所有关系都有 - create, // hasMany - update, // hasOne, hasMany, blongsToMany 中间表的数据更新 - destroy, // 所有情况 - set, // belongsTo、blongsToMany - add, // blongsToMany - remove, // belongsTo、blongsToMany - toggle, // blongsToMany -} diff --git a/packages/actions/src/actions/common.ts b/packages/actions/src/actions/common.ts deleted file mode 100644 index 3bc70e619..000000000 --- a/packages/actions/src/actions/common.ts +++ /dev/null @@ -1,662 +0,0 @@ -import { Utils, Op, Sequelize } from 'sequelize'; -import _ from 'lodash'; -import { Context, Next } from '.'; -import { - Model, - HASONE, - HASMANY, - BELONGSTO, - BELONGSTOMANY, - whereCompare -} from '@nocobase/database'; -import { PageParameter } from '@nocobase/resourcer'; -import { filterByFields } from '../utils'; - -async function hasManyGet(instances, options: any = {}) { - const where = {}; - - let Model = this.target; - Model = Model.database.getModel(Model.name); - - let instance; - let values; - - if (!Array.isArray(instances)) { - instance = instances; - instances = undefined; - } - - options = { ...options }; - - if (this.scope) { - Object.assign(where, this.scope); - } - - if (instances) { - values = instances.map(_instance => _instance.get(this.sourceKey, { raw: true })); - - if (options.limit && instances.length > 1) { - options.groupedLimit = { - limit: options.limit, - on: this, // association - values - }; - - delete options.limit; - } else { - where[this.foreignKey] = { - [Op.in]: values - }; - delete options.groupedLimit; - } - } else { - where[this.foreignKey] = instance.get(this.sourceKey, { raw: true }); - } - - options.where = options.where ? - { [Op.and]: [where, options.where] } : - where; - - if (Object.prototype.hasOwnProperty.call(options, 'scope')) { - if (!options.scope) { - Model = Model.unscoped(); - } else { - Model = Model.scope(options.scope); - } - } - - if (Object.prototype.hasOwnProperty.call(options, 'schema')) { - Model = Model.schema(options.schema, options.schemaDelimiter); - } - - const results = await Model.findAndCountAll(options); - if (instance) return results; - - const result = {}; - for (const _instance of instances) { - result[_instance.get(this.sourceKey, { raw: true })] = []; - } - - for (const _instance of results) { - result[_instance.get(this.foreignKey, { raw: true })].push(_instance); - } - - return result; -} - -async function belongsToManyGet(instance, options) { - options = Utils.cloneDeep(options) || {}; - - const through = this.through; - let scopeWhere; - let throughWhere; - - if (this.scope) { - scopeWhere = { ...this.scope }; - } - - options.where = { - [Op.and]: [ - scopeWhere, - options.where - ] - }; - - if (Object(through.model) === through.model) { - throughWhere = {}; - throughWhere[this.foreignKey] = instance.get(this.sourceKey); - - if (through.scope) { - Object.assign(throughWhere, through.scope); - } - - //If a user pass a where on the options through options, make an "and" with the current throughWhere - if (options.through && options.through.where) { - throughWhere = { - [Op.and]: [throughWhere, options.through.where] - }; - } - - options.include = options.include || []; - options.include.push({ - association: this.oneFromTarget, - attributes: options.joinTableAttributes, - required: true, - paranoid: _.get(options.through, 'paranoid', true), - where: throughWhere - }); - } - - let model = this.target; - if (Object.prototype.hasOwnProperty.call(options, 'scope')) { - if (!options.scope) { - model = model.unscoped(); - } else { - model = model.scope(options.scope); - } - } - - if (Object.prototype.hasOwnProperty.call(options, 'schema')) { - model = model.schema(options.schema, options.schemaDelimiter); - } - - return model.findAndCountAll(options); -} - - -/** - * 查询数据列表 - * - * - Signle - * - HasMany - * - BelongsToMany - * - * HasOne 和 belongsTo 不涉及到 list - * - * @param ctx - * @param next - */ -export async function list(ctx: Context, next: Next) { - const { - page = PageParameter.DEFAULT_PAGE, - perPage = PageParameter.DEFAULT_PER_PAGE, - sort = [], - fields = [], - filter = {}, - associated, - associatedName, - resourceName, - resourceField, - } = ctx.action.params; - let data = {}; - let options: any = {}; - let Model; - if (associated && resourceField) { - const AssociatedModel = ctx.db.getModel(associatedName); - Model = ctx.db.getModel(resourceField.options.target); - options = Model.parseApiJson({ - sort, - page, - perPage, - filter, - fields, - context: ctx, - }); - if (!(associated instanceof AssociatedModel)) { - throw new Error(`${associatedName} associated model invalid`); - } - // const getAccessor = resourceField.getAccessors().get; - // const countAccessor = resourceField.getAccessors().count; - options.scope = options.scopes || []; - const association = AssociatedModel.associations[resourceField.options.name]; - if (resourceField instanceof BELONGSTOMANY) { - data = await belongsToManyGet.call(association, associated, { - joinTableAttributes: [], - ...options, - context: ctx, - }); - } - if (resourceField instanceof HASMANY) { - data = await hasManyGet.call(association, associated, { - joinTableAttributes: [], - ...options, - context: ctx, - }); - } - } else { - Model = ctx.db.getModel(resourceName); - options = Model.parseApiJson({ - sort, - page, - perPage, - filter, - fields, - context: ctx, - }); - data = await Model.scope(options.scopes || []).findAndCountAll({ - ...options, - // @ts-ignore hooks 里添加 context - context: ctx, - }); - } - if (options.limit || typeof options.offset !== 'undefined') { - // Math.round 避免精度问题 - data['page'] = Math.round((options.offset || 0) / options.limit + 1); - data[Utils.underscoredIf('perPage', Model.options.underscored)] = options.limit; - } - ctx.body = data; - await next(); -} - -/** - * 新增数据 - * - * Signle - * HasMany - * - * resource action 层面一般不开放 HasOne、BelongsTo、BelongsToMany 的新增数据操作 - * 如果需要这类操作建议使用 model.updateAssociations 方法 - * - * TODO 字段验证 - * - * @param ctx - * @param next - */ -export async function create(ctx: Context, next: Next) { - const { - associated, - resourceField, - associatedName, - resourceName, - values: data, - fields - } = ctx.action.params; - const values = filterByFields(data, fields); - const transaction = await ctx.db.sequelize.transaction(); - const options = { transaction, context: ctx }; - let model: Model; - try { - if (associated && resourceField) { - const AssociatedModel = ctx.db.getModel(associatedName); - if (!(associated instanceof AssociatedModel)) { - throw new Error(`${associatedName} associated model invalid`); - } - const { create } = resourceField.getAccessors(); - model = await associated[create](values, options); - } else { - const ResourceModel = ctx.db.getModel(resourceName); - model = await ResourceModel.create(values, options); - } - await model.updateAssociations(values, options); - await transaction.commit(); - ctx.body = model; - await next(); - } catch (error) { - await transaction.rollback(); - throw error; - } -} - -/** - * 查询数据详情 - * - * @param ctx - * @param next - */ -export async function get(ctx: Context, next: Next) { - const { - associated, - resourceField, - associatedName, - resourceName, - resourceKey, - resourceKeyAttribute, - fields = [] - } = ctx.action.params; - if (associated && resourceField) { - const AssociatedModel = ctx.db.getModel(associatedName); - if (!(associated instanceof AssociatedModel)) { - throw new Error(`${associatedName} associated model invalid`); - } - const getAccessor = resourceField.getAccessors().get; - const TargetModel = ctx.db.getModel(resourceField.getTarget()); - const options = TargetModel.parseApiJson({ - fields, - }); - if (resourceField instanceof HASONE || resourceField instanceof BELONGSTO) { - let model: Model = await associated[getAccessor]({ context: ctx }); - if (model) { - model = await TargetModel.findOne({ - ...options, - context: ctx, - where: { - [TargetModel.primaryKeyAttribute]: model[TargetModel.primaryKeyAttribute], - }, - }); - } - ctx.body = model; - } else if (resourceField instanceof HASMANY || resourceField instanceof BELONGSTOMANY) { - const [model]: Model[] = await associated[getAccessor]({ - ...options, - where: { - [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, - }, - context: ctx, - }); - ctx.body = model; - } - } else { - const Model = ctx.db.getModel(resourceName); - const options = Model.parseApiJson({ - fields, - }); - const data = await Model.findOne({ - ...options, - where: { - [resourceKeyAttribute || Model.primaryKeyAttribute]: resourceKey, - }, - // @ts-ignore hooks 里添加 context - context: ctx, - }); - ctx.body = data; - } - await next(); -} - -/** - * 更新数据 - * - * TODO 字段验证 - * - * @param ctx - * @param next - */ -export async function update(ctx: Context, next: Next) { - const { - associated, - associatedName, - resourceField, - resourceName, - resourceKey, - // TODO(question): 这个属性从哪设置的? - resourceKeyAttribute, - fields, - values: data - } = ctx.action.params; - const values = filterByFields(data, fields); - const transaction = await ctx.db.sequelize.transaction(); - const options = { transaction, context: ctx }; - if (associated && resourceField) { - const AssociatedModel = ctx.db.getModel(associatedName); - if (!(associated instanceof AssociatedModel)) { - await transaction.rollback(); - throw new Error(`${associatedName} associated model invalid`); - } - const { get: getAccessor } = resourceField.getAccessors(); - if (resourceField instanceof HASONE || resourceField instanceof BELONGSTO) { - let model: Model = await associated[getAccessor](options); - if (model) { - // @ts-ignore - await model.update(values, options); - await model.updateAssociations(values, options); - ctx.body = model; - } - } else if (resourceField instanceof HASMANY || resourceField instanceof BELONGSTOMANY) { - const TargetModel = ctx.db.getModel(resourceField.getTarget()); - const [model]: Model[] = await associated[getAccessor]({ - ...options, - where: { - [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, - } - }); - - if (resourceField instanceof BELONGSTOMANY) { - const throughName = resourceField.getThroughName(); - if (typeof values[throughName] === 'object') { - const ThroughModel = resourceField.getThroughModel(); - const throughValues = values[throughName]; - const { foreignKey, sourceKey, otherKey } = resourceField.options; - const through = await ThroughModel.findOne({ - where: { - [foreignKey]: associated[sourceKey], - [otherKey]: resourceKey, - }, - transaction - }); - // TODO: 中间表的 Model 有问题,关联数据更新有 BUG - // await through.updateAssociations(throughValues, options); - await through.update(throughValues, options); - delete values[throughName]; - } - } - if (!_.isEmpty(values)) { - // @ts-ignore - await model.update(values, options); - await model.updateAssociations(values, options); - } - ctx.body = model; - } - } else { - const Model = ctx.db.getModel(resourceName); - const model = await Model.findOne({ - ...options, - where: { - [resourceKeyAttribute || Model.primaryKeyAttribute]: resourceKey, - } - }); - // @ts-ignore - await model.update(values, options); - // @ts-ignore - await model.updateAssociations(values, options); - ctx.body = model; - } - await transaction.commit(); - await next(); -} - -/** - * 删除数据,支持批量 - * - * Single - * HasOne - * HasMany - * - * TODO 关联数据的删除,建议在 onUpdate/onDelete 层面处理 - * - * @param ctx - * @param next - */ -export async function destroy(ctx: Context, next: Next) { - const { - associated, - resourceField, - associatedName, - resourceName, - resourceKey, - resourceKeyAttribute, - filter - } = ctx.action.params; - const transaction = await ctx.db.sequelize.transaction(); - const commonOptions = { transaction, context: ctx }; - let count; - if (associated && resourceField) { - const AssociatedModel = ctx.db.getModel(associatedName); - if (!(associated instanceof AssociatedModel)) { - await transaction.rollback(); - throw new Error(`${associatedName} associated model invalid`); - } - const { get: getAccessor, remove: removeAccessor, set: setAccessor } = resourceField.getAccessors(); - const TargetModel = ctx.db.getModel(resourceField.getTarget()); - const { where } = TargetModel.parseApiJson({ filter, context: ctx }); - if (resourceField instanceof HASONE || resourceField instanceof BELONGSTO) { - const model: Model = await associated[getAccessor](commonOptions); - // TODO:不能程序上解除关系,直接通过 onDelete 触发,或者通过 afterDestroy 处理 - // await associated[setAccessor](null, commonOptions); - // @ts-ignore - count = await model.destroy(commonOptions); - } else if (resourceField instanceof HASMANY || resourceField instanceof BELONGSTOMANY) { - const primaryKey = resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute; - const models: Model[] = await associated[getAccessor]({ - where: resourceKey ? { [primaryKey]: resourceKey } : where, - ...commonOptions - }); - // TODO:不能程序上解除关系,直接通过 onDelete 触发,或者通过 afterDestroy 处理 - // await associated[removeAccessor](models, commonOptions); - // @ts-ignore - count = await TargetModel.destroy({ - where: { [primaryKey]: { [Op.in]: models.map(item => item[primaryKey]) } }, - ...commonOptions, - individualHooks: true, - }); - } - } else { - const Model = ctx.db.getModel(resourceName); - const { where } = Model.parseApiJson({ filter, context: ctx }); - const primaryKey = resourceKeyAttribute || Model.primaryKeyAttribute; - count = await Model.destroy({ - where: resourceKey ? { [primaryKey]: resourceKey } : where, - // @ts-ignore hooks 里添加 context - ...commonOptions, - individualHooks: true, - }); - } - ctx.body = { count }; - await transaction.commit(); - await next(); -} - -/** - * 人工排序 - * - * 基于偏移量策略实现的排序方法 - * - * TODO 字段验证 - * - * @param ctx - * @param next - */ -export async function sort(ctx: Context, next: Next) { - const { - resourceName, - resourceKey, - resourceField, - associatedName, - associatedKey, - associated, - values - } = ctx.action.params; - - if (associated && resourceField) { - if (resourceField instanceof HASONE || resourceField instanceof BELONGSTO) { - throw new Error(`the association (${resourceName} belongs to ${associatedName}) cannot be sorted`); - } - // TODO(feature) - if (resourceField instanceof BELONGSTOMANY) { - throw new Error('sorting for belongs to many association has not been implemented'); - } - } - - const Model = ctx.db.getModel(resourceName); - const table = ctx.db.getTable(resourceName); - - const { sticky, field, target, insertAfter } = values; - if (!values.field || typeof target === 'undefined') { - return next(); - } - const sortField = table.getField(field); - if (!sortField) { - return next(); - } - const { primaryKeyAttribute } = Model; - const { name: sortAttr, scope = [] } = sortField.options; - - const transaction = await ctx.db.sequelize.transaction(); - - const where = {}; - if (associated && resourceField instanceof HASMANY) { - where[resourceField.options.foreignKey] = associatedKey; - } - - // 找到操作对象 - const source = await Model.findOne({ - where: { - ...where, - [primaryKeyAttribute]: resourceKey - }, - transaction - }); - - if (!source) { - await transaction.rollback(); - throw new Error(`resource(${resourceKey}) does not exist`); - } - const sourceScopeWhere = source.getValuesByFieldNames(scope); - - let targetScopeWhere: any; - let targetObject; - const { [primaryKeyAttribute]: targetId } = target; - if (targetId) { - targetObject = await Model.findByPk(targetId, { transaction }); - - if (!targetObject) { - await transaction.rollback(); - throw new Error(`resource(${targetId}) does not exist`); - } - - targetScopeWhere = targetObject.getValuesByFieldNames(scope); - } else { - targetScopeWhere = { ...sourceScopeWhere, ...target }; - } - - const sameScope = whereCompare(sourceScopeWhere, targetScopeWhere); - - const updates = { ...targetScopeWhere }; - if (targetObject) { - let increment: number; - const updateWhere = { ...targetScopeWhere }; - if (sameScope) { - const direction = source[sortAttr] < targetObject[sortAttr] ? { - sourceOp: Op.gt, - targetOp: insertAfter ? Op.lt : Op.lte, - increment: -1 - } : { - sourceOp: Op.lt, - targetOp: insertAfter ? Op.gt : Op.gte, - increment: 1 - }; - - increment = direction.increment; - - Object.assign(updateWhere, { - [sortAttr]: { - [direction.sourceOp]: source[sortAttr], - [direction.targetOp]: targetObject[sortAttr] - } - }); - } else { - increment = 1; - Object.assign(updateWhere, { - [sortAttr]: { - [Op.gte]: targetObject[sortAttr] - } - }); - } - - console.log({ insertAfter, updateWhere }) - - await Model.increment(sortAttr, { - by: increment, - where: updateWhere, - transaction - }); - - Object.assign(updates, { - [sortAttr]: insertAfter ? targetObject[sortAttr] + 1 : targetObject[sortAttr] - }); - } else { - Object.assign(updates, { - [sortAttr]: await sortField.getNextValue({ - next: sticky ? 'min' : 'max', - where: targetScopeWhere, - transaction - }) - }); - } - - await source.update(updates, { transaction }); - - await transaction.commit(); - - ctx.body = source; - - await next(); -} - -export default { - list, // single、hasMany、belongsToMany - create, // signle、hasMany - get, // all - update, // single、 - destroy, - sort -}; diff --git a/packages/actions/src/actions/create.ts b/packages/actions/src/actions/create.ts new file mode 100644 index 000000000..e84f035a7 --- /dev/null +++ b/packages/actions/src/actions/create.ts @@ -0,0 +1,55 @@ +import _ from 'lodash'; +import { Context, Next } from '..'; +import { Model } from '@nocobase/database'; +import { filterByFields } from '../utils'; + +/** + * 新增数据 + * + * Signle + * HasMany + * + * resource action 层面一般不开放 HasOne、BelongsTo、BelongsToMany 的新增数据操作 + * 如果需要这类操作建议使用 model.updateAssociations 方法 + * + * TODO 字段验证 + * + * @param ctx + * @param next + */ +export async function create(ctx: Context, next: Next) { + const { + associated, + resourceField, + associatedName, + resourceName, + values: data, + fields, + } = ctx.action.params; + const values = filterByFields(data, fields); + const transaction = await ctx.db.sequelize.transaction(); + const options = { transaction, context: ctx }; + let model: Model; + try { + if (associated && resourceField) { + const AssociatedModel = ctx.db.getModel(associatedName); + if (!(associated instanceof AssociatedModel)) { + throw new Error(`${associatedName} associated model invalid`); + } + const { create } = resourceField.getAccessors(); + model = await associated[create](values, options); + } else { + const ResourceModel = ctx.db.getModel(resourceName); + model = await ResourceModel.create(values, options); + } + await model.updateAssociations(values, options); + await transaction.commit(); + ctx.body = model; + await next(); + } catch (error) { + await transaction.rollback(); + throw error; + } +} + +export default create; diff --git a/packages/actions/src/actions/destroy.ts b/packages/actions/src/actions/destroy.ts new file mode 100644 index 000000000..8e05ef772 --- /dev/null +++ b/packages/actions/src/actions/destroy.ts @@ -0,0 +1,95 @@ +import _ from 'lodash'; +import { Op } from 'sequelize'; +import { + Model, + HASONE, + HASMANY, + BELONGSTO, + BELONGSTOMANY, +} from '@nocobase/database'; +import { Context, Next } from '..'; + +/** + * 删除数据,支持批量 + * + * Single + * HasOne + * HasMany + * + * TODO 关联数据的删除,建议在 onUpdate/onDelete 层面处理 + * + * @param ctx + * @param next + */ +export async function destroy(ctx: Context, next: Next) { + const { + associated, + resourceField, + associatedName, + resourceName, + resourceKey, + resourceKeyAttribute, + filter, + } = ctx.action.params; + const transaction = await ctx.db.sequelize.transaction(); + const commonOptions = { transaction, context: ctx }; + let count; + if (associated && resourceField) { + const AssociatedModel = ctx.db.getModel(associatedName); + if (!(associated instanceof AssociatedModel)) { + await transaction.rollback(); + throw new Error(`${associatedName} associated model invalid`); + } + const { + get: getAccessor, + remove: removeAccessor, + set: setAccessor, + } = resourceField.getAccessors(); + const TargetModel = ctx.db.getModel(resourceField.getTarget()); + const { where } = TargetModel.parseApiJson({ filter, context: ctx }); + if (resourceField instanceof HASONE || resourceField instanceof BELONGSTO) { + const model: Model = await associated[getAccessor](commonOptions); + // TODO:不能程序上解除关系,直接通过 onDelete 触发,或者通过 afterDestroy 处理 + // await associated[setAccessor](null, commonOptions); + // @ts-ignore + count = await model.destroy(commonOptions); + } else if ( + resourceField instanceof HASMANY || + resourceField instanceof BELONGSTOMANY + ) { + const primaryKey = + resourceKeyAttribute || + resourceField.options.targetKey || + TargetModel.primaryKeyAttribute; + const models: Model[] = await associated[getAccessor]({ + where: resourceKey ? { [primaryKey]: resourceKey } : where, + ...commonOptions, + }); + // TODO:不能程序上解除关系,直接通过 onDelete 触发,或者通过 afterDestroy 处理 + // await associated[removeAccessor](models, commonOptions); + // @ts-ignore + count = await TargetModel.destroy({ + where: { + [primaryKey]: { [Op.in]: models.map((item) => item[primaryKey]) }, + }, + ...commonOptions, + individualHooks: true, + }); + } + } else { + const Model = ctx.db.getModel(resourceName); + const { where } = Model.parseApiJson({ filter, context: ctx }); + const primaryKey = resourceKeyAttribute || Model.primaryKeyAttribute; + count = await Model.destroy({ + where: resourceKey ? { [primaryKey]: resourceKey } : where, + // @ts-ignore hooks 里添加 context + ...commonOptions, + individualHooks: true, + }); + } + ctx.body = { count }; + await transaction.commit(); + await next(); +} + +export default destroy; diff --git a/packages/actions/src/actions/get.ts b/packages/actions/src/actions/get.ts new file mode 100644 index 000000000..d6fe2efbf --- /dev/null +++ b/packages/actions/src/actions/get.ts @@ -0,0 +1,77 @@ +import _ from 'lodash'; +import { Context, Next } from '..'; +import { + Model, + HASONE, + HASMANY, + BELONGSTO, + BELONGSTOMANY, +} from '@nocobase/database'; + +/** + * 查询数据详情 + * + * @param ctx + * @param next + */ +export async function get(ctx: Context, next: Next) { + const { + associated, + resourceField, + associatedName, + resourceName, + resourceKey, + resourceKeyAttribute, + fields = [] + } = ctx.action.params; + if (associated && resourceField) { + const AssociatedModel = ctx.db.getModel(associatedName); + if (!(associated instanceof AssociatedModel)) { + throw new Error(`${associatedName} associated model invalid`); + } + const getAccessor = resourceField.getAccessors().get; + const TargetModel = ctx.db.getModel(resourceField.getTarget()); + const options = TargetModel.parseApiJson({ + fields, + }); + if (resourceField instanceof HASONE || resourceField instanceof BELONGSTO) { + let model: Model = await associated[getAccessor]({ context: ctx }); + if (model) { + model = await TargetModel.findOne({ + ...options, + context: ctx, + where: { + [TargetModel.primaryKeyAttribute]: model[TargetModel.primaryKeyAttribute], + }, + }); + } + ctx.body = model; + } else if (resourceField instanceof HASMANY || resourceField instanceof BELONGSTOMANY) { + const [model]: Model[] = await associated[getAccessor]({ + ...options, + where: { + [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, + }, + context: ctx, + }); + ctx.body = model; + } + } else { + const Model = ctx.db.getModel(resourceName); + const options = Model.parseApiJson({ + fields, + }); + const data = await Model.findOne({ + ...options, + where: { + [resourceKeyAttribute || Model.primaryKeyAttribute]: resourceKey, + }, + // @ts-ignore hooks 里添加 context + context: ctx, + }); + ctx.body = data; + } + await next(); +} + +export default get; diff --git a/packages/actions/src/actions/index.ts b/packages/actions/src/actions/index.ts index c31b38ba9..534665179 100644 --- a/packages/actions/src/actions/index.ts +++ b/packages/actions/src/actions/index.ts @@ -1,14 +1,10 @@ -import Koa from 'koa'; -import Database from '@nocobase/database'; -import { Action } from '@nocobase/resourcer'; - -export type Next = () => Promise; - -export interface Context extends Koa.Context { - db: Database; - action: Action; - body: any; -}; - -export { default as common } from './common'; -export { default as associate } from './associate'; +export * from './add'; +export * from './create'; +export * from './destroy'; +export * from './get'; +export * from './list'; +export * from './remove'; +export * from './set'; +export * from './sort'; +export * from './toggle'; +export * from './update'; diff --git a/packages/actions/src/actions/list.ts b/packages/actions/src/actions/list.ts new file mode 100644 index 000000000..ff47c26e4 --- /dev/null +++ b/packages/actions/src/actions/list.ts @@ -0,0 +1,229 @@ +import { Utils, Op, Sequelize } from 'sequelize'; +import _ from 'lodash'; +import { Context, Next } from '..'; +import { + Model, + HASONE, + HASMANY, + BELONGSTO, + BELONGSTOMANY, + whereCompare +} from '@nocobase/database'; +import { PageParameter } from '@nocobase/resourcer'; + +async function hasManyGet(instances, options: any = {}) { + const where = {}; + + let Model = this.target; + Model = Model.database.getModel(Model.name); + + let instance; + let values; + + if (!Array.isArray(instances)) { + instance = instances; + instances = undefined; + } + + options = { ...options }; + + if (this.scope) { + Object.assign(where, this.scope); + } + + if (instances) { + values = instances.map(_instance => _instance.get(this.sourceKey, { raw: true })); + + if (options.limit && instances.length > 1) { + options.groupedLimit = { + limit: options.limit, + on: this, // association + values + }; + + delete options.limit; + } else { + where[this.foreignKey] = { + [Op.in]: values + }; + delete options.groupedLimit; + } + } else { + where[this.foreignKey] = instance.get(this.sourceKey, { raw: true }); + } + + options.where = options.where ? + { [Op.and]: [where, options.where] } : + where; + + if (Object.prototype.hasOwnProperty.call(options, 'scope')) { + if (!options.scope) { + Model = Model.unscoped(); + } else { + Model = Model.scope(options.scope); + } + } + + if (Object.prototype.hasOwnProperty.call(options, 'schema')) { + Model = Model.schema(options.schema, options.schemaDelimiter); + } + + const results = await Model.findAndCountAll(options); + if (instance) return results; + + const result = {}; + for (const _instance of instances) { + result[_instance.get(this.sourceKey, { raw: true })] = []; + } + + for (const _instance of results) { + result[_instance.get(this.foreignKey, { raw: true })].push(_instance); + } + + return result; +} + +async function belongsToManyGet(instance, options) { + options = Utils.cloneDeep(options) || {}; + + const through = this.through; + let scopeWhere; + let throughWhere; + + if (this.scope) { + scopeWhere = { ...this.scope }; + } + + options.where = { + [Op.and]: [ + scopeWhere, + options.where + ] + }; + + if (Object(through.model) === through.model) { + throughWhere = {}; + throughWhere[this.foreignKey] = instance.get(this.sourceKey); + + if (through.scope) { + Object.assign(throughWhere, through.scope); + } + + //If a user pass a where on the options through options, make an "and" with the current throughWhere + if (options.through && options.through.where) { + throughWhere = { + [Op.and]: [throughWhere, options.through.where] + }; + } + + options.include = options.include || []; + options.include.push({ + association: this.oneFromTarget, + attributes: options.joinTableAttributes, + required: true, + paranoid: _.get(options.through, 'paranoid', true), + where: throughWhere + }); + } + + let model = this.target; + if (Object.prototype.hasOwnProperty.call(options, 'scope')) { + if (!options.scope) { + model = model.unscoped(); + } else { + model = model.scope(options.scope); + } + } + + if (Object.prototype.hasOwnProperty.call(options, 'schema')) { + model = model.schema(options.schema, options.schemaDelimiter); + } + + return model.findAndCountAll(options); +} + +/** + * 查询数据列表 + * + * - Signle + * - HasMany + * - BelongsToMany + * + * HasOne 和 belongsTo 不涉及到 list + * + * @param ctx + * @param next + */ +export async function list(ctx: Context, next: Next) { + const { + page = PageParameter.DEFAULT_PAGE, + perPage = PageParameter.DEFAULT_PER_PAGE, + sort = [], + fields = [], + filter = {}, + associated, + associatedName, + resourceName, + resourceField, + } = ctx.action.params; + let data = {}; + let options: any = {}; + let Model; + if (associated && resourceField) { + const AssociatedModel = ctx.db.getModel(associatedName); + Model = ctx.db.getModel(resourceField.options.target); + options = Model.parseApiJson({ + sort, + page, + perPage, + filter, + fields, + context: ctx, + }); + if (!(associated instanceof AssociatedModel)) { + throw new Error(`${associatedName} associated model invalid`); + } + // const getAccessor = resourceField.getAccessors().get; + // const countAccessor = resourceField.getAccessors().count; + options.scope = options.scopes || []; + const association = AssociatedModel.associations[resourceField.options.name]; + if (resourceField instanceof BELONGSTOMANY) { + data = await belongsToManyGet.call(association, associated, { + joinTableAttributes: [], + ...options, + context: ctx, + }); + } + if (resourceField instanceof HASMANY) { + data = await hasManyGet.call(association, associated, { + joinTableAttributes: [], + ...options, + context: ctx, + }); + } + } else { + Model = ctx.db.getModel(resourceName); + options = Model.parseApiJson({ + sort, + page, + perPage, + filter, + fields, + context: ctx, + }); + data = await Model.scope(options.scopes || []).findAndCountAll({ + ...options, + // @ts-ignore hooks 里添加 context + context: ctx, + }); + } + if (options.limit || typeof options.offset !== 'undefined') { + // Math.round 避免精度问题 + data['page'] = Math.round((options.offset || 0) / options.limit + 1); + data[Utils.underscoredIf('perPage', Model.options.underscored)] = options.limit; + } + ctx.body = data; + await next(); +} + +export default list; diff --git a/packages/actions/src/actions/remove.ts b/packages/actions/src/actions/remove.ts new file mode 100644 index 000000000..2003dd3ce --- /dev/null +++ b/packages/actions/src/actions/remove.ts @@ -0,0 +1,57 @@ +import { Context, Next } from '..'; +import { + Model, + Relation, + HASONE, + BELONGSTO, + BELONGSTOMANY, + HASMANY, +} from '@nocobase/database'; + +/** + * 删除关联 + * + * BlongsTo + * BlongsToMany + * + * @param ctx + * @param next + */ +export async function remove(ctx: Context, next: Next) { + const { + associated, + resourceField, + associatedName, + } = ctx.action.params as { + associated: Model, + associatedName: string, + resourceField: Relation, + values: any, + }; + const AssociatedModel = ctx.db.getModel(associatedName); + if (!(associated instanceof AssociatedModel)) { + throw new Error(`${associatedName} associated model invalid`); + } + const { get: getAccessor, remove: removeAccessor, set: setAccessor } = resourceField.getAccessors(); + const { resourceKey, resourceKeyAttribute, fields = [] } = ctx.action.params; + const TargetModel = ctx.db.getModel(resourceField.getTarget()); + const options = TargetModel.parseApiJson({ + fields, + }); + if (resourceField instanceof HASONE || resourceField instanceof BELONGSTO) { + ctx.body = await associated[setAccessor](null); + } else if (resourceField instanceof HASMANY || resourceField instanceof BELONGSTOMANY) { + const [model]: Model[] = await associated[getAccessor]({ + ...options, + where: { + [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, + }, + context: ctx, + }); + await associated[removeAccessor](model); + ctx.body = { id: model.id }; + } + await next(); +} + +export default remove; diff --git a/packages/actions/src/actions/set.ts b/packages/actions/src/actions/set.ts new file mode 100644 index 000000000..c30667006 --- /dev/null +++ b/packages/actions/src/actions/set.ts @@ -0,0 +1,52 @@ +import { Context, Next } from '..'; +import { + Model, + Relation, + HASONE, + BELONGSTO, + BELONGSTOMANY, + HASMANY, +} from '@nocobase/database'; + +/** + * 建立关联 + * + * BlongsTo + * BlongsToMany + * + * @param ctx + * @param next + */ +export async function set(ctx: Context, next: Next) { + const { + associated, + resourceField, + associatedName, + } = ctx.action.params as { + associated: Model, + associatedName: string, + resourceField: Relation, + values: any, + }; + const AssociatedModel = ctx.db.getModel(associatedName); + if (!(associated instanceof AssociatedModel)) { + throw new Error(`${associatedName} associated model invalid`); + } + const { set: setAccessor } = resourceField.getAccessors(); + const { resourceKey, resourceKeyAttribute, fields = [] } = ctx.action.params; + const TargetModel = ctx.db.getModel(resourceField.getTarget()); + // const options = TargetModel.parseApiJson({ + // fields, + // }); + const model = await TargetModel.findOne({ + where: { + [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, + }, + // @ts-ignore + context: ctx, + }); + ctx.body = await associated[setAccessor](model); + await next(); +} + +export default set; diff --git a/packages/actions/src/actions/sort.ts b/packages/actions/src/actions/sort.ts new file mode 100644 index 000000000..f61559a82 --- /dev/null +++ b/packages/actions/src/actions/sort.ts @@ -0,0 +1,170 @@ +import { Utils, Op, Sequelize } from 'sequelize'; +import _ from 'lodash'; +import { Context, Next } from '..'; +import { + HASONE, + HASMANY, + BELONGSTO, + BELONGSTOMANY, + whereCompare +} from '@nocobase/database'; + +/** + * 人工排序 + * + * 同 scope 时,往前挪动,插入到目标位置前面;往后挪动,插入到目标位置后面 + * 不同 scope 时,挪动到往目标位置时,默认插入后面位置,可指定 insertBefore + */ +export async function sort(ctx: Context, next: Next) { + const { + resourceName, + resourceKey, + resourceField, + associatedName, + associatedKey, + associated, + values = {}, + ...others + } = ctx.action.params; + + if (associated && resourceField) { + if (resourceField instanceof HASONE || resourceField instanceof BELONGSTO) { + throw new Error(`the association (${resourceName} belongs to ${associatedName}) cannot be sorted`); + } + // TODO(feature) + if (resourceField instanceof BELONGSTOMANY) { + throw new Error('sorting for belongs to many association has not been implemented'); + } + } + + const Model = ctx.db.getModel(resourceName); + const table = ctx.db.getTable(resourceName); + const { primaryKeyAttribute } = Model; + + const sourceId = others.sourceId || resourceKey; + const field = others.sortField || values?.sortField || values?.field || 'sort'; + const targetId = others.targetId || values?.targetId || values?.target?.[primaryKeyAttribute]; + const method = others.method || values?.method; + const insertAfter = method === 'insertAfter'; + const sticky = others.sticky || values?.sticky || method === 'prepend'; + const targetScope = others.targetScope || values?.targetScope; + + if (!sourceId) { + throw new Error('source id invalid'); + } + + if (!(sticky || targetId || targetScope)) { + throw new Error('target id/scope invalid'); + } + + const sortField = table.getField(field); + + if (!sortField) { + return next(); + } + + const { name: sortAttr, scope = [] } = sortField.options; + + const transaction = await ctx.db.sequelize.transaction(); + + const where = {}; + + if (associated && resourceField instanceof HASMANY) { + where[resourceField.options.foreignKey] = associatedKey; + } + + // 找到操作对象 + const source = await Model.findOne({ + where: { + ...where, + [primaryKeyAttribute]: sourceId + }, + transaction + }); + + if (!source) { + await transaction.rollback(); + throw new Error(`resource ${sourceId} does not exist`); + } + + const sourceScopeWhere = source.getValuesByFieldNames(scope); + + let targetScopeWhere: any; + let targetObject; + + if (targetId) { + targetObject = await Model.findByPk(targetId, { transaction }); + if (!targetObject) { + await transaction.rollback(); + throw new Error(`resource ${targetId} does not exist`); + } + targetScopeWhere = targetObject.getValuesByFieldNames(scope); + } else { + targetScopeWhere = { ...sourceScopeWhere, ...targetScope }; + } + + const sameScope = whereCompare(sourceScopeWhere, targetScopeWhere); + const updates = { ...targetScopeWhere }; + + if (targetObject) { + let increment: number; + const updateWhere = { ...targetScopeWhere }; + if (sameScope) { + const direction = source[sortAttr] < targetObject[sortAttr] ? { + sourceOp: Op.gt, + targetOp: insertAfter ? Op.lt : Op.lte, + increment: -1 + } : { + sourceOp: Op.lt, + targetOp: insertAfter ? Op.gt : Op.gte, + increment: 1 + }; + + increment = direction.increment; + + Object.assign(updateWhere, { + [sortAttr]: { + [direction.sourceOp]: source[sortAttr], + [direction.targetOp]: targetObject[sortAttr] + } + }); + } else { + increment = 1; + Object.assign(updateWhere, { + [sortAttr]: { + [insertAfter ? Op.gt : Op.gte]: targetObject[sortAttr] + } + }); + } + + console.log({ insertAfter, updateWhere }) + + await Model.increment(sortAttr, { + by: increment, + where: updateWhere, + transaction + }); + + Object.assign(updates, { + [sortAttr]: insertAfter ? targetObject[sortAttr] + 1 : targetObject[sortAttr] + }); + } else { + Object.assign(updates, { + [sortAttr]: await sortField.getNextValue({ + next: sticky ? 'min' : 'max', + where: targetScopeWhere, + transaction + }) + }); + } + + await source.update(updates, { transaction }); + + await transaction.commit(); + + ctx.body = source; + + await next(); +} + +export default sort; diff --git a/packages/actions/src/actions/toggle.ts b/packages/actions/src/actions/toggle.ts new file mode 100644 index 000000000..5ad7ee3d6 --- /dev/null +++ b/packages/actions/src/actions/toggle.ts @@ -0,0 +1,72 @@ +import { Context, Next } from '..'; +import { + Model, + Relation, + HASONE, + BELONGSTO, + BELONGSTOMANY, + HASMANY, +} from '@nocobase/database'; + +export async function toggle(ctx: Context, next: Next) { + const { + associated, + resourceField, + associatedName, + } = ctx.action.params as { + associated: Model, + associatedName: string, + resourceField: Relation, + values: any, + }; + const AssociatedModel = ctx.db.getModel(associatedName); + if (!(associated instanceof AssociatedModel)) { + throw new Error(`${associatedName} associated model invalid`); + } + const { get: getAccessor, remove: removeAccessor, set: setAccessor, add: addAccessor } = resourceField.getAccessors(); + const { resourceKey, resourceKeyAttribute, fields = [] } = ctx.action.params; + const TargetModel = ctx.db.getModel(resourceField.getTarget()); + const options = TargetModel.parseApiJson({ + fields, + }); + if (resourceField instanceof HASONE || resourceField instanceof BELONGSTO) { + const m1 = await associated[getAccessor](); + if (m1 && m1[resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute] == resourceKey) { + ctx.body = await associated[setAccessor](null); + } else { + const m2 = await TargetModel.findOne({ + // ...options, + where: { + [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, + }, + // @ts-ignore + context: ctx, + }); + ctx.body = await associated[setAccessor](m2); + } + } else if (resourceField instanceof HASMANY || resourceField instanceof BELONGSTOMANY) { + const [model]: Model[] = await associated[getAccessor]({ + ...options, + where: { + [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, + }, + context: ctx, + }); + if (model) { + ctx.body = await associated[removeAccessor](model); + } else { + const m2 = await TargetModel.findOne({ + // ...options, + where: { + [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, + }, + // @ts-ignore + context: ctx, + }); + ctx.body = await associated[addAccessor](m2); + } + } + await next(); +} + +export default toggle; diff --git a/packages/actions/src/actions/update.ts b/packages/actions/src/actions/update.ts new file mode 100644 index 000000000..3ab4cc5de --- /dev/null +++ b/packages/actions/src/actions/update.ts @@ -0,0 +1,104 @@ +import _ from 'lodash'; +import { Context, Next } from '..'; +import { + Model, + HASONE, + HASMANY, + BELONGSTO, + BELONGSTOMANY, + whereCompare +} from '@nocobase/database'; +import { filterByFields } from '../utils'; + +/** + * 更新数据 + * + * TODO 字段验证 + * + * @param ctx + * @param next + */ +export async function update(ctx: Context, next: Next) { + const { + associated, + associatedName, + resourceField, + resourceName, + resourceKey, + // TODO(question): 这个属性从哪设置的? + resourceKeyAttribute, + fields, + values: data + } = ctx.action.params; + const values = filterByFields(data, fields); + const transaction = await ctx.db.sequelize.transaction(); + const options = { transaction, context: ctx }; + if (associated && resourceField) { + const AssociatedModel = ctx.db.getModel(associatedName); + if (!(associated instanceof AssociatedModel)) { + await transaction.rollback(); + throw new Error(`${associatedName} associated model invalid`); + } + const { get: getAccessor } = resourceField.getAccessors(); + if (resourceField instanceof HASONE || resourceField instanceof BELONGSTO) { + let model: Model = await associated[getAccessor](options); + if (model) { + // @ts-ignore + await model.update(values, options); + await model.updateAssociations(values, options); + ctx.body = model; + } + } else if (resourceField instanceof HASMANY || resourceField instanceof BELONGSTOMANY) { + const TargetModel = ctx.db.getModel(resourceField.getTarget()); + const [model]: Model[] = await associated[getAccessor]({ + ...options, + where: { + [resourceKeyAttribute || resourceField.options.targetKey || TargetModel.primaryKeyAttribute]: resourceKey, + } + }); + + if (resourceField instanceof BELONGSTOMANY) { + const throughName = resourceField.getThroughName(); + if (typeof values[throughName] === 'object') { + const ThroughModel = resourceField.getThroughModel(); + const throughValues = values[throughName]; + const { foreignKey, sourceKey, otherKey } = resourceField.options; + const through = await ThroughModel.findOne({ + where: { + [foreignKey]: associated[sourceKey], + [otherKey]: resourceKey, + }, + transaction + }); + // TODO: 中间表的 Model 有问题,关联数据更新有 BUG + // await through.updateAssociations(throughValues, options); + await through.update(throughValues, options); + delete values[throughName]; + } + } + if (!_.isEmpty(values)) { + // @ts-ignore + await model.update(values, options); + await model.updateAssociations(values, options); + } + ctx.body = model; + } + } else { + const Model = ctx.db.getModel(resourceName); + const model = await Model.findOne({ + ...options, + where: { + [resourceKeyAttribute || Model.primaryKeyAttribute]: resourceKey, + } + }); + // @ts-ignore + await model.update(values, options); + // @ts-ignore + await model.updateAssociations(values, options); + ctx.body = model; + } + await transaction.commit(); + await next(); +} + +export default update; diff --git a/packages/actions/src/index.ts b/packages/actions/src/index.ts index 366bb2de9..876e6e9c6 100644 --- a/packages/actions/src/index.ts +++ b/packages/actions/src/index.ts @@ -1,6 +1,27 @@ +import Koa from 'koa'; +import Database from '@nocobase/database'; +import Resourcer, { Action } from '@nocobase/resourcer'; + import * as actions from './actions'; +import * as middlewares from './middlewares'; + +export type Next = () => Promise; + +export interface Context extends Koa.Context { + db: Database; + action: Action; + body: any; +}; export * as utils from './utils'; export * as actions from './actions'; export * as middlewares from './middlewares'; + +export function registerActions(api: any) { + const resourcer = api.resourcer as Resourcer; + resourcer.use(middlewares.associated); + resourcer.registerActions({ ...actions }); +} + export default actions; + diff --git a/packages/actions/src/middlewares/associated.ts b/packages/actions/src/middlewares/associated.ts index c14896da6..9715df1ae 100644 --- a/packages/actions/src/middlewares/associated.ts +++ b/packages/actions/src/middlewares/associated.ts @@ -1,4 +1,4 @@ -import { Context, Next } from '../actions'; +import { Context, Next } from '..'; import { Action } from '@nocobase/resourcer'; import { HASONE, HASMANY, BELONGSTO, BELONGSTOMANY } from '@nocobase/database'; diff --git a/packages/actions/src/middlewares/data-wrapping.ts b/packages/actions/src/middlewares/data-wrapping.ts deleted file mode 100644 index 3a64432dd..000000000 --- a/packages/actions/src/middlewares/data-wrapping.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Context, Next } from '../actions'; -import { Action } from '@nocobase/resourcer'; - -export async function dataWrapping(ctx: Context, next: Next) { - await next(); - if (!(ctx.action instanceof Action)) { - return; - } - if (ctx.withoutDataWrapping) { - return; - } - if (ctx.body instanceof Buffer) { - return; - } - if (!ctx.body) { - ctx.body = {}; - } - const { rows, ...meta } = ctx.body; - if (rows) { - ctx.body = { - data: rows, - meta, - }; - } else { - ctx.body = { - data: ctx.body, - }; - } -} - -export default dataWrapping; diff --git a/packages/actions/src/middlewares/index.ts b/packages/actions/src/middlewares/index.ts index 6455ad0be..7531ec270 100644 --- a/packages/actions/src/middlewares/index.ts +++ b/packages/actions/src/middlewares/index.ts @@ -1,2 +1 @@ export * from './associated'; -export * from './data-wrapping'; diff --git a/packages/test/src/mockServer.ts b/packages/test/src/mockServer.ts index 9c1cc8aa5..3531677a6 100644 --- a/packages/test/src/mockServer.ts +++ b/packages/test/src/mockServer.ts @@ -1,21 +1,59 @@ import qs from 'qs'; import supertest from 'supertest'; import Application, { ApplicationOptions } from '@nocobase/server'; -import { ActionParams } from '@nocobase/resourcer'; import { getConfig } from './mockDatabase'; +interface ActionParams { + fields?: string[] | { + only?: string[]; + except?: string[]; + appends?: string[]; + }; + filter?: any; + sort?: string[]; + page?: number; + perPage?: number; + values?: any; + resourceName?: string; + resourceKey?: string; + associatedName?: string; + associatedKey?: string; + [key: string]: any; +} + +interface SortActionParams { + resourceName?: string; + resourceKey?: any; + associatedName?: string; + associatedKey?: any; + sourceId?: any; + targetId?: any; + sortField?: string; + method?: string; + target?: any; + sticky?: boolean; + [key: string]: any; +} + interface Resource { get: (params?: ActionParams) => Promise; list: (params?: ActionParams) => Promise; create: (params?: ActionParams) => Promise; update: (params?: ActionParams) => Promise; destroy: (params?: ActionParams) => Promise; + sort: (params?: SortActionParams) => Promise; [name: string]: (params?: ActionParams) => Promise; } export class MockServer extends Application { + + protected agentInstance: supertest.SuperAgentTest; + agent() { - return supertest.agent(this.callback()); + if (!this.agentInstance) { + this.agentInstance = supertest.agent(this.callback()); + } + return this.agentInstance; } resource(name: string) { @@ -34,7 +72,7 @@ export class MockServer extends Application { } = params; let url = prefix; if (keys.length > 1) { - url = `/${keys[0]}/${associatedKey}/${keys[1]}}` + url = `/${keys[0]}/${associatedKey}/${keys[1]}` } else { url = `/${name}`; }