feat: set field (#1237)

* feat: set field

* feat: array field repository

* feat:  ArrayFieldRepository

* fix: add set parse

* test: array field repository

* chore: update submodule

* chore: field bind & unbind

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
ChengLei Shao 2022-12-13 18:02:03 +08:00 committed by GitHub
parent 0c80992f7d
commit 393ada2bc5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 388 additions and 12 deletions

View File

@ -25,6 +25,7 @@ describe('add action', () => {
{ type: 'hasOne', name: 'profile' },
{ type: 'belongsToMany', name: 'tags', through: 'posts_tags' },
{ type: 'string', name: 'status', defaultValue: 'draft' },
{ type: 'set', name: 'set_field' },
],
});
@ -51,6 +52,51 @@ describe('add action', () => {
await app.destroy();
});
test('add values to set field', async () => {
const p1 = await Post.repository.create({});
const response = await app
.agent()
.resource('posts.set_field', p1.get('id'))
.add({
values: ['a', 'b'],
});
expect(response.statusCode).toEqual(200);
const listResponse = await app.agent().resource('posts.set_field', p1.get('id')).list({
paginate: false,
});
expect(listResponse.status).toEqual(200);
expect(listResponse.body).toEqual(['a', 'b']);
await app
.agent()
.resource('posts.set_field', p1.get('id'))
.remove({
values: ['b'],
});
const listResponse1 = await app.agent().resource('posts.set_field', p1.get('id')).list({
paginate: false,
});
expect(listResponse1.body).toEqual(['a']);
await app
.agent()
.resource('posts.set_field', p1.get('id'))
.set({
values: ['b', 'c', 'd'],
});
const listResponse2 = await app.agent().resource('posts.set_field', p1.get('id')).list({
paginate: false,
});
expect(listResponse2.body).toEqual(['b', 'c', 'd']);
});
test('add belongs to many', async () => {
const p1 = await Post.repository.create({
values: {

View File

@ -1,11 +1,22 @@
import { Context } from '..';
import { getRepositoryFromParams } from '../utils';
import { BelongsToManyRepository, MultipleRelationRepository, HasManyRepository } from '@nocobase/database';
import {
BelongsToManyRepository,
MultipleRelationRepository,
HasManyRepository,
ArrayFieldRepository,
} from '@nocobase/database';
export async function add(ctx: Context, next) {
const repository = getRepositoryFromParams(ctx);
if (!(repository instanceof MultipleRelationRepository || repository instanceof HasManyRepository)) {
if (
!(
repository instanceof MultipleRelationRepository ||
repository instanceof HasManyRepository ||
repository instanceof ArrayFieldRepository
)
) {
return await next();
}

View File

@ -1,5 +1,6 @@
import path from 'path';
import { Database, Model } from '..';
import { ArrayFieldRepository } from '../field-repository/array-field-repository';
import { mockDatabase } from './index';
describe('database', () => {
@ -47,6 +48,15 @@ describe('database', () => {
);
});
it('should get array field repository', async () => {
db.collection({
name: 'tests',
fields: [{ type: 'set', name: 'set-field' }],
});
expect(db.getRepository('tests.set-field', '1')).toBeInstanceOf(ArrayFieldRepository);
});
test('import', async () => {
await db.import({
directory: path.resolve(__dirname, './fixtures/collections'),

View File

@ -0,0 +1,94 @@
import { mockDatabase } from '../index';
import Database from '../../database';
import { ArrayFieldRepository } from '../../field-repository/array-field-repository';
describe('Array field repository', () => {
let db: Database;
let TestCollection;
beforeEach(async () => {
db = mockDatabase();
TestCollection = db.collection({
name: 'test',
fields: [
{
type: 'set',
name: 'set-field',
},
],
});
await db.sync();
});
afterEach(async () => {
await db.close();
});
it('should add item into fields', async () => {
const a1 = await TestCollection.repository.create({});
const fieldRepository = new ArrayFieldRepository(TestCollection, 'set-field', a1.get('id'));
await fieldRepository.add({
values: 'a',
});
expect(await fieldRepository.get()).toEqual(['a']);
});
it('should remove item', async () => {
const a1 = await TestCollection.repository.create({});
const fieldRepository = new ArrayFieldRepository(TestCollection, 'set-field', a1.get('id'));
await fieldRepository.add({
values: ['a', 'b', 'c'],
});
expect(await fieldRepository.get()).toEqual(['a', 'b', 'c']);
await fieldRepository.remove({
values: ['c'],
});
expect(await fieldRepository.get()).toEqual(['a', 'b']);
});
it('should set items', async () => {
const a1 = await TestCollection.repository.create({});
const fieldRepository = new ArrayFieldRepository(TestCollection, 'set-field', a1.get('id'));
await fieldRepository.add({
values: ['a', 'b', 'c'],
});
expect(await fieldRepository.get()).toEqual(['a', 'b', 'c']);
await fieldRepository.set({
values: ['d', 'e'],
});
expect(await fieldRepository.get()).toEqual(['d', 'e']);
});
it('should toggle item', async () => {
const a1 = await TestCollection.repository.create({});
const fieldRepository = new ArrayFieldRepository(TestCollection, 'set-field', a1.get('id'));
await fieldRepository.add({
values: ['a', 'b', 'c'],
});
expect(await fieldRepository.get()).toEqual(['a', 'b', 'c']);
await fieldRepository.toggle({
value: 'c',
});
expect(await fieldRepository.get()).toEqual(['a', 'b']);
await fieldRepository.toggle({
value: 'c',
});
expect(await fieldRepository.get()).toEqual(['a', 'b', 'c']);
});
});

View File

@ -0,0 +1,37 @@
import { mockDatabase } from '../';
import { Database } from '../../database';
describe('set field', () => {
let db: Database;
beforeEach(async () => {
db = mockDatabase();
});
afterEach(async () => {
await db.close();
});
it('should set Set field', async () => {
const A = db.collection({
name: 'a',
fields: [
{
type: 'set',
name: 'set',
},
],
});
await db.sync();
const a = await A.repository.create({});
a.set('set', ['a', 'b', 'c', 'a']);
await a.save();
const setValue = a.get('set');
expect(setValue).toEqual(['a', 'b', 'c']);
});
});

View File

@ -15,13 +15,14 @@ import {
Sequelize,
SyncOptions,
Transactionable,
Utils
Utils,
} from 'sequelize';
import { SequelizeStorage, Umzug } from 'umzug';
import { Collection, CollectionOptions, RepositoryType } from './collection';
import { ImporterReader, ImportFileExtension } from './collection-importer';
import ReferencesMap from './features/ReferencesMap';
import { referentialIntegrityCheck } from './features/referential-integrity-check';
import { ArrayFieldRepository } from './field-repository/array-field-repository';
import * as FieldTypes from './fields';
import { Field, FieldContext, RelationField } from './fields';
import { InheritedCollection } from './inherited-collection';
@ -57,7 +58,7 @@ import {
SyncListener,
UpdateListener,
UpdateWithAssociationsListener,
ValidateListener
ValidateListener,
} from './types';
export interface MergeOptions extends merge.Options {}
@ -375,6 +376,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
getRepository<R extends Repository>(name: string): R;
getRepository<R extends RelationRepository>(name: string, relationId: string | number): R;
getRepository<R extends ArrayFieldRepository>(name: string, relationId: string | number): R;
getRepository<R extends RelationRepository>(name: string, relationId?: string | number): Repository | R {
if (relationId) {

View File

@ -0,0 +1,127 @@
import { Transactionable } from 'sequelize/types';
import { Collection } from '../collection';
import lodash from 'lodash';
import { transactionWrapperBuilder } from '../decorators/transaction-decorator';
import { ArrayField } from '../fields';
const transaction = transactionWrapperBuilder(function () {
return this.collection.model.sequelize.transaction();
});
export class ArrayFieldRepository {
constructor(protected collection: Collection, protected fieldName: string, protected targetValue: string | number) {
const field = collection.getField(fieldName);
if (!(field instanceof ArrayField)) {
throw new Error('Field must be of type Array');
}
}
@transaction()
async get(options?: Transactionable) {
const instance = await this.getInstance(options);
return instance.get(this.fieldName);
}
@transaction()
async find(options?: Transactionable) {
return await this.get(options);
}
@transaction((args, transaction) => {
return {
values: args[0],
transaction,
};
})
async set(
options: Transactionable & {
values: Array<string | number> | string | number;
},
) {
const { transaction } = options;
const instance = await this.getInstance({
transaction,
});
instance.set(this.fieldName, lodash.castArray(options.values));
await instance.save({ transaction });
}
@transaction((args, transaction) => {
return {
value: args[0],
transaction,
};
})
async toggle(
options: Transactionable & {
value: string | number;
},
) {
const { transaction } = options;
const instance = await this.getInstance({
transaction,
});
const oldValue = instance.get(this.fieldName) || [];
const newValue = oldValue.includes(options.value)
? lodash.without(oldValue, options.value)
: [...oldValue, options.value];
instance.set(this.fieldName, newValue);
await instance.save({ transaction });
}
@transaction((args, transaction) => {
return {
values: args[0],
transaction,
};
})
async add(
options: Transactionable & {
values: Array<string | number> | string | number;
},
) {
const { transaction } = options;
const instance = await this.getInstance({
transaction,
});
const oldValue = instance.get(this.fieldName) || [];
const newValue = [...oldValue, ...lodash.castArray(options.values)];
instance.set(this.fieldName, newValue);
await instance.save({ transaction });
}
@transaction((args, transaction) => {
return {
values: args[0],
transaction,
};
})
async remove(
options: Transactionable & {
values: Array<string | number> | string | number;
},
) {
const { transaction } = options;
const instance = await this.getInstance({
transaction,
});
const oldValue = instance.get(this.fieldName) || [];
instance.set(this.fieldName, lodash.without(oldValue, ...lodash.castArray(options.values)));
await instance.save({ transaction });
}
protected getInstance(options: Transactionable) {
return this.collection.repository.findOne({
filterByTk: this.targetValue,
});
}
}

View File

@ -10,23 +10,23 @@ export class ArrayField extends Field {
return DataTypes.JSON;
}
sortValue(model) {
sortValue = (model) => {
const oldValue = model.get(this.options.name);
if (oldValue) {
const newValue = oldValue.sort();
model.set(this.options.name, newValue);
}
}
};
bind() {
super.bind();
this.on('beforeSave', this.sortValue.bind(this));
this.on('beforeSave', this.sortValue);
}
unbind() {
super.unbind();
this.off('beforeSave', this.sortValue.bind(this));
this.off('beforeSave', this.sortValue);
}
}

View File

@ -13,7 +13,7 @@ import {
DoubleFieldOptions,
FloatFieldOptions,
IntegerFieldOptions,
RealFieldOptions
RealFieldOptions,
} from './number-field';
import { PasswordFieldOptions } from './password-field';
import { RadioFieldOptions } from './radio-field';
@ -26,8 +26,10 @@ import { UUIDFieldOptions } from './uuid-field';
import { VirtualFieldOptions } from './virtual-field';
import { FormulaFieldOptions } from './formula-field';
import { SequenceFieldOptions } from './sequence-field';
import { SetFieldOptions } from './set-field';
export * from './array-field';
export * from './set-field';
export * from './belongs-to-field';
export * from './belongs-to-many-field';
export * from './boolean-field';
@ -68,6 +70,7 @@ export type FieldOptions =
| VirtualFieldOptions
| FormulaFieldOptions
| ArrayFieldOptions
| SetFieldOptions
| TimeFieldOptions
| DateFieldOptions
| UidFieldOptions

View File

@ -0,0 +1,25 @@
import { ArrayField } from './array-field';
import { BaseColumnFieldOptions } from './field';
export interface SetFieldOptions extends BaseColumnFieldOptions {
type: 'set';
}
export class SetField extends ArrayField {
beforeSave = (model) => {
const oldValue = model.get(this.options.name);
if (oldValue) {
model.set(this.options.name, [...new Set(oldValue)]);
}
};
bind() {
super.bind();
this.on('beforeSave', this.beforeSave);
}
unbind() {
super.unbind();
this.off('beforeSave', this.beforeSave);
}
}

View File

@ -15,3 +15,4 @@ export * from './relation-repository/multiple-relation-repository';
export * from './relation-repository/single-relation-repository';
export * from './repository';
export * from './update-associations';
export * from './field-repository/array-field-repository';

View File

@ -10,14 +10,15 @@ import {
ModelCtor,
Op,
Transactionable,
UpdateOptions as SequelizeUpdateOptions
UpdateOptions as SequelizeUpdateOptions,
} from 'sequelize';
import { WhereOperators } from 'sequelize/types/lib/model';
import { Collection } from './collection';
import { Database } from './database';
import mustHaveFilter from './decorators/must-have-filter-decorator';
import { transactionWrapperBuilder } from './decorators/transaction-decorator';
import { RelationField } from './fields';
import { ArrayFieldRepository } from './field-repository/array-field-repository';
import { ArrayField, RelationField } from './fields';
import FilterParser from './filter-parser';
import { Model } from './model';
import operators from './operators';
@ -161,19 +162,29 @@ const transaction = transactionWrapperBuilder(function () {
class RelationRepositoryBuilder<R extends RelationRepository> {
collection: Collection;
associationName: string;
association: Association;
association: Association | { associationType: string };
builderMap = {
HasOne: HasOneRepository,
BelongsTo: BelongsToRepository,
BelongsToMany: BelongsToManyRepository,
HasMany: HasManyRepository,
ArrayField: ArrayFieldRepository,
};
constructor(collection: Collection, associationName: string) {
this.collection = collection;
this.associationName = associationName;
this.association = this.collection.model.associations[this.associationName];
if (!this.association) {
const field = collection.getField(associationName);
if (field && field instanceof ArrayField) {
this.association = {
associationType: 'ArrayField',
};
}
}
}
protected builder() {

View File

@ -286,6 +286,7 @@ export class Resourcer {
}
try {
const resource = this.getResource(getNameByParams(params));
// 为关系资源时,暂时需要再执行一遍 parseRequest
if (resource.options.type && resource.options.type !== 'single') {
params = parseRequest(
@ -299,6 +300,7 @@ export class Resourcer {
accessors: this.options.accessors || accessors,
},
);
if (!params) {
return next();
}

View File

@ -140,6 +140,13 @@ export function parseRequest(request: ParseRequest, options: ParseOptions = {}):
delete: accessors.remove,
},
},
set: {
'/:associatedName/:associatedIndex/:resourceName': {
get: accessors.list,
post: accessors.add,
delete: accessors.remove,
},
},
};
const params: ParsedParams = {};