Feat/plugin collection manager (#147)

* refactor: collection manager plugin

* feat(database): magic attribute model

* MagicAttributeModel

* load collections & fields options

* collections filterTargetKey
This commit is contained in:
chenos 2022-01-19 10:02:52 +08:00 committed by GitHub
parent 43f33044ea
commit 380b5e8c7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 967 additions and 4 deletions

View File

@ -188,9 +188,6 @@ export class Collection<
/**
* TODO
*
* @param name
* @param options
*/
updateOptions(options: CollectionOptions, mergeOptions?: any) {
let newOptions = lodash.cloneDeep(options);
@ -206,6 +203,8 @@ export class Collection<
}
this.context.database.emit('afterUpdateCollection', this);
return this;
}
setUpHooks(bindHooks) {

View File

@ -1,7 +1,7 @@
import { Collection } from '../collection';
import { Database } from '../database';
import _ from 'lodash';
import { DataType, ModelAttributeColumnOptions, ModelIndexesOptions } from 'sequelize';
import { DataType, ModelAttributeColumnOptions, ModelIndexesOptions, SyncOptions } from 'sequelize';
export interface FieldContext {
database: Database;
@ -45,6 +45,17 @@ export abstract class Field {
this.init();
}
// TODO
async sync(syncOptions: SyncOptions) {
await this.collection.sync({
...syncOptions,
force: false,
alter: {
drop: false,
},
});
}
init() {
// code
}

View File

@ -0,0 +1,7 @@
node_modules
*.log
docs
__tests__
tsconfig.json
src
.fatherrc.ts

View File

@ -0,0 +1,11 @@
{
"name": "@nocobase/plugin-plugin-manager",
"version": "0.6.0-alpha.0",
"main": "lib/index.js",
"license": "MIT",
"dependencies": {},
"devDependencies": {
"@nocobase/test": "^0.6.0-alpha.0"
},
"gitHead": "e7df1f93c4e23b9a666d99ee7372c02bdaec97c4"
}

View File

@ -0,0 +1,162 @@
import Database, { Collection as DBCollection } from '@nocobase/database';
import Application from '@nocobase/server';
import { createApp } from '.';
describe('collections repository', () => {
let db: Database;
let app: Application;
let Collection: DBCollection;
let Field: DBCollection;
beforeEach(async () => {
app = await createApp();
await app.db.sync();
db = app.db;
Collection = db.getCollection('collections');
Field = db.getCollection('fields');
});
afterEach(async () => {
await app.destroy();
});
it('case 1', async () => {
// 什么都没提供,随机 name 和 key
const data = await Collection.repository.create({
values: {},
});
expect(data.get('key')).toBeDefined();
expect(data.get('name')).toBeDefined();
});
it('case 2', async () => {
// 提供了 name
const data = await Collection.repository.create({
values: {
name: 'tests',
},
});
expect(data.toJSON()).toMatchObject({
name: 'tests',
});
});
it('case 3', async () => {
// 动态参数,存 options 字段里
const data = await Collection.repository.create({
values: {
name: 'tests',
createdBy: true,
updatedBy: true,
timestamps: true,
},
});
expect(data.toJSON()).toMatchObject({
name: 'tests',
createdBy: true,
updatedBy: true,
timestamps: true,
});
const [updated] = await Collection.repository.update({
filterByTk: data.get('key') as any,
values: {
createdBy: false,
updatedBy: false,
timestamps: false,
},
});
expect(updated.toJSON()).toMatchObject({
name: 'tests',
createdBy: false,
updatedBy: false,
timestamps: false,
});
});
it('case 4', async () => {
await Collection.repository.create({
values: {
name: 'tests',
fields: [
{
type: 'uid',
name: 'name',
prefix: 'f_',
},
{
type: 'string',
unique: true,
},
{
type: 'string',
name: 'title',
unique: true,
},
{
type: 'belongsToMany',
target: 'tests',
},
{
type: 'belongsTo',
target: 'foos',
},
{
type: 'hasMany',
target: 'foos',
},
{
type: 'hasOne',
target: 'foos',
},
],
},
});
const data = await Collection.repository.findOne({
filter: {
name: 'tests',
},
appends: ['fields'],
});
const json = data.toJSON();
expect(json.fields.length).toBe(7);
expect(json).toMatchObject({
name: 'tests',
fields: [
{
type: 'uid',
name: 'name',
prefix: 'f_',
},
{
type: 'string',
unique: true,
},
{
type: 'string',
name: 'title',
unique: true,
},
{
type: 'belongsToMany',
target: 'tests',
},
{
type: 'belongsTo',
target: 'foos',
},
{
type: 'hasMany',
target: 'foos',
},
{
type: 'hasOne',
target: 'foos',
},
],
});
});
});

View File

@ -0,0 +1,86 @@
import Database, { Collection as DBCollection, StringFieldOptions } from '@nocobase/database';
import Application from '@nocobase/server';
import { createApp } from '.';
describe('collections repository', () => {
let db: Database;
let app: Application;
let Collection: DBCollection;
let Field: DBCollection;
beforeEach(async () => {
app = await createApp();
await app.db.sync();
db = app.db;
Collection = db.getCollection('collections');
Field = db.getCollection('fields');
await Collection.repository.create({
values: {
name: 'tests',
},
});
await Collection.repository.create({
values: {
name: 'foos',
},
});
await Collection.repository.create({
values: {
name: 'bars',
},
});
});
afterEach(async () => {
await app.destroy();
});
it('should generate the name and key randomly', async () => {
const field = await Field.repository.create({
values: {
type: 'string',
collectionName: 'tests',
},
});
expect(field.toJSON()).toMatchObject({
type: 'string',
collectionName: 'tests',
});
expect(field.get('name')).toBeDefined();
expect(field.get('key')).toBeDefined();
});
it('should not generate the name randomly', async () => {
const field = await Field.repository.create({
values: {
type: 'string',
name: 'name',
collectionName: 'tests',
},
});
expect(field.toJSON()).toMatchObject({
type: 'string',
name: 'name',
collectionName: 'tests',
});
});
it('dynamic parameters', async () => {
const field = await Field.repository.create({
values: {
type: 'string',
name: 'name',
collectionName: 'tests',
unique: true,
defaultValue: 'abc',
} as StringFieldOptions,
});
expect(field.toJSON()).toMatchObject({
type: 'string',
name: 'name',
collectionName: 'tests',
unique: true,
defaultValue: 'abc',
});
});
});

View File

@ -0,0 +1,79 @@
import Database, { Collection as DBCollection, StringFieldOptions } from '@nocobase/database';
import Application from '@nocobase/server';
import { createApp } from '..';
describe('children options', () => {
let db: Database;
let app: Application;
let Collection: DBCollection;
let Field: DBCollection;
beforeEach(async () => {
app = await createApp();
await app.db.sync();
db = app.db;
Collection = db.getCollection('collections');
Field = db.getCollection('fields');
await Collection.repository.create({
values: {
name: 'tests',
},
});
await Collection.repository.create({
values: {
name: 'foos',
},
});
});
afterEach(async () => {
await app.destroy();
});
it('when there are no children, the target collection is not created', async () => {
const field = await Field.repository.create({
values: {
type: 'hasMany',
collectionName: 'tests',
},
});
const json = field.toJSON();
expect(json).toMatchObject({
type: 'hasMany',
collectionName: 'tests',
sourceKey: 'id',
targetKey: 'id',
});
expect(json.name).toBeDefined();
expect(json.target).toBeDefined();
expect(json.foreignKey).toBeDefined();
// 无 children 时target collection 不创建
const target = await Collection.model.findOne({
where: {
name: json.target,
},
});
expect(target).toBeNull();
});
it('the collectionName of the child field is the target of the parent field', async () => {
const field = await Field.repository.create({
values: {
type: 'hasMany',
collectionName: 'tests',
children: [{ type: 'string' }, { type: 'string' }],
},
});
const json = field.toJSON();
const target = await Collection.model.findOne({
where: {
name: json.target,
},
});
expect(target).toBeDefined();
// 子字段的 collectionName 是父字段的 target
for (const child of json.children) {
expect(child.collectionName).toBe(json.target);
}
});
});

View File

@ -0,0 +1,75 @@
import Database, { Collection as DBCollection, StringFieldOptions } from '@nocobase/database';
import Application from '@nocobase/server';
import { createApp } from '..';
describe('hasMany field options', () => {
let db: Database;
let app: Application;
let Collection: DBCollection;
let Field: DBCollection;
beforeEach(async () => {
app = await createApp();
await app.db.sync();
db = app.db;
Collection = db.getCollection('collections');
Field = db.getCollection('fields');
await Collection.repository.create({
values: {
name: 'tests',
},
});
await Collection.repository.create({
values: {
name: 'foos',
},
});
});
afterEach(async () => {
await app.destroy();
});
it('should generate the foreignKey randomly', async () => {
const field = await Field.repository.create({
values: {
type: 'hasMany',
collectionName: 'tests',
target: 'foos',
},
});
const json = field.toJSON();
expect(json).toMatchObject({
type: 'hasMany',
collectionName: 'tests',
target: 'foos',
sourceKey: 'id',
targetKey: 'id',
});
expect(json.name).toBeDefined();
expect(json.foreignKey).toBeDefined();
});
it('the parameters are not generated randomly', async () => {
const field = await Field.repository.create({
values: {
name: 'foos',
type: 'hasMany',
collectionName: 'tests',
target: 'foos',
sourceKey: 'abc',
foreignKey: 'def',
targetKey: 'ghi',
},
});
expect(field.toJSON()).toMatchObject({
name: 'foos',
type: 'hasMany',
collectionName: 'tests',
target: 'foos',
sourceKey: 'abc',
foreignKey: 'def',
targetKey: 'ghi',
});
});
});

View File

@ -0,0 +1,73 @@
import Database, { Collection as DBCollection, StringFieldOptions } from '@nocobase/database';
import Application from '@nocobase/server';
import { createApp } from '..';
describe('hasOne field options', () => {
let db: Database;
let app: Application;
let Collection: DBCollection;
let Field: DBCollection;
beforeEach(async () => {
app = await createApp();
await app.db.sync();
db = app.db;
Collection = db.getCollection('collections');
Field = db.getCollection('fields');
await Collection.repository.create({
values: {
name: 'tests',
},
});
await Collection.repository.create({
values: {
name: 'foos',
},
});
});
afterEach(async () => {
await app.destroy();
});
it('should generate the foreignKey randomly', async () => {
const field = await Field.repository.create({
values: {
type: 'hasOne',
collectionName: 'tests',
target: 'foos',
},
});
const json = field.toJSON();
// hasOne 的 sourceKey 默认为 idforeignKey 随机生成
expect(json).toMatchObject({
type: 'hasOne',
collectionName: 'tests',
target: 'foos',
sourceKey: 'id',
});
expect(json.name).toBeDefined();
expect(json.foreignKey).toBeDefined();
});
it('the parameters are not generated randomly', async () => {
const field = await Field.repository.create({
values: {
name: 'foo',
type: 'hasOne',
collectionName: 'tests',
target: 'foos',
sourceKey: 'abc',
foreignKey: 'def',
},
});
expect(field.toJSON()).toMatchObject({
name: 'foo',
type: 'hasOne',
collectionName: 'tests',
target: 'foos',
sourceKey: 'abc',
foreignKey: 'def',
});
});
});

View File

@ -0,0 +1,59 @@
import Database, { Collection as DBCollection, StringFieldOptions } from '@nocobase/database';
import Application from '@nocobase/server';
import { createApp } from '..';
describe('reverseField options', () => {
let db: Database;
let app: Application;
let Collection: DBCollection;
let Field: DBCollection;
beforeEach(async () => {
app = await createApp();
await app.db.sync();
db = app.db;
Collection = db.getCollection('collections');
Field = db.getCollection('fields');
await Collection.repository.create({
values: {
name: 'tests',
},
});
await Collection.repository.create({
values: {
name: 'targets',
},
});
});
afterEach(async () => {
await app.destroy();
});
it('reverseField', async () => {
const field = await Field.repository.create({
values: {
type: 'hasMany',
collectionName: 'tests',
target: 'targets',
reverseField: {},
},
});
const json = JSON.parse(JSON.stringify(field.toJSON()));
expect(json).toMatchObject({
type: 'hasMany',
collectionName: 'tests',
target: 'targets',
targetKey: 'id',
sourceKey: 'id',
reverseField: {
type: 'belongsTo',
collectionName: 'targets',
target: 'tests',
targetKey: 'id',
sourceKey: 'id',
},
});
expect(json.foreignKey).toBe(json.reverseField.foreignKey);
});
});

View File

@ -0,0 +1,11 @@
import { mockServer } from '@nocobase/test';
import CollectionManagerPlugin from '..';
export async function createApp() {
const app = mockServer();
const queryInterface = app.db.sequelize.getQueryInterface();
await queryInterface.dropAllTables();
app.plugin(CollectionManagerPlugin);
await app.load();
return app;
}

View File

@ -0,0 +1,42 @@
import { CollectionOptions } from '@nocobase/database';
export default {
name: 'collections',
title: '数据表配置',
sortable: 'sort',
autoGenId: false,
model: 'CollectionModel',
timestamps: false,
filterTargetKey: 'name',
fields: [
{
type: 'uid',
name: 'key',
primaryKey: true,
},
{
type: 'uid',
name: 'name',
unique: true,
prefix: 't_',
},
{
type: 'string',
name: 'title',
required: true,
},
{
type: 'json',
name: 'options',
defaultValue: {},
},
{
type: 'hasMany',
name: 'fields',
target: 'fields',
sourceKey: 'name',
targetKey: 'name',
foreignKey: 'collectionName',
},
],
} as CollectionOptions;

View File

@ -0,0 +1,65 @@
import { CollectionOptions } from '@nocobase/database';
export default {
name: 'fields',
autoGenId: false,
model: 'FieldModel',
timestamps: false,
sortable: {
type: 'sort',
name: 'sort',
scope: ['parentKey'],
},
fields: [
{
type: 'uid',
name: 'key',
primaryKey: true,
},
{
type: 'uid',
name: 'name',
prefix: 'f_',
},
{
type: 'string',
name: 'type',
},
{
type: 'string',
name: 'interface',
allowNull: true,
},
{
type: 'belongsTo',
name: 'collection',
target: 'collections',
foreignKey: 'collectionName',
targetKey: 'name',
},
{
type: 'hasMany',
name: 'children',
target: 'fields',
sourceKey: 'key',
foreignKey: 'parentKey',
},
{
type: 'hasOne',
name: 'reverseField',
target: 'fields',
sourceKey: 'key',
foreignKey: 'reverseKey',
},
{
type: 'belongsTo',
name: 'uiSchema',
target: 'ui_schemas',
},
{
type: 'json',
name: 'options',
defaultValue: {},
},
],
} as CollectionOptions;

View File

@ -0,0 +1,14 @@
import Database from '@nocobase/database';
export function afterCreateForReverseField(db: Database) {
const Field = db.getCollection('fields');
return async (model, { transaction }) => {
const reverseKey = model.get('reverseKey');
if (!reverseKey) {
return;
}
const reverse = await Field.model.findByPk(reverseKey, { transaction });
await reverse.update({ reverseKey: model.get('key') }, { hooks: false, transaction });
};
}

View File

@ -0,0 +1,25 @@
import Database from '@nocobase/database';
export function beforeCreateForChildrenCollection(db: Database) {
const Collection = db.getCollection('collections');
const Field = db.getCollection('fields');
return async (model, { transaction }) => {
const parentKey = model.get('parentKey');
if (!parentKey) {
return;
}
const parent = await Field.model.findByPk(parentKey, { transaction });
const parentTarget = parent.get('target');
model.set('collectionName', parentTarget);
const collection = await Collection.model.findOne({
transaction,
where: {
name: parentTarget,
},
});
if (!collection) {
await Collection.model.create({ name: parentTarget }, { transaction });
}
};
}

View File

@ -0,0 +1,38 @@
import Database from '@nocobase/database';
export function beforeCreateForReverseField(db: Database) {
const Field = db.getCollection('fields');
return async (model, { transaction }) => {
const reverseKey = model.get('reverseKey');
if (!reverseKey) {
return;
}
const reverse = await Field.model.findByPk(reverseKey, { transaction });
model.set('collectionName', reverse.get('target'));
model.set('target', reverse.get('collectionName'));
const reverseType = reverse.get('type') as any;
if (['hasMany', 'hasOne'].includes(reverseType)) {
model.set('type', 'belongsTo');
model.set('targetKey', reverse.get('sourceKey'));
model.set('foreignKey', reverse.get('foreignKey'));
model.set('sourceKey', reverse.get('targetKey'));
}
if (['belongsTo'].includes(reverseType)) {
if (!model.get('type')) {
model.set('type', 'hasMany');
}
model.set('sourceKey', reverse.get('targetKey'));
model.set('foreignKey', reverse.get('foreignKey'));
model.set('targetKey', reverse.get('sourceKey'));
}
if (['belongsToMany'].includes(reverseType)) {
model.set('type', 'belongsToMany');
model.set('through', reverse.get('through'));
model.set('sourceKey', reverse.get('targetKey'));
model.set('foreignKey', reverse.get('otherKey'));
model.set('targetKey', reverse.get('sourceKey'));
model.set('otherKey', reverse.get('foreignKey'));
}
};
}

View File

@ -0,0 +1,58 @@
import { uid } from '@nocobase/utils';
import { Model } from 'sequelize';
export default {
belongsTo(model: Model) {
const defaults = {
targetKey: 'id',
foreignKey: `f_${uid()}`,
};
for (const key in defaults) {
if (model.get(key)) {
continue;
}
model.set(key, defaults[key]);
}
},
belongsToMany(model: Model) {
const defaults = {
targetKey: 'id',
sourceKey: 'id',
through: `t_${uid()}`,
foreignKey: `f_${uid()}`,
otherKey: `f_${uid()}`,
};
for (const key in defaults) {
if (model.get(key)) {
continue;
}
model.set(key, defaults[key]);
}
},
hasMany(model: Model) {
const defaults = {
targetKey: 'id',
sourceKey: 'id',
foreignKey: `f_${uid()}`,
target: `t_${uid()}`,
};
for (const key in defaults) {
if (model.get(key)) {
continue;
}
model.set(key, defaults[key]);
}
},
hasOne(model: Model) {
const defaults = {
sourceKey: 'id',
foreignKey: `f_${uid()}`,
};
for (const key in defaults) {
if (model.get(key)) {
continue;
}
model.set(key, defaults[key]);
}
},
};

View File

@ -0,0 +1,45 @@
import path from 'path';
import { Plugin } from '@nocobase/server';
import { CollectionModel } from './models/collection';
import { FieldModel } from './models/field';
import { uid } from '@nocobase/utils';
import beforeInitOptions from './hooks/beforeInitOptions';
import { beforeCreateForChildrenCollection } from './hooks/beforeCreateForChildrenCollection';
import { beforeCreateForReverseField } from './hooks/beforeCreateForReverseField';
import { afterCreateForReverseField } from './hooks/afterCreateForReverseField';
export default class CollectionManagerPlugin extends Plugin {
async load() {
this.app.db.registerModels({
CollectionModel,
FieldModel,
});
await this.app.db.import({
directory: path.resolve(__dirname, './collections'),
});
// 要在 beforeInitOptions 之前处理
this.app.db.on('fields.beforeCreate', beforeCreateForReverseField(this.app.db));
this.app.db.on('fields.beforeCreate', beforeCreateForChildrenCollection(this.app.db));
this.app.db.on('fields.beforeCreate', async (model, options) => {
const type = model.get('type');
await this.app.db.emitAsync(`fields.${type}.beforeInitOptions`, model, options);
});
for (const key in beforeInitOptions) {
if (Object.prototype.hasOwnProperty.call(beforeInitOptions, key)) {
const fn = beforeInitOptions[key];
this.app.db.on(`fields.${key}.beforeInitOptions`, fn);
}
}
this.app.db.on('fields.afterCreate', afterCreateForReverseField(this.app.db));
this.app.db.on('collections.afterCreate', async (model, options) => {
if (options.context) {
await model.migrate();
}
});
this.app.db.on('fields.afterCreate', async (model, options) => {
if (options.context) {
await model.migrate();
}
});
}
}

View File

@ -0,0 +1,54 @@
import { SyncOptions } from 'sequelize';
import Database, { Collection, MagicAttributeModel } from '@nocobase/database';
import { FieldModel } from './field';
interface LoadOptions {
// TODO
skipField?: boolean;
skipExist?: boolean;
}
export class CollectionModel extends MagicAttributeModel {
get db(): Database {
return (<any>this.constructor).database;
}
async load(loadOptions?: LoadOptions) {
const { skipExist, skipField } = loadOptions;
const name = this.get('name');
let collection: Collection;
if (this.db.hasCollection(name)) {
collection = this.db.getCollection(name);
if (skipExist) {
return collection;
}
collection.updateOptions(this.get());
} else {
collection = this.db.collection(this.get());
}
if (!skipField) {
await this.loadFields();
}
return collection;
}
async loadFields() {
// @ts-ignore
const instances: FieldModel[] = await this.getFields();
for (const instance of instances) {
await instance.load();
}
}
async migrate(options?: SyncOptions) {
const collection = await this.load();
await collection.sync({
force: false,
alter: {
drop: false,
},
...options,
});
}
}

View File

@ -0,0 +1,32 @@
import { SyncOptions } from 'sequelize';
import Database, { MagicAttributeModel } from '@nocobase/database';
interface LoadOptions {
// TODO
skipExist?: boolean;
}
export class FieldModel extends MagicAttributeModel {
get db(): Database {
return (<any>this.constructor).database;
}
async load(loadOptions?: LoadOptions) {
const { skipExist } = loadOptions;
const collectionName = this.get('collectionName');
if (!this.db.hasCollection(collectionName)) {
throw new Error(`${collectionName} collection does not exist.`);
}
const collection = this.db.getCollection(collectionName);
const name = this.get('name');
if (skipExist && collection.hasField(name)) {
return collection.getField(name);
}
return collection.setField(name, this.get());
}
async migrate(options?: SyncOptions) {
const field = await this.load();
await field.sync(options);
}
}

View File

@ -0,0 +1,17 @@
import { Repository } from '@nocobase/database';
import { CollectionModel } from '../models/collection';
interface LoadOptions {
filter?: any;
skipExist?: boolean;
}
export class CollectionRepository extends Repository {
async load(options?: LoadOptions) {
const { filter, skipExist } = options;
const instances = (await this.find({ filter })) as CollectionModel[];
for (const instance of instances) {
await instance.load({ skipExist });
}
}
}