diff --git a/packages/collections/package.json b/packages/collections/package.json new file mode 100644 index 000000000..d66baf9be --- /dev/null +++ b/packages/collections/package.json @@ -0,0 +1,21 @@ +{ + "name": "@nocobase/collections", + "version": "0.4.0-alpha.7", + "description": "", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "license": "MIT", + "scripts": {}, + "dependencies": { + "bcrypt": "^5.0.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.6", + "sequelize": "^6.3.3" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nocobase/nocobase.git", + "directory": "packages/database" + }, + "gitHead": "f0b335ac30f29f25c95d7d137655fa64d8d67f1e" +} diff --git a/packages/collections/src/__tests__/index.ts b/packages/collections/src/__tests__/index.ts new file mode 100644 index 000000000..bc70cdd80 --- /dev/null +++ b/packages/collections/src/__tests__/index.ts @@ -0,0 +1,40 @@ +import merge from 'deepmerge'; +import { Database, DatabaseOptions } from '../database'; + +export function generatePrefixByPath() { + const { id } = require.main; + const key = id + .replace(`${process.env.PWD}/packages`, '') + .replace(/src\/__tests__/g, '') + .replace('.test.ts', '') + .replace(/[^\w]/g, '_') + .replace(/_+/g, '_'); + return key +} + +export function getConfig(config = {}, options?: any): DatabaseOptions { + return merge({ + username: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_DATABASE, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + dialect: process.env.DB_DIALECT, + logging: process.env.DB_LOG_SQL === 'on', + sync: { + force: true, + alter: { + drop: true, + }, + }, + hooks: { + beforeDefine(model, options) { + options.tableName = `${generatePrefixByPath()}_${options.tableName || options.modelName || options.name.plural}`; + }, + }, + }, config || {}, options) as any; +}; + +export function mockDatabase(options?: DatabaseOptions): Database { + return new Database(getConfig(options)); +} diff --git a/packages/collections/src/__tests__/schema-fields/belongs-to-field.test.ts b/packages/collections/src/__tests__/schema-fields/belongs-to-field.test.ts new file mode 100644 index 000000000..e43a6ba58 --- /dev/null +++ b/packages/collections/src/__tests__/schema-fields/belongs-to-field.test.ts @@ -0,0 +1,151 @@ +import { Database } from '../../database'; +import { mockDatabase } from '../'; + +describe('belongs to field', () => { + let db: Database; + + beforeEach(() => { + db = mockDatabase(); + }); + + afterEach(async () => { + await db.close(); + }); + + it('association undefined', async () => { + const Comment = db.collection({ + name: 'comments', + schema: [{ type: 'belongsTo', name: 'post' }], + }); + expect(Comment.model.associations['post']).toBeUndefined(); + }); + + it('association defined', async () => { + const Comment = db.collection({ + name: 'comments', + schema: [ + { type: 'string', name: 'content' }, + { type: 'belongsTo', name: 'post' }, + ], + }); + expect(Comment.model.associations.post).toBeUndefined(); + const Post = db.collection({ + name: 'posts', + schema: [ + { type: 'string', name: 'title' }, + ], + }); + const association = Comment.model.associations.post; + expect(Comment.model.associations.post).toBeDefined(); + expect(association.foreignKey).toBe('postId'); + // @ts-ignore + expect(association.targetKey).toBe('id'); + expect(Comment.model.rawAttributes.postId).toBeDefined(); + await db.sync(); + const comment = await Comment.model.create(); + await comment.createPost({ + title: 'title222', + }); + const post1 = await comment.getPost(); + expect(post1.toJSON()).toMatchObject({ + title: 'title222', + }); + const post = await Post.model.create({ + title: 'title111' + }); + await comment.setPost(post); + const post2 = await comment.getPost(); + expect(post2.toJSON()).toMatchObject({ + title: 'title111', + }); + }); + + it('custom targetKey and foreignKey', async () => { + const Post = db.collection({ + name: 'posts', + schema: [ + { type: 'string', name: 'key', unique: true }, + ], + }); + const Comment = db.collection({ + name: 'comments', + schema: [ + { + type: 'belongsTo', + name: 'post', + targetKey: 'key', + foreignKey: 'postKey', + }, + ], + }); + const association = Comment.model.associations.post; + expect(association).toBeDefined(); + expect(association.foreignKey).toBe('postKey'); + // @ts-ignore + expect(association.targetKey).toBe('key'); + expect(Comment.model.rawAttributes['postKey']).toBeDefined(); + }); + + it('custom name and target', async () => { + const Comment = db.collection({ + name: 'comments', + schema: [ + { type: 'string', name: 'content' }, + { + type: 'belongsTo', + name: 'article', + target: 'posts', + targetKey: 'key', + foreignKey: 'postKey', + }, + ], + }); + expect(Comment.model.associations.article).toBeUndefined(); + const Post = db.collection({ + name: 'posts', + schema: [ + { type: 'string', name: 'key', unique: true }, + ], + }); + const association = Comment.model.associations.article; + expect(Comment.model.associations.article).toBeDefined(); + expect(association.foreignKey).toBe('postKey'); + // @ts-ignore + expect(association.targetKey).toBe('key'); + expect(Comment.model.rawAttributes.postKey).toBeDefined(); + await db.sync(); + const comment = await Comment.model.create(); + await comment.createArticle({ + key: 'title222', + }); + const post1 = await comment.getArticle(); + expect(post1.toJSON()).toMatchObject({ + key: 'title222', + }); + const post = await Post.model.create({ + key: 'title111' + }); + await comment.setArticle(post); + const post2 = await comment.getArticle(); + expect(post2.toJSON()).toMatchObject({ + key: 'title111', + }); + }); + + it('schema delete', async () => { + const Comment = db.collection({ + name: 'comments', + schema: [{ type: 'belongsTo', name: 'post' }], + }); + const Post = db.collection({ + name: 'posts', + schema: [{ type: 'hasMany', name: 'comments' }], + }); + // await db.sync(); + Comment.schema.delete('post'); + expect(Comment.model.associations.post).toBeUndefined(); + expect(Comment.model.rawAttributes.postId).toBeDefined(); + Post.schema.delete('comments'); + expect(Comment.model.rawAttributes.postId).toBeUndefined(); + }); +}); diff --git a/packages/collections/src/__tests__/schema-fields/belongs-to-many-field.test.ts b/packages/collections/src/__tests__/schema-fields/belongs-to-many-field.test.ts new file mode 100644 index 000000000..1e41015b0 --- /dev/null +++ b/packages/collections/src/__tests__/schema-fields/belongs-to-many-field.test.ts @@ -0,0 +1,42 @@ +import { Database } from '../../database'; +import { mockDatabase } from '../'; + +describe('belongs to many field', () => { + let db: Database; + + beforeEach(() => { + db = mockDatabase(); + }); + + afterEach(async () => { + await db.close(); + }); + + it('association undefined', async () => { + const Post = db.collection({ + name: 'posts', + schema: [ + { type: 'string', name: 'name' }, + { type: 'belongsToMany', name: 'tags' }, + ], + }); + expect(Post.model.associations.tags).toBeUndefined(); + expect(db.getCollection('posts_tags')).toBeUndefined(); + const Tag = db.collection({ + name: 'tags', + schema: [ + { type: 'string', name: 'name' }, + ], + }); + expect(Post.model.associations.tags).toBeDefined(); + const Through = db.getCollection('posts_tags'); + expect(Through).toBeDefined(); + expect(Through.model.rawAttributes['postId']).toBeDefined(); + expect(Through.model.rawAttributes['tagId']).toBeDefined(); + const PostTag = db.collection({ + name: 'posts_tags', + }); + expect(PostTag.model.rawAttributes['postId']).toBeDefined(); + expect(PostTag.model.rawAttributes['tagId']).toBeDefined(); + }); +}); diff --git a/packages/collections/src/__tests__/schema-fields/has-many-field.test.ts b/packages/collections/src/__tests__/schema-fields/has-many-field.test.ts new file mode 100644 index 000000000..026043983 --- /dev/null +++ b/packages/collections/src/__tests__/schema-fields/has-many-field.test.ts @@ -0,0 +1,129 @@ +import { Database } from '../../database'; +import { mockDatabase } from '../'; + +describe('has many field', () => { + let db: Database; + + beforeEach(() => { + db = mockDatabase(); + }); + + afterEach(async () => { + await db.close(); + }); + + it('association undefined', async () => { + const collection = db.collection({ + name: 'posts', + schema: [{ type: 'hasMany', name: 'comments' }], + }); + await db.sync(); + expect(collection.model.associations['comments']).toBeUndefined(); + }); + + it('association defined', async () => { + const { model } = db.collection({ + name: 'posts', + schema: [{ type: 'hasMany', name: 'comments' }], + }); + expect(model.associations['comments']).toBeUndefined(); + const comments = db.collection({ + name: 'comments', + schema: [{ type: 'string', name: 'content' }], + }); + const association = model.associations.comments; + expect(association).toBeDefined(); + expect(association.foreignKey).toBe('postId'); + // @ts-ignore + expect(association.sourceKey).toBe('id'); + expect(comments.model.rawAttributes['postId']).toBeDefined(); + await db.sync(); + const post = await model.create(); + await post.createComment({ + content: 'content111', + }); + const postComments = await post.getComments(); + expect(postComments.map((comment) => comment.content)).toEqual([ + 'content111', + ]); + }); + + it('custom sourceKey and foreignKey', async () => { + const collection = db.collection({ + name: 'posts', + schema: [ + { type: 'string', name: 'key', unique: true }, + { + type: 'hasMany', + name: 'comments', + sourceKey: 'key', + foreignKey: 'postKey', + }, + ], + }); + const comments = db.collection({ + name: 'comments', + schema: [], + }); + const association = collection.model.associations.comments; + expect(association).toBeDefined(); + expect(association.foreignKey).toBe('postKey'); + // @ts-ignore + expect(association.sourceKey).toBe('key'); + expect(comments.model.rawAttributes['postKey']).toBeDefined(); + await db.sync(); + }); + + it('custom name and target', async () => { + const collection = db.collection({ + name: 'posts', + schema: [ + { type: 'string', name: 'key', unique: true }, + { + type: 'hasMany', + name: 'reviews', + target: 'comments', + sourceKey: 'key', + foreignKey: 'postKey', + }, + ], + }); + db.collection({ + name: 'comments', + schema: [{ type: 'string', name: 'content' }], + }); + const association = collection.model.associations.reviews; + expect(association).toBeDefined(); + expect(association.foreignKey).toBe('postKey'); + // @ts-ignore + expect(association.sourceKey).toBe('key'); + await db.sync(); + const post = await collection.model.create({ + key: 'key1', + }); + await post.createReview({ + content: 'content111', + }); + const postComments = await post.getReviews(); + expect(postComments.map((comment) => comment.content)).toEqual([ + 'content111', + ]); + }); + + it('schema delete', async () => { + const Post = db.collection({ + name: 'posts', + schema: [{ type: 'hasMany', name: 'comments' }], + }); + const Comment = db.collection({ + name: 'comments', + schema: [{ type: 'belongsTo', name: 'post' }], + }); + await db.sync(); + Post.schema.delete('comments'); + expect(Post.model.associations.comments).toBeUndefined(); + expect(Comment.model.rawAttributes.postId).toBeDefined(); + Comment.schema.delete('post'); + expect(Comment.model.rawAttributes.postId).toBeUndefined(); + }); +}); diff --git a/packages/collections/src/__tests__/schema-fields/string-field.test.ts b/packages/collections/src/__tests__/schema-fields/string-field.test.ts new file mode 100644 index 000000000..acd389817 --- /dev/null +++ b/packages/collections/src/__tests__/schema-fields/string-field.test.ts @@ -0,0 +1,79 @@ +import { Database } from '../../database'; +import { mockDatabase } from '../'; + +describe('string field', () => { + let db: Database; + + beforeEach(() => { + db = mockDatabase(); + }); + + afterEach(async () => { + await db.close(); + }); + + it('define', async () => { + const Test = db.collection({ + name: 'tests', + schema: [ + { type: 'string', name: 'name' }, + ], + }); + await db.sync(); + expect(Test.model.rawAttributes['name']).toBeDefined(); + const model = await Test.model.create({ + name: 'abc', + }); + expect(model.toJSON()).toMatchObject({ + name: 'abc', + }); + }); + + it('set', async () => { + const Test = db.collection({ + name: 'tests', + schema: [ + { type: 'string', name: 'name1' }, + ], + }); + await db.sync(); + Test.schema.set('name2', { type: 'string' }); + await db.sync(); + expect(Test.model.rawAttributes['name1']).toBeDefined(); + expect(Test.model.rawAttributes['name2']).toBeDefined(); + const model = await Test.model.create({ + name1: 'a1', + name2: 'a2', + }); + expect(model.toJSON()).toMatchObject({ + name1: 'a1', + name2: 'a2', + }); + }); + + it('model hook', async () => { + const collection = db.collection({ + name: 'tests', + schema: [ + { type: 'string', name: 'name' }, + ], + }); + await db.sync(); + collection.model.beforeCreate((model) => { + const changed = model.changed(); + for (const name of changed || []) { + model.set(name, `${model.get(name)}111`); + } + }); + collection.schema.set('name2', { type: 'string' }); + await db.sync(); + const model = await collection.model.create({ + name: 'n1', + name2: 'n2', + }); + expect(model.toJSON()).toMatchObject({ + name: 'n1111', + name2: 'n2111', + }); + }); +}); diff --git a/packages/collections/src/collection.ts b/packages/collections/src/collection.ts new file mode 100644 index 000000000..0fd5d8576 --- /dev/null +++ b/packages/collections/src/collection.ts @@ -0,0 +1,59 @@ +import { ModelCtor, Model } from 'sequelize'; +import { Database } from './database'; +import { Schema } from './schema'; +import { RelationField } from './schema-fields'; +import _ from 'lodash'; + +export interface CollectionOptions { + schema?: any; + [key: string]: any; +} + +export interface CollectionContext { + database: Database; +} + +export class Collection { + schema: Schema; + model: ModelCtor; + options: CollectionOptions; + context: CollectionContext; + + get name() { + return this.options.name; + } + + constructor(options: CollectionOptions, context: CollectionContext) { + this.options = options; + this.context = context; + const { name, tableName } = options; + this.model = class extends Model {}; + const attributes = {}; + this.model.init(attributes, { + ..._.omit(options, ['name', 'schema']), + sequelize: context.database.sequelize, + modelName: name, + tableName: tableName || name, + }); + this.schema = new Schema(options.schema, { + ...context, + collection: this, + }); + this.schema2model(); + this.context.database.emit('collection.init', this); + } + + schema2model() { + this.schema.forEach((field) => { + field.bind(); + }); + this.schema.on('setted', (field) => { + // console.log('setted', field); + field.bind(); + }); + this.schema.on('deleted', (field) => field.unbind()); + this.schema.on('merged', (field) => { + // + }); + } +} diff --git a/packages/collections/src/database.ts b/packages/collections/src/database.ts new file mode 100644 index 000000000..0d88b36fe --- /dev/null +++ b/packages/collections/src/database.ts @@ -0,0 +1,103 @@ +import { Sequelize, ModelCtor, Model, Options, SyncOptions } from 'sequelize'; +import { EventEmitter } from 'events'; +import { Collection, CollectionOptions } from './collection'; +import { + RelationField, + StringField, + HasManyField, + BelongsToField, + BelongsToManyField, + JsonField, + JsonbField, +} from './schema-fields'; + +export interface PendingOptions { + field: RelationField; + model: ModelCtor; +} + +export type DatabaseOptions = Options | Sequelize; + +export class Database extends EventEmitter { + sequelize: Sequelize; + schemaTypes = new Map(); + collections: Map; + pendingFields = new Map(); + + constructor(options: DatabaseOptions) { + super(); + if (options instanceof Sequelize) { + this.sequelize = options; + } else { + this.sequelize = new Sequelize(options); + } + this.collections = new Map(); + this.on('collection.init', (collection) => { + const items = this.pendingFields.get(collection.name); + for (const field of items || []) { + field.bind(); + } + }); + this.registerSchemaTypes({ + string: StringField, + json: JsonField, + jsonb: JsonbField, + hasMany: HasManyField, + belongsTo: BelongsToField, + belongsToMany: BelongsToManyField, + }); + } + + collection(options: CollectionOptions) { + let collection = this.collections.get(options.name); + if (collection) { + collection.schema.set(options.schema); + } else { + collection = new Collection(options, { database: this }); + } + this.collections.set(collection.name, collection); + return collection; + } + + getCollection(name: string) { + return this.collections.get(name); + } + + addPendingField(field: RelationField) { + const associating = this.pendingFields; + const items = this.pendingFields.get(field.target) || []; + items.push(field); + associating.set(field.target, items); + } + + removePendingField(field: RelationField) { + const items = this.pendingFields.get(field.target) || []; + const index = items.findIndex( + (item) => item && item.name === field.name, + ); + if (index !== -1) { + delete items[index]; + this.pendingFields.set(field.target, items); + } + } + + registerSchemaTypes(schemaTypes: any) { + for (const [type, schemaType] of Object.entries(schemaTypes)) { + this.schemaTypes.set(type, schemaType); + } + } + + buildSchemaField(options, context) { + const { type } = options; + const Field = this.schemaTypes.get(type); + return new Field(options, context); + } + + async sync(options?: SyncOptions) { + return this.sequelize.sync(options); + } + + async close() { + return this.sequelize.close(); + } +} diff --git a/packages/collections/src/index.ts b/packages/collections/src/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/collections/src/schema-fields/belongs-to-field.ts b/packages/collections/src/schema-fields/belongs-to-field.ts new file mode 100644 index 000000000..297a1a537 --- /dev/null +++ b/packages/collections/src/schema-fields/belongs-to-field.ts @@ -0,0 +1,53 @@ +import { omit } from 'lodash'; +import { Sequelize, ModelCtor, Model, DataTypes, Utils } from 'sequelize'; +import { RelationField } from './relation-field'; + +export class BelongsToField extends RelationField { + get target() { + const { target, name } = this.options; + return target || Utils.pluralize(name); + } + + bind() { + const { database, collection } = this.context; + const Target = this.TargetModel; + if (!Target) { + database.addPendingField(this); + return false; + } + const association = collection.model.belongsTo(Target, { + as: this.name, + ...omit(this.options, ['name', 'type', 'target']), + }); + // 建立关系之后从 pending 列表中删除 + database.removePendingField(this); + if (!this.options.foreignKey) { + this.options.foreignKey = association.foreignKey; + } + if (!this.options.sourceKey) { + // @ts-ignore + this.options.sourceKey = association.sourceKey; + } + return true; + } + + unbind() { + const { database, collection } = this.context; + // 如果关系字段还没建立就删除了,也同步删除待建立关联的关系字段 + database.removePendingField(this); + // 如果外键没有显式的创建,关系表也无反向关联字段,删除关系时,外键也删除掉 + const tcoll = database.collections.get(this.target); + const foreignKey = this.options.foreignKey; + const field1 = collection.schema.get(foreignKey); + const field2 = tcoll.schema.find((field) => { + return field.type === 'hasMany' && field.foreignKey === foreignKey; + }); + if (!field1 && !field2) { + collection.model.removeAttribute(foreignKey); + } + // 删掉 model 的关联字段 + delete collection.model.associations[this.name]; + // @ts-ignore + collection.model.refreshAttributes(); + } +} diff --git a/packages/collections/src/schema-fields/belongs-to-many-field.ts b/packages/collections/src/schema-fields/belongs-to-many-field.ts new file mode 100644 index 000000000..a5be4cb6c --- /dev/null +++ b/packages/collections/src/schema-fields/belongs-to-many-field.ts @@ -0,0 +1,69 @@ +import { omit } from 'lodash'; +import { Sequelize, ModelCtor, Model, DataTypes, Utils } from 'sequelize'; +import { RelationField } from './relation-field'; + +export class BelongsToManyField extends RelationField { + get target() { + const { target, name } = this.options; + return target || name; + } + + get through() { + return ( + this.options.through || + [this.context.collection.model.name, this.target] + .map((name) => name.toLowerCase()) + .sort() + .join('_') + ); + } + + bind() { + const { database, collection } = this.context; + const Target = this.TargetModel; + if (!Target) { + database.addPendingField(this); + return false; + } + const through = this.through; + let Through = + database.getCollection(through) || + database.collection({ + name: through, + }); + const association = collection.model.belongsToMany(Target, { + ...omit(this.options, ['name', 'type', 'target']), + as: this.name, + through: Through.model, + }); + // 建立关系之后从 pending 列表中删除 + database.removePendingField(this); + if (!this.options.foreignKey) { + this.options.foreignKey = association.foreignKey; + } + if (!this.options.sourceKey) { + this.options.sourceKey = association.sourceKey; + } + return true; + } + + unbind() { + // const { database, collection } = this.context; + // // 如果关系字段还没建立就删除了,也同步删除待建立关联的关系字段 + // database.removePendingField(this); + // // 如果外键没有显式的创建,关系表也无反向关联字段,删除关系时,外键也删除掉 + // const tcoll = database.collections.get(this.target); + // const foreignKey = this.options.foreignKey; + // const field1 = collection.schema.get(foreignKey); + // const field2 = tcoll.schema.find((field) => { + // return field.type === 'hasMany' && field.foreignKey === foreignKey; + // }); + // if (!field1 && !field2) { + // collection.model.removeAttribute(foreignKey); + // } + // // 删掉 model 的关联字段 + // delete collection.model.associations[this.name]; + // // @ts-ignore + // collection.model.refreshAttributes(); + } +} diff --git a/packages/collections/src/schema-fields/has-many-field.ts b/packages/collections/src/schema-fields/has-many-field.ts new file mode 100644 index 000000000..d7807820a --- /dev/null +++ b/packages/collections/src/schema-fields/has-many-field.ts @@ -0,0 +1,119 @@ +import { omit } from 'lodash'; +import { + Sequelize, + ModelCtor, + Model, + DataType, + AssociationScope, + ForeignKeyOptions, + HasManyOptions, +} from 'sequelize'; +import { RelationField } from './relation-field'; + +export interface HasManyFieldOptions extends HasManyOptions { + /** + * The name of the field to use as the key for the association in the source table. Defaults to the primary + * key of the source table + */ + sourceKey?: string; + + /** + * A string or a data type to represent the identifier in the table + */ + keyType?: DataType; + + scope?: AssociationScope; + + /** + * The alias of this model, in singular form. See also the `name` option passed to `sequelize.define`. If + * you create multiple associations between the same tables, you should provide an alias to be able to + * distinguish between them. If you provide an alias when creating the assocition, you should provide the + * same alias when eager loading and when getting associated models. Defaults to the singularized name of + * target + */ + as?: string | { singular: string; plural: string }; + + /** + * The name of the foreign key in the target table or an object representing the type definition for the + * foreign column (see `Sequelize.define` for syntax). When using an object, you can add a `name` property + * to set the name of the column. Defaults to the name of source + primary key of source + */ + foreignKey?: string | ForeignKeyOptions; + + /** + * What happens when delete occurs. + * + * Cascade if this is a n:m, and set null if it is a 1:m + * + * @default 'SET NULL' or 'CASCADE' + */ + onDelete?: string; + + /** + * What happens when update occurs + * + * @default 'CASCADE' + */ + onUpdate?: string; + + /** + * Should on update and on delete constraints be enabled on the foreign key. + */ + constraints?: boolean; + foreignKeyConstraint?: boolean; + + // scope?: AssociationScope; + + /** + * If `false` the applicable hooks will not be called. + * The default value depends on the context. + */ + hooks?: boolean; +} + +export class HasManyField extends RelationField { + bind() { + const { database, collection } = this.context; + const Target = this.TargetModel; + if (!Target) { + database.addPendingField(this); + return false; + } + const association = collection.model.hasMany(Target, { + as: this.name, + ...omit(this.options, ['name', 'type', 'target']), + }); + // 建立关系之后从 pending 列表中删除 + database.removePendingField(this); + if (!this.options.foreignKey) { + this.options.foreignKey = association.foreignKey; + } + if (!this.options.sourceKey) { + // @ts-ignore + this.options.sourceKey = association.sourceKey; + } + return true; + } + + unbind() { + const { database, collection } = this.context; + // 如果关系字段还没建立就删除了,也同步删除待建立关联的关系字段 + database.removePendingField(this); + // 如果关系表内没有显式的创建外键字段,删除关系时,外键也删除掉 + const tcoll = database.collections.get(this.target); + const foreignKey = this.options.foreignKey; + const field = tcoll.schema.find((field) => { + if (field.name === foreignKey) { + return true; + } + return field.type === 'belongsTo' && field.foreignKey === foreignKey; + }); + if (!field) { + tcoll.model.removeAttribute(foreignKey); + } + // 删掉 model 的关联字段 + delete collection.model.associations[this.name]; + // @ts-ignore + collection.model.refreshAttributes(); + } +} diff --git a/packages/collections/src/schema-fields/has-one-field.ts b/packages/collections/src/schema-fields/has-one-field.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/collections/src/schema-fields/index.ts b/packages/collections/src/schema-fields/index.ts new file mode 100644 index 000000000..4246b10a6 --- /dev/null +++ b/packages/collections/src/schema-fields/index.ts @@ -0,0 +1,7 @@ +export * from './schema-field'; +export * from './string-field'; +export * from './relation-field' +export * from './belongs-to-field' +export * from './belongs-to-many-field'; +export * from './has-many-field'; +export * from './json-field'; diff --git a/packages/collections/src/schema-fields/json-field.ts b/packages/collections/src/schema-fields/json-field.ts new file mode 100644 index 000000000..568e8d8d2 --- /dev/null +++ b/packages/collections/src/schema-fields/json-field.ts @@ -0,0 +1,32 @@ +import { DataTypes } from 'sequelize'; +import { SchemaField } from './schema-field'; + +export class JsonField extends SchemaField { + get dataType() { + return DataTypes.JSON; + } + + toSequelize() { + return { + ...this.options, + type: this.dataType, + }; + } +} + +export class JsonbField extends SchemaField { + get dataType() { + const dialect = this.context.database.sequelize.getDialect(); + if (dialect === 'postgres') { + return DataTypes.JSONB; + } + return DataTypes.JSON; + } + + toSequelize() { + return { + ...this.options, + type: this.dataType, + }; + } +} diff --git a/packages/collections/src/schema-fields/relation-field.ts b/packages/collections/src/schema-fields/relation-field.ts new file mode 100644 index 000000000..1efde44cd --- /dev/null +++ b/packages/collections/src/schema-fields/relation-field.ts @@ -0,0 +1,20 @@ +import { SchemaField } from './schema-field'; + +export abstract class RelationField extends SchemaField { + get target() { + const { target, name } = this.options; + return target || name; + } + + get foreignKey() { + return this.options.foreignKey; + } + + get sourceKey() { + return this.options.sourceKey; + } + + get TargetModel() { + return this.context.database.sequelize.models[this.target]; + } +} diff --git a/packages/collections/src/schema-fields/schema-field.ts b/packages/collections/src/schema-fields/schema-field.ts new file mode 100644 index 000000000..c872be6de --- /dev/null +++ b/packages/collections/src/schema-fields/schema-field.ts @@ -0,0 +1,65 @@ +import { Sequelize, ModelCtor, Model, DataTypes, Utils } from 'sequelize'; +import { EventEmitter } from 'events'; +import { Collection } from '../collection'; +import { Database } from '../database'; +import { Schema } from '../schema'; +import { RelationField } from './relation-field'; +import _ from 'lodash'; + +export interface SchemaFieldContext { + database: Database; + collection: Collection; + schema: Schema; +} + +export abstract class SchemaField { + options: any; + context: SchemaFieldContext; + [key: string]: any; + + get name() { + return this.options.name; + } + + get type() { + return this.options.type; + } + + get dataType() { + return this.options.dataType; + } + + constructor(options?: any, context?: SchemaFieldContext) { + this.context = context; + this.options = options || {}; + this.init(); + } + + init() { + // code + } + + get(name: string) { + return this.options[name]; + } + + merge(obj: any) { + Object.assign(this.options, obj); + } + + bind() { + const { model } = this.context.collection; + model.rawAttributes[this.name] = this.toSequelize(); + // @ts-ignore + model.refreshAttributes(); + } + + unbind() { + const { model } = this.context.collection; + model.removeAttribute(this.name); + } + + toSequelize(): any { + return _.omit(this.options, ['name']) + } +} diff --git a/packages/collections/src/schema-fields/string-field.ts b/packages/collections/src/schema-fields/string-field.ts new file mode 100644 index 000000000..cc8e06d55 --- /dev/null +++ b/packages/collections/src/schema-fields/string-field.ts @@ -0,0 +1,15 @@ +import { DataTypes } from 'sequelize'; +import { SchemaField } from './schema-field'; + +export class StringField extends SchemaField { + get dataType() { + return DataTypes.STRING; + } + + toSequelize() { + return { + ...this.options, + type: this.dataType, + }; + } +} diff --git a/packages/collections/src/schema.ts b/packages/collections/src/schema.ts new file mode 100644 index 000000000..2c5cf1250 --- /dev/null +++ b/packages/collections/src/schema.ts @@ -0,0 +1,80 @@ +import { Sequelize, ModelCtor, Model, DataTypes, Utils } from 'sequelize'; +import { EventEmitter } from 'events'; +import { Database } from './database'; +import { Collection } from './collection'; +import { SchemaField } from './schema-fields'; + +export interface SchemaContext { + database: Database; + collection: Collection; +} + +export class Schema extends EventEmitter { + fields: Map; + context: SchemaContext; + options: any; + + constructor(options?: any, context?: SchemaContext) { + super(); + this.options = options; + this.context = context; + this.fields = new Map(); + this.set(options); + } + + has(name: string) { + return this.fields.has(name); + } + + get(name: string) { + return this.fields.get(name); + } + + set(name: string | object, obj?: any) { + if (!name) { + return this; + } + if (typeof name === 'string') { + const { database } = this.context; + const field = database.buildSchemaField({ name, ...obj }, { + ...this.context, + schema: this, + model: this.context.collection.model, + }); + this.fields.set(name, field); + this.emit('setted', field); + } else if (Array.isArray(name)) { + for (const value of name) { + this.set(value.name, value); + } + } else if (typeof name === 'object') { + for (const [key, value] of Object.entries(name)) { + console.log({ key, value }) + this.set(key, value); + } + } + return this; + } + + delete(name: string) { + const field = this.fields.get(name); + const bool = this.fields.delete(name); + this.emit('deleted', field); + return bool; + } + + merge(name: string, obj) { + const field = this.get(name); + field.merge(obj); + this.emit('merged', field); + return field; + } + + forEach(callback: (field: SchemaField) => void) { + return [...this.fields.values()].forEach(callback); + } + + find(callback: (field: SchemaField) => boolean) { + return [...this.fields.values()].find(callback); + } +}