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: 'hasOne', name: 'profile' },
{ type: 'belongsToMany', name: 'tags', through: 'posts_tags' }, { type: 'belongsToMany', name: 'tags', through: 'posts_tags' },
{ type: 'string', name: 'status', defaultValue: 'draft' }, { type: 'string', name: 'status', defaultValue: 'draft' },
{ type: 'set', name: 'set_field' },
], ],
}); });
@ -51,6 +52,51 @@ describe('add action', () => {
await app.destroy(); 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 () => { test('add belongs to many', async () => {
const p1 = await Post.repository.create({ const p1 = await Post.repository.create({
values: { values: {

View File

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

View File

@ -1,5 +1,6 @@
import path from 'path'; import path from 'path';
import { Database, Model } from '..'; import { Database, Model } from '..';
import { ArrayFieldRepository } from '../field-repository/array-field-repository';
import { mockDatabase } from './index'; import { mockDatabase } from './index';
describe('database', () => { 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 () => { test('import', async () => {
await db.import({ await db.import({
directory: path.resolve(__dirname, './fixtures/collections'), 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, Sequelize,
SyncOptions, SyncOptions,
Transactionable, Transactionable,
Utils Utils,
} from 'sequelize'; } from 'sequelize';
import { SequelizeStorage, Umzug } from 'umzug'; import { SequelizeStorage, Umzug } from 'umzug';
import { Collection, CollectionOptions, RepositoryType } from './collection'; import { Collection, CollectionOptions, RepositoryType } from './collection';
import { ImporterReader, ImportFileExtension } from './collection-importer'; import { ImporterReader, ImportFileExtension } from './collection-importer';
import ReferencesMap from './features/ReferencesMap'; import ReferencesMap from './features/ReferencesMap';
import { referentialIntegrityCheck } from './features/referential-integrity-check'; import { referentialIntegrityCheck } from './features/referential-integrity-check';
import { ArrayFieldRepository } from './field-repository/array-field-repository';
import * as FieldTypes from './fields'; import * as FieldTypes from './fields';
import { Field, FieldContext, RelationField } from './fields'; import { Field, FieldContext, RelationField } from './fields';
import { InheritedCollection } from './inherited-collection'; import { InheritedCollection } from './inherited-collection';
@ -57,7 +58,7 @@ import {
SyncListener, SyncListener,
UpdateListener, UpdateListener,
UpdateWithAssociationsListener, UpdateWithAssociationsListener,
ValidateListener ValidateListener,
} from './types'; } from './types';
export interface MergeOptions extends merge.Options {} 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 Repository>(name: string): R;
getRepository<R extends RelationRepository>(name: string, relationId: string | number): 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 { getRepository<R extends RelationRepository>(name: string, relationId?: string | number): Repository | R {
if (relationId) { 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; return DataTypes.JSON;
} }
sortValue(model) { sortValue = (model) => {
const oldValue = model.get(this.options.name); const oldValue = model.get(this.options.name);
if (oldValue) { if (oldValue) {
const newValue = oldValue.sort(); const newValue = oldValue.sort();
model.set(this.options.name, newValue); model.set(this.options.name, newValue);
} }
} };
bind() { bind() {
super.bind(); super.bind();
this.on('beforeSave', this.sortValue.bind(this)); this.on('beforeSave', this.sortValue);
} }
unbind() { unbind() {
super.unbind(); super.unbind();
this.off('beforeSave', this.sortValue.bind(this)); this.off('beforeSave', this.sortValue);
} }
} }

View File

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

View File

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

View File

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

View File

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