diff --git a/packages/actions/src/__tests__/add-action.test.ts b/packages/actions/src/__tests__/add-action.test.ts new file mode 100644 index 000000000..1b7ff0f4d --- /dev/null +++ b/packages/actions/src/__tests__/add-action.test.ts @@ -0,0 +1,112 @@ +import { mockServer } from './index'; +import { registerActions } from '@nocobase/actions'; + +describe('add action', () => { + let app; + let Post; + let Comment; + let Tag; + let PostTag; + + beforeEach(async () => { + app = mockServer(); + registerActions(app); + + PostTag = app.collection({ + name: 'posts_tags', + fields: [{ type: 'string', name: 'tagged_at' }], + }); + + Post = app.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'hasMany', name: 'comments' }, + { type: 'hasOne', name: 'profile' }, + { type: 'belongsToMany', name: 'tags', through: 'posts_tags' }, + { type: 'string', name: 'status', defaultValue: 'draft' }, + ], + }); + + Comment = app.collection({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + { type: 'belongsTo', name: 'post' }, + ], + }); + + Tag = app.collection({ + name: 'tags', + fields: [ + { type: 'string', name: 'name' }, + { type: 'belongsToMany', name: 'posts', through: 'posts_tags' }, + ], + }); + + await app.db.sync(); + }); + + afterEach(async () => { + await app.destroy(); + }); + + test('add belongs to many', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + }, + }); + + const t1 = await Tag.repository.create({ + values: { + name: 't1', + }, + }); + + const t2 = await Tag.repository.create({ + values: { + name: 't2', + }, + }); + + const t3 = await Tag.repository.create({ + values: { + name: 't3', + }, + }); + + expect(await p1.countTags()).toEqual(0); + + const response = await app + .agent() + .resource('posts.tags') + .add({ + associatedIndex: p1.get('id'), + values: [t1.get('id'), t2.get('id')], + }); + + expect(await p1.countTags()).toEqual(2); + + // add with through values + await app + .agent() + .resource('posts.tags') + .add({ + associatedIndex: p1.get('id'), + values: [ + [ + t3.get('id'), + { + tagged_at: '123', + }, + ], + ], + }); + + const tags = await p1.getTags(); + expect(tags.length).toEqual(3); + const tag = tags.find((t) => t.id == t3.get('id')); + expect(tag.posts_tags.tagged_at).toEqual('123'); + }); +}); diff --git a/packages/actions/src/__tests__/create-action.test.ts b/packages/actions/src/__tests__/create-action.test.ts new file mode 100644 index 000000000..c6b0cba78 --- /dev/null +++ b/packages/actions/src/__tests__/create-action.test.ts @@ -0,0 +1,112 @@ +import { mockServer } from './index'; +import { registerActions } from '@nocobase/actions'; + +describe('create action', () => { + let app; + let Post; + let Comment; + let Tag; + + beforeEach(async () => { + app = mockServer(); + registerActions(app); + + Post = app.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'hasMany', name: 'comments' }, + { type: 'belongsToMany', name: 'tags' }, + { type: 'string', name: 'status', defaultValue: 'draft' }, + ], + }); + + Comment = app.collection({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + { type: 'belongsTo', name: 'post' }, + ], + }); + + Tag = app.collection({ + name: 'tags', + fields: [ + { type: 'string', name: 'name' }, + { type: 'belongsToMany', name: 'posts' }, + ], + }); + await app.db.sync(); + }); + + afterEach(async () => { + await app.destroy(); + }); + + test('create resource', async () => { + expect(await Post.repository.findOne()).toBeNull(); + const response = await app + .agent() + .resource('posts') + .create({ + values: { + title: 't1', + }, + }); + + expect(response.statusCode).toEqual(200); + const post = await Post.repository.findOne(); + expect(post).not.toBeNull(); + expect(post['title']).toEqual('t1'); + }); + + test('create has many nested resource', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + }, + }); + + expect(await Comment.repository.findOne()).toBeNull(); + + const response = await app + .agent() + .resource('posts.comments') + .create({ + associatedIndex: p1.get('id'), + values: { + content: 'hello', + }, + }); + + const comment = await Comment.repository.findOne(); + expect(comment).not.toBeNull(); + expect(comment.get('postId')).toEqual(p1.get('id')); + expect(comment.get('content')).toEqual('hello'); + }); + + test('create belongs to many nested resource', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + }, + }); + + expect(await Tag.repository.findOne()).toBeNull(); + const response = await app + .agent() + .resource('posts.tags') + .create({ + associatedIndex: p1.get('id'), + values: { + name: 'hello', + }, + }); + + const tag = await Tag.repository.findOne(); + + expect(tag).not.toBeNull(); + expect(await tag.hasPost(p1)).toBeTruthy(); + expect(tag.get('name')).toEqual('hello'); + }); +}); diff --git a/packages/actions/src/__tests__/destroy-action.test.ts b/packages/actions/src/__tests__/destroy-action.test.ts new file mode 100644 index 000000000..58d500831 --- /dev/null +++ b/packages/actions/src/__tests__/destroy-action.test.ts @@ -0,0 +1,156 @@ +import { mockServer } from './index'; +import { registerActions } from '@nocobase/actions'; + +describe('destroy action', () => { + let app; + let Post; + let Comment; + let Tag; + let PostTag; + let Profile; + + beforeEach(async () => { + app = mockServer(); + registerActions(app); + + PostTag = app.collection({ + name: 'posts_tags', + fields: [{ type: 'string', name: 'tagged_at' }], + }); + + Post = app.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'hasMany', name: 'comments' }, + { type: 'hasOne', name: 'profile' }, + { type: 'belongsToMany', name: 'tags', through: 'posts_tags' }, + { type: 'string', name: 'status', defaultValue: 'draft' }, + ], + }); + + Profile = app.collection({ + name: 'profiles', + fields: [ + { type: 'string', name: 'post_profile' }, + { type: 'belongsTo', name: 'post' }, + ], + }); + + Comment = app.collection({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + { type: 'belongsTo', name: 'post' }, + ], + }); + + Tag = app.collection({ + name: 'tags', + fields: [ + { type: 'string', name: 'name' }, + { type: 'belongsToMany', name: 'posts', through: 'posts_tags' }, + ], + }); + + await app.db.sync(); + }); + + afterEach(async () => { + await app.destroy(); + }); + + test('destroy resource', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + }, + }); + + const response = await app + .agent() + .resource('posts') + .destroy({ + resourceIndex: p1.get('id'), + }); + + expect(await Post.repository.count()).toEqual(0); + }); + + test('destroy has many resource', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + comments: [ + { + content: 'c1', + }, + ], + }, + }); + + const c1 = await Comment.repository.findOne(); + + const response = await app + .agent() + .resource('posts.comments') + .destroy({ + resourceIndex: c1.get('id'), + associatedIndex: p1.get('id'), + }); + + expect(await Comment.repository.count()).toEqual(0); + }); + + test('destroy belongs to many', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + tags: [ + { + name: 't1', + posts_tags: { + tagged_at: '123', + }, + }, + ], + }, + }); + + expect(await Tag.repository.count()).toEqual(1); + + const p1t1 = (await p1.getTags())[0]; + + const response = await app + .agent() + .resource('posts.tags') + .destroy({ + resourceIndex: p1.get('id'), + associatedIndex: p1t1.get('id'), + }); + + expect(await Tag.repository.count()).toEqual(0); + }); + + test('destroy has one', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + profile: { + post_profile: 'test', + }, + }, + }); + + const postProfile = await Profile.repository.findOne(); + + const response = await app + .agent() + .resource('posts.profile') + .destroy({ + associatedIndex: p1.get('id'), + }); + + expect(await Profile.repository.count()).toEqual(0); + }); +}); diff --git a/packages/actions/src/__tests__/get-action.test.ts b/packages/actions/src/__tests__/get-action.test.ts new file mode 100644 index 000000000..abf3ad634 --- /dev/null +++ b/packages/actions/src/__tests__/get-action.test.ts @@ -0,0 +1,133 @@ +import { mockServer } from './index'; +import { registerActions } from '@nocobase/actions'; + +describe('get action', () => { + let app; + let Post; + let Comment; + let Tag; + let PostTag; + let Profile; + + beforeEach(async () => { + app = mockServer(); + registerActions(app); + + PostTag = app.collection({ + name: 'posts_tags', + fields: [{ type: 'string', name: 'tagged_at' }], + }); + + Post = app.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'hasMany', name: 'comments' }, + { type: 'hasOne', name: 'profile' }, + { type: 'belongsToMany', name: 'tags', through: 'posts_tags' }, + { type: 'string', name: 'status', defaultValue: 'draft' }, + ], + }); + + Profile = app.collection({ + name: 'profiles', + fields: [ + { type: 'string', name: 'post_profile' }, + { type: 'belongsTo', name: 'post' }, + ], + }); + + Comment = app.collection({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + { type: 'belongsTo', name: 'post' }, + ], + }); + + Tag = app.collection({ + name: 'tags', + fields: [ + { type: 'string', name: 'name' }, + { type: 'belongsToMany', name: 'posts', through: 'posts_tags' }, + ], + }); + + await app.db.sync(); + }); + + afterEach(async () => { + await app.destroy(); + }); + + test('get resource', async () => { + await Post.repository.create({ + values: { + title: 'p0', + }, + }); + + const p1 = await Post.repository.create({ + values: { + title: 'p1', + }, + }); + + const response = await app + .agent() + .resource('posts') + .get({ + resourceIndex: p1.get('id'), + }); + + const body = response.body; + expect(body['id']).toEqual(p1.get('id')); + }); + + test('get has many resource', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + comments: [ + { + content: 'c1', + }, + ], + }, + }); + + const c1 = await Comment.repository.findOne(); + + const response = await app + .agent() + .resource('posts.comments') + .get({ + resourceIndex: c1.get('id'), + associatedIndex: p1.get('id'), + }); + + expect(response.body['id']).toEqual(c1.get('id')); + }); + + test('get has one resource', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + profile: { + post_profile: 'test', + }, + }, + }); + + const postProfile = await Profile.repository.findOne(); + + const response = await app + .agent() + .resource('posts.profile') + .get({ + associatedIndex: p1.get('id'), + }); + + expect(response.body['id']).toEqual(postProfile.get('id')); + }); +}); diff --git a/packages/actions/src/__tests__/hello.test.ts b/packages/actions/src/__tests__/hello.test.ts deleted file mode 100644 index d9b8705d2..000000000 --- a/packages/actions/src/__tests__/hello.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { mockServer } from '.'; - -async function test(ctx, next) { - ctx.body = ctx.action.params; - await next(); -} - -describe('hello', () => { - it('hello', async () => { - const app = mockServer(); - app.collection({ - name: 'tests', - }); - app.actions({ test }); - const response = await app.agent().resource('tests').test({ key1: 'val1' }); - expect(response.body.key1).toBe('val1'); - await app.destroy(); - }); -}); diff --git a/packages/actions/src/__tests__/index.ts b/packages/actions/src/__tests__/index.ts index d240a30b0..fd8fba364 100644 --- a/packages/actions/src/__tests__/index.ts +++ b/packages/actions/src/__tests__/index.ts @@ -27,12 +27,10 @@ export function getConfig(config = {}, options?: any): DatabaseOptions { host: process.env.DB_HOST, port: process.env.DB_PORT, dialect: process.env.DB_DIALECT, + storage: process.env.DB_STORAGE, logging: process.env.DB_LOG_SQL === 'on', sync: { force: true, - alter: { - drop: true, - }, }, hooks: { beforeDefine(model, options) { @@ -99,7 +97,11 @@ export class MockServer extends Koa { constructor() { super(); - this.db = mockDatabase(); + this.db = mockDatabase({ + sync: { + force: true, + }, + }); this.resourcer = new Resourcer({ prefix: '/api', }); @@ -118,7 +120,7 @@ export class MockServer extends Koa { } collection(options: CollectionOptions) { - this.db.collection(options); + return this.db.collection(options); } resource(options: ResourceOptions) { @@ -158,8 +160,6 @@ export class MockServer extends Koa { url += `/${resourceIndex}`; } - console.log(url); - switch (method) { case 'upload': return agent diff --git a/packages/actions/src/__tests__/list-action.test.ts b/packages/actions/src/__tests__/list-action.test.ts new file mode 100644 index 000000000..f47fae7f9 --- /dev/null +++ b/packages/actions/src/__tests__/list-action.test.ts @@ -0,0 +1,113 @@ +import { mockServer } from './index'; +import { registerActions } from '@nocobase/actions'; + +describe('list action', () => { + let app; + beforeEach(async () => { + app = mockServer(); + registerActions(app); + + const Post = app.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'hasMany', name: 'comments' }, + { + type: 'belongsToMany', + name: 'tags', + }, + { type: 'string', name: 'status', defaultValue: 'draft' }, + ], + }); + + const Tag = app.collection({ + name: 'tags', + fields: [ + { type: 'string', name: 'name' }, + { type: 'belongsToMany', name: 'posts' }, + ], + }); + + app.collection({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + { type: 'string', name: 'status', defaultValue: 'draft' }, + ], + }); + + await app.db.sync(); + + const t1 = await Tag.repository.create({ + values: { + name: 't1', + }, + }); + + const t2 = await Tag.repository.create({ + values: { + name: 't2', + }, + }); + + const t3 = await Tag.repository.create({ + values: { + name: 't3', + }, + }); + + const p1 = await Post.repository.create({ + values: { + title: 'pt1', + tags: [t1.get('id'), t2.get('id')], + }, + }); + + await Post.repository.createMany({ + records: [ + { + title: 'pt2', + tags: [t2.get('id')], + }, + { + title: 'pt3', + tags: [t3.get('id')], + }, + ], + }); + }); + + afterEach(async () => { + await app.destroy(); + }); + + test('list with pagination', async () => { + const response = await app + .agent() + .resource('posts') + .list({ + fields: ['id'], + perPage: 1, + page: 2, + sort: ['id'], + }); + + const body = response.body; + expect(body.rows.length).toEqual(1); + expect(body.rows[0]['id']).toEqual(2); + expect(body.count).toEqual(3); + expect(body.totalPage).toEqual(3); + }); + + test('list by association', async () => { + // tags with posts id eq 1 + const response = await app + .agent() + .resource('posts.tags') + .list({ associatedIndex: 1, fields: ['id', 'posts_tags.createdAt'], sort: ['id'] }); + + const body = response.body; + expect(body.count).toEqual(2); + expect(body.rows).toEqual([{ id: 1 }, { id: 2 }]); + }); +}); diff --git a/packages/actions/src/__tests__/move-action.test.ts b/packages/actions/src/__tests__/move-action.test.ts new file mode 100644 index 000000000..6348d31bb --- /dev/null +++ b/packages/actions/src/__tests__/move-action.test.ts @@ -0,0 +1,579 @@ +import { mockServer, MockServer } from './index'; +import { registerActions } from '@nocobase/actions'; + +describe('sort action', () => { + describe('same scope', () => { + let api: MockServer; + + beforeEach(async () => { + api = mockServer(); + + registerActions(api); + api.db.collection({ + name: 'tests', + fields: [ + { type: 'string', name: 'title' }, + { type: 'sort', name: 'sort' }, + { type: 'sort', name: 'sort2' }, + ], + }); + await api.db.sync(); + const Test = api.db.getCollection('tests'); + + for (let index = 1; index < 5; index++) { + await Test.repository.create({ values: { title: `t${index}` } }); + } + }); + + afterEach(async () => { + return api.destroy(); + }); + + it('targetId', async () => { + await api.agent().resource('tests').move({ + sourceId: 1, + targetId: 3, + }); + + const response = await api + .agent() + .resource('tests') + .list({ + sort: ['sort'], + }); + + expect(response.body).toMatchObject({ + rows: [ + { + title: 't2', + sort: 1, + }, + { + title: 't3', + sort: 2, + }, + { + title: 't1', + sort: 3, + }, + { + title: 't4', + sort: 4, + }, + ], + }); + }); + + it('targetId', async () => { + await api.agent().resource('tests').move({ + sourceId: 3, + targetId: 1, + }); + + const response = await api + .agent() + .resource('tests') + .list({ + sort: ['sort'], + }); + + expect(response.body).toMatchObject({ + rows: [ + { + title: 't3', + sort: 1, + }, + { + title: 't1', + sort: 2, + }, + { + title: 't2', + sort: 3, + }, + { + title: 't4', + sort: 4, + }, + ], + }); + }); + + it('sortField', async () => { + await api.agent().resource('tests').move({ + sortField: 'sort2', + sourceId: 1, + targetId: 3, + }); + + const response = await api + .agent() + .resource('tests') + .list({ + sort: ['sort2'], + }); + expect(response.body).toMatchObject({ + rows: [ + { + title: 't2', + sort2: 1, + }, + { + title: 't3', + sort2: 2, + }, + { + title: 't1', + sort2: 3, + }, + { + title: 't4', + sort2: 4, + }, + ], + }); + }); + + it('sticky', async () => { + await api.agent().resource('tests').move({ + sourceId: 3, + sticky: true, + }); + + const response = await api + .agent() + .resource('tests') + .list({ + sort: ['sort'], + }); + expect(response.body).toMatchObject({ + rows: [ + { + title: 't3', + sort: 0, + }, + { + title: 't1', + sort: 1, + }, + { + title: 't2', + sort: 2, + }, + { + title: 't4', + sort: 4, + }, + ], + }); + }); + }); + + describe('different scope', () => { + let api: MockServer; + + beforeEach(async () => { + api = mockServer(); + registerActions(api); + api.db.collection({ + name: 'tests', + fields: [ + { type: 'string', name: 'title' }, + { type: 'integer', name: 'state' }, + { type: 'sort', name: 'sort', scopeKey: 'state' }, + ], + }); + await api.db.sync(); + const Test = api.db.getCollection('tests'); + + for (let index = 1; index < 5; index++) { + await Test.repository.create({ values: { title: `t1${index}`, state: 1 } }); + } + for (let index = 1; index < 5; index++) { + await Test.repository.create({ values: { title: `t2${index}`, state: 2 } }); + } + }); + + afterEach(async () => { + return api.destroy(); + }); + + it('targetId/1->6', async () => { + await api.agent().resource('tests').move({ + sourceId: 1, + targetId: 6, + }); + + let response = await api + .agent() + .resource('tests') + .list({ + sort: ['sort'], + filter: { state: 1 }, + fields: ['title', 'sort'], + }); + + expect(response.body).toMatchObject({ + rows: [ + { + title: 't12', + sort: 2, + }, + { + title: 't13', + sort: 3, + }, + { + title: 't14', + sort: 4, + }, + ], + }); + + response = await api + .agent() + .resource('tests') + .list({ + sort: ['sort'], + filter: { state: 2 }, + }); + + expect(response.body).toMatchObject({ + rows: [ + { + title: 't21', + sort: 1, + }, + { + title: 't11', + sort: 2, + }, + { + title: 't22', + sort: 3, + }, + { + title: 't23', + sort: 4, + }, + { + title: 't24', + sort: 5, + }, + ], + }); + }); + + it('targetId/1->6 - method=insertAfter', async () => { + await api.agent().resource('tests').move({ + sourceId: 1, + targetId: 6, + method: 'insertAfter', + }); + + let response = await api + .agent() + .resource('tests') + .list({ + sort: ['sort'], + filter: { state: 1 }, + }); + + expect(response.body).toMatchObject({ + rows: [ + { + title: 't12', + sort: 2, + }, + { + title: 't13', + sort: 3, + }, + { + title: 't14', + sort: 4, + }, + ], + }); + response = await api + .agent() + .resource('tests') + .list({ + sort: ['sort'], + filter: { state: 2 }, + }); + expect(response.body).toMatchObject({ + rows: [ + { + title: 't21', + sort: 1, + }, + { + title: 't22', + sort: 2, + }, + { + title: 't11', + sort: 3, + }, + { + title: 't23', + sort: 4, + }, + { + title: 't24', + sort: 5, + }, + ], + }); + }); + + it('targetId/6->2', async () => { + await api.agent().resource('tests').move({ + sourceId: 6, + targetId: 2, + }); + let response = await api + .agent() + .resource('tests') + .list({ + sort: ['sort'], + filter: { state: 1 }, + }); + expect(response.body).toMatchObject({ + rows: [ + { + title: 't11', + sort: 1, + }, + { + title: 't22', + sort: 2, + }, + { + title: 't12', + sort: 3, + }, + { + title: 't13', + sort: 4, + }, + { + title: 't14', + sort: 5, + }, + ], + }); + response = await api + .agent() + .resource('tests') + .list({ + sort: ['sort'], + filter: { state: 2 }, + }); + expect(response.body).toMatchObject({ + rows: [ + { + title: 't21', + sort: 1, + }, + { + title: 't23', + sort: 3, + }, + { + title: 't24', + sort: 4, + }, + ], + }); + }); + + it('targetId/6->2 - method=insertAfter', async () => { + await api.agent().resource('tests').move({ + sourceId: 6, + targetId: 2, + method: 'insertAfter', + }); + let response = await api + .agent() + .resource('tests') + .list({ + sort: ['sort'], + filter: { state: 1 }, + }); + expect(response.body).toMatchObject({ + rows: [ + { + title: 't11', + sort: 1, + }, + { + title: 't12', + sort: 2, + }, + { + title: 't22', + sort: 3, + }, + { + title: 't13', + sort: 4, + }, + { + title: 't14', + sort: 5, + }, + ], + }); + response = await api + .agent() + .resource('tests') + .list({ + sort: ['sort'], + filter: { state: 2 }, + }); + expect(response.body).toMatchObject({ + rows: [ + { + title: 't21', + sort: 1, + }, + { + title: 't23', + sort: 3, + }, + { + title: 't24', + sort: 4, + }, + ], + }); + }); + + it('targetScope', async () => { + await api + .agent() + .resource('tests') + .move({ + sourceId: 1, + targetScope: { + state: 2, + }, + }); + let response = await api + .agent() + .resource('tests') + .list({ + sort: ['sort'], + filter: { state: 1 }, + }); + expect(response.body).toMatchObject({ + rows: [ + { + title: 't12', + sort: 2, + }, + { + title: 't13', + sort: 3, + }, + { + title: 't14', + sort: 4, + }, + ], + }); + response = await api + .agent() + .resource('tests') + .list({ + sort: ['sort'], + filter: { state: 2 }, + }); + expect(response.body).toMatchObject({ + rows: [ + { + title: 't21', + sort: 1, + }, + { + title: 't22', + sort: 2, + }, + { + title: 't23', + sort: 3, + }, + { + title: 't24', + sort: 4, + }, + { + title: 't11', + sort: 5, + }, + ], + }); + }); + + it('targetScope - method=prepend', async () => { + await api + .agent() + .resource('tests') + .move({ + sourceId: 1, + targetScope: { + state: 2, + }, + method: 'prepend', + }); + let response = await api + .agent() + .resource('tests') + .list({ + sort: ['sort'], + filter: { state: 1 }, + }); + expect(response.body).toMatchObject({ + rows: [ + { + title: 't12', + }, + { + title: 't13', + }, + { + title: 't14', + }, + ], + }); + response = await api + .agent() + .resource('tests') + .list({ + sort: ['sort'], + filter: { state: 2 }, + }); + expect(response.body).toMatchObject({ + rows: [ + { + title: 't11', + }, + { + title: 't21', + }, + { + title: 't22', + }, + { + title: 't23', + }, + { + title: 't24', + }, + ], + }); + }); + }); +}); diff --git a/packages/actions/src/__tests__/remove-action.test.ts b/packages/actions/src/__tests__/remove-action.test.ts new file mode 100644 index 000000000..3cfd20a86 --- /dev/null +++ b/packages/actions/src/__tests__/remove-action.test.ts @@ -0,0 +1,126 @@ +import { mockServer } from './index'; +import { registerActions } from '@nocobase/actions'; + +describe('remove action', () => { + let app; + let Post; + let Comment; + let Tag; + let PostTag; + let Profile; + + beforeEach(async () => { + app = mockServer(); + registerActions(app); + + PostTag = app.collection({ + name: 'posts_tags', + fields: [{ type: 'string', name: 'tagged_at' }], + }); + + Post = app.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'hasMany', name: 'comments' }, + { type: 'hasOne', name: 'profile' }, + { type: 'belongsToMany', name: 'tags', through: 'posts_tags' }, + { type: 'string', name: 'status', defaultValue: 'draft' }, + ], + }); + + Profile = app.collection({ + name: 'profiles', + fields: [ + { type: 'string', name: 'post_profile' }, + { type: 'belongsTo', name: 'post' }, + ], + }); + + Comment = app.collection({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + { type: 'belongsTo', name: 'post' }, + ], + }); + + Tag = app.collection({ + name: 'tags', + fields: [ + { type: 'string', name: 'name' }, + { type: 'belongsToMany', name: 'posts', through: 'posts_tags' }, + ], + }); + + await app.db.sync(); + }); + + afterEach(async () => { + await app.destroy(); + }); + + test('remove belongs to many', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + }, + }); + + const t1 = await Tag.repository.create({ + values: { + name: 't1', + }, + }); + + const t2 = await Tag.repository.create({ + values: { + name: 't2', + }, + }); + + const t3 = await Tag.repository.create({ + values: { + name: 't3', + }, + }); + + await p1.setTags([t1, t2]); + + expect(await p1.countTags()).toEqual(2); + + const response = await app + .agent() + .resource('posts.tags') + .remove({ + associatedIndex: p1.get('id'), + values: [t1.get('id')], + }); + + expect(await p1.countTags()).toEqual(1); + }); + + test('remove has one', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + profile: { + post_profile: 'test', + }, + }, + }); + + const postProfile = await Profile.repository.findOne(); + expect(await postProfile.getPost()).not.toBeNull(); + + const response = await app + .agent() + .resource('posts.profile') + .remove({ + associatedIndex: p1.get('id'), + }); + + await postProfile.reload(); + expect(await postProfile.getPost()).toBeNull(); + }); +}); diff --git a/packages/actions/src/__tests__/set-action.test.ts b/packages/actions/src/__tests__/set-action.test.ts new file mode 100644 index 000000000..2883cf25b --- /dev/null +++ b/packages/actions/src/__tests__/set-action.test.ts @@ -0,0 +1,112 @@ +import { mockServer } from './index'; +import { registerActions } from '@nocobase/actions'; + +describe('set action', () => { + let app; + let Post; + let Comment; + let Tag; + let PostTag; + + beforeEach(async () => { + app = mockServer(); + registerActions(app); + + PostTag = app.collection({ + name: 'posts_tags', + fields: [{ type: 'string', name: 'tagged_at' }], + }); + + Post = app.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'hasMany', name: 'comments' }, + { type: 'hasOne', name: 'profile' }, + { type: 'belongsToMany', name: 'tags', through: 'posts_tags' }, + { type: 'string', name: 'status', defaultValue: 'draft' }, + ], + }); + + Comment = app.collection({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + { type: 'belongsTo', name: 'post' }, + ], + }); + + Tag = app.collection({ + name: 'tags', + fields: [ + { type: 'string', name: 'name' }, + { type: 'belongsToMany', name: 'posts', through: 'posts_tags' }, + ], + }); + + await app.db.sync(); + }); + + afterEach(async () => { + await app.destroy(); + }); + + test('set belongs to many', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + }, + }); + + const t1 = await Tag.repository.create({ + values: { + name: 't1', + }, + }); + + const t2 = await Tag.repository.create({ + values: { + name: 't2', + }, + }); + + const t3 = await Tag.repository.create({ + values: { + name: 't3', + }, + }); + + expect(await p1.countTags()).toEqual(0); + + const response = await app + .agent() + .resource('posts.tags') + .set({ + associatedIndex: p1.get('id'), + values: [t1.get('id'), t2.get('id')], + }); + + expect(await p1.countTags()).toEqual(2); + + // add with through values + await app + .agent() + .resource('posts.tags') + .set({ + associatedIndex: p1.get('id'), + values: [ + [ + t3.get('id'), + { + tagged_at: '123', + }, + ], + ], + }); + + const tags = await p1.getTags(); + expect(tags.length).toEqual(1); + const tag = tags.find((t) => t.id == t3.get('id')); + expect(tag.posts_tags.tagged_at).toEqual('123'); + }); +}); diff --git a/packages/actions/src/__tests__/sort-collection.test.ts b/packages/actions/src/__tests__/sort-collection.test.ts new file mode 100644 index 000000000..28e5c6567 --- /dev/null +++ b/packages/actions/src/__tests__/sort-collection.test.ts @@ -0,0 +1,264 @@ +import { mockServer } from './index'; +import { SortAbleCollection } from '../actions'; +import lodash from 'lodash'; + +describe('sort collections', () => { + let app; + let Post; + + beforeEach(async () => { + app = mockServer(); + }); + + afterEach(async () => { + await app.destroy(); + }); + + describe('sort collection', () => { + beforeEach(async () => { + Post = app.db.collection({ + name: 'posts', + fields: [ + { + type: 'string', + name: 'title', + }, + { + type: 'sort', + name: 'sort', + }, + ], + }); + + await app.db.sync(); + + for (let i = 0; i < 5; i++) { + await Post.repository.create({ + values: { + title: `t${i + 1}`, + }, + }); + } + }); + + test('forward insert', async () => { + const t2 = await Post.repository.findOne({ + filter: { + title: 't2', + }, + }); + + const t4 = await Post.repository.findOne({ + filter: { + title: 't4', + }, + }); + const sortCollection = new SortAbleCollection(Post); + + await sortCollection.move(t2.get('id'), t4.get('id')); + + const results = ( + await Post.repository.find({ + sort: ['sort'], + }) + ).map((r) => { + return lodash.pick(r.toJSON(), ['title', 'sort']); + }); + + expect(results).toEqual([ + { title: 't1', sort: 1 }, + { title: 't3', sort: 2 }, + { title: 't4', sort: 3 }, + { title: 't2', sort: 4 }, + { title: 't5', sort: 5 }, + ]); + }); + + test('backward insert', async () => { + const t2 = await Post.repository.findOne({ + filter: { + title: 't2', + }, + }); + + const t4 = await Post.repository.findOne({ + filter: { + title: 't4', + }, + }); + const sortCollection = new SortAbleCollection(Post); + + await sortCollection.move(t4.get('id'), t2.get('id')); + + const results = ( + await Post.repository.find({ + sort: ['sort'], + }) + ).map((r) => { + return lodash.pick(r.toJSON(), ['title', 'sort']); + }); + + expect(results).toEqual([ + { title: 't1', sort: 1 }, + { title: 't4', sort: 2 }, + { title: 't2', sort: 3 }, + { title: 't3', sort: 4 }, + { title: 't5', sort: 5 }, + ]); + }); + }); + + describe('two scope move', () => { + beforeEach(async () => { + Post = app.db.collection({ + name: 'posts', + fields: [ + { + type: 'string', + name: 'title', + }, + { + type: 'sort', + name: 'sort', + scopeKey: 'status', + }, + + { + type: 'string', + name: 'status', + }, + ], + }); + + await app.db.sync(); + }); + + test('move in scope', async () => { + for (let i = 0; i < 5; i++) { + await Post.repository.create({ + values: { + title: `s1:t${i + 1}`, + status: 'status1', + }, + }); + } + + for (let i = 0; i < 5; i++) { + await Post.repository.create({ + values: { + title: `s2:t${i + 1}`, + status: 'status2', + }, + }); + } + + const s1t2 = await Post.repository.findOne({ + filter: { + title: 's1:t2', + }, + }); + + const s1t4 = await Post.repository.findOne({ + filter: { + title: 's1:t4', + }, + }); + + const sortCollection = new SortAbleCollection(Post); + await sortCollection.move(s1t2.get('id'), s1t4.get('id')); + const results = ( + await Post.repository.find({ + sort: ['sort'], + filter: { + status: 'status1', + }, + }) + ).map((r) => { + return lodash.pick(r.toJSON(), ['title', 'sort']); + }); + + expect(results).toEqual([ + { title: 's1:t1', sort: 1 }, + { title: 's1:t3', sort: 2 }, + { title: 's1:t4', sort: 3 }, + { title: 's1:t2', sort: 4 }, + { title: 's1:t5', sort: 5 }, + ]); + + const s2results = ( + await Post.repository.find({ + sort: ['sort'], + filter: { + status: 'status2', + }, + }) + ).map((r) => { + return lodash.pick(r.toJSON(), ['title', 'sort']); + }); + + expect(s2results).toEqual([ + { title: 's2:t1', sort: 1 }, + { title: 's2:t2', sort: 2 }, + { title: 's2:t3', sort: 3 }, + { title: 's2:t4', sort: 4 }, + { title: 's2:t5', sort: 5 }, + ]); + }); + + test('move between scope', async () => { + for (let i = 0; i < 5; i++) { + await Post.repository.create({ + values: { + title: `s1:t${i + 1}`, + status: 'status1', + }, + }); + } + + for (let i = 0; i < 5; i++) { + await Post.repository.create({ + values: { + title: `s2:t${i + 1}`, + status: 'status2', + }, + }); + } + + const s1t1 = await Post.repository.findOne({ + filter: { + title: 's1:t1', + }, + }); + + const s2t3 = await Post.repository.findOne({ + filter: { + title: 's2:t3', + }, + }); + + const sortCollection = new SortAbleCollection(Post); + + await sortCollection.move(s1t1.get('id'), s2t3.get('id')); + + const results = ( + await Post.repository.find({ + sort: ['sort'], + filter: { + status: 'status2', + }, + }) + ).map((r) => { + return lodash.pick(r.toJSON(), ['title', 'sort']); + }); + + expect(results).toEqual([ + { title: 's2:t1', sort: 1 }, + { title: 's2:t2', sort: 2 }, + { title: 's1:t1', sort: 3 }, + { title: 's2:t3', sort: 4 }, + { title: 's2:t4', sort: 5 }, + { title: 's2:t5', sort: 6 }, + ]); + }); + }); +}); diff --git a/packages/actions/src/__tests__/toggle-action.test.ts b/packages/actions/src/__tests__/toggle-action.test.ts new file mode 100644 index 000000000..39caf0b1d --- /dev/null +++ b/packages/actions/src/__tests__/toggle-action.test.ts @@ -0,0 +1,101 @@ +import { mockServer } from './index'; +import { registerActions } from '@nocobase/actions'; + +describe('toggle action', () => { + let app; + let Post; + let Comment; + let Tag; + let PostTag; + + beforeEach(async () => { + app = mockServer(); + registerActions(app); + + PostTag = app.collection({ + name: 'posts_tags', + fields: [{ type: 'string', name: 'tagged_at' }], + }); + + Post = app.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'hasMany', name: 'comments' }, + { type: 'hasOne', name: 'profile' }, + { type: 'belongsToMany', name: 'tags', through: 'posts_tags' }, + { type: 'string', name: 'status', defaultValue: 'draft' }, + ], + }); + + Comment = app.collection({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + { type: 'belongsTo', name: 'post' }, + ], + }); + + Tag = app.collection({ + name: 'tags', + fields: [ + { type: 'string', name: 'name' }, + { type: 'belongsToMany', name: 'posts', through: 'posts_tags' }, + ], + }); + + await app.db.sync(); + }); + + afterEach(async () => { + await app.destroy(); + }); + + test('toggle belongs to many', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + }, + }); + + const t1 = await Tag.repository.create({ + values: { + name: 't1', + }, + }); + + const t2 = await Tag.repository.create({ + values: { + name: 't2', + }, + }); + + const t3 = await Tag.repository.create({ + values: { + name: 't3', + }, + }); + + expect(await p1.countTags()).toEqual(0); + + const response = await app + .agent() + .resource('posts.tags') + .toggle({ + associatedIndex: p1.get('id'), + values: [t1.get('id'), t2.get('id')], + }); + + expect(await p1.countTags()).toEqual(2); + + await app + .agent() + .resource('posts.tags') + .toggle({ + associatedIndex: p1.get('id'), + values: [t2.get('id')], + }); + + expect(await p1.countTags()).toEqual(1); + }); +}); diff --git a/packages/actions/src/__tests__/update-action.test.ts b/packages/actions/src/__tests__/update-action.test.ts new file mode 100644 index 000000000..24aa21a2e --- /dev/null +++ b/packages/actions/src/__tests__/update-action.test.ts @@ -0,0 +1,172 @@ +import { mockServer } from './index'; +import { registerActions } from '@nocobase/actions'; + +describe('update action', () => { + let app; + let Post; + let Comment; + let Tag; + let PostTag; + let Profile; + + beforeEach(async () => { + app = mockServer(); + registerActions(app); + + PostTag = app.collection({ + name: 'posts_tags', + fields: [{ type: 'string', name: 'tagged_at' }], + }); + + Post = app.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'hasMany', name: 'comments' }, + { type: 'hasOne', name: 'profile' }, + { type: 'belongsToMany', name: 'tags', through: 'posts_tags' }, + { type: 'string', name: 'status', defaultValue: 'draft' }, + ], + }); + + Profile = app.collection({ + name: 'profiles', + fields: [ + { type: 'string', name: 'post_profile' }, + { type: 'belongsTo', name: 'post' }, + ], + }); + + Comment = app.collection({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + { type: 'belongsTo', name: 'post' }, + ], + }); + + Tag = app.collection({ + name: 'tags', + fields: [ + { type: 'string', name: 'name' }, + { type: 'belongsToMany', name: 'posts', through: 'posts_tags' }, + ], + }); + + await app.db.sync(); + }); + + afterEach(async () => { + await app.destroy(); + }); + + test('update resource', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + }, + }); + + const response = await app + .agent() + .resource('posts') + .update({ + resourceIndex: p1.get('id'), + values: { + title: 'p0', + }, + }); + + await p1.reload(); + expect(p1.get('title')).toEqual('p0'); + }); + + test('update has many resource', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + comments: [ + { + content: 'c1', + }, + ], + }, + }); + + const c1 = await Comment.repository.findOne(); + + const response = await app + .agent() + .resource('posts.comments') + .update({ + resourceIndex: c1.get('id'), + associatedIndex: p1.get('id'), + values: { + content: 'c0', + }, + }); + + await c1.reload(); + expect(c1.get('content')).toEqual('c0'); + }); + + test('update belongs to many through value', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + tags: [ + { + name: 't1', + posts_tags: { + tagged_at: '123', + }, + }, + ], + }, + }); + + const p1t1 = (await p1.getTags())[0]; + + const response = await app + .agent() + .resource('posts.tags') + .update({ + resourceIndex: p1.get('id'), + associatedIndex: p1t1.get('id'), + values: { + posts_tags: { + tagged_at: 'test', + }, + }, + }); + + await p1t1.reload(); + expect(p1t1.posts_tags.tagged_at).toEqual('test'); + }); + + test('update has one', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + profile: { + post_profile: 'test', + }, + }, + }); + + const postProfile = await Profile.repository.findOne(); + + const response = await app + .agent() + .resource('posts.profile') + .update({ + associatedIndex: p1.get('id'), + values: { + post_profile: 'test0', + }, + }); + + await postProfile.reload(); + expect(postProfile.get('post_profile')).toEqual('test0'); + }); +}); diff --git a/packages/actions/src/actions/add.ts b/packages/actions/src/actions/add.ts new file mode 100644 index 000000000..2c381c89d --- /dev/null +++ b/packages/actions/src/actions/add.ts @@ -0,0 +1,14 @@ +import { Context } from '..'; +import { getRepositoryFromParams } from './utils'; +import { BelongsToManyRepository, MultipleRelationRepository, HasManyRepository } from '@nocobase/database'; + +export async function add(ctx: Context, next) { + const repository = getRepositoryFromParams(ctx); + + if (!(repository instanceof MultipleRelationRepository || repository instanceof HasManyRepository)) { + return await next(); + } + + await (repository).add(ctx.action.params.values); + await next(); +} diff --git a/packages/actions/src/actions/create.ts b/packages/actions/src/actions/create.ts new file mode 100644 index 000000000..95028c2bc --- /dev/null +++ b/packages/actions/src/actions/create.ts @@ -0,0 +1,19 @@ +import { Context } from '..'; +import { getRepositoryFromParams } from './utils'; + +export async function create(ctx: Context, next) { + const repository = getRepositoryFromParams(ctx); + const { whitelist, blacklist, updateAssociationValues, values } = ctx.action.params; + + const instance = await repository.create({ + values, + whitelist, + blacklist, + updateAssociationValues, + context: ctx, + }); + + ctx.body = instance; + + await next(); +} diff --git a/packages/actions/src/actions/destroy.ts b/packages/actions/src/actions/destroy.ts new file mode 100644 index 000000000..f08604933 --- /dev/null +++ b/packages/actions/src/actions/destroy.ts @@ -0,0 +1,17 @@ +import { Context } from '..'; +import { getRepositoryFromParams } from './utils'; + +export async function destroy(ctx: Context, next) { + const repository = getRepositoryFromParams(ctx); + + const { resourceIndex, filter } = ctx.action.params; + + const instance = await repository.destroy({ + filter, + filterByPk: resourceIndex, + context: ctx, + }); + + ctx.body = instance; + await next(); +} diff --git a/packages/actions/src/actions/get.ts b/packages/actions/src/actions/get.ts new file mode 100644 index 000000000..4c004ca9a --- /dev/null +++ b/packages/actions/src/actions/get.ts @@ -0,0 +1,20 @@ +import { Context } from '..'; +import { getRepositoryFromParams } from './utils'; +import { SingleRelationRepository } from '@nocobase/database'; + +export async function get(ctx: Context, next) { + const repository = getRepositoryFromParams(ctx); + + const { resourceIndex, fields, appends, except, filter } = ctx.action.params; + + const instance = await repository.findOne({ + filterByPk: resourceIndex, + fields, + appends, + except, + filter, + }); + + ctx.body = instance; + await next(); +} diff --git a/packages/actions/src/actions/index.ts b/packages/actions/src/actions/index.ts new file mode 100644 index 000000000..e01c0640d --- /dev/null +++ b/packages/actions/src/actions/index.ts @@ -0,0 +1,10 @@ +export * from './list'; +export * from './create'; +export * from './update'; +export * from './destroy'; +export * from './get'; +export * from './add'; +export * from './set'; +export * from './remove'; +export * from './toggle'; +export * from './move'; diff --git a/packages/actions/src/actions/list.ts b/packages/actions/src/actions/list.ts new file mode 100644 index 000000000..9fca3bc97 --- /dev/null +++ b/packages/actions/src/actions/list.ts @@ -0,0 +1,47 @@ +import { Context } from '..'; +import { getRepositoryFromParams } from './utils'; + +export const DEFAULT_PAGE = 1; +export const DEFAULT_PER_PAGE = 20; + +function pageArgsToLimitArgs( + page: number, + perPage: number, +): { + offset: number; + limit: number; +} { + return { + offset: (page - 1) * perPage, + limit: perPage, + }; +} + +function totalPage(total, perPage): number { + return Math.ceil(total / perPage); +} + +export async function list(ctx: Context, next) { + const { page = DEFAULT_PAGE, perPage = DEFAULT_PER_PAGE, fields, filter, appends, except, sort } = ctx.action.params; + + const repository = getRepositoryFromParams(ctx); + + const [rows, count] = await repository.findAndCount({ + filter, + fields, + appends, + except, + sort, + ...pageArgsToLimitArgs(page, perPage), + }); + + ctx.body = { + count, + rows, + page, + perPage, + totalPage: totalPage(count, perPage), + }; + + await next(); +} diff --git a/packages/actions/src/actions/move.ts b/packages/actions/src/actions/move.ts new file mode 100644 index 000000000..445072bd2 --- /dev/null +++ b/packages/actions/src/actions/move.ts @@ -0,0 +1,138 @@ +import { Op, Model } from 'sequelize'; + +import { Context } from '..'; +import { Collection, PrimaryKey, Repository, SortField } from '@nocobase/database'; +import { getRepositoryFromParams } from './utils'; + +export async function move(ctx: Context, next) { + const repository = getRepositoryFromParams(ctx); + + const { sourceId, targetId, sortField, targetScope, sticky, method } = ctx.action.params; + + if (repository instanceof Repository) { + const sortAbleCollection = new SortAbleCollection(repository.collection, sortField); + + if (sourceId && targetId) { + await sortAbleCollection.move(sourceId, targetId, { + insertAfter: method === 'insertAfter', + }); + } + + // change scope + if (sourceId && targetScope) { + await sortAbleCollection.changeScope(sourceId, targetScope, method); + } + + if (sourceId && sticky) { + await sortAbleCollection.sticky(sourceId); + } + } + + await next(); +} + +interface SortPosition { + scope?: string; + id: PrimaryKey; +} + +interface MoveOptions { + insertAfter?: boolean; +} + +export class SortAbleCollection { + collection: Collection; + field: SortField; + scopeKey: string; + + constructor(collection: Collection, fieldName: string = 'sort') { + this.collection = collection; + this.field = collection.getField(fieldName); + + if (!(this.field instanceof SortField)) { + throw new Error(`${fieldName} is not a sort field`); + } + + this.scopeKey = this.field.get('scopeKey'); + } + + // insert source position to target position + async move(sourceInstanceId: PrimaryKey, targetInstanceId: PrimaryKey, options: MoveOptions = {}) { + const sourceInstance = await this.collection.repository.findById(sourceInstanceId); + const targetInstance = await this.collection.repository.findById(targetInstanceId); + + if (this.scopeKey && sourceInstance.get(this.scopeKey) !== targetInstance.get(this.scopeKey)) { + await sourceInstance.set(this.scopeKey, targetInstance.get(this.scopeKey)); + await sourceInstance.save(); + } + + await this.sameScopeMove(sourceInstance, targetInstance, options); + } + + async changeScope(sourceInstanceId: PrimaryKey, targetScope: any, method?: string) { + const sourceInstance = await this.collection.repository.findById(sourceInstanceId); + const targetScopeValue = targetScope[this.scopeKey]; + + if (targetScopeValue && sourceInstance.get(this.scopeKey) !== targetScopeValue) { + await sourceInstance.set(this.scopeKey, targetScopeValue); + await sourceInstance.save(); + + if (method === 'prepend') { + await this.sticky(sourceInstanceId); + } + } + } + + async sticky(sourceInstanceId: PrimaryKey) { + const sourceInstance = await this.collection.repository.findById(sourceInstanceId); + sourceInstance.set(this.field.get('name'), 0); + await sourceInstance.save(); + } + + async sameScopeMove(sourceInstance: Model, targetInstance: Model, options: MoveOptions) { + const fieldName = this.field.get('name'); + + const sourceSort = sourceInstance.get(fieldName); + let targetSort = targetInstance.get(fieldName); + + if (options.insertAfter) { + targetSort = targetSort + 1; + } + + let scopeValue = this.scopeKey ? sourceInstance.get(this.scopeKey) : null; + let updateCondition; + let change; + + if (targetSort > sourceSort) { + updateCondition = { + [Op.gt]: sourceSort, + [Op.lte]: targetSort, + }; + change = -1; + } else { + updateCondition = { + [Op.lt]: sourceSort, + [Op.gte]: targetSort, + }; + change = 1; + } + + const where = { + [fieldName]: updateCondition, + }; + + if (scopeValue) { + where[this.scopeKey] = { + [Op.eq]: scopeValue, + }; + } + await this.collection.model.increment(fieldName, { + where, + by: change, + }); + + await sourceInstance.update({ + [fieldName]: targetSort, + }); + } +} diff --git a/packages/actions/src/actions/remove.ts b/packages/actions/src/actions/remove.ts new file mode 100644 index 000000000..d52c4301c --- /dev/null +++ b/packages/actions/src/actions/remove.ts @@ -0,0 +1,2 @@ +import { RelationRepositoryActionBuilder } from './utils'; +export const remove = RelationRepositoryActionBuilder('remove'); diff --git a/packages/actions/src/actions/set.ts b/packages/actions/src/actions/set.ts new file mode 100644 index 000000000..700c2b908 --- /dev/null +++ b/packages/actions/src/actions/set.ts @@ -0,0 +1,2 @@ +import { RelationRepositoryActionBuilder } from './utils'; +export const set = RelationRepositoryActionBuilder('set'); diff --git a/packages/actions/src/actions/toggle.ts b/packages/actions/src/actions/toggle.ts new file mode 100644 index 000000000..192e17c1f --- /dev/null +++ b/packages/actions/src/actions/toggle.ts @@ -0,0 +1,14 @@ +import { Context } from '..'; +import { getRepositoryFromParams } from './utils'; +import { BelongsToManyRepository } from '@nocobase/database'; + +export async function toggle(ctx: Context, next) { + const repository = getRepositoryFromParams(ctx); + + if (!(repository instanceof BelongsToManyRepository)) { + return await next(); + } + + await (repository).toggle(ctx.action.params.values); + await next(); +} diff --git a/packages/actions/src/actions/update.ts b/packages/actions/src/actions/update.ts new file mode 100644 index 000000000..0dd00e474 --- /dev/null +++ b/packages/actions/src/actions/update.ts @@ -0,0 +1,20 @@ +import { Context } from '..'; +import { getRepositoryFromParams } from './utils'; + +export async function update(ctx: Context, next) { + const repository = getRepositoryFromParams(ctx); + const { resourceIndex, values, whitelist, blacklist, filter, updateAssociationValues } = ctx.action.params; + + const instance = await repository.update({ + filterByPk: resourceIndex, + values, + whitelist, + blacklist, + filter, + updateAssociationValues, + context: ctx, + }); + + ctx.body = instance; + await next(); +} diff --git a/packages/actions/src/actions/utils.ts b/packages/actions/src/actions/utils.ts new file mode 100644 index 000000000..c9536066c --- /dev/null +++ b/packages/actions/src/actions/utils.ts @@ -0,0 +1,27 @@ +import { MultipleRelationRepository, Repository } from '@nocobase/database'; +import { Context } from '..'; + +export function getRepositoryFromParams(ctx: Context) { + const { resourceName, associatedName, associatedIndex } = ctx.action.params; + + let repository: MultipleRelationRepository | Repository; + + if (associatedName) { + repository = ( + ctx.db.getCollection(associatedName).repository.relation(resourceName).of(associatedIndex) + ); + } else { + repository = ctx.db.getCollection(resourceName).repository; + } + + return repository; +} + +export function RelationRepositoryActionBuilder(method: 'remove' | 'set') { + return async function (ctx: Context, next) { + const repository = getRepositoryFromParams(ctx); + + await repository[method](ctx.action.params.values); + await next(); + }; +} diff --git a/packages/actions/src/index.ts b/packages/actions/src/index.ts index b4dcd229f..036e5cb6e 100644 --- a/packages/actions/src/index.ts +++ b/packages/actions/src/index.ts @@ -1,6 +1,8 @@ import Koa from 'koa'; import { Database } from '@nocobase/database'; import { Action } from '@nocobase/resourcer'; +import lodash from 'lodash'; +import * as actions from './actions'; export type Next = () => Promise; @@ -11,6 +13,10 @@ export interface Context extends Koa.Context { [key: string]: any; } -export function registerActions(api: any) {} +export function registerActions(api: any) { + api.actions( + lodash.pick(actions, ['add', 'create', 'destroy', 'get', 'list', 'remove', 'set', 'toggle', 'update', 'move']), + ); +} export default {}; diff --git a/packages/database/package.json b/packages/database/package.json index 45f4a5309..b9aafd6a8 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@nocobase/utils": "^0.6.0-alpha.0", + "async-mutex": "^0.3.2", "bcrypt": "^5.0.0", "deepmerge": "^4.2.2", "flat": "^5.0.2", diff --git a/packages/database/src/__tests__/fields/sort-field.test.ts b/packages/database/src/__tests__/fields/sort-field.test.ts index e4ac3d5cc..2f73d2f6d 100644 --- a/packages/database/src/__tests__/fields/sort-field.test.ts +++ b/packages/database/src/__tests__/fields/sort-field.test.ts @@ -22,6 +22,7 @@ describe('string field', () => { fields: [{ type: 'sort', name: 'sort' }], }); await db.sync(); + const test1 = await Test.model.create(); expect(test1.sort).toBe(1); const test2 = await Test.model.create(); @@ -30,6 +31,24 @@ describe('string field', () => { expect(test3.sort).toBe(3); }); + test('simultaneously create ', async () => { + const Test = db.collection({ + name: 'tests', + fields: [{ type: 'sort', name: 'sort' }], + }); + await db.sync(); + + const promise = []; + for (let i = 0; i < 3; i++) { + promise.push(Test.model.create()); + } + + await Promise.all(promise); + const tests = await Test.model.findAll(); + const sortValues = tests.map((t) => t.get('sort')).sort(); + expect(sortValues).toEqual([1, 2, 3]); + }); + it('skip if sort value not empty', async () => { const Test = db.collection({ name: 'tests', @@ -53,13 +72,21 @@ describe('string field', () => { ], }); await db.sync(); + const t1 = await Test.model.create({ status: 'publish' }); const t2 = await Test.model.create({ status: 'publish' }); const t3 = await Test.model.create({ status: 'draft' }); const t4 = await Test.model.create({ status: 'draft' }); + expect(t1.get('sort')).toBe(1); expect(t2.get('sort')).toBe(2); expect(t3.get('sort')).toBe(1); expect(t4.get('sort')).toBe(2); + + t1.set('status', 'draft'); + await t1.save(); + + await t1.reload(); + expect(t1.get('sort')).toBe(3); }); }); diff --git a/packages/database/src/__tests__/relation-repository/belongs-to-many-repository.test.ts b/packages/database/src/__tests__/relation-repository/belongs-to-many-repository.test.ts index 8d9566c4b..dfd2b554f 100644 --- a/packages/database/src/__tests__/relation-repository/belongs-to-many-repository.test.ts +++ b/packages/database/src/__tests__/relation-repository/belongs-to-many-repository.test.ts @@ -47,6 +47,7 @@ describe('belongs to many', () => { fields: [ { type: 'belongsToMany', name: 'posts', through: 'posts_tags' }, { type: 'string', name: 'name' }, + { type: 'string', name: 'status' }, ], }); @@ -124,7 +125,9 @@ describe('belongs to many', () => { }); const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); - let [findResult, count] = await PostTagRepository.findAndCount(); + let [findResult, count] = await PostTagRepository.findAndCount({ + fields: ['id'], + }); expect(count).toEqual(2); @@ -450,6 +453,43 @@ describe('belongs to many', () => { expect(count).toEqual(0); }); + test('destroy by id and filter', async () => { + let t1 = await Tag.repository.create({ + values: { + name: 't1', + status: 'published', + }, + }); + + const t2 = await Tag.repository.create({ + values: { + name: 't2', + status: 'draft', + }, + }); + + const p1 = await Post.repository.create({ + values: { title: 'p1' }, + }); + + const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); + + await PostTagRepository.set([t1.id, t2.id]); + + let [_, count] = await PostTagRepository.findAndCount(); + expect(count).toEqual(2); + + await PostTagRepository.destroy({ + filterByPk: t1.get('id') as number, + filter: { + status: 'draft', + }, + }); + + [_, count] = await PostTagRepository.findAndCount(); + expect(count).toEqual(2); + }); + test('destroy with id', async () => { let t1 = await Tag.repository.create({ values: { diff --git a/packages/database/src/__tests__/relation-repository/has-many-repository.test.ts b/packages/database/src/__tests__/relation-repository/has-many-repository.test.ts index d52010811..53517d061 100644 --- a/packages/database/src/__tests__/relation-repository/has-many-repository.test.ts +++ b/packages/database/src/__tests__/relation-repository/has-many-repository.test.ts @@ -29,6 +29,7 @@ describe('has many repository', () => { { type: 'string', name: 'title' }, { type: 'belongsToMany', name: 'tags', through: 'posts_tags' }, { type: 'hasMany', name: 'comments' }, + { type: 'string', name: 'status' }, ], }); @@ -167,6 +168,56 @@ describe('has many repository', () => { expect(findAndCount[1]).toEqual(6); }); + test('destroy by pk and filter', async () => { + const u1 = await User.repository.create({ + values: { name: 'u1' }, + }); + + const UserPostRepository = new HasManyRepository(User, 'posts', u1.id); + + const p1 = await UserPostRepository.create({ + values: { + title: 't1', + status: 'published', + }, + }); + + const p2 = await UserPostRepository.create({ + values: { + title: 't2', + status: 'draft', + }, + }); + + await UserPostRepository.destroy({ + filterByPk: p1.id, + filter: { + status: 'draft', + }, + }); + + expect(await UserPostRepository.count()).toEqual(2); + + await UserPostRepository.destroy({ + filterByPk: p1.id, + filter: { + status: 'published', + }, + }); + + expect( + await UserPostRepository.findOne({ + filterByPk: p1.id, + }), + ).toBeNull(); + + expect( + await UserPostRepository.findOne({ + filterByPk: p2.id, + }), + ).not.toBeNull(); + }); + test('destroy by pk', async () => { const u1 = await User.repository.create({ values: { name: 'u1' }, diff --git a/packages/database/src/__tests__/respsitory/destroy.test.ts b/packages/database/src/__tests__/respsitory/destroy.test.ts index 616a0bcf1..a703115bc 100644 --- a/packages/database/src/__tests__/respsitory/destroy.test.ts +++ b/packages/database/src/__tests__/respsitory/destroy.test.ts @@ -20,12 +20,31 @@ describe('destroy', () => { name: 'posts', fields: [ { type: 'string', name: 'title' }, + { type: 'string', name: 'status' }, { type: 'belongsTo', name: 'user' }, ], }); await db.sync(); }); + test('destroy with filter and filterByPk', async () => { + const p1 = await Post.repository.create({ + values: { + name: 'u1', + status: 'published', + }, + }); + + await Post.repository.destroy({ + filterByPk: p1.get('id') as number, + filter: { + status: 'draft', + }, + }); + + expect(await Post.repository.count()).toEqual(1); + }); + test('destroy all', async () => { await User.repository.create({ values: { diff --git a/packages/database/src/fields/sort-field.ts b/packages/database/src/fields/sort-field.ts index 1aaf799b2..78845872d 100644 --- a/packages/database/src/fields/sort-field.ts +++ b/packages/database/src/fields/sort-field.ts @@ -1,27 +1,54 @@ +import { Mutex } from 'async-mutex'; import { isNumber } from 'lodash'; import { DataTypes } from 'sequelize'; import { BaseColumnFieldOptions, Field } from './field'; +const sortFieldMutex = new Mutex(); + export class SortField extends Field { get dataType() { return DataTypes.INTEGER; } - init() { + async setSortValue(instance, options) { const { name, scopeKey } = this.options; const { model } = this.context.collection; - model.beforeCreate(async (instance, options) => { - if (isNumber(instance.get(name))) { - return; - } - const where = {}; - if (scopeKey) { - where[scopeKey] = instance.get(scopeKey); - } + + if (isNumber(instance.get(name)) && instance._previousDataValues[scopeKey] == instance[scopeKey]) { + return; + } + + const where = {}; + + if (scopeKey) { + where[scopeKey] = instance.get(scopeKey); + } + + await sortFieldMutex.runExclusive(async () => { const max = await model.max(name, { ...options, where }); - instance.set(name, (max || 0) + 1); + const newValue = (max || 0) + 1; + instance.set(name, newValue); }); } + + async onScopeChange(instance, options) { + const { scopeKey } = this.options; + if (scopeKey && !instance.isNewRecord && instance._previousDataValues[scopeKey] != instance[scopeKey]) { + await this.setSortValue(instance, options); + } + } + + bind() { + super.bind(); + this.on('beforeUpdate', this.onScopeChange.bind(this)); + this.on('beforeCreate', this.setSortValue.bind(this)); + } + + unbind() { + super.unbind(); + this.off('beforeUpdate', this.onScopeChange.bind(this)); + this.off('beforeCreate', this.setSortValue.bind(this)); + } } export interface SortFieldOptions extends BaseColumnFieldOptions { diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index a07d1542c..d223f0a60 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,9 +1,14 @@ export * from './database'; export * from './collection'; +export * from './repository'; export * from './utils'; export { Database as default } from './database'; export * from './relation-repository/belongs-to-many-repository'; export * from './relation-repository/belongs-to-repository'; +export * from './relation-repository/hasmany-repository'; +export * from './relation-repository/single-relation-repository'; +export * from './relation-repository/multiple-relation-repository'; + export { Model, ModelCtor } from 'sequelize'; export * from './fields'; -export * from './update-associations'; \ No newline at end of file +export * from './update-associations'; diff --git a/packages/database/src/relation-repository/belongs-to-many-repository.ts b/packages/database/src/relation-repository/belongs-to-many-repository.ts index 969019eb4..8ef45a830 100644 --- a/packages/database/src/relation-repository/belongs-to-many-repository.ts +++ b/packages/database/src/relation-repository/belongs-to-many-repository.ts @@ -76,12 +76,14 @@ export class BelongsToManyRepository extends MultipleRelationRepository implemen }); ids = instancesToIds(instances); - } else if (options && options['filterByPk']) { - options = options['filterByPk']; + } - const instances = (this.association).toInstanceArray(options); - ids = instancesToIds(instances); - } else if (options && !options['filterByPk']) { + if (options && options['filterByPk']) { + const instances = (this.association).toInstanceArray(options['filterByPk']); + ids = ids ? lodash.intersection(ids, instancesToIds(instances)) : instancesToIds(instances); + } + + if (options && !options['filterByPk'] && !options['filter']) { const sourceModel = await this.getSourceModel(transaction); const instances = await sourceModel[this.accessors().get]({ @@ -228,6 +230,18 @@ export class BelongsToManyRepository extends MultipleRelationRepository implemen return; } + extendFindOptions(findOptions) { + let joinTableAttributes; + if (lodash.get(findOptions, 'fields')) { + joinTableAttributes = []; + } + + return { + ...findOptions, + joinTableAttributes, + }; + } + throughName() { return this.throughModel().name; } diff --git a/packages/database/src/relation-repository/belongs-to-repository.ts b/packages/database/src/relation-repository/belongs-to-repository.ts index bea4e9d3a..726385e11 100644 --- a/packages/database/src/relation-repository/belongs-to-repository.ts +++ b/packages/database/src/relation-repository/belongs-to-repository.ts @@ -8,6 +8,7 @@ interface BelongsToFindOptions extends SingleRelationFindOption {} interface IBelongsToRepository { // 不需要 findOne,find 就是 findOne find(options?: BelongsToFindOptions): Promise; + findOne(options?: BelongsToFindOptions): Promise; // 新增并关联,如果存在关联,解除之后,与新数据建立关联 create(options?: CreateOptions): Promise; // 更新 diff --git a/packages/database/src/relation-repository/hasmany-repository.ts b/packages/database/src/relation-repository/hasmany-repository.ts index 304521300..ccd568b7f 100644 --- a/packages/database/src/relation-repository/hasmany-repository.ts +++ b/packages/database/src/relation-repository/hasmany-repository.ts @@ -53,7 +53,8 @@ export class HasManyRepository extends MultipleRelationRepository implements IHa } where.push(filterResult.where); - } else if (options && options['filterByPk']) { + } + if (options && options['filterByPk']) { if (typeof options === 'object' && options['filterByPk']) { options = options['filterByPk']; } diff --git a/packages/database/src/relation-repository/hasone-repository.ts b/packages/database/src/relation-repository/hasone-repository.ts index 9dd551ffd..b07d29cf2 100644 --- a/packages/database/src/relation-repository/hasone-repository.ts +++ b/packages/database/src/relation-repository/hasone-repository.ts @@ -6,7 +6,8 @@ interface HasOneFindOptions extends SingleRelationFindOption {} interface IHasOneRepository { // 不需要 findOne,find 就是 findOne - find(options?: HasOneFindOptions): Promise>; + find(options?: HasOneFindOptions): Promise; + findOne(options?: HasOneFindOptions): Promise; // 新增并关联,如果存在关联,解除之后,与新数据建立关联 create(options?: CreateOptions): Promise; // 更新 @@ -19,4 +20,4 @@ interface IHasOneRepository { remove(): Promise; } -export class HasOneRepository extends SingleRelationRepository implements IHasOneRepository {} +export class HasOneRepository extends SingleRelationRepository implements IHasOneRepository {} diff --git a/packages/database/src/relation-repository/multiple-relation-repository.ts b/packages/database/src/relation-repository/multiple-relation-repository.ts index 2bc5069e9..f0fd1d526 100644 --- a/packages/database/src/relation-repository/multiple-relation-repository.ts +++ b/packages/database/src/relation-repository/multiple-relation-repository.ts @@ -25,12 +25,18 @@ export interface AssociatedOptions extends TransactionAble { } export abstract class MultipleRelationRepository extends RelationRepository { + extendFindOptions(findOptions) { + return findOptions; + } + async find(options?: FindOptions): Promise { const transaction = await this.getTransaction(options); - const findOptions = this.buildQueryOptions({ - ...options, - }); + const findOptions = this.extendFindOptions( + this.buildQueryOptions({ + ...options, + }), + ); const getAccessor = this.accessors().get; const sourceModel = await this.getSourceModel(transaction); @@ -77,7 +83,7 @@ export abstract class MultipleRelationRepository extends RelationRepository { ]; } - async count(options: CountOptions) { + async count(options?: CountOptions) { const transaction = await this.getTransaction(options); const sourceModel = await this.getSourceModel(transaction); @@ -101,7 +107,7 @@ export abstract class MultipleRelationRepository extends RelationRepository { transaction, }); - return count.count; + return parseInt(count.count); } async findOne(options?: FindOneOptions): Promise { diff --git a/packages/database/src/relation-repository/single-relation-repository.ts b/packages/database/src/relation-repository/single-relation-repository.ts index 3d9b8691f..4ae85b924 100644 --- a/packages/database/src/relation-repository/single-relation-repository.ts +++ b/packages/database/src/relation-repository/single-relation-repository.ts @@ -2,12 +2,13 @@ import { RelationRepository, transaction } from './relation-repository'; import { Model, SingleAssociationAccessors } from 'sequelize'; import { updateModelByValues } from '../update-associations'; import lodash from 'lodash'; -import { Appends, Except, Fields, PrimaryKey, TransactionAble, UpdateOptions } from '../repository'; +import { Appends, Except, Fields, Filter, PrimaryKey, TransactionAble, UpdateOptions } from '../repository'; export interface SingleRelationFindOption extends TransactionAble { fields?: Fields; except?: Except; appends?: Appends; + filter?: Filter; } interface SetOption extends TransactionAble { @@ -56,6 +57,10 @@ export abstract class SingleRelationRepository extends RelationRepository { }); } + async findOne(options?: SingleRelationFindOption): Promise> { + return this.find(options); + } + @transaction() async destroy(options?: TransactionAble): Promise { const transaction = await this.getTransaction(options); diff --git a/packages/database/src/repository.ts b/packages/database/src/repository.ts index 6a0aa8a86..dd09a1c62 100644 --- a/packages/database/src/repository.ts +++ b/packages/database/src/repository.ts @@ -83,6 +83,7 @@ export interface DestroyOptions extends SequelizeDestroyOptions { filter?: Filter; filterByPk?: PrimaryKey | PrimaryKey[]; truncate?: boolean; + context?: any; } interface FindAndCountOptions extends Omit { @@ -197,7 +198,7 @@ export class Repositoryoptions; - let filterByPk = options.filterByPk; - - if (filterByPk) { - if (!Array.isArray(filterByPk)) { - filterByPk = [filterByPk]; - } + const filterByPk: PrimaryKey[] | undefined = + options.filterByPk && !lodash.isArray(options.filterByPk) + ? [options.filterByPk] + : (options.filterByPk as PrimaryKey[] | undefined); + if (filterByPk && !options.filter) { return await this.model.destroy({ + ...options, where: { [this.model.primaryKeyAttribute]: { [Op.in]: filterByPk, @@ -388,19 +389,27 @@ export class Repository instance[this.model.primaryKeyAttribute]); + + if (filterByPk) { + pks = lodash.intersection(pks, filterByPk); + } return await this.destroy({ - filterByPk: instances.map((instance) => instance[this.model.primaryKeyAttribute]), + context: options.context, + filterByPk: pks, transaction, }); } if (options.truncate) { return await this.model.destroy({ + ...options, truncate: true, transaction, }); diff --git a/packages/resourcer/src/resourcer.ts b/packages/resourcer/src/resourcer.ts index 44b1be266..f8931988a 100644 --- a/packages/resourcer/src/resourcer.ts +++ b/packages/resourcer/src/resourcer.ts @@ -1,4 +1,3 @@ -import qs from 'qs'; import glob from 'glob'; import compose from 'koa-compose'; import Action, { ActionName } from './action'; @@ -280,6 +279,7 @@ export class Resourcer { accessors: this.options.accessors || accessors, }, ); + if (!params) { return next(); } diff --git a/yarn.lock b/yarn.lock index 1af5953bf..e8fd1e521 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3368,6 +3368,13 @@ astral-regex@^2.0.0: resolved "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== +async-mutex@^0.3.2: + version "0.3.2" + resolved "https://registry.nlark.com/async-mutex/download/async-mutex-0.3.2.tgz#1485eda5bda1b0ec7c8df1ac2e815757ad1831df" + integrity sha1-FIXtpb2hsOx8jfGsLoFXV60YMd8= + dependencies: + tslib "^2.3.1" + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -13249,7 +13256,7 @@ tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.1: +tslib@^2.0.1, tslib@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==