From d7e6b7b3203b878050b1980360753d4b7e01ea34 Mon Sep 17 00:00:00 2001 From: ChengLei Shao Date: Tue, 6 Jun 2023 11:36:18 +0800 Subject: [PATCH] feat(database): add firstOrCreate and updateOrCreate in repository (#1943) * chore: tmp commit * feat: firstOrCreate * chore: firstOrCreate * feat: updateOrCreate * chore: test * feat: values to filter * feat: firstOrCreate http api * fix: build error --- .../src/__tests__/first-or-create.test.ts | 44 ++++++ .../actions/src/__tests__/get-action.test.ts | 1 + .../src/__tests__/update-or-create.test.ts | 44 ++++++ .../actions/src/actions/first-or-create.ts | 3 + packages/core/actions/src/actions/get.ts | 23 +-- packages/core/actions/src/actions/index.ts | 2 + .../src/actions/proxy-to-repository.ts | 17 +++ .../actions/src/actions/update-or-create.ts | 3 + packages/core/actions/src/actions/update.ts | 24 +--- packages/core/actions/src/index.ts | 15 +- .../database/src/__tests__/repository.test.ts | 24 ++++ .../src/__tests__/repository/create.test.ts | 135 +++++++++++++++++- packages/core/database/src/repository.ts | 83 +++++++++++ 13 files changed, 371 insertions(+), 47 deletions(-) create mode 100644 packages/core/actions/src/__tests__/first-or-create.test.ts create mode 100644 packages/core/actions/src/__tests__/update-or-create.test.ts create mode 100644 packages/core/actions/src/actions/first-or-create.ts create mode 100644 packages/core/actions/src/actions/proxy-to-repository.ts create mode 100644 packages/core/actions/src/actions/update-or-create.ts diff --git a/packages/core/actions/src/__tests__/first-or-create.test.ts b/packages/core/actions/src/__tests__/first-or-create.test.ts new file mode 100644 index 000000000..1f21407ca --- /dev/null +++ b/packages/core/actions/src/__tests__/first-or-create.test.ts @@ -0,0 +1,44 @@ +import { mockServer } from './index'; +import { registerActions } from '@nocobase/actions'; +import { Collection } from '@nocobase/database'; + +describe('first or create', () => { + let app; + + let Post: Collection; + + beforeEach(async () => { + app = mockServer(); + await app.db.clean({ drop: true }); + registerActions(app); + + Post = app.collection({ + name: 'posts', + fields: [{ type: 'string', name: 'title' }], + }); + + await app.db.sync(); + }); + + afterEach(async () => { + await app.destroy(); + }); + + test('first or create resource', async () => { + expect(await Post.repository.findOne()).toBeNull(); + const response = await app + .agent() + .resource('posts') + .firstOrCreate({ + values: { + title: 't1', + }, + filterKeys: ['title'], + }); + + expect(response.statusCode).toEqual(200); + const post = await Post.repository.findOne(); + expect(post).not.toBeNull(); + expect(post['title']).toEqual('t1'); + }); +}); diff --git a/packages/core/actions/src/__tests__/get-action.test.ts b/packages/core/actions/src/__tests__/get-action.test.ts index 473b14c78..658e5b47f 100644 --- a/packages/core/actions/src/__tests__/get-action.test.ts +++ b/packages/core/actions/src/__tests__/get-action.test.ts @@ -81,6 +81,7 @@ describe('get action', () => { }); const body = response.body; + expect(body['id']).toEqual(p1.get('id')); }); diff --git a/packages/core/actions/src/__tests__/update-or-create.test.ts b/packages/core/actions/src/__tests__/update-or-create.test.ts new file mode 100644 index 000000000..ed50b9e55 --- /dev/null +++ b/packages/core/actions/src/__tests__/update-or-create.test.ts @@ -0,0 +1,44 @@ +import { mockServer } from './index'; +import { registerActions } from '@nocobase/actions'; +import { Collection } from '@nocobase/database'; + +describe('update or create', () => { + let app; + + let Post: Collection; + + beforeEach(async () => { + app = mockServer(); + await app.db.clean({ drop: true }); + registerActions(app); + + Post = app.collection({ + name: 'posts', + fields: [{ type: 'string', name: 'title' }], + }); + + await app.db.sync(); + }); + + afterEach(async () => { + await app.destroy(); + }); + + test('update or create resource', async () => { + expect(await Post.repository.findOne()).toBeNull(); + const response = await app + .agent() + .resource('posts') + .updateOrCreate({ + values: { + title: 't1', + }, + filterKeys: ['title'], + }); + + expect(response.statusCode).toEqual(200); + const post = await Post.repository.findOne(); + expect(post).not.toBeNull(); + expect(post['title']).toEqual('t1'); + }); +}); diff --git a/packages/core/actions/src/actions/first-or-create.ts b/packages/core/actions/src/actions/first-or-create.ts new file mode 100644 index 000000000..8b64d1fd2 --- /dev/null +++ b/packages/core/actions/src/actions/first-or-create.ts @@ -0,0 +1,3 @@ +import { proxyToRepository } from './proxy-to-repository'; + +export const firstOrCreate = proxyToRepository(['values', 'filterKeys'], 'firstOrCreate'); diff --git a/packages/core/actions/src/actions/get.ts b/packages/core/actions/src/actions/get.ts index 37a300bc6..42b3fb77b 100644 --- a/packages/core/actions/src/actions/get.ts +++ b/packages/core/actions/src/actions/get.ts @@ -1,22 +1,3 @@ -import { Context } from '..'; -import { getRepositoryFromParams } from '../utils'; +import { proxyToRepository } from './proxy-to-repository'; -export async function get(ctx: Context, next) { - const repository = getRepositoryFromParams(ctx); - - const { filterByTk, fields, appends, except, filter } = ctx.action.params; - - const instance = await repository.findOne({ - filterByTk, - fields, - appends, - except, - filter, - context: ctx, - }); - - ctx.body = instance || null; - ctx.status = 200; - - await next(); -} +export const get = proxyToRepository(['filterByTk', 'fields', 'appends', 'except', 'filter'], 'findOne'); diff --git a/packages/core/actions/src/actions/index.ts b/packages/core/actions/src/actions/index.ts index e01c0640d..bff23b93b 100644 --- a/packages/core/actions/src/actions/index.ts +++ b/packages/core/actions/src/actions/index.ts @@ -8,3 +8,5 @@ export * from './set'; export * from './remove'; export * from './toggle'; export * from './move'; +export * from './first-or-create'; +export * from './update-or-create'; diff --git a/packages/core/actions/src/actions/proxy-to-repository.ts b/packages/core/actions/src/actions/proxy-to-repository.ts new file mode 100644 index 000000000..493f30cd8 --- /dev/null +++ b/packages/core/actions/src/actions/proxy-to-repository.ts @@ -0,0 +1,17 @@ +import { getRepositoryFromParams } from '../utils'; +import { Context } from '../index'; +import lodash from 'lodash'; + +export function proxyToRepository(paramKeys: string[], repositoryMethod: string) { + return async function (ctx: Context, next) { + const repository = getRepositoryFromParams(ctx); + + const callObj = lodash.pick(ctx.action.params, paramKeys); + callObj.context = ctx; + + ctx.body = await repository[repositoryMethod](callObj); + + ctx.status = 200; + await next(); + }; +} diff --git a/packages/core/actions/src/actions/update-or-create.ts b/packages/core/actions/src/actions/update-or-create.ts new file mode 100644 index 000000000..b98030390 --- /dev/null +++ b/packages/core/actions/src/actions/update-or-create.ts @@ -0,0 +1,3 @@ +import { proxyToRepository } from './proxy-to-repository'; + +export const updateOrCreate = proxyToRepository(['values', 'filterKeys'], 'updateOrCreate'); diff --git a/packages/core/actions/src/actions/update.ts b/packages/core/actions/src/actions/update.ts index d7f0bb34f..dc72b469d 100644 --- a/packages/core/actions/src/actions/update.ts +++ b/packages/core/actions/src/actions/update.ts @@ -1,20 +1,6 @@ -import { Context } from '..'; -import { getRepositoryFromParams } from '../utils'; +import { proxyToRepository } from './proxy-to-repository'; -export async function update(ctx: Context, next) { - const repository = getRepositoryFromParams(ctx); - const { forceUpdate, filterByTk, values, whitelist, blacklist, filter, updateAssociationValues } = ctx.action.params; - - ctx.body = await repository.update({ - filterByTk, - values, - whitelist, - blacklist, - filter, - updateAssociationValues, - context: ctx, - forceUpdate, - }); - - await next(); -} +export const update = proxyToRepository( + ['filterByTk', 'values', 'whitelist', 'blacklist', 'filter', 'updateAssociationValues', 'forceUpdate'], + 'update', +); diff --git a/packages/core/actions/src/index.ts b/packages/core/actions/src/index.ts index cce1598f1..f18d4f107 100644 --- a/packages/core/actions/src/index.ts +++ b/packages/core/actions/src/index.ts @@ -21,7 +21,20 @@ export interface Context extends Koa.Context { export function registerActions(api: any) { api.actions( - lodash.pick(actions, ['add', 'create', 'destroy', 'get', 'list', 'remove', 'set', 'toggle', 'update', 'move']), + lodash.pick(actions, [ + 'add', + 'create', + 'destroy', + 'get', + 'list', + 'remove', + 'set', + 'toggle', + 'update', + 'move', + 'firstOrCreate', + 'updateOrCreate', + ]), ); } diff --git a/packages/core/database/src/__tests__/repository.test.ts b/packages/core/database/src/__tests__/repository.test.ts index c12ce2654..b0ffc6c90 100644 --- a/packages/core/database/src/__tests__/repository.test.ts +++ b/packages/core/database/src/__tests__/repository.test.ts @@ -1,6 +1,29 @@ import { Collection } from '../collection'; import { Database } from '../database'; import { mockDatabase } from './'; +import { Repository } from '@nocobase/database'; + +describe('repository', () => { + test('value to filter', async () => { + const value = { + tags: [ + { + categories: [{ name: 'c1' }, { name: 'c2' }], + }, + { + categories: [{ name: 'c3' }, { name: 'c4' }], + }, + ], + }; + + const filter = Repository.valuesToFilter(value, ['tags.categories.name']); + expect(filter.$and).toEqual([ + { + 'tags.categories.name': ['c1', 'c2', 'c3', 'c4'], + }, + ]); + }); +}); describe('find by targetKey', function () { let db: Database; @@ -563,6 +586,7 @@ describe('repository.relatedQuery', () => { const post2 = await userPostRepository.create({ values: { name: 'post2' }, }); + expect(post2).toMatchObject({ name: 'post2', userId: user.get('id'), diff --git a/packages/core/database/src/__tests__/repository/create.test.ts b/packages/core/database/src/__tests__/repository/create.test.ts index a1072e405..ed81bd78b 100644 --- a/packages/core/database/src/__tests__/repository/create.test.ts +++ b/packages/core/database/src/__tests__/repository/create.test.ts @@ -1,10 +1,11 @@ import { mockDatabase } from '../index'; import Database from '../../database'; +import { Collection } from '../../collection'; describe('create with hasMany', () => { let db: Database; - let Post; - let User; + let Post: Collection; + let User: Collection; afterEach(async () => { await db.close(); @@ -61,8 +62,8 @@ describe('create with hasMany', () => { describe('create with belongsToMany', () => { let db: Database; - let Post; - let Tag; + let Post: Collection; + let Tag: Collection; afterEach(async () => { await db.close(); @@ -122,16 +123,36 @@ describe('create with belongsToMany', () => { describe('create', () => { let db: Database; - let User; - let Post; + let User: Collection; + let Post: Collection; + let Group: Collection; + let Role: Collection; beforeEach(async () => { db = mockDatabase(); + await db.clean({ drop: true }); + + Group = db.collection({ + name: 'groups', + fields: [{ type: 'string', name: 'name' }], + }); + User = db.collection({ name: 'users', fields: [ { type: 'string', name: 'name' }, + { type: 'integer', name: 'age' }, { type: 'hasMany', name: 'posts' }, + { type: 'belongsTo', name: 'group' }, + { type: 'belongsToMany', name: 'roles' }, + ], + }); + + Role = db.collection({ + name: 'roles', + fields: [ + { type: 'string', name: 'name' }, + { type: 'belongsToMany', name: 'users' }, ], }); @@ -149,6 +170,108 @@ describe('create', () => { await db.close(); }); + test('firstOrCreate by filterKeys', async () => { + const u1 = await User.repository.firstOrCreate({ + filterKeys: ['name'], + values: { + name: 'u1', + age: 10, + group: { + name: 'g1', + }, + }, + }); + expect(u1.name).toEqual('u1'); + const group = await u1.get('group'); + expect(group.name).toEqual('g1'); + + const u2 = await User.repository.firstOrCreate({ + filterKeys: ['group'], + values: { + group: { + name: 'g1', + }, + }, + }); + + expect(u2.name).toEqual('u1'); + }); + + test('firstOrCreate by many to many associations', async () => { + const roles = await Role.repository.create({ + values: [{ name: 'r1' }, { name: 'r2' }], + }); + + const u1 = await User.repository.firstOrCreate({ + values: { + name: 'u1', + roles: [{ name: 'r1' }, { name: 'r2' }], + }, + filterKeys: ['roles.name'], + }); + + expect(u1.get('roles')).toHaveLength(2); + + const u1a = await User.repository.firstOrCreate({ + values: { + name: 'u1', + roles: [{ name: 'r1' }, { name: 'r2' }], + }, + filterKeys: ['roles.name'], + }); + + expect(u1a.get('id')).toEqual(u1.get('id')); + }); + + test('firstOrCreate', async () => { + const u1 = await User.repository.firstOrCreate({ + filterKeys: ['name'], + values: { + name: 'u1', + age: 10, + }, + }); + + expect(u1.name).toEqual('u1'); + expect(u1.age).toEqual(10); + + expect( + ( + await User.repository.firstOrCreate({ + filterKeys: ['name'], + values: { + name: 'u1', + age: 10, + }, + }) + ).get('id'), + ).toEqual(u1.get('id')); + }); + + test('updateOrCreate', async () => { + const u1 = await User.repository.updateOrCreate({ + filterKeys: ['name'], + values: { + name: 'u1', + age: 10, + }, + }); + + expect(u1.name).toEqual('u1'); + expect(u1.age).toEqual(10); + + await User.repository.updateOrCreate({ + filterKeys: ['name'], + values: { + name: 'u1', + age: 20, + }, + }); + + await u1.reload(); + expect(u1.age).toEqual(20); + }); + test('create with association', async () => { const u1 = await User.repository.create({ values: { diff --git a/packages/core/database/src/repository.ts b/packages/core/database/src/repository.ts index b320c9230..606194a1e 100644 --- a/packages/core/database/src/repository.ts +++ b/packages/core/database/src/repository.ts @@ -32,6 +32,7 @@ import { RelationRepository } from './relation-repository/relation-repository'; import { updateAssociations, updateModelByValues } from './update-associations'; import { UpdateGuard } from './update-guard'; import { EagerLoadingTree } from './eager-loading/eager-loading-tree'; +import { flatten } from 'flat'; const debug = require('debug')('noco-database'); @@ -210,6 +211,11 @@ export interface AggregateOptions { distinct?: boolean; } +interface FirstOrCreateOptions extends Transactionable { + filterKeys: string[]; + values?: Values; +} + export class Repository implements IRepository { @@ -223,6 +229,50 @@ export class Repository) { + const filterAnd = []; + const flattedValues = flatten(values); + + const keyWithOutArrayIndex = (key) => { + const chunks = key.split('.'); + return chunks + .filter((chunk) => { + return !Boolean(chunk.match(/\d+/)); + }) + .join('.'); + }; + + for (const filterKey of filterKeys) { + let filterValue; + + for (const flattedKey of Object.keys(flattedValues)) { + const flattedKeyWithoutIndex = keyWithOutArrayIndex(flattedKey); + + if (flattedKeyWithoutIndex === filterKey) { + if (filterValue) { + if (Array.isArray(filterValue)) { + filterValue.push(flattedValues[flattedKey]); + } else { + filterValue = [filterValue, flattedValues[flattedKey]]; + } + } else { + filterValue = flattedValues[flattedKey]; + } + } + } + + if (filterValue) { + filterAnd.push({ + [filterKey]: filterValue, + }); + } + } + + return { + $and: filterAnd, + }; + } + /** * return count by filter */ @@ -428,6 +478,39 @@ export class Repository