diff --git a/packages/core/actions/src/__tests__/add-action.test.ts b/packages/core/actions/src/__tests__/add-action.test.ts index d8540229f..868941789 100644 --- a/packages/core/actions/src/__tests__/add-action.test.ts +++ b/packages/core/actions/src/__tests__/add-action.test.ts @@ -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: { diff --git a/packages/core/actions/src/actions/add.ts b/packages/core/actions/src/actions/add.ts index 6f388120a..0a768fb5b 100644 --- a/packages/core/actions/src/actions/add.ts +++ b/packages/core/actions/src/actions/add.ts @@ -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(); } diff --git a/packages/core/database/src/__tests__/database.test.ts b/packages/core/database/src/__tests__/database.test.ts index f2279d08b..f0c84b8b3 100644 --- a/packages/core/database/src/__tests__/database.test.ts +++ b/packages/core/database/src/__tests__/database.test.ts @@ -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'), diff --git a/packages/core/database/src/__tests__/field-repository/array-field-repository.test.ts b/packages/core/database/src/__tests__/field-repository/array-field-repository.test.ts new file mode 100644 index 000000000..1becbd5ff --- /dev/null +++ b/packages/core/database/src/__tests__/field-repository/array-field-repository.test.ts @@ -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']); + }); +}); diff --git a/packages/core/database/src/__tests__/fields/set.test.ts b/packages/core/database/src/__tests__/fields/set.test.ts new file mode 100644 index 000000000..81ceb3ce6 --- /dev/null +++ b/packages/core/database/src/__tests__/fields/set.test.ts @@ -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']); + }); +}); diff --git a/packages/core/database/src/database.ts b/packages/core/database/src/database.ts index 28a668d1e..6c79596aa 100644 --- a/packages/core/database/src/database.ts +++ b/packages/core/database/src/database.ts @@ -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(name: string): R; getRepository(name: string, relationId: string | number): R; + getRepository(name: string, relationId: string | number): R; getRepository(name: string, relationId?: string | number): Repository | R { if (relationId) { diff --git a/packages/core/database/src/field-repository/array-field-repository.ts b/packages/core/database/src/field-repository/array-field-repository.ts new file mode 100644 index 000000000..90a2c701a --- /dev/null +++ b/packages/core/database/src/field-repository/array-field-repository.ts @@ -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; + }, + ) { + 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; + }, + ) { + 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; + }, + ) { + 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, + }); + } +} diff --git a/packages/core/database/src/fields/array-field.ts b/packages/core/database/src/fields/array-field.ts index 888dabc60..7475a8916 100644 --- a/packages/core/database/src/fields/array-field.ts +++ b/packages/core/database/src/fields/array-field.ts @@ -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); } } diff --git a/packages/core/database/src/fields/index.ts b/packages/core/database/src/fields/index.ts index 24e9a728a..9f781cf04 100644 --- a/packages/core/database/src/fields/index.ts +++ b/packages/core/database/src/fields/index.ts @@ -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 diff --git a/packages/core/database/src/fields/set-field.ts b/packages/core/database/src/fields/set-field.ts new file mode 100644 index 000000000..5afc0e970 --- /dev/null +++ b/packages/core/database/src/fields/set-field.ts @@ -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); + } +} diff --git a/packages/core/database/src/index.ts b/packages/core/database/src/index.ts index 16495fa45..d0c76d921 100644 --- a/packages/core/database/src/index.ts +++ b/packages/core/database/src/index.ts @@ -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'; diff --git a/packages/core/database/src/repository.ts b/packages/core/database/src/repository.ts index 97d1c4c07..27c22574c 100644 --- a/packages/core/database/src/repository.ts +++ b/packages/core/database/src/repository.ts @@ -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 { 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() { diff --git a/packages/core/resourcer/src/resourcer.ts b/packages/core/resourcer/src/resourcer.ts index ac406dd80..2ac2dfa8e 100644 --- a/packages/core/resourcer/src/resourcer.ts +++ b/packages/core/resourcer/src/resourcer.ts @@ -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(); } diff --git a/packages/core/resourcer/src/utils.ts b/packages/core/resourcer/src/utils.ts index 2b6baff7c..aa9955b4f 100644 --- a/packages/core/resourcer/src/utils.ts +++ b/packages/core/resourcer/src/utils.ts @@ -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 = {};