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
This commit is contained in:
ChengLei Shao 2023-06-06 11:36:18 +08:00 committed by GitHub
parent 57d47371da
commit d7e6b7b320
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 371 additions and 47 deletions

View File

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

View File

@ -81,6 +81,7 @@ describe('get action', () => {
});
const body = response.body;
expect(body['id']).toEqual(p1.get('id'));
});

View File

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

View File

@ -0,0 +1,3 @@
import { proxyToRepository } from './proxy-to-repository';
export const firstOrCreate = proxyToRepository(['values', 'filterKeys'], 'firstOrCreate');

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
import { proxyToRepository } from './proxy-to-repository';
export const updateOrCreate = proxyToRepository(['values', 'filterKeys'], 'updateOrCreate');

View File

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

View File

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

View File

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

View File

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

View File

@ -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<TModelAttributes extends {} = any, TCreationAttributes extends {} = TModelAttributes>
implements IRepository
{
@ -223,6 +229,50 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
this.model = collection.model;
}
public static valuesToFilter(values: Values, filterKeys: Array<string>) {
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<TModelAttributes extends {} = any, TCreationAttributes e
return rows.length == 1 ? rows[0] : null;
}
/**
* Get the first record matching the attributes or create it.
*/
async firstOrCreate(options: FirstOrCreateOptions) {
const { filterKeys, values, transaction } = options;
const filter = Repository.valuesToFilter(values, filterKeys);
const instance = await this.findOne({ filter, transaction });
if (instance) {
return instance;
}
return this.create({ values, transaction });
}
async updateOrCreate(options: FirstOrCreateOptions) {
const { filterKeys, values, transaction } = options;
const filter = Repository.valuesToFilter(values, filterKeys);
const instance = await this.findOne({ filter, transaction });
if (instance) {
return await this.update({
filterByTk: instance.get(this.collection.model.primaryKeyAttribute),
values,
transaction,
});
}
return this.create({ values, transaction });
}
/**
* Save instance to database
*