diff --git a/packages/database-next/src/__tests__/collection-importer.test.ts b/packages/database-next/src/__tests__/collection-importer.test.ts new file mode 100644 index 000000000..9c0310007 --- /dev/null +++ b/packages/database-next/src/__tests__/collection-importer.test.ts @@ -0,0 +1,24 @@ +import { ImporterReader } from '../collection-importer'; +import * as path from 'path'; +import { extend } from '../database'; +import { mockDatabase } from './index'; + +describe('collection importer', () => { + test('import reader', async () => { + const reader = new ImporterReader( + path.resolve(__dirname, './fixtures/collections'), + ); + + const modules = await reader.read(); + expect(modules).toBeDefined(); + }); + + test('extend', async () => { + const extendObject = extend({ + name: 'tags', + fields: [{ type: 'string', name: 'title' }], + }); + + expect(extendObject).toHaveProperty('extend'); + }); +}); diff --git a/packages/database-next/src/__tests__/collection.test.ts b/packages/database-next/src/__tests__/collection.test.ts new file mode 100644 index 000000000..3a596fbe4 --- /dev/null +++ b/packages/database-next/src/__tests__/collection.test.ts @@ -0,0 +1,234 @@ +import { generatePrefixByPath, mockDatabase } from './index'; +import { Collection } from '../collection'; +import { Database } from '../database'; + +test('new collection', async () => { + const db = mockDatabase(); + const collection = new Collection( + { + name: 'test', + }, + { database: db }, + ); + + expect(collection.name).toEqual('test'); +}); + +test('collection create field', async () => { + const db = mockDatabase(); + const collection = new Collection( + { + name: 'user', + }, + { database: db }, + ); + + collection.addField('age', { + type: 'integer', + }); + + const ageField = collection.getField('age'); + expect(ageField).toBeDefined(); + expect(collection.hasField('age')).toBeTruthy(); + expect(collection.hasField('test')).toBeFalsy(); + + collection.removeField('age'); + expect(collection.hasField('age')).toBeFalsy(); +}); + +test('collection set fields', () => { + const db = mockDatabase(); + const collection = new Collection( + { + name: 'user', + }, + { database: db }, + ); + + collection.setFields([{ type: 'string', name: 'firstName' }]); + expect(collection.hasField('firstName')).toBeTruthy(); +}); + +describe('collection sync', () => { + let db: Database; + + beforeEach(() => { + db = mockDatabase(); + }); + + afterEach(async () => { + await db.close(); + }); + + test('sync fields', async () => { + const collection = new Collection( + { + name: 'users', + }, + { database: db }, + ); + + collection.setFields([ + { type: 'string', name: 'firstName' }, + { type: 'string', name: 'lastName' }, + { type: 'integer', name: 'age' }, + ]); + + await collection.sync(); + const tableFields = await (( + collection.model + )).queryInterface.describeTable(`${generatePrefixByPath()}_users`); + + expect(tableFields).toHaveProperty('firstName'); + expect(tableFields).toHaveProperty('lastName'); + expect(tableFields).toHaveProperty('age'); + }); + + test('sync with association not exists', async () => { + const collection = new Collection( + { + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'belongsTo', name: 'users' }, + ], + }, + { database: db }, + ); + + await collection.sync(); + + const model = collection.model; + + const tableFields = await (model).queryInterface.describeTable( + `${generatePrefixByPath()}_posts`, + ); + + expect(tableFields['user_id']).toBeUndefined(); + }); + + test('sync with association', async () => { + new Collection( + { + name: 'tags', + fields: [{ type: 'string', name: 'name' }], + }, + { database: db }, + ); + + const collection = new Collection( + { + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'belongsToMany', name: 'tags' }, + ], + }, + { + database: db, + }, + ); + + const model = collection.model; + await collection.sync(); + const tableFields = await (model).queryInterface.describeTable( + `${generatePrefixByPath()}_posts_tags`, + ); + expect(tableFields['postId']).toBeDefined(); + expect(tableFields['tagId']).toBeDefined(); + }); +}); + +test('update collection field', async () => { + const db = mockDatabase(); + + const collection = new Collection( + { + name: 'posts', + fields: [{ type: 'string', name: 'title' }], + }, + { + database: db, + }, + ); + expect(collection.hasField('title')).toBeTruthy(); + + collection.updateField('title', { + type: 'string', + name: 'content', + }); + + expect(collection.hasField('title')).toBeFalsy(); + expect(collection.hasField('content')).toBeTruthy(); +}); + +test.skip('update collection options', async () => { + const db = mockDatabase(); + const collection = new Collection( + { + name: 'posts', + fields: [{ type: 'string', name: 'title' }], + }, + { + database: db, + }, + ); + + expect(collection.model.getTableName()).toEqual( + `${generatePrefixByPath()}_posts`, + ); + + collection.updateOptions({ + name: 'articles', + }); + + expect(collection.model.getTableName()).toEqual( + `${generatePrefixByPath()}_articles`, + ); +}); + +test('collection with association', async () => { + const db = mockDatabase(); + const User = db.collection({ + name: 'users', + fields: [ + { type: 'string', name: 'name' }, + { type: 'integer', name: 'age' }, + { type: 'hasMany', name: 'posts' }, + ], + }); + + const Post = db.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'string', name: 'content' }, + { + type: 'belongsTo', + name: 'user', + }, + { + type: 'hasMany', + name: 'comments', + }, + ], + }); + + const Comment = db.collection({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + { type: 'string', name: 'comment_as' }, + { type: 'belongsTo', name: 'post' }, + ], + }); + + expect(User.model.associations['posts']).toBeDefined(); + expect(Post.model.associations['comments']).toBeDefined(); + + expect( + User.model.associations['posts'].target.associations['comments'], + ).toBeDefined(); + + await db.close(); +}); diff --git a/packages/database-next/src/__tests__/database.import.test.ts b/packages/database-next/src/__tests__/database.import.test.ts new file mode 100644 index 000000000..f7ba9c5c7 --- /dev/null +++ b/packages/database-next/src/__tests__/database.import.test.ts @@ -0,0 +1,23 @@ +import { mockDatabase } from './index'; +import path from 'path'; + +describe('database', () => { + test('import', async () => { + const db = mockDatabase(); + await db.import({ + directory: path.resolve(__dirname, './fixtures/c0'), + }); + await db.import({ + directory: path.resolve(__dirname, './fixtures/c1'), + }); + await db.import({ + directory: path.resolve(__dirname, './fixtures/c2'), + }); + + const test = db.getCollection('tests'); + + expect(test.getField('n0')).toBeDefined(); + expect(test.getField('n1')).toBeDefined(); + expect(test.getField('n2')).toBeDefined(); + }); +}); diff --git a/packages/database-next/src/__tests__/database.test.ts b/packages/database-next/src/__tests__/database.test.ts new file mode 100644 index 000000000..93cd23b2a --- /dev/null +++ b/packages/database-next/src/__tests__/database.test.ts @@ -0,0 +1,206 @@ +import { mockDatabase } from './index'; +import path from 'path'; + +describe('database', () => { + test('import', async () => { + const db = mockDatabase(); + await db.import({ + directory: path.resolve(__dirname, './fixtures/collections'), + }); + + expect(db.getCollection('posts')).toBeDefined(); + expect(db.getCollection('users')).toBeDefined(); + expect(db.getCollection('tags')).toBeDefined(); + + const tagCollection = db.getCollection('tags'); + + // extend field + expect(tagCollection.fields.has('color')).toBeTruthy(); + expect(tagCollection.fields.has('color2')).toBeTruthy(); + expect(tagCollection.fields.has('name')).toBeTruthy(); + + // delay extend + expect(db.getCollection('images')).toBeUndefined(); + + db.collection({ + name: 'images', + fields: [{ type: 'string', name: 'name' }], + }); + + const imageCollection = db.getCollection('images'); + + expect(imageCollection).toBeDefined(); + expect(imageCollection.fields.has('name')).toBeTruthy(); + expect(imageCollection.fields.has('url')).toBeTruthy(); + expect(imageCollection.fields.has('url2')).toBeTruthy(); + }); + + test('hasMany with inverse belongsTo relation', async () => { + const db = mockDatabase(); + const UserCollection = db.collection({ + name: 'users', + fields: [ + { type: 'string', name: 'name' }, + { type: 'hasMany', name: 'posts' }, + ], + }); + + const PostCollection = db.collection({ + name: 'posts', + fields: [{ type: 'string', name: 'title' }], + }); + + expect(UserCollection.model.associations.posts).toBeDefined(); + expect(PostCollection.model.associations.user).toBeDefined(); + }); + + test('belongsTo with inverse hasMany relation', async () => { + const db = mockDatabase(); + const UserCollection = db.collection({ + name: 'users', + fields: [{ type: 'string', name: 'name' }], + }); + + const PostCollection = db.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'belongsTo', name: 'user' }, + ], + }); + + expect(PostCollection.model.associations.user).toBeDefined(); + expect(UserCollection.model.associations.posts).toBeDefined(); + }); + + test('get collection', async () => { + const db = mockDatabase(); + expect(db.getCollection('test')).toBeUndefined(); + expect(db.hasCollection('test')).toBeFalsy(); + db.collection({ + name: 'test', + }); + + expect(db.getCollection('test')).toBeDefined(); + expect(db.hasCollection('test')).toBeTruthy(); + }); + + test('collection beforeBulkCreate event', async () => { + const db = mockDatabase(); + const listener = jest.fn(); + + db.on('posts.beforeBulkUpdate', listener); + + const Post = db.collection({ + name: 'posts', + fields: [{ type: 'string', name: 'title' }], + }); + + await db.sync(); + + await Post.repository.create({ + values: { + title: 'old', + }, + }); + + await Post.model.update( + { + title: 'new', + }, + { + where: { + title: 'old', + }, + }, + ); + expect(listener).toHaveBeenCalled(); + }); + + test('global model event', async () => { + const db = mockDatabase(); + const listener = jest.fn(); + const listener2 = jest.fn(); + + const Post = db.collection({ + name: 'posts', + fields: [{ type: 'string', name: 'title' }], + }); + + await db.sync(); + + db.on('afterCreate', listener); + db.on('posts.afterCreate', listener2); + + await Post.repository.create({ + values: { + title: 'test', + }, + }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledTimes(1); + }); + + test('collection multiple model event', async () => { + const db = mockDatabase(); + const listener = jest.fn(); + const listener2 = jest.fn(); + + const Post = db.collection({ + name: 'posts', + fields: [{ type: 'string', name: 'title' }], + }); + + await db.sync(); + + db.on('posts.afterCreate', listener); + db.on('posts.afterCreate', listener2); + + await Post.repository.create({ + values: { + title: 'test', + }, + }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledTimes(1); + }); + + test('collection afterCreate model event', async () => { + const db = mockDatabase(); + const postAfterCreateListener = jest.fn(); + + db.on('posts.afterCreate', postAfterCreateListener); + + const Post = db.collection({ + name: 'posts', + fields: [{ type: 'string', name: 'title' }], + }); + + await db.sync(); + + await Post.repository.create({ + values: { + title: 'test', + }, + }); + + await Post.repository.find(); + + expect(postAfterCreateListener).toHaveBeenCalled(); + }); + + test('collection event', async () => { + const db = mockDatabase(); + const listener = jest.fn(); + db.on('beforeDefineCollection', listener); + + const Post = db.collection({ + name: 'posts', + fields: [{ type: 'string', name: 'title' }], + }); + + expect(listener).toHaveBeenCalled(); + }); +}); diff --git a/packages/database-next/src/__tests__/fields/belongs-to-field.test.ts b/packages/database-next/src/__tests__/fields/belongs-to-field.test.ts index f17fe8428..abc468682 100644 --- a/packages/database-next/src/__tests__/fields/belongs-to-field.test.ts +++ b/packages/database-next/src/__tests__/fields/belongs-to-field.test.ts @@ -31,9 +31,7 @@ describe('belongs to field', () => { expect(Comment.model.associations.post).toBeUndefined(); const Post = db.collection({ name: 'posts', - fields: [ - { type: 'string', name: 'title' }, - ], + fields: [{ type: 'string', name: 'title' }], }); const association = Comment.model.associations.post; expect(Comment.model.associations.post).toBeDefined(); @@ -51,7 +49,7 @@ describe('belongs to field', () => { title: 'title222', }); const post = await Post.model.create({ - title: 'title111' + title: 'title111', }); await comment.setPost(post); const post2 = await comment.getPost(); @@ -63,9 +61,7 @@ describe('belongs to field', () => { it('custom targetKey and foreignKey', async () => { const Post = db.collection({ name: 'posts', - fields: [ - { type: 'string', name: 'key', unique: true }, - ], + fields: [{ type: 'string', name: 'key', unique: true }], }); const Comment = db.collection({ name: 'comments', @@ -103,9 +99,7 @@ describe('belongs to field', () => { expect(Comment.model.associations.article).toBeUndefined(); const Post = db.collection({ name: 'posts', - fields: [ - { type: 'string', name: 'key', unique: true }, - ], + fields: [{ type: 'string', name: 'key', unique: true }], }); const association = Comment.model.associations.article; expect(Comment.model.associations.article).toBeDefined(); @@ -123,7 +117,7 @@ describe('belongs to field', () => { key: 'title222', }); const post = await Post.model.create({ - key: 'title111' + key: 'title111', }); await comment.setArticle(post); const post2 = await comment.getArticle(); @@ -148,4 +142,21 @@ describe('belongs to field', () => { Post.removeField('comments'); expect(Comment.model.rawAttributes.postId).toBeUndefined(); }); + + it('has inverse field', async () => { + const Post = db.collection({ + name: 'posts', + fields: [{ type: 'hasMany', name: 'comments' }], + }); + + const Comment = db.collection({ + name: 'comments', + fields: [{ type: 'belongsTo', name: 'post' }], + }); + + const belongsToField = Comment.fields.get('post'); + expect(belongsToField).toBeDefined(); + const association = Post.model.associations; + expect(association['comments']).toBeDefined(); + }); }); diff --git a/packages/database-next/src/__tests__/fields/belongs-to-many-field.test.ts b/packages/database-next/src/__tests__/fields/belongs-to-many-field.test.ts index 4301610be..fd4237c29 100644 --- a/packages/database-next/src/__tests__/fields/belongs-to-many-field.test.ts +++ b/packages/database-next/src/__tests__/fields/belongs-to-many-field.test.ts @@ -12,7 +12,7 @@ describe('belongs to many field', () => { await db.close(); }); - it('association undefined', async () => { + test('association undefined', async () => { const Post = db.collection({ name: 'posts', fields: [ @@ -20,22 +20,41 @@ describe('belongs to many field', () => { { type: 'belongsToMany', name: 'tags' }, ], }); + expect(Post.model.associations.tags).toBeUndefined(); expect(db.getCollection('posts_tags')).toBeUndefined(); + const Tag = db.collection({ name: 'tags', - fields: [ - { type: 'string', name: 'name' }, - ], + fields: [{ 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', + }); + + test('redefine collection', () => { + const Post = db.collection({ + name: 'posts', + fields: [ + { 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', + fields: [{ type: 'string', name: 'name' }], + }); + + const PostTag = db.collection({ name: 'posts_tags' }); + expect(PostTag.model.rawAttributes['postId']).toBeDefined(); expect(PostTag.model.rawAttributes['tagId']).toBeDefined(); }); diff --git a/packages/database-next/src/__tests__/fields/sort-field.test.ts b/packages/database-next/src/__tests__/fields/sort-field.test.ts index 637644623..e4ac3d5cc 100644 --- a/packages/database-next/src/__tests__/fields/sort-field.test.ts +++ b/packages/database-next/src/__tests__/fields/sort-field.test.ts @@ -8,7 +8,7 @@ describe('string field', () => { beforeEach(() => { db = mockDatabase(); db.registerFieldTypes({ - sort: SortField + sort: SortField, }); }); @@ -19,9 +19,7 @@ describe('string field', () => { it('sort', async () => { const Test = db.collection({ name: 'tests', - fields: [ - { type: 'sort', name: 'sort' }, - ], + fields: [{ type: 'sort', name: 'sort' }], }); await db.sync(); const test1 = await Test.model.create(); @@ -35,9 +33,7 @@ describe('string field', () => { it('skip if sort value not empty', async () => { const Test = db.collection({ name: 'tests', - fields: [ - { type: 'sort', name: 'sort' }, - ], + fields: [{ type: 'sort', name: 'sort' }], }); await db.sync(); const test1 = await Test.model.create({ sort: 3 }); @@ -66,5 +62,4 @@ describe('string field', () => { expect(t3.get('sort')).toBe(1); expect(t4.get('sort')).toBe(2); }); - }); diff --git a/packages/database-next/src/__tests__/fields/string-field.test.ts b/packages/database-next/src/__tests__/fields/string-field.test.ts index 817fffedc..293e83dbb 100644 --- a/packages/database-next/src/__tests__/fields/string-field.test.ts +++ b/packages/database-next/src/__tests__/fields/string-field.test.ts @@ -15,9 +15,7 @@ describe('string field', () => { it('define', async () => { const Test = db.collection({ name: 'tests', - fields: [ - { type: 'string', name: 'name' }, - ], + fields: [{ type: 'string', name: 'name' }], }); await db.sync(); expect(Test.model.rawAttributes['name']).toBeDefined(); @@ -32,13 +30,13 @@ describe('string field', () => { it('set', async () => { const Test = db.collection({ name: 'tests', - fields: [ - { type: 'string', name: 'name1' }, - ], + fields: [{ type: 'string', name: 'name1' }], }); await db.sync(); - Test.addField({ type: 'string', name: 'name2' }); - await db.sync(); + Test.addField('name2', { type: 'string', name: 'name2' }); + await db.sync({ + alter: true, + }); expect(Test.model.rawAttributes['name1']).toBeDefined(); expect(Test.model.rawAttributes['name2']).toBeDefined(); const model = await Test.model.create({ @@ -54,9 +52,7 @@ describe('string field', () => { it('model hook', async () => { const collection = db.collection({ name: 'tests', - fields: [ - { type: 'string', name: 'name' }, - ], + fields: [{ type: 'string', name: 'name' }], }); await db.sync(); collection.model.beforeCreate((model) => { @@ -65,8 +61,10 @@ describe('string field', () => { model.set(name, `${model.get(name)}111`); } }); - collection.addField({ type: 'string', name: 'name2' }); - await db.sync(); + collection.addField('name2', { type: 'string', name: 'name2' }); + await db.sync({ + alter: true, + }); const model = await collection.model.create({ name: 'n1', name2: 'n2', diff --git a/packages/database-next/src/__tests__/filter-parser.test.ts b/packages/database-next/src/__tests__/filter-parser.test.ts new file mode 100644 index 000000000..9f6453498 --- /dev/null +++ b/packages/database-next/src/__tests__/filter-parser.test.ts @@ -0,0 +1,114 @@ +import { mockDatabase } from './index'; +import FilterParser from '../filter-parser'; +import { Op } from 'sequelize'; +import { Database } from '../database'; + +test('filter item by string', async () => { + const database = mockDatabase(); + const UserCollection = database.collection({ + name: 'users', + fields: [{ type: 'string', name: 'name' }], + }); + + await database.sync(); + + const filterParser = new FilterParser( + UserCollection.model, + UserCollection.context.database, + { + name: 'hello', + }, + ); + + const filterParams = filterParser.toSequelizeParams(); + + expect(filterParams).toMatchObject({ + where: { + name: 'hello', + }, + }); + + await database.close(); +}); + +describe('filter by related', () => { + let db: Database; + + beforeEach(async () => { + db = mockDatabase(); + db.collection({ + name: 'users', + fields: [ + { type: 'string', name: 'name' }, + { type: 'hasMany', name: 'posts' }, + ], + }); + + db.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { + type: 'hasMany', + name: 'comments', + }, + ], + }); + + db.collection({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + { + type: 'belongsTo', + name: 'post', + }, + ], + }); + + await db.sync(); + }); + + afterEach(async () => { + await db.close(); + }); + + test('hasMany', async () => { + const filter = { + 'posts.title.$iLike': '%hello%', + }; + + const filterParser = new FilterParser( + db.getCollection('users').model, + db.getCollection('users').context.database, + filter, + ); + + const filterParams = filterParser.toSequelizeParams(); + + expect(filterParams.where['$posts.title$'][Op.iLike]).toEqual('%hello%'); + expect(filterParams.include[0]['association']).toEqual('posts'); + }); + + test('belongsTo', async () => { + const filter = { + 'posts.comments.content.$iLike': '%hello%', + }; + + const filterParser = new FilterParser( + db.getCollection('users').model, + db.getCollection('users').context.database, + filter, + ); + + const filterParams = filterParser.toSequelizeParams(); + + expect(filterParams.where['$posts.comments.content$'][Op.iLike]).toEqual( + '%hello%', + ); + expect(filterParams.include[0]['association']).toEqual('posts'); + expect(filterParams.include[0]['include'][0]['association']).toEqual( + 'comments', + ); + }); +}); diff --git a/packages/database-next/src/__tests__/fixtures/c0/a.ts b/packages/database-next/src/__tests__/fixtures/c0/a.ts new file mode 100644 index 000000000..668dd4fc2 --- /dev/null +++ b/packages/database-next/src/__tests__/fixtures/c0/a.ts @@ -0,0 +1,6 @@ +import { extend } from '../../../database'; + +export default extend({ + name: 'tests', + fields: [{ type: 'string', name: 'n0' }], +}); diff --git a/packages/database-next/src/__tests__/fixtures/c1/b.ts b/packages/database-next/src/__tests__/fixtures/c1/b.ts new file mode 100644 index 000000000..fb88fac43 --- /dev/null +++ b/packages/database-next/src/__tests__/fixtures/c1/b.ts @@ -0,0 +1,6 @@ +import { extend } from '../../../database'; + +export default extend({ + name: 'tests', + fields: [{ type: 'string', name: 'n1' }], +}); diff --git a/packages/database-next/src/__tests__/fixtures/c2/a.ts b/packages/database-next/src/__tests__/fixtures/c2/a.ts new file mode 100644 index 000000000..fdd619202 --- /dev/null +++ b/packages/database-next/src/__tests__/fixtures/c2/a.ts @@ -0,0 +1,6 @@ +import { extend } from '../../../database'; + +export default { + name: 'tests', + fields: [{ type: 'string', name: 'n2' }], +}; diff --git a/packages/database-next/src/__tests__/fixtures/collections/delay-extend.ts b/packages/database-next/src/__tests__/fixtures/collections/delay-extend.ts new file mode 100644 index 000000000..7d53adcff --- /dev/null +++ b/packages/database-next/src/__tests__/fixtures/collections/delay-extend.ts @@ -0,0 +1,6 @@ +import { extend } from '../../../database'; + +export default extend({ + name: 'images', + fields: [{ type: 'string', name: 'url' }], +}); diff --git a/packages/database-next/src/__tests__/fixtures/collections/delay-extend2.ts b/packages/database-next/src/__tests__/fixtures/collections/delay-extend2.ts new file mode 100644 index 000000000..97d36b26d --- /dev/null +++ b/packages/database-next/src/__tests__/fixtures/collections/delay-extend2.ts @@ -0,0 +1,6 @@ +import { extend } from '../../../database'; + +export default extend({ + name: 'images', + fields: [{ type: 'string', name: 'url2' }], +}); diff --git a/packages/database-next/src/__tests__/fixtures/collections/extend.ts b/packages/database-next/src/__tests__/fixtures/collections/extend.ts new file mode 100644 index 000000000..19f976170 --- /dev/null +++ b/packages/database-next/src/__tests__/fixtures/collections/extend.ts @@ -0,0 +1,6 @@ +import { extend } from '../../../database'; + +export default extend({ + name: 'tags', + fields: [{ type: 'string', name: 'color' }], +}); diff --git a/packages/database-next/src/__tests__/fixtures/collections/extend2.ts b/packages/database-next/src/__tests__/fixtures/collections/extend2.ts new file mode 100644 index 000000000..35f8dc050 --- /dev/null +++ b/packages/database-next/src/__tests__/fixtures/collections/extend2.ts @@ -0,0 +1,6 @@ +import { extend } from '../../../database'; + +export default extend({ + name: 'tags', + fields: [{ type: 'string', name: 'color2' }], +}); diff --git a/packages/database-next/src/__tests__/fixtures/collections/posts.ts b/packages/database-next/src/__tests__/fixtures/collections/posts.ts new file mode 100644 index 000000000..eb99a4920 --- /dev/null +++ b/packages/database-next/src/__tests__/fixtures/collections/posts.ts @@ -0,0 +1,4 @@ +export default { + name: 'posts', + fields: [{ type: 'string', name: 'title' }], +}; diff --git a/packages/database-next/src/__tests__/fixtures/collections/tags.js b/packages/database-next/src/__tests__/fixtures/collections/tags.js new file mode 100644 index 000000000..c9e4cef08 --- /dev/null +++ b/packages/database-next/src/__tests__/fixtures/collections/tags.js @@ -0,0 +1,4 @@ +module.exports = { + name: 'tags', + fields: [{ type: 'string', name: 'name' }], +}; diff --git a/packages/database-next/src/__tests__/fixtures/collections/test.jpg b/packages/database-next/src/__tests__/fixtures/collections/test.jpg new file mode 100644 index 000000000..e69de29bb diff --git a/packages/database-next/src/__tests__/fixtures/collections/user.json b/packages/database-next/src/__tests__/fixtures/collections/user.json new file mode 100644 index 000000000..0a8056d85 --- /dev/null +++ b/packages/database-next/src/__tests__/fixtures/collections/user.json @@ -0,0 +1,9 @@ +{ + "name": "users", + "fields": [ + { + "type": "string", + "name": "name" + } + ] +} diff --git a/packages/database-next/src/__tests__/index.ts b/packages/database-next/src/__tests__/index.ts index bc70cdd80..a702c8949 100644 --- a/packages/database-next/src/__tests__/index.ts +++ b/packages/database-next/src/__tests__/index.ts @@ -9,31 +9,27 @@ export function generatePrefixByPath() { .replace('.test.ts', '') .replace(/[^\w]/g, '_') .replace(/_+/g, '_'); - return key + 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, + return merge( + { + dialect: 'sqlite', + storage: options?.storage || ':memory:', + logging: options?.logging || false, + hooks: { + beforeDefine(model, options) { + options.tableName = `${generatePrefixByPath()}_${ + options.tableName || options.modelName || options.name.plural + }`; + }, }, }, - hooks: { - beforeDefine(model, options) { - options.tableName = `${generatePrefixByPath()}_${options.tableName || options.modelName || options.name.plural}`; - }, - }, - }, config || {}, options) as any; -}; + config || {}, + options, + ) as any; +} export function mockDatabase(options?: DatabaseOptions): Database { return new Database(getConfig(options)); diff --git a/packages/database-next/src/__tests__/operator/array-operator.test.ts b/packages/database-next/src/__tests__/operator/array-operator.test.ts new file mode 100644 index 000000000..607202f22 --- /dev/null +++ b/packages/database-next/src/__tests__/operator/array-operator.test.ts @@ -0,0 +1,171 @@ +import { mockDatabase } from '../index'; +import Database from '../../database'; + +describe('array field operator', function () { + let db: Database; + let Test; + + let t1; + let t2; + + afterEach(async () => { + await db.close(); + }); + + beforeEach(async () => { + db = mockDatabase(); + + Test = db.collection({ + name: 'test', + fields: [ + { type: 'array', name: 'selected' }, + { type: 'string', name: 'name' }, + ], + }); + + await db.sync({ force: true }); + + t1 = await Test.repository.create({ + values: { + selected: [1, 2, 'a', 'b'], + name: 't1', + }, + }); + + t2 = await Test.repository.create({ + values: { + selected: [11, 22, 'aa', 'bb', 'cc'], + name: 't2', + }, + }); + }); + + test('nested array field', async () => { + const User = db.collection({ + name: 'users', + fields: [ + { type: 'hasMany', name: 'posts' }, + { type: 'string', name: 'name' }, + ], + }); + + const Post = db.collection({ + name: 'posts', + fields: [ + { type: 'belongsTo', name: 'user' }, + { type: 'string', name: 'title' }, + { type: 'array', name: 'tags' }, + ], + }); + + await db.sync(); + + await User.repository.createMany({ + records: [ + { + name: 'u0', + posts: [{ title: 'u0p1' }], + }, + { + name: 'u1', + posts: [{ title: 'u1p1', tags: ['t1', 't2'] }], + }, + ], + }); + + let result = await User.repository.find({ + filter: { + 'posts.tags.$empty': true, + }, + }); + + expect(result.length).toEqual(1); + expect(result[0].get('name')).toEqual('u0'); + result = await User.repository.find({ + filter: { + 'posts.tags.$anyOf': ['t1'], + }, + }); + + expect(result.length).toEqual(1); + expect(result[0].get('name')).toEqual('u1'); + }); + + test('$match', async () => { + const filter1 = await Test.repository.find({ + filter: { + 'selected.$match': [2, 1, 'a', 'b'], + }, + }); + + expect(filter1.length).toEqual(1); + expect(filter1[0].get('name')).toEqual(t1.get('name')); + }); + + test('$notMatch', async () => { + const filter2 = await Test.repository.find({ + filter: { + 'selected.$notMatch': [1, 2, 'a', 'b'], + }, + }); + + expect(filter2.length).toEqual(1); + expect(filter2[0].get('name')).toEqual(t2.get('name')); + }); + + test('$anyOf', async () => { + const filter3 = await Test.repository.find({ + filter: { + 'selected.$anyOf': ['aa'], + }, + }); + + expect(filter3.length).toEqual(1); + expect(filter3[0].get('name')).toEqual(t2.get('name')); + }); + + test('$noneOf', async () => { + const filter = await Test.repository.find({ + filter: { + 'selected.$noneOf': ['aa'], + }, + }); + + expect(filter.length).toEqual(1); + expect(filter[0].get('name')).toEqual(t1.get('name')); + }); + + test('$empty', async () => { + const t3 = await Test.repository.create({ + values: { + name: 't3', + selected: [], + }, + }); + + const filter = await Test.repository.find({ + filter: { + 'selected.$empty': true, + }, + }); + expect(filter.length).toEqual(1); + expect(filter[0].get('name')).toEqual(t3.get('name')); + }); + + test('$notEmpty', async () => { + const t3 = await Test.repository.create({ + values: { + name: 't3', + selected: [], + }, + }); + + const filter = await Test.repository.find({ + filter: { + 'selected.$notEmpty': true, + }, + }); + + expect(filter.length).toEqual(2); + }); +}); diff --git a/packages/database-next/src/__tests__/operator/association-operator.test.ts b/packages/database-next/src/__tests__/operator/association-operator.test.ts new file mode 100644 index 000000000..855461ac1 --- /dev/null +++ b/packages/database-next/src/__tests__/operator/association-operator.test.ts @@ -0,0 +1,267 @@ +import Database from '../../database'; +import { Collection } from '../../collection'; + +import { mockDatabase } from '../index'; + +describe('association operator', () => { + let db: Database; + + let Group: Collection; + + let User: Collection; + let Profile: Collection; + let Post: Collection; + let Tag: Collection; + + afterEach(async () => { + await db.close(); + }); + + beforeEach(async () => { + db = mockDatabase(); + + Group = db.collection({ + name: 'groups', + fields: [ + { type: 'string', name: 'name' }, + { type: 'hasMany', name: 'users' }, + ], + }); + + User = db.collection({ + name: 'users', + fields: [ + { type: 'belongsTo', name: 'group' }, + + { type: 'string', name: 'name' }, + { + type: 'hasMany', + name: 'posts', + }, + ], + }); + + Post = db.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { + type: 'belongsTo', + name: 'user', + }, + { + type: 'belongsToMany', + name: 'tags', + }, + ], + }); + + Tag = db.collection({ + name: 'tags', + fields: [ + { type: 'string', name: 'name' }, + { + type: 'belongsToMany', + name: 'posts', + }, + ], + }); + + await db.sync({ + force: true, + }); + }); + + test('nested association', async () => { + const u1 = await User.repository.create({ + values: { + name: 'u1', + posts: [ + { + title: 'u1p1', + }, + ], + }, + }); + + const u2 = await User.repository.create({ + values: { + name: 'u2', + posts: [ + { + title: 'u1p1', + }, + { + title: 'u1p2', + tags: [ + { + name: 't1', + }, + ], + }, + ], + }, + }); + + const u3 = await User.repository.create({ + values: { + name: 'u3', + }, + }); + + let result = await User.repository.find({ + filter: { + 'posts.tags.$exists': true, + }, + }); + + expect(result.length).toEqual(1); + expect(result[0].get('id')).toEqual(u2.get('id')); + + result = await User.repository.find({ + filter: { + 'posts.tags.id.$exists': true, + }, + }); + + expect(result.length).toEqual(1); + expect(result[0].get('id')).toEqual(u2.get('id')); + }); + + test('belongs to', async () => { + const g1 = await Group.repository.create({ + values: { + name: 'g1', + }, + }); + + const u1 = await User.repository.create({ + values: { + name: 'u1', + group: g1['id'], + }, + }); + + const u2 = await User.repository.create({ + values: { + name: 'u2', + }, + }); + + let result = await User.repository.find({ + filter: { + 'group.id.$exists': true, + }, + }); + + expect(result.length).toEqual(1); + expect(result[0].get('name')).toEqual(u1.get('name')); + + result = await User.repository.find({ + filter: { + 'group.id.$notExists': true, + }, + }); + + expect(result.length).toEqual(1); + expect(result[0].get('name')).toEqual(u2.get('name')); + }); + + test('belongs to many', async () => { + const t1 = await Tag.repository.create({ + values: { + name: 't1', + }, + }); + + const t2 = await Tag.repository.create({ + values: { + name: 't2', + }, + }); + + const t3 = await Tag.repository.create({ + values: { + name: 't3', + }, + }); + + const p1 = await Post.repository.create({ + values: { + title: 'p1', + }, + }); + + // p2 belongs to many t1 t2 + const p2 = await Post.repository.create({ + values: { + title: 'p2', + tags: [t1['id'], t2['id']], + }, + }); + + const p3 = await Post.repository.create({ + values: { + title: 'p3', + }, + }); + + let result = await Post.repository.find({ + filter: { + 'tags.id.$exists': true, + }, + }); + + expect(result.length).toEqual(1); + expect(result[0].get('title')).toEqual(p2.get('title')); + + result = await Tag.repository.find({ + filter: { + 'posts.id.$exists': true, + }, + }); + + expect(result.length).toEqual(2); + expect(result.map((r) => r.get('id'))).toEqual([ + t1.get('id'), + t2.get('id'), + ]); + + result = await Tag.repository.find({ + filter: { + 'posts.id.$notExists': true, + }, + }); + expect(result.length).toEqual(1); + expect(result[0].get('id')).toEqual(t3.get('id')); + }); + + test('has many', async () => { + const u1 = await User.repository.create({ + values: { + name: 'u1', + posts: [ + { + title: 'p1', + }, + { title: 'p2' }, + ], + }, + }); + + const u2 = await User.repository.create({ + values: { + name: 'u2', + }, + }); + + const result = await User.repository.find({ + filter: { + 'posts.id.$exists': true, + }, + }); + + expect(result.length).toEqual(1); + expect(result[0].get('id')).toEqual(u1.get('id')); + }); +}); diff --git a/packages/database-next/src/__tests__/operator/date-operator.test.ts b/packages/database-next/src/__tests__/operator/date-operator.test.ts new file mode 100644 index 000000000..7317a3685 --- /dev/null +++ b/packages/database-next/src/__tests__/operator/date-operator.test.ts @@ -0,0 +1,163 @@ +import { mockDatabase } from '../index'; +import { Collection } from '../../collection'; +import Database from '../../database'; + +describe('date operator test', () => { + let db: Database; + + let User: Collection; + + beforeEach(async () => { + db = mockDatabase({ + logging: console.log, + }); + + User = db.collection({ + name: 'users', + fields: [ + { + name: 'birthday', + type: 'date', + }, + { + type: 'string', + name: 'name', + }, + ], + }); + + await db.sync(); + }); + + test('$dateOn', async () => { + const u0 = await User.repository.create({ + values: { + birthday: '1990-01-02 12:03:02', + name: 'u0', + }, + }); + + const user1 = await User.repository.create({ + values: { + birthday: '1990-01-01 12:03:02', + name: 'user1', + }, + }); + + const user = await User.repository.findOne({ + filter: { 'birthday.$dateOn': '1990-01-01' }, + }); + + expect(user.get('id')).toEqual(user1.get('id')); + }); + + test('$dateNotOn', async () => { + const u0 = await User.repository.create({ + values: { + birthday: '1990-01-02 12:03:02', + name: 'u0', + }, + }); + + const user1 = await User.repository.create({ + values: { + birthday: '1990-01-01 12:03:02', + name: 'user1', + }, + }); + + const user = await User.repository.findOne({ + filter: { 'birthday.$dateNotOn': '1990-01-01' }, + }); + + expect(user.get('id')).toEqual(u0.get('id')); + }); + + test('$dateBefore', async () => { + const u0 = await User.repository.create({ + values: { + birthday: '1990-05-01 12:03:02', + name: 'u0', + }, + }); + + const user1 = await User.repository.create({ + values: { + birthday: '1990-01-01 12:03:02', + name: 'user1', + }, + }); + + const user = await User.repository.findOne({ + filter: { 'birthday.$dateBefore': '1990-04-01' }, + }); + + expect(user.get('id')).toEqual(user1.get('id')); + }); + + test('$dateNotBefore', async () => { + const u0 = await User.repository.create({ + values: { + birthday: '1990-05-01 12:03:02', + name: 'u0', + }, + }); + + const user1 = await User.repository.create({ + values: { + birthday: '1990-01-01 12:03:02', + name: 'user1', + }, + }); + + const user = await User.repository.findOne({ + filter: { 'birthday.$dateNotBefore': '1990-04-01' }, + }); + + expect(user.get('id')).toEqual(u0.get('id')); + }); + + test('$dateAfter', async () => { + const u0 = await User.repository.create({ + values: { + birthday: '1990-05-01 12:03:02', + name: 'u0', + }, + }); + + const user1 = await User.repository.create({ + values: { + birthday: '1990-01-01 12:03:02', + name: 'user1', + }, + }); + + const user = await User.repository.findOne({ + filter: { 'birthday.$dateAfter': '1990-04-01' }, + }); + + expect(user.get('id')).toEqual(u0.get('id')); + }); + + test('$dateNotAfter', async () => { + const u0 = await User.repository.create({ + values: { + birthday: '1990-05-01 12:03:02', + name: 'u0', + }, + }); + + const user1 = await User.repository.create({ + values: { + birthday: '1990-01-01 12:03:02', + name: 'user1', + }, + }); + + const user = await User.repository.findOne({ + filter: { 'birthday.$dateNotAfter': '1990-04-01' }, + }); + + expect(user.get('id')).toEqual(user1.get('id')); + }); +}); diff --git a/packages/database-next/src/__tests__/operator/empty-operator.test.ts b/packages/database-next/src/__tests__/operator/empty-operator.test.ts new file mode 100644 index 000000000..0c62bb18d --- /dev/null +++ b/packages/database-next/src/__tests__/operator/empty-operator.test.ts @@ -0,0 +1,52 @@ +import Database from '../../database'; +import { Collection } from '../../collection'; + +import { mockDatabase } from '../index'; + +describe('empty operator', () => { + let db: Database; + + let User: Collection; + + afterEach(async () => { + await db.close(); + }); + + beforeEach(async () => { + db = mockDatabase({ + logging: console.log, + }); + + User = db.collection({ + name: 'users', + fields: [{ type: 'string', name: 'name' }], + }); + + await db.sync({ + force: true, + }); + }); + + test('string field empty', async () => { + const u1 = await User.repository.create({ + values: { + name: 'u1', + }, + }); + + const u2 = await User.repository.create({ + values: { + name: '', + }, + }); + + const result = await User.repository.find({ + filter: { + 'name.$empty': true, + }, + }); + + expect(result.length).toEqual(1); + expect(result[0].get('id')).toEqual(u2.get('id')); + }); +}); diff --git a/packages/database-next/src/__tests__/option-parser.test.ts b/packages/database-next/src/__tests__/option-parser.test.ts new file mode 100644 index 000000000..8ca6a49d6 --- /dev/null +++ b/packages/database-next/src/__tests__/option-parser.test.ts @@ -0,0 +1,166 @@ +import { Collection } from '../collection'; +import { mockDatabase } from './index'; +import { OptionsParser } from '../options-parser'; +import { Database } from '../database'; + +describe('option parser', () => { + let db: Database; + let User: Collection; + let Post: Collection; + let Comment: Collection; + let Tag: Collection; + + beforeEach(async () => { + const db = mockDatabase(); + User = db.collection<{ id: number; name: string }, { name: string }>({ + name: 'users', + fields: [ + { type: 'string', name: 'name' }, + { type: 'integer', name: 'age' }, + ], + }); + + Post = db.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { + type: 'belongsTo', + name: 'user', + }, + { + type: 'hasMany', + name: 'comments', + }, + { + type: 'belongsToMany', + name: 'tags', + }, + ], + }); + + Comment = db.collection({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + { type: 'belongsTo', name: 'posts' }, + ], + }); + + Tag = db.collection({ + name: 'tags', + fields: [{ type: 'string', name: 'name' }], + }); + await db.sync(); + }); + + test('fields with association', () => { + let options: any = { + fields: ['id', 'name', 'tags.id', 'tags.name'], + }; + + const parser = new OptionsParser( + Post.model, + Post.context.database, + options, + ); + const params = parser.toSequelizeParams(); + + expect(params).toEqual({ + attributes: ['id', 'name'], + include: [ + { + association: 'tags', + attributes: ['id', 'name'], + }, + ], + }); + }); + test('with sort option', () => { + let options: any = { + sort: ['id'], + }; + + let parser = new OptionsParser(User.model, User.context.database, options); + let params = parser.toSequelizeParams(); + expect(params['order']).toEqual([['id', 'ASC']]); + + options = { + sort: ['id', '-posts.title', 'posts.comments.createdAt'], + }; + + parser = new OptionsParser(User.model, User.context.database, options); + params = parser.toSequelizeParams(); + expect(params['order']).toEqual([ + ['id', 'ASC'], + [Post.model, 'title', 'DESC'], + [Post.model, Comment.model, 'createdAt', 'ASC'], + ]); + }); + + test('option parser with fields option', async () => { + let options: any = { + fields: ['id', 'posts'], + }; + // 转换为 attributes: ['id'], include: [{association: 'posts'}] + let parser = new OptionsParser(User.model, User.context.database, options); + let params = parser.toSequelizeParams(); + + expect(params['attributes']).toContain('id'); + expect(params['include'][0]['association']).toEqual('posts'); + + // only appends + options = { + appends: ['posts'], + }; + + parser = new OptionsParser(User.model, User.context.database, options); + params = parser.toSequelizeParams(); + expect(params['attributes']['include']).toEqual([]); + expect(params['include'][0]['association']).toEqual('posts'); + + // fields with association field + options = { + fields: ['id', 'posts.title'], + }; + + parser = new OptionsParser(User.model, User.context.database, options); + params = parser.toSequelizeParams(); + expect(params['attributes']).toContain('id'); + expect(params['include'][0]['association']).toEqual('posts'); + expect(params['include'][0]['attributes']).toContain('title'); + + // fields with nested field + options = { + fields: ['id', 'posts', 'posts.comments.content'], + }; + + parser = new OptionsParser(User.model, User.context.database, options); + params = parser.toSequelizeParams(); + expect(params['attributes']).toContain('id'); + expect(params['include'][0]['association']).toEqual('posts'); + expect(params['include'][0]['attributes']).toEqual({ include: [] }); + expect(params['include'][0]['include'][0]['association']).toEqual( + 'comments', + ); + + // fields with expect + options = { + except: ['id'], + }; + parser = new OptionsParser(User.model, User.context.database, options); + params = parser.toSequelizeParams(); + expect(params['attributes']['exclude']).toContain('id'); + + // expect with association + options = { + fields: ['posts'], + except: ['posts.id'], + }; + + parser = new OptionsParser(User.model, User.context.database, options); + params = parser.toSequelizeParams(); + + expect(params['include'][0]['attributes']['exclude']).toContain('id'); + }); +}); diff --git a/packages/database-next/src/__tests__/relation-repository/belongs-to-many-repository.test.ts b/packages/database-next/src/__tests__/relation-repository/belongs-to-many-repository.test.ts new file mode 100644 index 000000000..397c46f4b --- /dev/null +++ b/packages/database-next/src/__tests__/relation-repository/belongs-to-many-repository.test.ts @@ -0,0 +1,444 @@ +import { mockDatabase } from '../index'; +import { BelongsToManyRepository } from '../../relation-repository/belongs-to-many-repository'; + +describe('belongs to many', () => { + let db; + let Post; + let Tag; + let PostTag; + + beforeEach(async () => { + db = mockDatabase(); + PostTag = db.collection({ + name: 'posts_tags', + fields: [{ type: 'string', name: 'tagged_at' }], + }); + + Post = db.collection({ + name: 'posts', + fields: [ + { type: 'belongsToMany', name: 'tags', through: 'posts_tags' }, + { type: 'string', name: 'title' }, + ], + }); + + Tag = db.collection({ + name: 'tags', + fields: [ + { type: 'belongsToMany', name: 'posts', through: 'posts_tags' }, + { type: 'string', name: 'name' }, + ], + }); + + await db.sync({ force: true }); + }); + + afterEach(async () => { + await db.close(); + }); + + test('create with through values', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + }, + }); + + const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); + + await PostTagRepository.create({ + values: { + name: 't1', + posts_tags: { + tagged_at: '123', + }, + }, + }); + + const t1 = await PostTagRepository.findOne(); + expect(t1.posts_tags.tagged_at).toEqual('123'); + }); + + test('create', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + }, + }); + + const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); + + const t1 = await PostTagRepository.create({ + values: { + name: 't1', + }, + }); + + expect(t1).toBeDefined(); + + const t2 = await Tag.repository.create({ + values: { + name: 't2', + }, + }); + + await PostTagRepository.add(t2.id); + + const findResult = await PostTagRepository.find(); + expect(findResult.length).toEqual(2); + + const findFilterResult = await PostTagRepository.find({ + filter: { name: 't2' }, + }); + + expect(findFilterResult.length).toEqual(1); + expect(findFilterResult[0].name).toEqual('t2'); + }); + + test('find and count', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + tags: [{ name: 't1' }, { name: 't2' }], + }, + }); + + const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); + let [findResult, count] = await PostTagRepository.findAndCount(); + + expect(count).toEqual(2); + + [findResult, count] = await PostTagRepository.findAndCount({ + filter: { + name: 't1', + }, + }); + + expect(count).toEqual(1); + expect(findResult[0].name).toEqual('t1'); + }); + + test('find one', async () => { + const p1 = await Post.repository.create({ + values: { title: 'p1', tags: [{ name: 't1' }, { name: 't2' }] }, + }); + + const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); + let t1 = await PostTagRepository.findOne({ + filter: { + name: 't1', + }, + }); + + expect(t1.name).toEqual('t1'); + + t1 = await PostTagRepository.findOne({ + filter: { + name: 'tabcaa', + }, + }); + expect(t1).toBeNull(); + }); + + test('update raw attribute', async () => { + const otherTag = await Tag.repository.create({ + values: { name: 'other_tag' }, + }); + + const p1 = await Post.repository.create({ + values: { + title: 'p1', + tags: [{ name: 't1' }, { name: 't2' }], + }, + }); + + const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); + + // rename t1 to t3 + await PostTagRepository.update({ + filter: { + name: 't1', + }, + values: { + name: 't3', + }, + }); + + const t1 = await PostTagRepository.findOne({ + filter: { + name: 't1', + }, + }); + + expect(t1).toBeNull(); + + const t3 = await PostTagRepository.findOne({ + filter: { + name: 't3', + }, + }); + + expect(t3.name).toEqual('t3'); + + await PostTagRepository.update({ + values: { + name: 'updated', + }, + }); + + await otherTag.reload(); + expect(otherTag.name).toEqual('other_tag'); + }); + + test('update through table attribute', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'p1', + tags: [ + { + name: 't1', + posts_tags: { + tagged_at: '123', + }, + }, + { name: 't2' }, + ], + }, + }); + + const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); + + let t1 = await PostTagRepository.findOne({ + filter: { + name: 't1', + }, + }); + + expect(t1.posts_tags.tagged_at).toEqual('123'); + + const p2 = await Post.repository.create({ + values: { + title: 'p2', + tags: [t1.id], + }, + }); + + const Post2TagRepository = new BelongsToManyRepository(Post, 'tags', p2.id); + const p2Tag = await Post2TagRepository.findOne(); + expect(p2Tag.posts_tags.tagged_at).toBeNull(); + + // 设置p1与t1关联的tagged_at + await PostTagRepository.update({ + filter: { + name: 't1', + }, + values: { + posts_tags: { + tagged_at: '456', + }, + }, + }); + + await t1.reload(); + + expect(t1.posts_tags.tagged_at).toEqual('456'); + + await p2Tag.reload(); + // p2-tag1 still not change + expect(p2Tag.posts_tags.tagged_at).toBeNull(); + }); + + test('add', async () => { + let t1 = await Tag.repository.create({ + values: { name: 't1' }, + }); + + const p1 = await Post.repository.create({ + values: { title: 'p1' }, + }); + + const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); + await PostTagRepository.add([[t1.id, { tagged_at: '123' }]]); + + let p1Tag = await PostTagRepository.findOne(); + expect(p1Tag.posts_tags.tagged_at).toEqual('123'); + }); + + test('set', async () => { + let t1 = await Tag.repository.create({ + values: { name: 't1' }, + }); + + const t2 = await Tag.repository.create({ + values: { name: 't2' }, + }); + + const p1 = await Post.repository.create({ + values: { title: 'p1' }, + }); + + const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); + + await PostTagRepository.set([t1.id]); + + let p1Tags = await PostTagRepository.find(); + expect(p1Tags.length).toEqual(1); + + await PostTagRepository.set([[t1.id, { tagged_at: '999' }]]); + + t1 = await PostTagRepository.findOne({ + filter: { + name: 't1', + }, + }); + + expect(t1.posts_tags.tagged_at).toEqual('999'); + }); + + test('find by pk', async () => { + let t1 = await Tag.repository.create({ + values: { + name: 't1', + }, + }); + + const t2 = await Tag.repository.create({ + values: { + name: 't2', + }, + }); + + const p1 = await Post.repository.create({ + values: { title: 'p1' }, + }); + + const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); + + await PostTagRepository.set([t1.id, t2.id]); + + const findByPkResult = await PostTagRepository.findOne({ + filterByPk: t2.id, + }); + + expect(findByPkResult.name).toEqual('t2'); + }); + + test('toggle', async () => { + let t1 = await Tag.repository.create({ + values: { name: 't1' }, + }); + + const p1 = await Post.repository.create({ + values: { title: 'p1' }, + }); + + const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); + + await PostTagRepository.toggle(t1.id); + expect(await PostTagRepository.findOne()).not.toBeNull(); + + await PostTagRepository.toggle(t1.id); + expect(await PostTagRepository.findOne()).toBeNull(); + }); + + test('remove', async () => { + let t1 = await Tag.repository.create({ + values: { name: 't1' }, + }); + + const p1 = await Post.repository.create({ + values: { title: 'p1' }, + }); + + const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); + + await PostTagRepository.add(t1.id); + expect(await PostTagRepository.findOne()).not.toBeNull(); + + await PostTagRepository.remove(t1.id); + expect(await PostTagRepository.findOne()).toBeNull(); + }); + + test('destroy all', async () => { + let t1 = await Tag.repository.create({ + values: { + name: 't1', + }, + }); + + const t2 = await Tag.repository.create({ + values: { + name: 't2', + }, + }); + + const p1 = await Post.repository.create({ + values: { title: 'p1' }, + }); + + const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); + + await PostTagRepository.set([t1.id, t2.id]); + + await PostTagRepository.destroy(); + + const [_, count] = await PostTagRepository.findAndCount(); + expect(count).toEqual(0); + }); + + test('destroy with id', async () => { + let t1 = await Tag.repository.create({ + values: { + name: 't1', + }, + }); + + const t2 = await Tag.repository.create({ + values: { + name: 't2', + }, + }); + + const p1 = await Post.repository.create({ + values: { title: 'p1' }, + }); + + const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); + + await PostTagRepository.set([t1.id, t2.id]); + + await PostTagRepository.destroy(t2.id); + + const result = await PostTagRepository.findAndCount(); + expect(result[1]).toEqual(1); + }); + + test('transaction', async () => { + let t1 = await Tag.repository.create({ + values: { + name: 't1', + }, + }); + + const t2 = await Tag.repository.create({ + values: { + name: 't2', + }, + }); + + const p1 = await Post.repository.create({ + values: { title: 'p1' }, + }); + + const transaction = await Tag.model.sequelize.transaction(); + + const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); + + await PostTagRepository.set({ + pk: [t1.id, t2.id], + transaction, + }); + + await transaction.commit(); + }); +}); diff --git a/packages/database-next/src/__tests__/relation-repository/has-many-repository.test.ts b/packages/database-next/src/__tests__/relation-repository/has-many-repository.test.ts new file mode 100644 index 000000000..d52010811 --- /dev/null +++ b/packages/database-next/src/__tests__/relation-repository/has-many-repository.test.ts @@ -0,0 +1,245 @@ +import { mockDatabase } from '../index'; +import { HasManyRepository } from '../../relation-repository/hasmany-repository'; +import { BelongsToManyRepository } from '../../relation-repository/belongs-to-many-repository'; + +describe('has many repository', () => { + let db; + let User; + let Post; + let Comment; + let Tag; + + afterEach(async () => { + await db.close(); + }); + + beforeEach(async () => { + db = mockDatabase(); + User = db.collection({ + name: 'users', + fields: [ + { type: 'string', name: 'name' }, + { type: 'hasMany', name: 'posts' }, + ], + }); + + Post = db.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'belongsToMany', name: 'tags', through: 'posts_tags' }, + { type: 'hasMany', name: 'comments' }, + ], + }); + + Tag = db.collection({ + name: 'tags', + fields: [ + { type: 'belongsToMany', name: 'posts', through: 'posts_tags' }, + { type: 'string', name: 'name' }, + ], + }); + + Comment = db.collection({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + { type: 'belongsTo', name: 'post' }, + ], + }); + + await db.sync({ force: true }); + }); + + test('find', async () => { + const u1 = await User.repository.create({ + values: { name: 'u1' }, + }); + + const UserPostRepository = new HasManyRepository(User, 'posts', u1.id); + await UserPostRepository.create({ + values: { + title: 't1', + }, + }); + + const t1 = await UserPostRepository.findOne(); + expect(t1.title).toEqual('t1'); + }); + + test('create', async () => { + const u1 = await User.repository.create({ + values: { name: 'u1' }, + }); + + const UserPostRepository = new HasManyRepository(User, 'posts', u1.id); + const post = await UserPostRepository.create({ + values: { + title: 't1', + comments: [{ content: 'content1' }], + }, + }); + + expect(post.title).toEqual('t1'); + expect(post.userId).toEqual(u1.id); + }); + + test('update', async () => { + const u1 = await User.repository.create({ + values: { name: 'u1' }, + }); + + const UserPostRepository = new HasManyRepository(User, 'posts', u1.id); + await UserPostRepository.create({ + values: { + title: 't1', + comments: [{ content: 'content1' }], + }, + }); + + await UserPostRepository.update({ + filter: { + title: 't1', + }, + values: { + title: 'u1t1', + }, + }); + + const p1 = await UserPostRepository.findOne(); + expect(p1.title).toEqual('u1t1'); + }); + + test('find', async () => { + const u1 = await User.repository.create({ values: { name: 'u1' } }); + + const t1 = await Tag.repository.create({ values: { name: 't1' } }); + + const t2 = await Tag.repository.create({ values: { name: 't2' } }); + + const t3 = await Tag.repository.create({ values: { name: 't3' } }); + + const p1 = await Post.repository.create({ + values: { title: 'p1' }, + }); + + const p2 = await Post.repository.create({ + values: { title: 'p2', tags: [t1, t2, t3] }, + }); + + const p3 = await Post.repository.create({ + values: { title: 'p3', tags: [t1, t2, t3] }, + }); + const p4 = await Post.repository.create({ + values: { title: 'p4', tags: [t1, t2, t3] }, + }); + const p5 = await Post.repository.create({ + values: { title: 'p5', tags: [t1, t2, t3] }, + }); + const p6 = await Post.repository.create({ + values: { title: 'p6', tags: [t1, t2, t3] }, + }); + + const UserPostRepository = new HasManyRepository(User, 'posts', u1.id); + const ids = [p1, p2, p3, p4, p5, p6].map((p) => p.id); + await UserPostRepository.add(ids); + + const PostTagRepository = new BelongsToManyRepository(Post, 'tags', p1.id); + await PostTagRepository.set([t1.id, t2.id, t3.id]); + + const posts = await UserPostRepository.find({ + filter: { + 'tags.name': 't1', + }, + appends: ['tags'], + }); + + const post = posts[0]; + + expect(post.tags.length).toEqual(3); + + const findAndCount = await UserPostRepository.findAndCount({ + filter: { + 'tags.name.$like': 't%', + }, + }); + + expect(findAndCount[1]).toEqual(6); + }); + + test('destroy by pk', async () => { + const u1 = await User.repository.create({ + values: { name: 'u1' }, + }); + + const UserPostRepository = new HasManyRepository(User, 'posts', u1.id); + const p1 = await UserPostRepository.create({ + values: { + title: 't1', + }, + }); + + await UserPostRepository.destroy(p1.id); + + expect(await UserPostRepository.findOne()).toBeNull(); + }); + + test('destroy', async () => { + const u1 = await User.repository.create({ + values: { name: 'u1' }, + }); + + const UserPostRepository = new HasManyRepository(User, 'posts', u1.id); + const p1 = await UserPostRepository.create({ + values: { + title: 't1', + }, + }); + + await UserPostRepository.destroy(); + + expect(await UserPostRepository.findOne()).toBeNull(); + }); + + test('destroy by filter', async () => { + const u1 = await User.repository.create({ + values: { name: 'u1' }, + }); + + const UserPostRepository = new HasManyRepository(User, 'posts', u1.id); + const p1 = await UserPostRepository.create({ + values: { + title: 't1', + }, + }); + + await UserPostRepository.destroy({ + filter: { + title: 't1', + }, + }); + + expect(await UserPostRepository.findOne()).toBeNull(); + }); + + test('transaction', async () => { + const u1 = await User.repository.create({ + values: { name: 'u1' }, + }); + + const UserPostRepository = new HasManyRepository(User, 'posts', u1.id); + const p1 = await UserPostRepository.create({ + values: { + title: 't1', + }, + }); + + await UserPostRepository.destroy({ + filter: { + title: 't1', + }, + }); + + expect(await UserPostRepository.findOne()).toBeNull(); + }); +}); diff --git a/packages/database-next/src/__tests__/relation-repository/hasone-repository.test.ts b/packages/database-next/src/__tests__/relation-repository/hasone-repository.test.ts new file mode 100644 index 000000000..e0b15fca0 --- /dev/null +++ b/packages/database-next/src/__tests__/relation-repository/hasone-repository.test.ts @@ -0,0 +1,84 @@ +import { mockDatabase } from '../index'; +import { HasOneRepository } from '../../relation-repository/hasone-repository'; +import Database from '../../database'; +import { Collection } from '../../collection'; + +describe('has one repository', () => { + let db: Database; + + let User: Collection; + let Profile: Collection; + + afterEach(async () => { + await db.close(); + }); + + beforeEach(async () => { + db = mockDatabase(); + + User = db.collection({ + name: 'users', + fields: [ + { type: 'hasOne', name: 'profile' }, + { type: 'string', name: 'name' }, + ], + }); + + Profile = db.collection({ + name: 'profiles', + fields: [{ type: 'string', name: 'avatar' }], + }); + + await db.sync(); + }); + + test('find', async () => { + const user = await User.repository.create({ + values: { name: 'u1' }, + }); + + const UserProfileRepository = new HasOneRepository( + User, + 'profile', + user['id'], + ); + + let userProfile = await UserProfileRepository.create({ + values: { + avatar: 'test_avatar', + }, + }); + + expect(userProfile).toBeDefined(); + + userProfile = await UserProfileRepository.find(); + expect(userProfile['avatar']).toEqual('test_avatar'); + userProfile = await UserProfileRepository.find({ + fields: ['id'], + }); + expect(userProfile['id']).toBeDefined(); + expect(userProfile['avatar']).toBeUndefined(); + + await UserProfileRepository.remove(); + expect(await UserProfileRepository.find()).toBeNull(); + + const newProfile = await Profile.repository.create({ + values: { avatar: 'new_avatar' }, + }); + + await UserProfileRepository.set(newProfile['id']); + userProfile = await UserProfileRepository.find(); + expect(userProfile['id']).toEqual(newProfile['id']); + + await UserProfileRepository.update({ + values: { + avatar: 'new_updated_avatar', + }, + }); + expect((await UserProfileRepository.find())['avatar']).toEqual( + 'new_updated_avatar', + ); + await UserProfileRepository.destroy(); + expect(await UserProfileRepository.find()).toBeNull(); + }); +}); diff --git a/packages/database-next/src/__tests__/repository.test.ts b/packages/database-next/src/__tests__/repository.test.ts index a1d6668f1..7e658f4d6 100644 --- a/packages/database-next/src/__tests__/repository.test.ts +++ b/packages/database-next/src/__tests__/repository.test.ts @@ -29,98 +29,100 @@ describe('repository.find', () => { fields: [{ type: 'string', name: 'name' }], }); await db.sync(); - await User.repository.createMany([ - { - name: 'user1', - posts: [ - { - name: 'post11', - comments: [ - { name: 'comment111' }, - { name: 'comment112' }, - { name: 'comment113' }, - ], - }, - { - name: 'post12', - comments: [ - { name: 'comment121' }, - { name: 'comment122' }, - { name: 'comment123' }, - ], - }, - { - name: 'post13', - comments: [ - { name: 'comment131' }, - { name: 'comment132' }, - { name: 'comment133' }, - ], - }, - { - name: 'post14', - comments: [ - { name: 'comment141' }, - { name: 'comment142' }, - { name: 'comment143' }, - ], - }, - ], - }, - { - name: 'user2', - posts: [ - { - name: 'post21', - comments: [ - { name: 'comment211' }, - { name: 'comment212' }, - { name: 'comment213' }, - ], - }, - { - name: 'post22', - comments: [ - { name: 'comment221' }, - { name: 'comment222' }, - { name: 'comment223' }, - ], - }, - { - name: 'post23', - comments: [ - { name: 'comment231' }, - { name: 'comment232' }, - { name: 'comment233' }, - ], - }, - { name: 'post24' }, - ], - }, - { - name: 'user3', - posts: [ - { - name: 'post31', - comments: [ - { name: 'comment311' }, - { name: 'comment312' }, - { name: 'comment313' }, - ], - }, - { name: 'post32' }, - { - name: 'post33', - comments: [ - { name: 'comment331' }, - { name: 'comment332' }, - { name: 'comment333' }, - ], - }, - { name: 'post34' }, - ], - }, - ]); + await User.repository.createMany({ + records: [ + { + name: 'user1', + posts: [ + { + name: 'post11', + comments: [ + { name: 'comment111' }, + { name: 'comment112' }, + { name: 'comment113' }, + ], + }, + { + name: 'post12', + comments: [ + { name: 'comment121' }, + { name: 'comment122' }, + { name: 'comment123' }, + ], + }, + { + name: 'post13', + comments: [ + { name: 'comment131' }, + { name: 'comment132' }, + { name: 'comment133' }, + ], + }, + { + name: 'post14', + comments: [ + { name: 'comment141' }, + { name: 'comment142' }, + { name: 'comment143' }, + ], + }, + ], + }, + { + name: 'user2', + posts: [ + { + name: 'post21', + comments: [ + { name: 'comment211' }, + { name: 'comment212' }, + { name: 'comment213' }, + ], + }, + { + name: 'post22', + comments: [ + { name: 'comment221' }, + { name: 'comment222' }, + { name: 'comment223' }, + ], + }, + { + name: 'post23', + comments: [ + { name: 'comment231' }, + { name: 'comment232' }, + { name: 'comment233' }, + ], + }, + { name: 'post24' }, + ], + }, + { + name: 'user3', + posts: [ + { + name: 'post31', + comments: [ + { name: 'comment311' }, + { name: 'comment312' }, + { name: 'comment313' }, + ], + }, + { name: 'post32' }, + { + name: 'post33', + comments: [ + { name: 'comment331' }, + { name: 'comment332' }, + { name: 'comment333' }, + ], + }, + { name: 'post34' }, + ], + }, + ], + }); }); afterEach(async () => { @@ -136,25 +138,12 @@ describe('repository.find', () => { console.log(data); }); - it('findMany', async () => { - const data = await User.repository.findMany({ + it('find item', async () => { + const data = await User.repository.find({ filter: { 'posts.comments.id': null, }, - page: 1, - pageSize: 1, }); - console.log( - data.count, - JSON.stringify( - data.rows.map((row) => row.toJSON()), - null, - 2, - ), - ); - // expect(data.toJSON()).toMatchObject({ - // name: 'user3', - // }); }); }); @@ -193,17 +182,19 @@ describe('repository.create', () => { it('create', async () => { const user = await User.repository.create({ - name: 'user1', - posts: [ - { - name: 'post11', - comments: [ - { name: 'comment111' }, - { name: 'comment112' }, - { name: 'comment113' }, - ], - }, - ], + values: { + name: 'user1', + posts: [ + { + name: 'post11', + comments: [ + { name: 'comment111' }, + { name: 'comment112' }, + { name: 'comment113' }, + ], + }, + ], + }, }); const post = await Post.model.findOne(); expect(post).toMatchObject({ @@ -256,22 +247,25 @@ describe('repository.update', () => { const user = await User.model.create({ name: 'user1', }); - await User.repository.update( - { + await User.repository.update({ + filterByPk: user.id, + values: { name: 'user11', posts: [{ name: 'post1' }], }, - user, - ); + }); + const updated = await User.model.findByPk(user.id); expect(updated).toMatchObject({ name: 'user11', }); + const post = await Post.model.findOne({ where: { name: 'post1', }, }); + expect(post).toMatchObject({ name: 'post1', userId: user.id, @@ -283,13 +277,13 @@ describe('repository.update', () => { name: 'user1', posts: [{ name: 'post1' }], }); - await User.repository.update( - { + await User.repository.update({ + filterByPk: user.id, + values: { name: 'user11', posts: [{ name: 'post1' }], }, - user.id, - ); + }); const updated = await User.model.findByPk(user.id); expect(updated).toMatchObject({ name: 'user11', @@ -393,34 +387,31 @@ describe('repository.relatedQuery', () => { }); it('create', async () => { - const user = await User.repository.create(); - const post = await User.repository.relatedQuery('posts').for(user).create({ - name: 'post1', + const user = await User.repository.create({ + values: { + name: 'u1', + }, }); + + const userPostRepository = await User.repository + .relation('posts') + .of(user.get('id')); + + const post = await userPostRepository.create({ + values: { name: 'post1' }, + }); + expect(post).toMatchObject({ name: 'post1', - userId: user.id, + userId: user.get('id'), + }); + + const post2 = await userPostRepository.create({ + values: { name: 'post2' }, }); - const post2 = await User.repository - .relatedQuery('posts') - .for(user.id) - .create({ - name: 'post2', - }); expect(post2).toMatchObject({ name: 'post2', - userId: user.id, - }); - }); - - it('update', async () => { - const post = await Post.repository.create({ - user: { - name: 'user11', - } - }); - await Post.repository.relatedQuery('user').for(post).update({ - name: 'user12', + userId: user.get('id'), }); }); }); diff --git a/packages/database-next/src/__tests__/respsitory/count.test.ts b/packages/database-next/src/__tests__/respsitory/count.test.ts new file mode 100644 index 000000000..60afd4434 --- /dev/null +++ b/packages/database-next/src/__tests__/respsitory/count.test.ts @@ -0,0 +1,177 @@ +import { mockDatabase } from '../index'; +import { HasManyRepository } from '../../relation-repository/hasmany-repository'; +import { Collection } from '../../collection'; + +describe('count', () => { + let db; + let User: Collection; + let Post: Collection; + let Tag; + + beforeEach(async () => { + db = mockDatabase(); + User = db.collection({ + name: 'users', + fields: [ + { type: 'string', name: 'name' }, + { type: 'hasMany', name: 'posts' }, + ], + }); + + Post = db.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'belongsTo', name: 'user' }, + { type: 'belongsToMany', name: 'tags' }, + ], + }); + + Tag = db.collection({ + name: 'tags', + fields: [ + { type: 'string', name: 'name' }, + { type: 'belongsToMany', name: 'posts' }, + ], + }); + await db.sync(); + }); + + test('count with association', async () => { + const user1 = await User.repository.create({ + values: { + name: 'u1', + }, + }); + + const t1 = await Tag.repository.create({ + values: { + name: 't1', + }, + }); + + const t2 = await Tag.repository.create({ + values: { + name: 't2', + }, + }); + + const t3 = await Tag.repository.create({ + values: { + name: 't3', + }, + }); + + const UserPostRepository = + User.repository.relation('posts'); + + await UserPostRepository.of(user1['id']).create({ + values: { + title: 'u1p1', + tags: [t1, t2, t3], + }, + }); + + await UserPostRepository.of(user1['id']).create({ + values: { + title: 'u1p2', + tags: [t1, t2, t3], + }, + }); + + await UserPostRepository.of(user1['id']).create({ + values: { + title: 'u1p3', + tags: [t1, t2, t3], + }, + }); + + expect(await Post.repository.count()).toEqual(3); + + expect( + await Post.repository.count({ + filter: { + 'tags.name': 't1', + }, + }), + ).toEqual(3); + + let posts = await Post.repository.findAndCount(); + expect(posts[1]).toEqual(3); + + posts = await Post.repository.findAndCount({ + filter: { + title: 'u1p1', + }, + }); + + expect(posts[0][0]['tags']).toBeUndefined(); + + posts = await Post.repository.findAndCount({ + filter: { + title: 'u1p1', + }, + appends: ['tags'], + }); + + expect(posts[0][0]['tags']).toBeDefined(); + }); + + test('without filter params', async () => { + const repository = User.repository; + + await repository.createMany({ + records: [ + { + name: 'u1', + age: 10, + posts: [{ title: 'u1t1', comments: ['u1t1c1'] }], + }, + { + name: 'u2', + age: 20, + posts: [{ title: 'u2t1', comments: ['u2t1c1'] }], + }, + { + name: 'u3', + age: 30, + posts: [{ title: 'u3t1', comments: ['u3t1c1'] }], + }, + ], + }); + + expect(await User.repository.count()).toEqual(3); + }); + + test('with filter params', async () => { + const repository = User.repository; + + await repository.createMany({ + records: [ + { + name: 'u1', + age: 10, + posts: [{ title: 'u1t1', comments: ['u1t1c1'] }], + }, + { + name: 'u2', + age: 20, + posts: [{ title: 'u2t1', comments: ['u2t1c1'] }], + }, + { + name: 'u3', + age: 30, + posts: [{ title: 'u3t1', comments: ['u3t1c1'] }], + }, + ], + }); + + expect( + await User.repository.count({ + filter: { + name: 'u1', + }, + }), + ).toEqual(1); + }); +}); diff --git a/packages/database-next/src/__tests__/respsitory/create.test.ts b/packages/database-next/src/__tests__/respsitory/create.test.ts new file mode 100644 index 000000000..5f3d3860a --- /dev/null +++ b/packages/database-next/src/__tests__/respsitory/create.test.ts @@ -0,0 +1,39 @@ +import { mockDatabase } from '../index'; +import Database from '../../database'; + +describe('create', () => { + let db: Database; + let User; + let Post; + + beforeEach(async () => { + db = mockDatabase(); + User = db.collection({ + name: 'users', + fields: [ + { type: 'string', name: 'name' }, + { type: 'hasMany', name: 'posts' }, + ], + }); + + Post = db.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'belongsTo', name: 'user' }, + ], + }); + await db.sync(); + }); + test('create with association', async () => { + const u1 = await User.repository.create({ + values: { + name: 'u1', + posts: [{ title: 'u1p1' }], + }, + }); + + expect(u1.name).toEqual('u1'); + expect(await u1.countPosts()).toEqual(1); + }); +}); diff --git a/packages/database-next/src/__tests__/respsitory/destroy.test.ts b/packages/database-next/src/__tests__/respsitory/destroy.test.ts new file mode 100644 index 000000000..616a0bcf1 --- /dev/null +++ b/packages/database-next/src/__tests__/respsitory/destroy.test.ts @@ -0,0 +1,98 @@ +import { mockDatabase } from '../index'; +import { Collection } from '../../collection'; + +describe('destroy', () => { + let db; + let User: Collection; + let Post: Collection; + + beforeEach(async () => { + db = mockDatabase(); + User = db.collection({ + name: 'users', + fields: [ + { type: 'string', name: 'name' }, + { type: 'hasMany', name: 'posts' }, + ], + }); + + Post = db.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'belongsTo', name: 'user' }, + ], + }); + await db.sync(); + }); + + test('destroy all', async () => { + await User.repository.create({ + values: { + name: 'u1', + posts: [{ title: 'u1p1' }], + }, + }); + + await User.repository.destroy(); + expect(await User.repository.count()).toEqual(1); + await User.repository.destroy({ truncate: true }); + expect(await User.repository.count()).toEqual(0); + }); + + test('destroy with filter', async () => { + await User.repository.createMany({ + records: [ + { + name: 'u1', + }, + { + name: 'u3', + }, + { + name: 'u2', + }, + ], + }); + + await User.repository.destroy({ + filter: { + name: 'u1', + }, + }); + + expect( + await User.repository.findOne({ + filter: { + name: 'u1', + }, + }), + ).toBeNull(); + expect(await User.repository.count()).toEqual(2); + }); + + test('destroy with filterByPK', async () => { + await User.repository.createMany({ + records: [ + { + name: 'u1', + }, + { + name: 'u3', + }, + { + name: 'u2', + }, + ], + }); + + const u2 = await User.repository.findOne({ + filter: { + name: 'u2', + }, + }); + + await User.repository.destroy(u2['id']); + expect(await User.repository.count()).toEqual(2); + }); +}); diff --git a/packages/database-next/src/__tests__/respsitory/find.test.ts b/packages/database-next/src/__tests__/respsitory/find.test.ts new file mode 100644 index 000000000..a6c7996b8 --- /dev/null +++ b/packages/database-next/src/__tests__/respsitory/find.test.ts @@ -0,0 +1,204 @@ +import { mockDatabase } from '../index'; +import Database from '@nocobase/database'; +import { Collection } from '../../collection'; +import { OptionsParser } from '../../options-parser'; + +describe('repository find', () => { + let db: Database; + let User: Collection; + let Post: Collection; + let Comment: Collection; + beforeEach(async () => { + const db = mockDatabase(); + User = db.collection<{ id: number; name: string }, { name: string }>({ + name: 'users', + fields: [ + { type: 'string', name: 'name' }, + { type: 'integer', name: 'age' }, + ], + }); + + Post = db.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { + type: 'belongsTo', + name: 'user', + }, + { + type: 'hasMany', + name: 'comments', + }, + ], + }); + + Comment = db.collection({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + { type: 'belongsTo', name: 'posts' }, + ], + }); + + await db.sync(); + const repository = User.repository; + + await repository.createMany({ + records: [ + { + name: 'u1', + age: 10, + posts: [{ title: 'u1t1', comments: ['u1t1c1'] }], + }, + { + name: 'u2', + age: 20, + posts: [{ title: 'u2t1', comments: ['u2t1c1'] }], + }, + { + name: 'u3', + age: 30, + posts: [{ title: 'u3t1', comments: ['u3t1c1'] }], + }, + ], + }); + }); + + describe('findOne', () => { + test('find one with attribute', async () => { + const user = await User.repository.findOne({ + filter: { + name: 'u2', + }, + }); + expect(user['name']).toEqual('u2'); + }); + + test('find one with relation', async () => { + const user = await User.repository.findOne({ + filter: { + 'posts.title': 'u2t1', + }, + }); + expect(user['name']).toEqual('u2'); + }); + + test('find one with fields', async () => { + const user = await User.repository.findOne({ + filter: { + name: 'u2', + }, + fields: ['id'], + appends: ['posts'], + except: ['posts.id'], + }); + + const data = user.toJSON(); + expect(Object.keys(data)).toEqual(['id', 'posts']); + expect(Object.keys(data['posts'])).not.toContain('id'); + }); + }); + + describe('find', () => { + test('find with logic or', async () => { + const users = await User.repository.findAndCount({ + filter: { + $or: [{ 'posts.title': 'u1t1' }, { 'posts.title': 'u2t1' }], + }, + }); + + expect(users[1]).toEqual(2); + }); + + test('find with fields', async () => { + let user = await User.repository.findOne({ + fields: ['name'], + }); + + expect(user['name']).toBeDefined(); + expect(user['age']).toBeUndefined(); + expect(user['posts']).toBeUndefined(); + + user = await User.repository.findOne({ + fields: ['name', 'posts'], + }); + + expect(user['posts']).toBeDefined(); + }); + + describe('find with appends', () => { + test('filter attribute', async () => { + const user = await User.repository.findOne({ + filter: { + name: 'u1', + }, + appends: ['posts'], + }); + + expect(user['posts']).toBeDefined(); + }); + + test('filter association attribute', async () => { + const user2 = await User.repository.findOne({ + filter: { + 'posts.title': 'u1t1', + }, + appends: ['posts'], + }); + expect(user2['posts']).toBeDefined(); + }); + + test('without appends', async () => { + const user3 = await User.repository.findOne({ + filter: { + 'posts.title': 'u1t1', + }, + }); + + expect(user3['posts']).toBeUndefined(); + }); + }); + + describe('find all', () => { + test('without params', async () => { + expect((await User.repository.find()).length).toEqual(3); + }); + test('with limit', async () => { + expect( + ( + await User.repository.find({ + limit: 1, + }) + ).length, + ).toEqual(1); + }); + }); + + describe('find and count', () => { + test('without params', async () => { + expect((await User.repository.findAndCount())[1]).toEqual(3); + }); + }); + + test('find with filter', async () => { + const results = await User.repository.find({ + filter: { + name: 'u1', + }, + }); + + expect(results.length).toEqual(1); + }); + + test('find with association', async () => { + const results = await User.repository.find({ + filter: { + 'posts.title': 'u1t1', + }, + }); + + expect(results.length).toEqual(1); + }); + }); +}); diff --git a/packages/database-next/src/__tests__/update-associations.test.ts b/packages/database-next/src/__tests__/update-associations.test.ts index 4148c75aa..8e7a4dd30 100644 --- a/packages/database-next/src/__tests__/update-associations.test.ts +++ b/packages/database-next/src/__tests__/update-associations.test.ts @@ -4,20 +4,25 @@ import { updateAssociation, updateAssociations } from '../update-associations'; import { mockDatabase } from './'; describe('update associations', () => { - describe('belongsTo', () => { let db: Database; beforeEach(() => { db = mockDatabase(); }); + afterEach(async () => { await db.close(); }); + it('post.user', async () => { - const User = db.collection({ + const User = db.collection< + { id: string; name: string }, + { name: string } + >({ name: 'users', fields: [{ type: 'string', name: 'name' }], }); + const Post = db.collection({ name: 'posts', fields: [ @@ -25,12 +30,16 @@ describe('update associations', () => { { type: 'belongsTo', name: 'user' }, ], }); + await db.sync(); - const user = await User.model.create({ name: 'user1' }); + + const user = await User.model.create({ name: 'user1' }); const post1 = await Post.model.create({ name: 'post1' }); + await updateAssociations(post1, { user, }); + expect(post1.toJSON()).toMatchObject({ id: 1, name: 'post1', @@ -40,21 +49,25 @@ describe('update associations', () => { name: 'user1', }, }); + const post2 = await Post.model.create({ name: 'post2' }); await updateAssociations(post2, { - user: user.id, + user: user.getDataValue('id'), }); + expect(post2.toJSON()).toMatchObject({ id: 2, name: 'post2', userId: 1, }); + const post3 = await Post.model.create({ name: 'post3' }); await updateAssociations(post3, { user: { name: 'user3', }, }); + expect(post3.toJSON()).toMatchObject({ id: 3, name: 'post3', @@ -64,13 +77,15 @@ describe('update associations', () => { name: 'user3', }, }); + const post4 = await Post.model.create({ name: 'post4' }); await updateAssociations(post4, { user: { - id: user.id, + id: user.getDataValue('id'), name: 'user4', }, }); + expect(post4.toJSON()).toMatchObject({ id: 4, name: 'post4', @@ -98,9 +113,7 @@ describe('update associations', () => { }); Post = db.collection({ name: 'posts', - fields: [ - { type: 'string', name: 'name' }, - ], + fields: [{ type: 'string', name: 'name' }], }); await db.sync(); }); @@ -120,16 +133,18 @@ describe('update associations', () => { { name: 'post1', userId: user1.id, - } + }, ], }); }); it('user.posts', async () => { const user1 = await User.model.create({ name: 'user1' }); await updateAssociations(user1, { - posts: [{ - name: 'post1', - }], + posts: [ + { + name: 'post1', + }, + ], }); expect(user1.toJSON()).toMatchObject({ name: 'user1', @@ -137,7 +152,7 @@ describe('update associations', () => { { name: 'post1', userId: user1.id, - } + }, ], }); }); @@ -202,7 +217,7 @@ describe('update associations', () => { }, post2.id, post3, - ] + ], }); console.log(JSON.stringify(user1, null, 2)); expect(user1.toJSON()).toMatchObject({ @@ -263,8 +278,41 @@ describe('update associations', () => { await db.close(); }); + test('create many with nested associations', async () => { + await User.repository.createMany({ + records: [ + { + name: 'u1', + posts: [ + { + name: 'u1p1', + comments: [ + { + name: 'u1p1c1', + }, + ], + }, + ], + }, + { + name: 'u2', + posts: [ + { + name: 'u2p1', + comments: [ + { + name: 'u2p1c1', + }, + ], + }, + ], + }, + ], + }); + }); + it('nested', async () => { - const user = await User.model.create({ name: 'user1' }); + const user = await User.model.create({ name: 'user1' }); await updateAssociations(user, { posts: [ { @@ -273,22 +321,92 @@ describe('update associations', () => { { name: 'comment1', }, + { + name: 'comment12', + }, + ], + }, + { + name: 'post2', + comments: [ + { + name: 'comment21', + }, + { + name: 'comment22', + }, ], }, ], }); + const post1 = await Post.model.findOne({ - where: { name: 'post1' } + where: { name: 'post1' }, }); + const comment1 = await Comment.model.findOne({ - where: { name: 'comment1' } + where: { name: 'comment1' }, }); + expect(post1).toMatchObject({ userId: user.get('id'), }); + expect(comment1).toMatchObject({ postId: post1.get('id'), }); }); }); + + describe('belongsToMany', () => { + let db; + let Post; + let Tag; + let PostTag; + + beforeEach(async () => { + db = mockDatabase(); + PostTag = db.collection({ + name: 'posts_tags', + fields: [{ type: 'string', name: 'tagged_at' }], + }); + Post = db.collection({ + name: 'posts', + fields: [ + { type: 'belongsToMany', name: 'tags', through: 'posts_tags' }, + { type: 'string', name: 'title' }, + ], + }); + + Tag = db.collection({ + name: 'tags', + fields: [ + { type: 'belongsToMany', name: 'posts', through: 'posts_tags' }, + { type: 'string', name: 'name' }, + ], + }); + + await db.sync(); + }); + + test('set through value', async () => { + const p1 = await Post.repository.create({ + values: { + title: 'hello', + tags: [ + { + name: 't1', + posts_tags: { + tagged_at: '123', + }, + }, + { name: 't2' }, + ], + }, + }); + + const t1 = (await p1.getTags())[0]; + expect(t1.posts_tags.tagged_at).toEqual('123'); + }); + }); }); diff --git a/packages/database-next/src/__tests__/update-guard.test.ts b/packages/database-next/src/__tests__/update-guard.test.ts new file mode 100644 index 000000000..2c96b12a5 --- /dev/null +++ b/packages/database-next/src/__tests__/update-guard.test.ts @@ -0,0 +1,372 @@ +import { Collection } from '../collection'; +import { mockDatabase } from './index'; +import { UpdateGuard } from '../update-guard'; +import lodash from 'lodash'; +import { Database } from '../database'; + +describe('update-guard', () => { + let db: Database; + let User: Collection; + let Post: Collection; + let Comment: Collection; + + beforeEach(async () => { + db = mockDatabase(); + + User = db.collection({ + name: 'users', + fields: [ + { type: 'string', name: 'name' }, + { type: 'integer', name: 'age' }, + { type: 'hasMany', name: 'posts' }, + ], + }); + + Post = db.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'string', name: 'content' }, + { + type: 'belongsTo', + name: 'user', + }, + { + type: 'hasMany', + name: 'comments', + }, + ], + }); + + Comment = db.collection({ + name: 'comments', + fields: [ + { type: 'string', name: 'content' }, + { type: 'string', name: 'comment_as' }, + { type: 'belongsTo', name: 'post' }, + ], + }); + + await db.sync(); + const repository = User.repository; + + await repository.createMany({ + records: [ + { + name: 'u1', + age: 10, + posts: [{ title: 'u1t1', comments: [{ content: 'u1t1c1' }] }], + }, + { + name: 'u2', + age: 20, + posts: [{ title: 'u2t1', comments: ['u2t1c1'] }], + }, + { + name: 'u3', + age: 30, + posts: [{ title: 'u3t1', comments: ['u3t1c1'] }], + }, + ], + }); + }); + + afterEach(async () => { + await db.close(); + }); + + test('white list', () => { + const values = { + name: '123', + age: 30, + }; + const guard = new UpdateGuard(); + guard.setModel(User.model); + guard.setWhiteList(['name']); + + expect(guard.sanitize(values)).toEqual({ + name: '123', + }); + }); + + test('black list', () => { + const values = { + name: '123', + age: 30, + }; + + const guard = new UpdateGuard(); + guard.setModel(User.model); + guard.setBlackList(['name']); + + expect(guard.sanitize(values)).toEqual({ + age: 30, + }); + }); + + test('association black list', () => { + const values = { + name: 'username123', + age: 30, + posts: [ + { + title: 'post-title123', + content: '345', + }, + ], + }; + + const guard = new UpdateGuard(); + guard.setModel(User.model); + guard.setBlackList(['name', 'posts']); + + expect(guard.sanitize(values)).toEqual({ + age: 30, + }); + }); + + test('association fields black list', () => { + const values = { + name: 'username123', + age: 30, + posts: [ + { + title: 'post-title123', + content: '345', + }, + ], + }; + + const guard = new UpdateGuard(); + guard.setModel(User.model); + guard.setBlackList(['name', 'posts.content']); + + expect(guard.sanitize(values)).toEqual({ + age: 30, + posts: values.posts.map((p) => { + return { + title: p.title, + }; + }), + }); + }); + + test('association subfield white list', () => { + const values = { + name: 'username123', + age: 30, + posts: [ + { + title: 'post-title123', + content: '345', + }, + ], + }; + + const guard = new UpdateGuard(); + guard.setModel(User.model); + guard.setWhiteList(['name', 'posts.title']); + + expect(guard.sanitize(values)).toEqual({ + name: values.name, + posts: values.posts.map((post) => { + return { + title: post.title, + }; + }), + }); + }); + + test('association nested fields white list', () => { + const values = { + name: 'username123', + age: 30, + posts: [ + { + title: 'post-title123', + content: '345', + comments: [1, 2, 3], + }, + ], + }; + + const guard = new UpdateGuard(); + guard.setModel(User.model); + guard.setWhiteList(['name', 'posts.comments']); + + expect(guard.sanitize(values)).toEqual({ + name: values.name, + posts: values.posts.map((post) => { + return { + comments: post.comments, + }; + }), + }); + }); + + test('association white list', () => { + const values = { + name: '123', + age: 30, + posts: [ + { + title: '123', + content: '345', + }, + ], + }; + + const guard = new UpdateGuard(); + guard.setModel(User.model); + guard.setWhiteList(['posts']); + + expect(guard.sanitize(values)).toEqual({ + posts: values.posts, + }); + }); + + test('associationKeysToBeUpdate', () => { + const values = { + name: '123', + age: 30, + posts: [ + { + title: '123', + content: '345', + }, + { + id: 1, + title: '456', + content: '789', + }, + ], + }; + + const guard = new UpdateGuard(); + guard.setModel(User.model); + + expect(guard.sanitize(values)).toEqual({ + name: '123', + age: 30, + posts: [ + { + title: '123', + content: '345', + }, + { + id: 1, + }, + ], + }); + + guard.setAssociationKeysToBeUpdate(['posts']); + expect(guard.sanitize(values)).toEqual(values); + }); + + test('associationKeysToBeUpdate nested association', () => { + const values = { + name: '123', + age: 30, + posts: [ + { + title: '123', + content: '345', + }, + { + id: 1, + title: '456', + content: '789', + comments: [ + { + id: 1, + content: '123', + }, + ], + }, + ], + }; + + const guard = new UpdateGuard(); + guard.setModel(User.model); + + expect(guard.sanitize(lodash.clone(values))).toEqual({ + name: '123', + age: 30, + posts: [ + { + title: '123', + content: '345', + }, + { + id: 1, + comments: [ + { + id: 1, + }, + ], + }, + ], + }); + + guard.setAssociationKeysToBeUpdate(['posts']); + + expect(guard.sanitize(lodash.clone(values))).toEqual({ + name: '123', + age: 30, + posts: [ + { + title: '123', + content: '345', + }, + { + id: 1, + title: '456', + content: '789', + comments: [ + { + id: 1, + }, + ], + }, + ], + }); + }); +}); + +describe('One2One Association', () => { + test('associationKeysToBeUpdate hasOne & BelongsTo', () => { + const db = mockDatabase(); + const Post = db.collection({ + name: 'posts', + fields: [{ type: 'belongsTo', name: 'user', targetKey: 'uid' }], + }); + + const User = db.collection({ + name: 'users', + fields: [{ type: 'string', name: 'uid', unique: true }], + }); + + const values = { + title: '123', + content: '456', + user: { + uid: 1, + name: '123', + }, + }; + + const guard = new UpdateGuard(); + guard.setModel(Post.model); + + expect(guard.sanitize(values)).toEqual({ + title: '123', + content: '456', + user: { + uid: 1, + }, + }); + + guard.setAssociationKeysToBeUpdate(['user']); + expect(guard.sanitize(values)).toEqual(values); + }); +}); diff --git a/packages/database-next/src/collection-importer.ts b/packages/database-next/src/collection-importer.ts new file mode 100644 index 000000000..77a91eeaa --- /dev/null +++ b/packages/database-next/src/collection-importer.ts @@ -0,0 +1,50 @@ +import * as fs from 'fs'; +import path from 'path'; +import lodash from 'lodash'; + +export type ImportFileExtension = 'js' | 'ts' | 'json'; + +async function requireModule(module: any) { + if (typeof module === 'string') { + module = require(module); + } + + if (typeof module !== 'object') { + return module; + } + return module.__esModule ? module.default : module; +} + +export class ImporterReader { + directory: string; + extensions: Set; + + constructor(directory: string, extensions?: ImportFileExtension[]) { + this.directory = directory; + + if (!extensions) { + extensions = ['js', 'ts', 'json']; + } + + this.extensions = new Set(extensions); + } + + async read() { + const modules = ( + await fs.promises.readdir(this.directory, { + encoding: 'utf-8', + }) + ) + .filter((fileName) => + this.extensions.has(path.parse(fileName).ext.replace('.', '')), + ) + .map( + async (fileName) => + await requireModule(path.join(this.directory, fileName)), + ); + + return (await Promise.all(modules)).filter((module) => + lodash.isPlainObject(module), + ); + } +} diff --git a/packages/database-next/src/collection.ts b/packages/database-next/src/collection.ts index cea603c3d..bf612eae0 100644 --- a/packages/database-next/src/collection.ts +++ b/packages/database-next/src/collection.ts @@ -1,27 +1,39 @@ -import { Sequelize, ModelCtor, Model, DataTypes, Utils } from 'sequelize'; +import { Sequelize, ModelCtor, Model, ModelOptions } from 'sequelize'; import { EventEmitter } from 'events'; import { Database } from './database'; -import { Field } from './fields'; +import { Field, FieldOptions } from './fields'; + import _ from 'lodash'; import { Repository } from './repository'; +import { SyncOptions } from 'sequelize/types/lib/sequelize'; +import lodash from 'lodash'; +import merge from 'deepmerge'; +const { hooks } = require('sequelize/lib/hooks'); -export interface CollectionOptions { +export type RepositoryType = typeof Repository; + +export interface CollectionOptions extends Omit { name: string; tableName?: string; - fields?: any; - [key: string]: any; + fields?: FieldOptions[]; + model?: string | ModelCtor; + repository?: string | RepositoryType; } export interface CollectionContext { database: Database; } -export class Collection extends EventEmitter { +export class Collection< + TModelAttributes extends {} = any, + TCreationAttributes extends {} = TModelAttributes, +> extends EventEmitter { options: CollectionOptions; context: CollectionContext; - fields: Map; - model: ModelCtor; - repository: Repository; + isThrough?: boolean; + fields: Map = new Map(); + model: ModelCtor>; + repository: Repository; get name() { return this.options.name; @@ -29,23 +41,62 @@ export class Collection extends EventEmitter { constructor(options: CollectionOptions, context?: CollectionContext) { super(); - this.options = options; this.context = context; - this.fields = new Map(); - this.model = class extends Model {}; - const attributes = {}; - const { name, tableName } = options; - // TODO: 不能重复 model.init,如果有涉及 InitOptions 参数修改,需要另外处理。 - this.model.init(attributes, { - ..._.omit(options, ['name', 'fields']), - sequelize: context.database.sequelize, - modelName: name, - tableName: tableName || name, - }); - this.on('field.afterAdd', (field) => field.bind()); - this.on('field.afterRemove', (field) => field.unbind()); + this.options = options; + this.bindFieldEventListener(); + this.modelInit(); this.setFields(options.fields); - this.repository = new Repository(this); + this.setRepository(options.repository); + } + + private sequelizeModelOptions() { + const { name, tableName } = this.options; + return { + ..._.omit(this.options, ['name', 'fields']), + modelName: name, + sequelize: this.context.database.sequelize, + tableName: tableName || name, + }; + } + + /** + * TODO + */ + modelInit() { + if (this.model) { + return; + } + const { name, model } = this.options; + let M = Model; + if (this.context.database.sequelize.isDefined(name)) { + const m = this.context.database.sequelize.model(name); + if ((m as any).isThrough) { + this.model = m; + return; + } + } + if (typeof model === 'string') { + M = this.context.database.models.get(model) || Model; + } else if (model) { + M = model; + } + this.model = class extends M {}; + this.model.init(null, this.sequelizeModelOptions()); + } + + setRepository(repository?: RepositoryType | string) { + let repo = Repository; + if (typeof repository === 'string') { + repo = this.context.database.repositories.get(repository) || Repository; + } + this.repository = new repo(this); + } + + private bindFieldEventListener() { + this.on('field.afterAdd', (field: Field) => { + field.bind(); + }); + this.on('field.afterRemove', (field) => field.unbind()); } forEachField(callback: (field: Field) => void) { @@ -64,36 +115,44 @@ export class Collection extends EventEmitter { return this.fields.get(name); } - addField(options) { - const { name, ...others } = options; - if (!name) { - return this; - } - const { database } = this.context; - const field = database.buildField({ name, ...others }, { - ...this.context, - collection: this, - model: this.model, - }); - this.fields.set(name, field); - this.emit('field.afterAdd', field); + addField(name: string, options: Omit): Field { + return this.setField(name, options); } - setFields(fields: any, reset = true) { - if (!fields) { - return this; + setField(name: string, options: Omit): Field { + const { database } = this.context; + + const field = database.buildField( + { name, ...options }, + { + ...this.context, + collection: this, + }, + ); + + this.fields.set(name, field); + this.emit('field.afterAdd', field); + return field; + } + + setFields(fields: FieldOptions[], resetFields = true) { + if (!Array.isArray(fields)) { + return; } - if (reset) { - this.fields.clear(); + + if (resetFields) { + this.resetFields(); } - if (Array.isArray(fields)) { - for (const field of fields) { - this.addField(field); - } - } else if (typeof fields === 'object') { - for (const [name, options] of Object.entries(fields)) { - this.addField({...options, name}); - } + + for (const { name, ...options } of fields) { + this.addField(name, options); + } + } + + resetFields() { + const fieldNames = this.fields.keys(); + for (const fieldName of fieldNames) { + this.removeField(fieldName); } } @@ -106,13 +165,73 @@ export class Collection extends EventEmitter { return bool; } - // TODO - extend(options) { - const { fields } = options; - this.setFields(fields); + /** + * TODO + * + * @param name + * @param options + */ + updateOptions(options: CollectionOptions, mergeOptions?: any) { + let newOptions = lodash.cloneDeep(options); + newOptions = merge(this.options, newOptions, mergeOptions); + + this.context.database.emit('beforeUpdateCollection', this, newOptions); + + this.setFields(options.fields, false); + this.setRepository(options.repository); + + if (newOptions.hooks) { + this.setUpHooks(newOptions.hooks); + } + + this.context.database.emit('afterUpdateCollection', this); } - sync() { - + setUpHooks(bindHooks) { + (this.model)._setupHooks(bindHooks); + } + + /** + * TODO + * + * @param name + * @param options + */ + updateField(name: string, options: FieldOptions) { + if (!this.hasField(name)) { + throw new Error(`field ${name} not exists`); + } + + if (options.name && options.name !== name) { + this.removeField(name); + } + + this.setField(options.name || name, options); + } + + async sync(syncOptions?: SyncOptions) { + const modelNames = [this.model.name]; + + const associations = this.model.associations; + + for (const associationKey in associations) { + const association = associations[associationKey]; + modelNames.push(association.target.name); + if ((association).through) { + modelNames.push((association).through.model.name); + } + } + + const models: ModelCtor[] = []; + // @ts-ignore + this.context.database.sequelize.modelManager.forEachModel((model) => { + if (modelNames.includes(model.name)) { + models.push(model); + } + }); + + for (const model of models) { + await model.sync(syncOptions); + } } } diff --git a/packages/database-next/src/database.ts b/packages/database-next/src/database.ts index 64acf66de..ef1cd153d 100644 --- a/packages/database-next/src/database.ts +++ b/packages/database-next/src/database.ts @@ -7,26 +7,62 @@ import { Op, Utils, } from 'sequelize'; + import { EventEmitter } from 'events'; -import { Collection, CollectionOptions } from './collection'; +import { Collection, CollectionOptions, RepositoryType } from './collection'; import * as FieldTypes from './fields'; -import { RelationField } from './fields'; +import { + BaseFieldOptions, + Field, + FieldContext, + FieldOptions, + RelationField, +} from './fields'; +import { applyMixins, AsyncEmitter } from '@nocobase/utils'; + +import merge from 'deepmerge'; +import { ModelHook } from './model-hook'; +import { ImporterReader, ImportFileExtension } from './collection-importer'; + +import extendOperators from './operators'; + +export interface MergeOptions extends merge.Options {} export interface PendingOptions { field: RelationField; model: ModelCtor; } +interface MapOf { + [key: string]: T; +} + export type DatabaseOptions = Options | Sequelize; -export class Database extends EventEmitter { +interface RegisterOperatorsContext { + db?: Database; + path?: string; + field?: Field; +} + +type OperatorFunc = (value: any, ctx?: RegisterOperatorsContext) => any; + +export class Database extends EventEmitter implements AsyncEmitter { sequelize: Sequelize; fieldTypes = new Map(); - models = new Map(); - repositories = new Map(); + models = new Map>(); + repositories = new Map(); operators = new Map(); - collections: Map; + collections = new Map(); pendingFields = new Map(); + modelCollection = new Map, Collection>(); + + modelHook: ModelHook; + + delayCollectionExtend = new Map< + string, + { collectionOptions: CollectionOptions; mergeOptions?: any }[] + >(); constructor(options: DatabaseOptions) { super(); @@ -38,14 +74,22 @@ export class Database extends EventEmitter { } this.collections = new Map(); + this.modelHook = new ModelHook(this); - this.on('collection.afterDefine', (collection) => { - const items = this.pendingFields.get(collection.name); - for (const field of items || []) { - field.bind(); - } + this.on('afterDefineCollection', (collection: Collection) => { + // after collection defined, call bind method on pending fields + this.pendingFields.get(collection.name)?.forEach((field) => field.bind()); + this.delayCollectionExtend + .get(collection.name) + ?.forEach((collectionExtend) => { + collection.updateOptions( + collectionExtend.collectionOptions, + collectionExtend.mergeOptions, + ); + }); }); + // register database field types for (const [name, field] of Object.entries(FieldTypes)) { if (['Field', 'RelationField'].includes(name)) { continue; @@ -57,35 +101,53 @@ export class Database extends EventEmitter { }); } - const operators = new Map(); - - // Sequelize 内置 - for (const key in Op) { - operators.set('$' + key, Op[key]); - const val = Utils.underscoredIf(key, true); - operators.set('$' + val, Op[key]); - operators.set('$' + val.replace(/_/g, ''), Op[key]); - } - - this.operators = operators; + this.initOperators(); } - collection(options: CollectionOptions) { - let collection = this.collections.get(options.name); - if (collection) { - collection.extend(options); - } else { - collection = new Collection(options, { database: this }); - } + /** + * Add collection to database + * @param options + */ + collection( + options: CollectionOptions, + ): Collection { + this.emit('beforeDefineCollection', options); + + const collection = new Collection(options, { + database: this, + }); + this.collections.set(collection.name, collection); - this.emit('collection.afterDefine', collection); + this.modelCollection.set(collection.model, collection); + + this.emit('afterDefineCollection', collection); + return collection; } - getCollection(name: string) { + /** + * get exists collection by its name + * @param name + */ + getCollection(name: string): Collection { return this.collections.get(name); } + hasCollection(name: string): boolean { + return this.collections.has(name); + } + + removeCollection(name: string) { + const collection = this.collections.get(name); + this.emit('beforeRemoveCollection', collection); + + const result = this.collections.delete(name); + + if (result) { + this.emit('afterRemoveCollection', collection); + } + } + addPendingField(field: RelationField) { const associating = this.pendingFields; const items = this.pendingFields.get(field.target) || []; @@ -102,33 +164,54 @@ export class Database extends EventEmitter { } } - registerFieldTypes(fieldTypes: any) { + registerFieldTypes(fieldTypes: MapOf) { for (const [type, fieldType] of Object.entries(fieldTypes)) { this.fieldTypes.set(type, fieldType); } } - registerModels(models: any) { + registerModels(models: MapOf>) { for (const [type, schemaType] of Object.entries(models)) { this.models.set(type, schemaType); } } - registerRepositories(repositories: any) { + registerRepositories(repositories: MapOf) { for (const [type, schemaType] of Object.entries(repositories)) { this.repositories.set(type, schemaType); } } - registerOperators(operators) { + initOperators() { + const operators = new Map(); + + // Sequelize 内置 + for (const key in Op) { + operators.set('$' + key, Op[key]); + const val = Utils.underscoredIf(key, true); + operators.set('$' + val, Op[key]); + operators.set('$' + val.replace(/_/g, ''), Op[key]); + } + + this.operators = operators; + + this.registerOperators({ + ...extendOperators, + }); + } + + registerOperators(operators: MapOf) { for (const [key, operator] of Object.entries(operators)) { this.operators.set(key, operator); } } - buildField(options, context) { + buildField(options, context: FieldContext) { const { type } = options; const Field = this.fieldTypes.get(type); + if (!Field) { + throw Error(`unsupported field type ${type}`); + } return new Field(options, context); } @@ -147,4 +230,71 @@ export class Database extends EventEmitter { async close() { return this.sequelize.close(); } + + on(event: string | symbol, listener: (...args: any[]) => void): this { + const modelEventName = this.modelHook.isModelHook(event); + + if (modelEventName && !this.modelHook.hasBindEvent(modelEventName)) { + this.sequelize.addHook( + modelEventName, + this.modelHook.sequelizeHookBuilder(modelEventName), + ); + + this.modelHook.bindEvent(modelEventName); + } + + return super.on(event, listener); + } + + async import(options: { + directory: string; + extensions?: ImportFileExtension[]; + }): Promise> { + const reader = new ImporterReader(options.directory, options.extensions); + const modules = await reader.read(); + const result = new Map(); + + for (const module of modules) { + if (module.extend) { + const collectionName = module.collectionOptions.name; + const existCollection = this.getCollection(collectionName); + if (existCollection) { + existCollection.updateOptions( + module.collectionOptions, + module.mergeOptions, + ); + } else { + const existDelayExtends = + this.delayCollectionExtend.get(collectionName) || []; + + this.delayCollectionExtend.set(collectionName, [ + ...existDelayExtends, + module, + ]); + } + } else { + const collection = this.collection(module); + result.set(collection.name, collection); + } + } + + return result; + } + + emitAsync: (event: string | symbol, ...args: any[]) => Promise; } + +export function extend( + collectionOptions: CollectionOptions, + mergeOptions?: MergeOptions, +) { + return { + collectionOptions, + mergeOptions, + extend: true, + }; +} + +applyMixins(Database, [AsyncEmitter]); + +export default Database; diff --git a/packages/database-next/src/fields/array-field.ts b/packages/database-next/src/fields/array-field.ts new file mode 100644 index 000000000..f48fbaaa3 --- /dev/null +++ b/packages/database-next/src/fields/array-field.ts @@ -0,0 +1,43 @@ +import { BaseColumnFieldOptions, Field } from './field'; +import { DataTypes } from 'sequelize'; + +export class ArrayField extends Field { + get dataType() { + if (this.database.sequelize.getDialect() === 'postgres') { + return DataTypes.JSONB; + } + + return DataTypes.JSON; + } + + sortValue(model) { + const oldValue = model.get(this.options.name); + if (oldValue) { + const newValue = oldValue.sort(); + model.set(this.options.name, newValue); + } + } + + bind() { + super.bind(); + + if (this.isSqlite()) { + this.collection.model.addHook( + 'beforeCreate', + 'array-field-sort', + this.sortValue.bind(this), + ); + } + } + + unbind() { + super.unbind(); + if (this.isSqlite()) { + this.collection.model.removeHook('beforeCreate', 'array-field-sort'); + } + } +} + +export interface ArrayFieldOptions extends BaseColumnFieldOptions { + type: 'array'; +} diff --git a/packages/database-next/src/fields/belongs-to-field.ts b/packages/database-next/src/fields/belongs-to-field.ts index ef095eee2..9c40a62e5 100644 --- a/packages/database-next/src/fields/belongs-to-field.ts +++ b/packages/database-next/src/fields/belongs-to-field.ts @@ -1,9 +1,12 @@ import { omit } from 'lodash'; import { Sequelize, ModelCtor, Model, DataTypes, Utils } from 'sequelize'; -import { RelationField } from './relation-field'; +import { BaseRelationFieldOptions, RelationField } from './relation-field'; +import { HasInverseField } from './has-inverse-field'; +import { BaseColumnFieldOptions, Field } from './field'; +import { HasManyField } from './has-many-field'; +import { BelongsToOptions as SequelizeBelongsToOptions } from 'sequelize/types/lib/associations/belongs-to'; export class BelongsToField extends RelationField { - static type = 'belongsTo'; get target() { @@ -14,23 +17,39 @@ export class BelongsToField extends RelationField { bind() { const { database, collection } = this.context; const Target = this.TargetModel; + + // if target model not exists, add it to pending field, + // it will bind later if (!Target) { database.addPendingField(this); return false; } + + if (collection.model.associations[this.name]) { + delete collection.model.associations[this.name]; + } + + // define relation on sequelize model const association = collection.model.belongsTo(Target, { as: this.name, ...omit(this.options, ['name', 'type', 'target']), }); + + // inverse relation + this.TargetModel.hasMany(collection.model); + // 建立关系之后从 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; } @@ -54,3 +73,9 @@ export class BelongsToField extends RelationField { collection.model.refreshAttributes(); } } + +export interface BelongsToFieldOptions + extends BaseRelationFieldOptions, + SequelizeBelongsToOptions { + type: 'belongsTo'; +} diff --git a/packages/database-next/src/fields/belongs-to-many-field.ts b/packages/database-next/src/fields/belongs-to-many-field.ts index 5a0cad0bc..e0e8acacf 100644 --- a/packages/database-next/src/fields/belongs-to-many-field.ts +++ b/packages/database-next/src/fields/belongs-to-many-field.ts @@ -1,9 +1,11 @@ import { omit } from 'lodash'; import { Sequelize, ModelCtor, Model, DataTypes, Utils } from 'sequelize'; -import { RelationField } from './relation-field'; +import { Collection } from '../collection'; +import { BaseRelationFieldOptions, RelationField } from './relation-field'; +import { BaseColumnFieldOptions } from './field'; +import { BelongsToManyOptions as SequelizeBelongsToManyOptions } from 'sequelize/types/lib/associations/belongs-to-many'; export class BelongsToManyField extends RelationField { - get through() { return ( this.options.through || @@ -22,18 +24,27 @@ export class BelongsToManyField extends RelationField { return false; } const through = this.through; - let Through = - database.getCollection(through) || - database.collection({ + + let Through: Collection; + + if (database.hasCollection(through)) { + Through = database.getCollection(through); + } else { + Through = database.collection({ name: through, }); + Object.defineProperty(Through.model, 'isThrough', { value: true }); + } + 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; } @@ -51,3 +62,10 @@ export class BelongsToManyField extends RelationField { delete collection.model.associations[this.name]; } } + +export interface BelongsToManyFieldOptions + extends BaseRelationFieldOptions, + Omit { + type: 'belongsToMany'; + through?: string; +} diff --git a/packages/database-next/src/fields/boolean-field.ts b/packages/database-next/src/fields/boolean-field.ts new file mode 100644 index 000000000..ee97a43b6 --- /dev/null +++ b/packages/database-next/src/fields/boolean-field.ts @@ -0,0 +1,12 @@ +import { DataTypes } from 'sequelize'; +import { BaseColumnFieldOptions, Field } from './field'; + +export class BooleanField extends Field { + get dataType() { + return DataTypes.BOOLEAN; + } +} + +export interface BooleanFieldOptions extends BaseColumnFieldOptions { + type: 'boolean'; +} diff --git a/packages/database-next/src/fields/date-field.ts b/packages/database-next/src/fields/date-field.ts index 5de85c954..3d37b403d 100644 --- a/packages/database-next/src/fields/date-field.ts +++ b/packages/database-next/src/fields/date-field.ts @@ -1,8 +1,12 @@ import { DataTypes } from 'sequelize'; -import { Field } from './field'; +import { BaseColumnFieldOptions, Field } from './field'; export class DateField extends Field { get dataType() { return DataTypes.DATE; } } + +export interface DateFieldOptions extends BaseColumnFieldOptions { + type: 'date'; +} diff --git a/packages/database-next/src/fields/field.ts b/packages/database-next/src/fields/field.ts index 02502a0cb..373486560 100644 --- a/packages/database-next/src/fields/field.ts +++ b/packages/database-next/src/fields/field.ts @@ -1,14 +1,28 @@ -import { Sequelize, ModelCtor, Model, DataTypes, Utils } from 'sequelize'; -import { EventEmitter } from 'events'; import { Collection } from '../collection'; import { Database } from '../database'; import _ from 'lodash'; +import { + DataType, + ModelAttributeColumnOptions, + ModelIndexesOptions, +} from 'sequelize'; export interface FieldContext { database: Database; collection: Collection; } +export interface BaseFieldOptions { + name: string; +} + +export interface BaseColumnFieldOptions + extends BaseFieldOptions, + Omit { + dataType?: DataType; + index?: boolean | ModelIndexesOptions; +} + export abstract class Field { options: any; context: FieldContext; @@ -67,4 +81,8 @@ export abstract class Field { } return opts; } + + isSqlite() { + return this.database.sequelize.getDialect() === 'sqlite'; + } } diff --git a/packages/database-next/src/fields/float-field.ts b/packages/database-next/src/fields/float-field.ts deleted file mode 100644 index 8f83a44f6..000000000 --- a/packages/database-next/src/fields/float-field.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { DataTypes } from 'sequelize'; -import { Field } from './field'; - -export class FloatField extends Field { - get dataType() { - return DataTypes.FLOAT; - } -} diff --git a/packages/database-next/src/fields/has-inverse-field.ts b/packages/database-next/src/fields/has-inverse-field.ts new file mode 100644 index 000000000..7640e298a --- /dev/null +++ b/packages/database-next/src/fields/has-inverse-field.ts @@ -0,0 +1,5 @@ +import { Field } from './field'; + +export interface HasInverseField { + inverseField: () => Field; +} diff --git a/packages/database-next/src/fields/has-many-field.ts b/packages/database-next/src/fields/has-many-field.ts index fb9d062fc..fe7832d53 100644 --- a/packages/database-next/src/fields/has-many-field.ts +++ b/packages/database-next/src/fields/has-many-field.ts @@ -9,7 +9,9 @@ import { HasManyOptions, Utils, } from 'sequelize'; -import { RelationField } from './relation-field'; +import { BaseRelationFieldOptions, RelationField } from './relation-field'; +import { BaseColumnFieldOptions } from './field'; +import { HasManyOptions as SequelizeHasManyOptions } from 'sequelize/types/lib/associations/has-many'; export interface HasManyFieldOptions extends HasManyOptions { /** @@ -73,7 +75,6 @@ export interface HasManyFieldOptions extends HasManyOptions { } export class HasManyField extends RelationField { - get foreignKey() { if (this.options.foreignKey) { return this.options.foreignKey; @@ -82,8 +83,8 @@ export class HasManyField extends RelationField { return Utils.camelize( [ model.options.name.singular, - this.sourceKey || model.primaryKeyAttribute - ].join('_') + this.sourceKey || model.primaryKeyAttribute, + ].join('_'), ); } @@ -94,13 +95,23 @@ export class HasManyField extends RelationField { database.addPendingField(this); return false; } + + if (collection.model.associations[this.name]) { + delete collection.model.associations[this.name]; + } + const association = collection.model.hasMany(Target, { as: this.name, foreignKey: this.foreignKey, ...omit(this.options, ['name', 'type', 'target']), }); + + // inverse relation + this.TargetModel.belongsTo(collection.model); + // 建立关系之后从 pending 列表中删除 database.removePendingField(this); + if (!this.options.foreignKey) { this.options.foreignKey = association.foreignKey; } @@ -133,3 +144,9 @@ export class HasManyField extends RelationField { collection.model.refreshAttributes(); } } + +export interface HasManyFieldOptions + extends BaseRelationFieldOptions, + SequelizeHasManyOptions { + type: 'hasMany'; +} diff --git a/packages/database-next/src/fields/has-one-field.ts b/packages/database-next/src/fields/has-one-field.ts index 8d21f7e7a..2f0e3b7f0 100644 --- a/packages/database-next/src/fields/has-one-field.ts +++ b/packages/database-next/src/fields/has-one-field.ts @@ -9,7 +9,9 @@ import { HasOneOptions, Utils, } from 'sequelize'; -import { RelationField } from './relation-field'; +import { BaseRelationFieldOptions, RelationField } from './relation-field'; +import { BaseColumnFieldOptions } from './field'; +import { HasOneOptions as SequelizeHasOneOptions } from 'sequelize/types/lib/associations/has-one'; export interface HasOneFieldOptions extends HasOneOptions { /** @@ -73,7 +75,6 @@ export interface HasOneFieldOptions extends HasOneOptions { } export class HasOneField extends RelationField { - get target() { const { target, name } = this.options; return target || Utils.pluralize(name); @@ -85,10 +86,7 @@ export class HasOneField extends RelationField { } const { model } = this.context.collection; return Utils.camelize( - [ - model.options.name.singular, - model.primaryKeyAttribute - ].join('_') + [model.options.name.singular, model.primaryKeyAttribute].join('_'), ); } @@ -138,3 +136,9 @@ export class HasOneField extends RelationField { collection.model.refreshAttributes(); } } + +export interface HasOneFieldOptions + extends BaseRelationFieldOptions, + SequelizeHasOneOptions { + type: 'hasOne'; +} diff --git a/packages/database-next/src/fields/index.ts b/packages/database-next/src/fields/index.ts index 7e9dab58a..05e39fdac 100644 --- a/packages/database-next/src/fields/index.ts +++ b/packages/database-next/src/fields/index.ts @@ -1,9 +1,60 @@ -export * from './field'; -export * from './string-field'; -export * from './relation-field' -export * from './belongs-to-field' +import { StringFieldOptions } from './string-field'; + +import { BooleanFieldOptions } from './boolean-field'; +import { BelongsToFieldOptions } from './belongs-to-field'; +import { HasOneFieldOptions } from './has-one-field'; +import { HasManyFieldOptions } from './has-many-field'; +import { BelongsToManyFieldOptions } from './belongs-to-many-field'; +import { + DecimalFieldOptions, + DoubleFieldOptions, + FloatFieldOptions, + IntegerFieldOptions, + RealFieldOptions, +} from './number-field'; +import { JsonbFieldOptions, JsonFieldOptions } from './json-field'; +import { SortFieldOptions } from './sort-field'; +import { TextFieldOptions } from './text-field'; +import { VirtualFieldOptions } from './virtual-field'; +import { TimeFieldOptions } from './time-field'; +import { DateFieldOptions } from './date-field'; +import { ArrayFieldOptions } from './array-field'; + +export * from './array-field'; +export * from './belongs-to-field'; export * from './belongs-to-many-field'; -export * from './has-one-field'; +export * from './boolean-field'; +export * from './date-field'; export * from './has-many-field'; +export * from './has-one-field'; export * from './json-field'; +export * from './number-field'; +export * from './relation-field'; export * from './sort-field'; +export * from './string-field'; +export * from './text-field'; +export * from './time-field'; +export * from './uid-field'; +export * from './virtual-field'; +export * from './field'; + +export type FieldOptions = + | StringFieldOptions + | IntegerFieldOptions + | FloatFieldOptions + | DecimalFieldOptions + | DoubleFieldOptions + | RealFieldOptions + | JsonFieldOptions + | JsonbFieldOptions + | BooleanFieldOptions + | SortFieldOptions + | TextFieldOptions + | VirtualFieldOptions + | ArrayFieldOptions + | TimeFieldOptions + | DateFieldOptions + | BelongsToFieldOptions + | HasOneFieldOptions + | HasManyFieldOptions + | BelongsToManyFieldOptions; diff --git a/packages/database-next/src/fields/integer-field.ts b/packages/database-next/src/fields/integer-field.ts deleted file mode 100644 index eda93e17f..000000000 --- a/packages/database-next/src/fields/integer-field.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { DataTypes } from 'sequelize'; -import { Field } from './field'; - -export class IntegerField extends Field { - get dataType() { - return DataTypes.INTEGER; - } -} diff --git a/packages/database-next/src/fields/json-field.ts b/packages/database-next/src/fields/json-field.ts index 3641bbf7d..19ea21cdb 100644 --- a/packages/database-next/src/fields/json-field.ts +++ b/packages/database-next/src/fields/json-field.ts @@ -1,5 +1,5 @@ import { DataTypes } from 'sequelize'; -import { Field } from './field'; +import { BaseColumnFieldOptions, Field } from './field'; export class JsonField extends Field { get dataType() { @@ -7,6 +7,10 @@ export class JsonField extends Field { } } +export interface JsonFieldOptions extends BaseColumnFieldOptions { + type: 'json'; +} + export class JsonbField extends Field { get dataType() { const dialect = this.context.database.sequelize.getDialect(); @@ -16,3 +20,6 @@ export class JsonbField extends Field { return DataTypes.JSON; } } +export interface JsonbFieldOptions extends BaseColumnFieldOptions { + type: 'jsonb'; +} diff --git a/packages/database-next/src/fields/number-field.ts b/packages/database-next/src/fields/number-field.ts index b012d3d54..09b828000 100644 --- a/packages/database-next/src/fields/number-field.ts +++ b/packages/database-next/src/fields/number-field.ts @@ -1,5 +1,5 @@ import { DataTypes } from 'sequelize'; -import { Field } from './field'; +import { BaseColumnFieldOptions, Field } from './field'; export class IntegerField extends Field { get dataType() { @@ -7,26 +7,46 @@ export class IntegerField extends Field { } } +export interface IntegerFieldOptions extends BaseColumnFieldOptions { + type: 'integer'; +} + export class FloatField extends Field { get dataType() { return DataTypes.FLOAT; } } +export interface FloatFieldOptions extends BaseColumnFieldOptions { + type: 'float'; +} + export class DoubleField extends Field { get dataType() { return DataTypes.DOUBLE; } } +export interface DoubleFieldOptions extends BaseColumnFieldOptions { + type: 'double'; +} + export class RealField extends Field { get dataType() { return DataTypes.REAL; } } +export interface RealFieldOptions extends BaseColumnFieldOptions { + type: 'real'; +} + export class DecimalField extends Field { get dataType() { return DataTypes.DECIMAL; } } + +export interface DecimalFieldOptions extends BaseColumnFieldOptions { + type: 'decimal'; +} diff --git a/packages/database-next/src/fields/relation-field.ts b/packages/database-next/src/fields/relation-field.ts index fbefd216c..1fa365287 100644 --- a/packages/database-next/src/fields/relation-field.ts +++ b/packages/database-next/src/fields/relation-field.ts @@ -1,6 +1,11 @@ -import { Field } from './field'; +import { BaseFieldOptions, Field } from './field'; + +export interface BaseRelationFieldOptions extends BaseFieldOptions {} export abstract class RelationField extends Field { + /** + * target relation name + */ get target() { const { target, name } = this.options; return target || name; @@ -14,6 +19,10 @@ export abstract class RelationField extends Field { return this.options.sourceKey; } + /** + * get target model from database by it's name + * @constructor + */ get TargetModel() { return this.context.database.sequelize.models[this.target]; } diff --git a/packages/database-next/src/fields/sort-field.ts b/packages/database-next/src/fields/sort-field.ts index a47b98037..1aaf799b2 100644 --- a/packages/database-next/src/fields/sort-field.ts +++ b/packages/database-next/src/fields/sort-field.ts @@ -1,6 +1,6 @@ import { isNumber } from 'lodash'; import { DataTypes } from 'sequelize'; -import { Field } from './field'; +import { BaseColumnFieldOptions, Field } from './field'; export class SortField extends Field { get dataType() { @@ -23,3 +23,8 @@ export class SortField extends Field { }); } } + +export interface SortFieldOptions extends BaseColumnFieldOptions { + type: 'sort'; + scopeKey?: string; +} diff --git a/packages/database-next/src/fields/string-field.ts b/packages/database-next/src/fields/string-field.ts index b29d0061d..db7e60617 100644 --- a/packages/database-next/src/fields/string-field.ts +++ b/packages/database-next/src/fields/string-field.ts @@ -1,8 +1,12 @@ import { DataTypes } from 'sequelize'; -import { Field } from './field'; +import { BaseColumnFieldOptions, Field } from './field'; export class StringField extends Field { get dataType() { return DataTypes.STRING; } } + +export interface StringFieldOptions extends BaseColumnFieldOptions { + type: 'string'; +} diff --git a/packages/database-next/src/fields/text-field.ts b/packages/database-next/src/fields/text-field.ts index f049e407d..c1d15e4e4 100644 --- a/packages/database-next/src/fields/text-field.ts +++ b/packages/database-next/src/fields/text-field.ts @@ -1,8 +1,12 @@ import { DataTypes } from 'sequelize'; -import { Field } from './field'; +import { BaseColumnFieldOptions, Field } from './field'; export class TextField extends Field { get dataType() { return DataTypes.TEXT; } } + +export interface TextFieldOptions extends BaseColumnFieldOptions { + type: 'text'; +} diff --git a/packages/database-next/src/fields/time-field.ts b/packages/database-next/src/fields/time-field.ts index 9de30d5b7..f9a487b9b 100644 --- a/packages/database-next/src/fields/time-field.ts +++ b/packages/database-next/src/fields/time-field.ts @@ -1,8 +1,12 @@ import { DataTypes } from 'sequelize'; -import { Field } from './field'; +import { BaseColumnFieldOptions, Field } from './field'; export class TimeField extends Field { get dataType() { return DataTypes.TIME; } } + +export interface TimeFieldOptions extends BaseColumnFieldOptions { + type: 'time'; +} diff --git a/packages/database-next/src/fields/uid-field.ts b/packages/database-next/src/fields/uid-field.ts new file mode 100644 index 000000000..3e2be1ba3 --- /dev/null +++ b/packages/database-next/src/fields/uid-field.ts @@ -0,0 +1,23 @@ +import { DataTypes } from 'sequelize'; +import { uid } from '../utils'; +import { BaseColumnFieldOptions, Field } from './field'; + +export class UidField extends Field { + get dataType() { + return DataTypes.STRING; + } + + init() { + const { name, prefix = '' } = this.options; + const { model } = this.context.collection; + model.beforeCreate(async (instance) => { + if (!instance.get(name)) { + instance.set(name, `${prefix}${uid()}`); + } + }); + } +} + +export interface UidFieldOptions extends BaseColumnFieldOptions { + type: 'uid'; +} diff --git a/packages/database-next/src/fields/virtual-field.ts b/packages/database-next/src/fields/virtual-field.ts index 2a706f47a..7d03b1fe5 100644 --- a/packages/database-next/src/fields/virtual-field.ts +++ b/packages/database-next/src/fields/virtual-field.ts @@ -1,8 +1,12 @@ import { DataTypes } from 'sequelize'; -import { Field } from './field'; +import { BaseColumnFieldOptions, Field } from './field'; export class VirtualField extends Field { get dataType() { return DataTypes.VIRTUAL; } } + +export interface VirtualFieldOptions extends BaseColumnFieldOptions { + type: 'virtual'; +} diff --git a/packages/database-next/src/filter-parser.ts b/packages/database-next/src/filter-parser.ts new file mode 100644 index 000000000..7d01bbd2c --- /dev/null +++ b/packages/database-next/src/filter-parser.ts @@ -0,0 +1,220 @@ +import { Model, ModelCtor } from 'sequelize'; +import _ from 'lodash'; +import { flatten, unflatten } from 'flat'; +import { Database } from './database'; +import lodash from 'lodash'; + +const debug = require('debug')('noco-database'); + +type FilterType = any; + +export default class FilterParser { + database: Database; + model: ModelCtor; + filter: FilterType; + + constructor(model: ModelCtor, database: Database, filter: FilterType) { + this.model = model; + this.filter = this.prepareFilter(filter); + this.database = database; + } + + prepareFilter(filter: FilterType) { + if (lodash.isPlainObject(filter)) { + const renamedKey = {}; + + for (const key of Object.keys(filter)) { + if (key.endsWith('.$exists') || key.endsWith('.$notExists')) { + const keyArr = key.split('.'); + if (keyArr[keyArr.length - 2] == 'id') { + continue; + } + + keyArr.splice(keyArr.length - 1, 0, 'id'); + renamedKey[key] = keyArr.join('.'); + } + } + + for (const [oldKey, newKey] of Object.entries(renamedKey)) { + // @ts-ignore + filter[newKey] = filter[oldKey]; + delete filter[oldKey]; + } + } + + return filter; + } + + toSequelizeParams() { + debug('filter %o', this.filter); + + if (!this.filter) { + return {}; + } + + const filter = this.filter; + + const model = this.model; + + // supported operators + const operators = this.database.operators; + + const originalFiler = lodash.cloneDeep(filter || {}); + + const flattenedFilter = flatten(filter || {}); + + debug('flattened filter %o', flattenedFilter); + + const include = {}; + const where = {}; + const filter2 = lodash.cloneDeep(flattenedFilter); + + let skipPrefix = null; + const associations = model.associations; + debug('associations %O', associations); + + for (let [key, value] of Object.entries(flattenedFilter)) { + // 处理 filter 条件 + if (skipPrefix && key.startsWith(skipPrefix)) { + continue; + } + + debug('handle filter key "%s: "%s"', key, value); + let keys = key.split('.'); + + // paths ? + const paths = []; + + // origins ? + const origins = []; + + while (keys.length) { + debug('keys: %o, paths: %o, origins: %o', keys, paths, origins); + + // move key from keys to origins + const firstKey = keys.shift(); + origins.push(firstKey); + + debug('origins: %o', origins); + + if (firstKey.startsWith('$')) { + if (operators.has(firstKey)) { + debug('%s is operator', firstKey); + // if firstKey is operator + const opKey = operators.get(firstKey); + debug('operator key %s, operator: %o', firstKey, opKey); + + // 默认操作符 + if (typeof opKey === 'symbol') { + paths.push(opKey); + continue; + } else if (typeof opKey === 'function') { + skipPrefix = origins.join('.'); + + value = opKey(originalFiler[skipPrefix], { + db: this.database, + path: skipPrefix, + fieldName: skipPrefix.replace(`.${firstKey}`, ''), + model: this.model, + }); + break; + } + } else { + paths.push(firstKey); + continue; + } + } + + // firstKey is number + if (/\d+/.test(firstKey)) { + paths.push(firstKey); + continue; + } + + // firstKey is not association + if (!associations[firstKey]) { + paths.push(firstKey); + continue; + } + + const associationKeys = []; + + associationKeys.push(firstKey); + + debug('associationKeys %o', associationKeys); + + // set sequelize include option + _.set(include, firstKey, { + association: firstKey, + attributes: [], // out put empty fields by default + }); + + // association target model + let target = associations[firstKey].target; + debug('association target %o', target); + + while (target) { + const attr = keys.shift(); + origins.push(attr); + // if it is target model attribute + if (target.rawAttributes[attr]) { + associationKeys.push(attr); + target = null; + } else if (target.associations[attr]) { + // if it is target model association (nested association filter) + associationKeys.push(attr); + const assoc = []; + associationKeys.forEach((associationKey, index) => { + if (index > 0) { + assoc.push('include'); + } + assoc.push(associationKey); + }); + _.set(include, assoc, { + association: attr, + attributes: [], + }); + target = target.associations[attr].target; + } else { + throw new Error( + `${attr} neither ${firstKey}'s association nor ${firstKey}'s attribute`, + ); + } + } + + debug('associationKeys %o', associationKeys); + + if (associationKeys.length > 1) { + paths.push(`$${associationKeys.join('.')}$`); + } else { + paths.push(firstKey); + } + } + + debug('where %o, paths %o, value, %o', where, paths, value); + + const values = _.get(where, paths); + + if ( + values && + typeof values === 'object' && + value && + typeof value === 'object' + ) { + value = { ...value, ...values }; + } + _.set(where, paths, value); + } + + const toInclude = (items) => { + return Object.values(items).map((item: any) => { + if (item.include) { + item.include = toInclude(item.include); + } + return item; + }); + }; + debug('where %o, include %o', where, include); + return { where, include: toInclude(include) }; + } +} diff --git a/packages/database-next/src/index.ts b/packages/database-next/src/index.ts index e69de29bb..446c4e4d9 100644 --- a/packages/database-next/src/index.ts +++ b/packages/database-next/src/index.ts @@ -0,0 +1,4 @@ +export * from './database'; +export * from './collection'; +export * from './utils'; +export { Database as default } from './database'; diff --git a/packages/database-next/src/model-hook.ts b/packages/database-next/src/model-hook.ts new file mode 100644 index 000000000..55ba6bd6f --- /dev/null +++ b/packages/database-next/src/model-hook.ts @@ -0,0 +1,62 @@ +import Database from './database'; +import lodash from 'lodash'; +import { Model } from 'sequelize'; +import { SequelizeHooks } from 'sequelize/types/lib/hooks'; + +const { hooks } = require('sequelize/lib/hooks'); + +export class ModelHook { + database: Database; + boundEvent = new Set(); + + constructor(database: Database) { + this.database = database; + } + + isModelHook(eventName: string | symbol): keyof SequelizeHooks | false { + if (lodash.isString(eventName)) { + const hookType = eventName.split('.').pop(); + + if (hooks[hookType]) { + return hookType; + } + } + + return false; + } + + findModelName(hookArgs) { + for (const arg of hookArgs) { + if (arg instanceof Model) { + return (arg).constructor.name; + } + + if (lodash.isPlainObject(arg) && arg['model']) { + return arg['model'].name; + } + } + + return null; + } + + bindEvent(eventName) { + this.boundEvent.add(eventName); + } + + hasBindEvent(eventName) { + return this.boundEvent.has(eventName); + } + + sequelizeHookBuilder(eventName) { + return async (...args: any[]) => { + const modelName = this.findModelName(args); + if (modelName) { + // emit model event + await this.database.emitAsync(`${modelName}.${eventName}`, ...args); + } + + // emit sequelize global event + await this.database.emitAsync(eventName, ...args); + }; + } +} diff --git a/packages/database-next/src/operators/array.ts b/packages/database-next/src/operators/array.ts new file mode 100644 index 000000000..50a2ff0bf --- /dev/null +++ b/packages/database-next/src/operators/array.ts @@ -0,0 +1,158 @@ +import { Op, Sequelize } from 'sequelize'; + +const getFieldName = (ctx) => { + const fieldName = ctx.fieldName; + return fieldName; +}; + +const escape = (value, ctx) => { + const sequelize: Sequelize = ctx.db.sequelize; + return sequelize.escape(value); +}; + +const sqliteExistQuery = (value, ctx) => { + const fieldName = getFieldName(ctx); + + const sqlArray = `(${value + .map((v) => JSON.stringify(v.toString())) + .join(', ')})`; + + const subQuery = `exists (select * from json_each(${fieldName}) where json_each.value in ${sqlArray})`; + + return subQuery; +}; + +const emptyQuery = (ctx, operator: '=' | '>') => { + const fieldName = getFieldName(ctx); + + let funcName = 'json_array_length'; + let ifNull = 'IFNULL'; + + if (isPg(ctx)) { + funcName = 'jsonb_array_length'; + ifNull = 'coalesce'; + } + + if (isMySQL(ctx)) { + funcName = 'json_length'; + } + + return `(select ${ifNull}(${funcName}(${fieldName}), 0) ${operator} 0)`; +}; + +const getDialect = (ctx) => { + return ctx.db.sequelize.getDialect(); +}; + +const isPg = (ctx) => { + return getDialect(ctx) === 'postgres'; +}; + +const isMySQL = (ctx) => { + return getDialect(ctx) === 'mysql'; +}; + +export default { + $match(value, ctx) { + value = escape(JSON.stringify(value.sort()), ctx); + + const fieldName = getFieldName(ctx); + if (isPg(ctx)) { + return { + [Op.contained]: value, + [Op.contains]: value, + }; + } + + if (isMySQL(ctx)) { + return Sequelize.literal( + `JSON_CONTAINS(${fieldName}, ${value}) AND JSON_CONTAINS(${value}, ${fieldName})`, + ); + } + + return { + [Op.eq]: Sequelize.literal(`json(${value})`), + }; + }, + + $notMatch(value, ctx) { + const fieldName = getFieldName(ctx); + value = escape(JSON.stringify(value), ctx); + + if (isPg(ctx)) { + return Sequelize.literal( + `not (${fieldName} <@ ${escape( + value, + ctx, + )}::JSONB and ${fieldName} @> ${value}::JSONB)`, + ); + } + + if (isMySQL(ctx)) { + return Sequelize.literal( + `not (JSON_CONTAINS(${fieldName}, ${value}) AND JSON_CONTAINS(${value}, ${fieldName}))`, + ); + } + return { + [Op.ne]: Sequelize.literal(`json(${value})`), + }; + }, + + // TODO sql injection + $anyOf(value, ctx) { + if (isPg(ctx)) { + return { + [Op.contains]: value, + }; + } + + if (isMySQL(ctx)) { + const fieldName = getFieldName(ctx); + value = escape(JSON.stringify(value), ctx); + return Sequelize.literal(`JSON_OVERLAPS(${fieldName}, ${value})`); + } + + const subQuery = sqliteExistQuery(value, ctx); + + return Sequelize.literal(subQuery); + }, + + $noneOf(value, ctx) { + if (isPg(ctx)) { + const fieldName = getFieldName(ctx); + // pg single quote + const queryValue = JSON.stringify(value).replace("'", "''"); + return Sequelize.literal( + `not (${fieldName} @> ${escape(queryValue, ctx)}::JSONB)`, + ); + } + + if (isMySQL(ctx)) { + const fieldName = getFieldName(ctx); + value = escape(JSON.stringify(value), ctx); + return Sequelize.literal(`NOT JSON_OVERLAPS(${fieldName}, ${value})`); + } + + const subQuery = sqliteExistQuery(value, ctx); + + return { + [Op.and]: [Sequelize.literal(`not ${subQuery}`)], + }; + }, + + $arrayEmpty(value, ctx) { + const subQuery = emptyQuery(ctx, '='); + + return { + [Op.and]: [Sequelize.literal(`${subQuery}`)], + }; + }, + + $arrayNotEmpty(value, ctx) { + const subQuery = emptyQuery(ctx, '>'); + + return { + [Op.and]: [Sequelize.literal(`${subQuery}`)], + }; + }, +}; diff --git a/packages/database-next/src/operators/association.ts b/packages/database-next/src/operators/association.ts new file mode 100644 index 000000000..9f3babb33 --- /dev/null +++ b/packages/database-next/src/operators/association.ts @@ -0,0 +1,14 @@ +import { Op, Sequelize } from 'sequelize'; + +export default { + $exists(value, ctx) { + return { + [Op.not]: null, + }; + }, + $notExists(value, ctx) { + return { + [Op.is]: null, + }; + }, +}; diff --git a/packages/database-next/src/operators/date.ts b/packages/database-next/src/operators/date.ts new file mode 100644 index 000000000..206e515f6 --- /dev/null +++ b/packages/database-next/src/operators/date.ts @@ -0,0 +1,47 @@ +import { Op } from 'sequelize'; +import moment, { MomentInput } from 'moment'; +function stringToDate(value: string): Date { + return moment(value).toDate(); +} + +function getNextDay(value: MomentInput): Date { + return moment(value).add(1, 'd').toDate(); +} + +export default { + $dateOn(value) { + return { + [Op.and]: [ + { [Op.gte]: stringToDate(value) }, + { [Op.lt]: getNextDay(value) }, + ], + }; + }, + + $dateNotOn(value) { + return { + [Op.or]: [ + { [Op.lt]: stringToDate(value) }, + { [Op.gte]: getNextDay(value) }, + ], + }; + }, + + $dateBefore(value) { + return { [Op.lt]: stringToDate(value) }; + }, + + $dateNotBefore(value) { + return { + [Op.gte]: stringToDate(value), + }; + }, + + $dateAfter(value) { + return { [Op.gte]: getNextDay(value) }; + }, + + $dateNotAfter(value) { + return { [Op.lt]: getNextDay(value) }; + }, +}; diff --git a/packages/database-next/src/operators/empty.ts b/packages/database-next/src/operators/empty.ts new file mode 100644 index 000000000..627b0893f --- /dev/null +++ b/packages/database-next/src/operators/empty.ts @@ -0,0 +1,69 @@ +import { DataTypes, Op } from 'sequelize'; +import { ArrayField, StringField } from '../fields'; +import arrayOperators from './array'; + +const findFilterFieldType = (ctx) => { + const db = ctx.db; + + let path = ctx.path.split('.'); + + // remove operators + path.pop(); + + const fieldName = path.pop(); + + let model = ctx.model; + + const associationPath = path; + + for (const association of associationPath) { + model = model.associations[association].target; + } + + const collection = db.modelCollection.get(model); + + return collection.getField(fieldName); +}; +export default { + $empty(_, ctx) { + const field = findFilterFieldType(ctx); + + if (field instanceof StringField) { + return { + [Op.or]: { + [Op.is]: null, + [Op.eq]: '', + }, + }; + } + + if (field instanceof ArrayField) { + return arrayOperators.$arrayEmpty(_, ctx); + } + + return { + [Op.is]: null, + }; + }, + + $notEmpty(_, ctx) { + const field = findFilterFieldType(ctx); + + if (field instanceof StringField) { + return { + [Op.and]: { + [Op.not]: null, + [Op.ne]: '', + }, + }; + } + + if (field instanceof ArrayField) { + return arrayOperators.$arrayNotEmpty(_, ctx); + } + + return { + [Op.not]: null, + }; + }, +}; diff --git a/packages/database-next/src/operators/index.ts b/packages/database-next/src/operators/index.ts new file mode 100644 index 000000000..a76fc9276 --- /dev/null +++ b/packages/database-next/src/operators/index.ts @@ -0,0 +1,6 @@ +export default { + ...require('./association').default, + ...require('./date').default, + ...require('./array').default, + ...require('./empty').default, +}; diff --git a/packages/database-next/src/options-parser.ts b/packages/database-next/src/options-parser.ts new file mode 100644 index 000000000..379b10673 --- /dev/null +++ b/packages/database-next/src/options-parser.ts @@ -0,0 +1,273 @@ +import { Appends, Except, FindOptions } from './repository'; +import FilterParser from './filter-parser'; +import { FindAttributeOptions, ModelCtor } from 'sequelize'; +import { Database } from './database'; + +const debug = require('debug')('noco-database'); + +export class OptionsParser { + options: FindOptions; + database: Database; + model: ModelCtor; + filterParser: FilterParser; + + constructor(model: ModelCtor, database: Database, options: FindOptions) { + this.model = model; + this.options = options; + this.database = database; + this.filterParser = new FilterParser(model, this.database, options?.filter); + } + + isAssociation(key: string) { + return this.model.associations[key] !== undefined; + } + + isAssociationPath(path: string) { + return this.isAssociation(path.split('.')[0]); + } + + parseFilterByPk() { + if (this.options?.filterByPk) { + return { + where: { + [this.model.primaryKeyAttribute]: this.options.filterByPk, + }, + }; + } + + return null; + } + + toSequelizeParams() { + const filterParams = this.options?.filterByPk + ? this.parseFilterByPk() + : this.filterParser.toSequelizeParams(); + return this.parseSort(this.parseFields(filterParams)); + } + + /** + * parser sort options + * @param filterParams + * @protected + */ + protected parseSort(filterParams) { + const sort = this.options?.sort || []; + + const orderParams = sort.map((sortKey: string) => { + const direction = sortKey.startsWith('-') ? 'DESC' : 'ASC'; + const sortField: Array = sortKey.replace('-', '').split('.'); + + // handle sort by association + if (sortField.length > 1) { + let associationModel = this.model; + + for (let i = 0; i < sortField.length - 1; i++) { + const associationKey = sortField[i]; + sortField[i] = associationModel.associations[associationKey].target; + associationModel = sortField[i]; + } + } + + sortField.push(direction); + return sortField; + }); + + if (orderParams.length > 0) { + return { + order: orderParams, + ...filterParams, + }; + } + + return filterParams; + } + + protected parseFields(filterParams: any) { + const appends = this.options?.appends || []; + const except = []; + + let attributes: FindAttributeOptions = { + include: [], + exclude: [], + }; // out put all fields by default + + if (this.options?.fields) { + // 将fields拆分为 attributes 和 appends + for (const field of this.options.fields) { + if (this.isAssociationPath(field)) { + // field is association field + appends.push(field); + } else { + // field is model attribute, change attributes to array type + if (!Array.isArray(attributes)) attributes = []; + + attributes.push(field); + } + } + } + + if (this.options?.except) { + for (const exceptKey of this.options.except) { + if (this.isAssociationPath(exceptKey)) { + // except association field + except.push(exceptKey); + } else { + // if attributes is array form, ignore except + if (Array.isArray(attributes)) continue; + attributes.exclude.push(exceptKey); + } + } + } + + return { + attributes, + ...this.parseExcept(except, this.parseAppends(appends, filterParams)), + }; + } + + protected parseExcept(except: Except, filterParams: any) { + if (!except) return filterParams; + const setExcept = (queryParams: any, except: string) => { + // split exceptKey to path form + // posts.comments.content => ['posts', 'comments', 'content'] + // then set except on include attributes + const exceptPath = except.split('.'); + const association = exceptPath[0]; + const lastLevel = exceptPath.length <= 2; + + let existIncludeIndex = queryParams['include'].findIndex( + (include) => include['association'] == association, + ); + + if (existIncludeIndex == -1) { + // if include not exists, ignore this except + return; + } + + if (lastLevel) { + // if it not have exclude form + if ( + Array.isArray(queryParams['include'][existIncludeIndex]['attributes']) + ) { + return; + } else { + if ( + !queryParams['include'][existIncludeIndex]['attributes']['exclude'] + ) { + queryParams['include'][existIncludeIndex]['attributes']['exclude'] = + []; + } + + queryParams['include'][existIncludeIndex]['attributes'][ + 'exclude' + ].push(exceptPath[1]); + } + } else { + setExcept( + queryParams['include'][existIncludeIndex], + exceptPath.filter((_, index) => index !== 0).join('.'), + ); + } + }; + + for (const exceptKey of except) { + setExcept(filterParams, exceptKey); + } + + return filterParams; + } + + protected parseAppends(appends: Appends, filterParams: any) { + if (!appends) return filterParams; + const associations = this.model.associations; + + /** + * set include params + * @param includeRoot + * @param appends + */ + const setInclude = (queryParams: any, append: string) => { + const appendFields = append.split('.'); + const appendAssociation = appendFields[0]; + + // if append length less or equal 2 + // example: + // appends: ['posts'] + // appends: ['posts.title'] + // All of these can be seen as last level + const lastLevel = appendFields.length <= 2; + + // find association index + if (queryParams['include'] == undefined) { + queryParams['include'] = []; + } + + let existIncludeIndex = queryParams['include'].findIndex( + (include) => include['association'] == appendAssociation, + ); + + // if association not exist, create it + if (existIncludeIndex == -1) { + // association not exists + queryParams['include'].push({ + association: appendAssociation, + }); + + existIncludeIndex = 0; + } + + // end appends + // without nests association + if (lastLevel) { + // get exist association attributes + let attributes = queryParams['include'][existIncludeIndex][ + 'attributes' + ] || { + include: [], // all fields are output by default + }; + + // if need set attribute + if (appendFields.length == 2) { + if (!Array.isArray(attributes)) { + attributes = []; + } + + // push field to it + attributes.push(appendFields[1]); + } else { + // if attributes is empty array, change it to object + if (Array.isArray(attributes) && attributes.length == 0) { + attributes = { + include: [], + }; + } + } + + // set new attributes + queryParams['include'][existIncludeIndex] = { + ...queryParams['include'][existIncludeIndex], + attributes, + }; + } else { + setInclude( + queryParams['include'][existIncludeIndex], + appendFields.filter((_, index) => index !== 0).join('.'), + ); + } + }; + + // handle every appends + for (const append of appends) { + const appendFields = append.split('.'); + + if (!associations[appendFields[0]]) { + throw new Error(`${append} is not a valid association`); + } + + setInclude(filterParams, append); + } + + debug('filter params: %o', filterParams); + return filterParams; + } +} diff --git a/packages/database-next/src/playground.ts b/packages/database-next/src/playground.ts new file mode 100644 index 000000000..99685a31a --- /dev/null +++ b/packages/database-next/src/playground.ts @@ -0,0 +1,52 @@ +import { Database } from './database'; +import FilterParser from './filter-parser'; + +const db = new Database({ + dialect: 'sqlite', + dialectModule: require('sqlite3'), + storage: ':memory:', +}); + +(async () => { + const User = db.collection({ + name: 'users', + fields: [{ type: 'string', name: 'name' }], + }); + + const Post = db.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { + type: 'belongsTo', + name: 'user', + }, + ], + }); + + await db.sync(); + + const repository = User.repository; + + await repository.createMany({ + records: [ + { name: 'u1', posts: [{ title: 'u1t1' }] }, + { name: 'u2', posts: [{ title: 'u2t1' }] }, + { name: 'u3', posts: [{ title: 'u3t1' }] }, + ] + }); + + const Model = User.model; + const user = await Model.findOne({ + subQuery: false, + where: { + '$posts.title$': 'u1t1', + }, + include: { association: 'posts', attributes: [] }, + attributes: { + include: [], + }, + }); + + console.log(user.toJSON()); +})(); diff --git a/packages/database-next/src/relation-repository/belongs-to-many-repository.ts b/packages/database-next/src/relation-repository/belongs-to-many-repository.ts new file mode 100644 index 000000000..000fb9205 --- /dev/null +++ b/packages/database-next/src/relation-repository/belongs-to-many-repository.ts @@ -0,0 +1,270 @@ +import { BelongsToMany, Model, Op, Transaction } from 'sequelize'; +import { updateThroughTableValue } from '../update-associations'; +import { + FindAndCountOptions, + FindOneOptions, + MultipleRelationRepository, +} from './multiple-relation-repository'; +import { + CreateOptions, + DestroyOptions, + FindOptions, + PrimaryKey, + UpdateOptions, +} from '../repository'; +import { AssociatedOptions, PrimaryKeyWithThroughValues } from './types'; +import lodash from 'lodash'; +import { transaction } from './relation-repository'; + +type CreateBelongsToManyOptions = CreateOptions; + +interface IBelongsToManyRepository { + find(options?: FindOptions): Promise; + findAndCount(options?: FindAndCountOptions): Promise<[M[], number]>; + findOne(options?: FindOneOptions): Promise; + // 新增并关联,存在中间表数据 + create(options?: CreateBelongsToManyOptions): Promise; + // 更新,存在中间表数据 + update(options?: UpdateOptions): Promise; + // 删除 + destroy( + options?: number | string | number[] | string[] | DestroyOptions, + ): Promise; + // 建立关联 + set(options: PrimaryKey | PrimaryKey[] | AssociatedOptions): Promise; + // 附加关联,存在中间表数据 + add(options: PrimaryKey | PrimaryKey[] | AssociatedOptions): Promise; + // 移除关联 + remove(options: PrimaryKey | PrimaryKey[] | AssociatedOptions): Promise; + toggle( + options: PrimaryKey | { pk?: PrimaryKey; transaction?: Transaction }, + ): Promise; +} + +export class BelongsToManyRepository + extends MultipleRelationRepository + implements IBelongsToManyRepository +{ + @transaction() + async create(options?: CreateBelongsToManyOptions): Promise { + const transaction = await this.getTransaction(options); + + const createAccessor = this.accessors().create; + const values = options.values; + + const sourceModel = await this.getSourceModel(transaction); + + const createOptions = { + through: values[this.throughName()], + transaction, + }; + + return sourceModel[createAccessor](values, createOptions); + } + + @transaction((args, transaction) => { + return { + filterByPk: args[0], + transaction, + }; + }) + async destroy( + options?: PrimaryKey | PrimaryKey[] | DestroyOptions, + ): Promise { + const transaction = await this.getTransaction(options); + const association = this.association; + + const instancesToIds = (instances) => { + return instances.map((instance) => + instance.get(this.target.primaryKeyAttribute), + ); + }; + + // Through Table + const throughTableWhere: Array = [ + { + [association.foreignKey]: this.sourceId, + }, + ]; + + let ids; + + if (options && options['filter']) { + const instances = await this.find({ + filter: options['filter'], + transaction, + }); + + ids = instancesToIds(instances); + } else if (options && options['filterByPk']) { + options = options['filterByPk']; + + const instances = (this.association).toInstanceArray(options); + ids = instancesToIds(instances); + } else if (options && !options['filterByPk']) { + const sourceModel = await this.getSourceModel(transaction); + + const instances = await sourceModel[this.accessors().get]({ + transaction, + }); + + ids = instancesToIds(instances); + } + + throughTableWhere.push({ + [association.otherKey]: { + [Op.in]: ids, + }, + }); + + // delete through table data + await this.throughModel().destroy({ + where: throughTableWhere, + transaction, + }); + + await this.target.destroy({ + where: { + [this.target.primaryKeyAttribute]: { + [Op.in]: ids, + }, + }, + transaction, + }); + + return true; + } + + protected async setTargets( + call: 'add' | 'set', + options: + | PrimaryKey + | PrimaryKey[] + | PrimaryKeyWithThroughValues + | PrimaryKeyWithThroughValues[] + | AssociatedOptions, + ) { + let handleKeys: PrimaryKey[] | PrimaryKeyWithThroughValues[]; + + const transaction = await this.getTransaction(options, false); + + if (lodash.isPlainObject(options)) { + options = (options).pk || []; + } + + if (lodash.isString(options) || lodash.isNumber(options)) { + handleKeys = [options]; + } // if it is type primaryKeyWithThroughValues + else if ( + lodash.isArray(options) && + options.length == 2 && + lodash.isPlainObject(options[0][1]) + ) { + handleKeys = [options]; + } else { + handleKeys = options; + } + + const sourceModel = await this.getSourceModel(transaction); + + const setObj = (handleKeys).reduce((carry, item) => { + if (Array.isArray(item)) { + carry[item[0]] = item[1]; + } else { + carry[item] = true; + } + return carry; + }, {}); + + await sourceModel[this.accessors()[call]](Object.keys(setObj), { + transaction, + }); + + for (const [id, throughValues] of Object.entries(setObj)) { + if (typeof throughValues === 'object') { + const instance = await this.target.findByPk(id, { + transaction, + }); + await updateThroughTableValue( + instance, + this.throughName(), + throughValues, + sourceModel, + transaction, + ); + } + } + } + + @transaction((args, transaction) => { + return { + pk: args[0], + transaction, + }; + }) + async add( + options: + | PrimaryKey + | PrimaryKey[] + | PrimaryKeyWithThroughValues + | PrimaryKeyWithThroughValues[] + | AssociatedOptions, + ): Promise { + await this.setTargets('add', options); + } + + @transaction((args, transaction) => { + return { + pk: args[0], + transaction, + }; + }) + async set( + options: + | PrimaryKey + | PrimaryKey[] + | PrimaryKeyWithThroughValues + | PrimaryKeyWithThroughValues[] + | AssociatedOptions, + ): Promise { + await this.setTargets('set', options); + } + + @transaction((args, transaction) => { + return { + pk: args[0], + transaction, + }; + }) + async toggle( + options: PrimaryKey | { pk?: PrimaryKey; transaction?: Transaction }, + ): Promise { + const transaction = await this.getTransaction(options); + const sourceModel = await this.getSourceModel(transaction); + const has = await sourceModel[this.accessors().hasSingle](options['pk'], { + transaction, + }); + + if (has) { + await this.remove({ + ...(options), + transaction, + }); + } else { + await this.add({ + ...(options), + transaction, + }); + } + + return; + } + + throughName() { + return this.throughModel().name; + } + + throughModel() { + return (this.association).through.model; + } +} diff --git a/packages/database-next/src/relation-repository/belongs-to-repository.ts b/packages/database-next/src/relation-repository/belongs-to-repository.ts new file mode 100644 index 000000000..24e12fba0 --- /dev/null +++ b/packages/database-next/src/relation-repository/belongs-to-repository.ts @@ -0,0 +1,28 @@ +import { Model } from 'sequelize'; + +import { + SingleRelationFindOption, + SingleRelationRepository, +} from './single-relation-repository'; +import { CreateOptions, UpdateOptions } from '../repository'; + +interface BelongsToFindOptions extends SingleRelationFindOption {} + +interface IBelongsToRepository { + // 不需要 findOne,find 就是 findOne + find(options?: BelongsToFindOptions): Promise; + // 新增并关联,如果存在关联,解除之后,与新数据建立关联 + create(options?: CreateOptions): Promise; + // 更新 + update(options?: UpdateOptions): Promise; + // 删除 + destroy(): Promise; + // 建立关联 + set(primaryKey: any): Promise; + // 移除关联 + remove(): Promise; +} + +export class BelongsToRepository + extends SingleRelationRepository + implements IBelongsToRepository {} diff --git a/packages/database-next/src/relation-repository/hasmany-repository.ts b/packages/database-next/src/relation-repository/hasmany-repository.ts new file mode 100644 index 000000000..332f5f286 --- /dev/null +++ b/packages/database-next/src/relation-repository/hasmany-repository.ts @@ -0,0 +1,143 @@ +import { BelongsToMany, HasMany, Model, Op, Sequelize } from 'sequelize'; + +import { + AssociatedOptions, + FindAndCountOptions, + FindOneOptions, + MultipleRelationRepository, +} from './multiple-relation-repository'; +import { + CreateOptions, + DestroyOptions, + FindOptions, + PK, + PrimaryKey, + UpdateOptions, +} from '../repository'; +import { transaction } from './relation-repository'; + +interface IHasManyRepository { + find(options?: FindOptions): Promise; + findAndCount(options?: FindAndCountOptions): Promise<[M[], number]>; + findOne(options?: FindOneOptions): Promise; + // 新增并关联 + create(options?: CreateOptions): Promise; + // 更新 + update(options?: UpdateOptions): Promise; + // 删除 + destroy(options?: PK | DestroyOptions): Promise; + // 建立关联 + set(options: PrimaryKey | PrimaryKey[] | AssociatedOptions): Promise; + // 附加关联 + add(options: PrimaryKey | PrimaryKey[] | AssociatedOptions): Promise; + // 移除关联 + remove(options: PrimaryKey | PrimaryKey[] | AssociatedOptions): Promise; +} + +export class HasManyRepository + extends MultipleRelationRepository + implements IHasManyRepository +{ + @transaction((args, transaction) => { + return { + filterByPk: args[0], + transaction, + }; + }) + async destroy(options?: PK | DestroyOptions): Promise { + const transaction = await this.getTransaction(options); + + const sourceModel = await this.getSourceModel(transaction); + + const where = [ + { + [this.association.foreignKey]: sourceModel.get( + this.source.model.primaryKeyAttribute, + ), + }, + ]; + + if (options && options['filter']) { + const filterResult = this.parseFilter(options['filter']); + + if (filterResult.include && filterResult.include.length > 0) { + return await this.destroyByFilter(options['filter'], transaction); + } + + where.push(filterResult.where); + } else if (options && options['filterByPk']) { + if (typeof options === 'object' && options['filterByPk']) { + options = options['filterByPk']; + } + + const targetInstances = (this.association).toInstanceArray(options); + + where.push({ + [this.target.primaryKeyAttribute]: targetInstances.map( + (targetInstance) => + targetInstance.get(this.target.primaryKeyAttribute), + ), + }); + } + + await this.target.destroy({ + where: { + [Op.and]: where, + }, + transaction, + }); + + return true; + } + + handleKeyOfAdd(options) { + let handleKeys; + + if (typeof options !== 'object' && !Array.isArray(options)) { + handleKeys = [options]; + } else { + handleKeys = options['pk']; + } + return handleKeys; + } + + @transaction((args, transaction) => { + return { + pk: args[0], + transaction, + }; + }) + async set( + options: PrimaryKey | PrimaryKey[] | AssociatedOptions, + ): Promise { + const transaction = await this.getTransaction(options); + + const sourceModel = await this.getSourceModel(transaction); + + await sourceModel[this.accessors().set](this.handleKeyOfAdd(options), { + transaction, + }); + } + + @transaction((args, transaction) => { + return { + pk: args[0], + transaction, + }; + }) + async add( + options: PrimaryKey | PrimaryKey[] | AssociatedOptions, + ): Promise { + const transaction = await this.getTransaction(options); + + const sourceModel = await this.getSourceModel(transaction); + + await sourceModel[this.accessors().add](this.handleKeyOfAdd(options), { + transaction, + }); + } + + accessors() { + return (this.association).accessors; + } +} diff --git a/packages/database-next/src/relation-repository/hasone-repository.ts b/packages/database-next/src/relation-repository/hasone-repository.ts new file mode 100644 index 000000000..f737f7635 --- /dev/null +++ b/packages/database-next/src/relation-repository/hasone-repository.ts @@ -0,0 +1,27 @@ +import { Model } from 'sequelize'; +import { + SingleRelationFindOption, + SingleRelationRepository, +} from './single-relation-repository'; +import { CreateOptions } from '../repository'; + +interface HasOneFindOptions extends SingleRelationFindOption {} + +interface IHasOneRepository { + // 不需要 findOne,find 就是 findOne + find(options?: HasOneFindOptions): Promise>; + // 新增并关联,如果存在关联,解除之后,与新数据建立关联 + create(options?: CreateOptions): Promise; + // 更新 + update(options?): Promise; + // 删除 + destroy(): Promise; + // 建立关联 + set(primaryKey: any): Promise; + // 移除关联 + remove(): Promise; +} + +export class HasOneRepository + extends SingleRelationRepository + implements IHasOneRepository {} diff --git a/packages/database-next/src/relation-repository/multiple-relation-repository.ts b/packages/database-next/src/relation-repository/multiple-relation-repository.ts new file mode 100644 index 000000000..796a92cdc --- /dev/null +++ b/packages/database-next/src/relation-repository/multiple-relation-repository.ts @@ -0,0 +1,193 @@ +import { RelationRepository, transaction } from './relation-repository'; +import { omit } from 'lodash'; +import { + MultiAssociationAccessors, + Op, + Sequelize, + Transaction, +} from 'sequelize'; +import { UpdateGuard } from '../update-guard'; +import { updateModelByValues } from '../update-associations'; +import { + CommonFindOptions, + CountOptions, + DestroyOptions, + Filter, + FilterByPK, + FindOptions, + PK, + PrimaryKey, + TransactionAble, + UpdateOptions, +} from '../repository'; + +export interface FindAndCountOptions extends CommonFindOptions {} + +export interface FindOneOptions extends CommonFindOptions, FilterByPK {} + +export interface AssociatedOptions extends TransactionAble { + pk?: PK; +} + +export abstract class MultipleRelationRepository extends RelationRepository { + async find(options?: FindOptions): Promise { + const transaction = await this.getTransaction(options); + + const findOptions = this.buildQueryOptions({ + ...options, + }); + + const getAccessor = this.accessors().get; + const sourceModel = await this.getSourceModel(transaction); + + if (findOptions.include && findOptions.include.length > 0) { + const ids = ( + await sourceModel[getAccessor]({ + ...findOptions, + includeIgnoreAttributes: false, + attributes: [this.target.primaryKeyAttribute], + group: `${this.target.name}.${this.target.primaryKeyAttribute}`, + transaction, + }) + ).map((row) => row.get(this.target.primaryKeyAttribute)); + + return await sourceModel[getAccessor]({ + ...omit(findOptions, ['limit', 'offset']), + where: { + [this.target.primaryKeyAttribute]: { + [Op.in]: ids, + }, + }, + transaction, + }); + } + + return await sourceModel[getAccessor]({ + ...findOptions, + transaction, + }); + } + + async findAndCount(options?: FindAndCountOptions): Promise<[any[], number]> { + const transaction = await this.getTransaction(options, false); + return [ + await this.find({ + ...options, + transaction, + }), + await this.count({ + ...options, + transaction, + }), + ]; + } + + async count(options: CountOptions) { + const transaction = await this.getTransaction(options); + + const sourceModel = await this.getSourceModel(transaction); + const queryOptions = this.buildQueryOptions(options); + + const count = await sourceModel[this.accessors().get]({ + where: queryOptions.where, + include: queryOptions.include, + includeIgnoreAttributes: false, + attributes: [ + [ + Sequelize.fn( + 'COUNT', + Sequelize.fn( + 'DISTINCT', + Sequelize.col( + `${this.target.name}.${this.target.primaryKeyAttribute}`, + ), + ), + ), + 'count', + ], + ], + raw: true, + plain: true, + transaction, + }); + + return count.count; + } + + async findOne(options?: FindOneOptions): Promise { + const transaction = await this.getTransaction(options, false); + const rows = await this.find({ ...options, limit: 1, transaction }); + return rows.length == 1 ? rows[0] : null; + } + + @transaction((args, transaction) => { + return { + pk: args[0], + transaction, + }; + }) + async remove( + options: PrimaryKey | PrimaryKey[] | AssociatedOptions, + ): Promise { + const transaction = await this.getTransaction(options); + let handleKeys = options['pk']; + + if (!Array.isArray(handleKeys)) { + handleKeys = [handleKeys]; + } + + const sourceModel = await this.getSourceModel(transaction); + await sourceModel[this.accessors().removeMultiple](handleKeys, { + transaction, + }); + return; + } + + @transaction() + async update(options?: UpdateOptions): Promise { + const transaction = await this.getTransaction(options); + + const guard = UpdateGuard.fromOptions(this.target, options); + + const values = guard.sanitize(options.values); + + const queryOptions = this.buildQueryOptions(options); + + const instances = await this.find(queryOptions); + + for (const instance of instances) { + await updateModelByValues(instance, values, { + sanitized: true, + sourceModel: this.sourceModel, + transaction, + }); + } + + return true; + } + + async destroy(options?: PK | DestroyOptions): Promise { + return false; + } + + protected async destroyByFilter(filter: Filter, transaction?: Transaction) { + const instances = await this.find({ + filter: filter, + transaction, + }); + return await this.destroy({ + filterByPk: instances.map( + (instance) => instance[this.target.primaryKeyAttribute], + ), + transaction, + }); + } + + protected filterHasInclude(filter: Filter) { + const filterResult = this.parseFilter(filter); + return filterResult.include && filterResult.include.length > 0; + } + protected accessors() { + return super.accessors(); + } +} diff --git a/packages/database-next/src/relation-repository/relation-repository.ts b/packages/database-next/src/relation-repository/relation-repository.ts new file mode 100644 index 000000000..616acea0a --- /dev/null +++ b/packages/database-next/src/relation-repository/relation-repository.ts @@ -0,0 +1,108 @@ +import { + Association, + BelongsTo, + BelongsToMany, + HasMany, + HasOne, + Model, + ModelCtor, + Transaction, +} from 'sequelize'; +import { OptionsParser } from '../options-parser'; +import { Collection } from '../collection'; +import { CreateOptions, Filter, FindOptions } from '../repository'; +import FilterParser from '../filter-parser'; +import { UpdateGuard } from '../update-guard'; +import { updateAssociations } from '../update-associations'; +import lodash from 'lodash'; +import { transactionWrapperBuilder } from '../transaction-decorator'; + +export const transaction = transactionWrapperBuilder(function () { + return this.source.model.sequelize.transaction(); +}); + +export abstract class RelationRepository { + source: Collection; + association: Association; + target: ModelCtor; + sourceId: string | number; + sourceModel: Model; + + constructor( + source: Collection, + association: string, + sourceId: string | number, + ) { + this.source = source; + this.sourceId = sourceId; + this.association = this.source.model.associations[association]; + + this.target = this.association.target; + } + + protected accessors() { + return (this.association) + .accessors; + } + + async create(options?: CreateOptions): Promise { + const createAccessor = this.accessors().create; + + const guard = UpdateGuard.fromOptions(this.target, options); + const values = options.values; + + const sourceModel = await this.getSourceModel(); + + const instance = await sourceModel[createAccessor]( + guard.sanitize(options.values), + ); + + await updateAssociations(instance, values, options); + + return instance; + } + + async getSourceModel(transaction?: any) { + if (!this.sourceModel) { + this.sourceModel = await this.source.model.findByPk(this.sourceId, { + transaction, + }); + } + + return this.sourceModel; + } + + protected buildQueryOptions(options: FindOptions) { + const parser = new OptionsParser( + this.target, + this.source.context.database, + options, + ); + const params = parser.toSequelizeParams(); + return { ...options, ...params }; + } + + protected parseFilter(filter: Filter) { + const parser = new FilterParser( + this.target, + this.source.context.database, + filter, + ); + return parser.toSequelizeParams(); + } + + protected async getTransaction( + options: any, + autoGen = false, + ): Promise { + if (lodash.isPlainObject(options) && options.transaction) { + return options.transaction; + } + + if (autoGen) { + return await this.source.model.sequelize.transaction(); + } + + return null; + } +} diff --git a/packages/database-next/src/relation-repository/single-relation-repository.ts b/packages/database-next/src/relation-repository/single-relation-repository.ts new file mode 100644 index 000000000..7902930df --- /dev/null +++ b/packages/database-next/src/relation-repository/single-relation-repository.ts @@ -0,0 +1,102 @@ +import { RelationRepository, transaction } from './relation-repository'; +import { Model, SingleAssociationAccessors } from 'sequelize'; +import { updateModelByValues } from '../update-associations'; +import lodash from 'lodash'; +import { + Appends, + Except, + Fields, + PrimaryKey, + TransactionAble, + UpdateOptions, +} from '../repository'; + +export interface SingleRelationFindOption extends TransactionAble { + fields?: Fields; + except?: Except; + appends?: Appends; +} + +interface SetOption extends TransactionAble { + pk?: PrimaryKey; +} + +export abstract class SingleRelationRepository extends RelationRepository { + @transaction() + async remove(options?: TransactionAble): Promise { + const transaction = await this.getTransaction(options); + const sourceModel = await this.getSourceModel(transaction); + return await sourceModel[this.accessors().set](null, { + transaction, + }); + } + + @transaction((args, transaction) => { + return { + pk: args[0], + transaction, + }; + }) + async set(options: PrimaryKey | SetOption): Promise { + const transaction = await this.getTransaction(options); + let handleKey = lodash.isPlainObject(options) + ? (options).pk + : options; + + const sourceModel = await this.getSourceModel(transaction); + + return await sourceModel[this.accessors().set](handleKey, { + transaction, + }); + } + + async find(options?: SingleRelationFindOption): Promise> { + const transaction = await this.getTransaction(options); + const findOptions = this.buildQueryOptions({ + ...options, + }); + + const getAccessor = this.accessors().get; + const sourceModel = await this.getSourceModel(transaction); + + return await sourceModel[getAccessor]({ + ...findOptions, + transaction, + }); + } + + @transaction() + async destroy(options?: TransactionAble): Promise { + const transaction = await this.getTransaction(options); + + const target = await this.find({ + transaction, + }); + + await target.destroy({ + transaction, + }); + + return true; + } + + @transaction() + async update(options: UpdateOptions): Promise { + const transaction = await this.getTransaction(options); + + const target = await this.find({ + transaction, + }); + + await updateModelByValues(target, options?.values, { + ...lodash.omit(options, 'values'), + transaction, + }); + + return target; + } + + accessors() { + return super.accessors(); + } +} diff --git a/packages/database-next/src/relation-repository/types.ts b/packages/database-next/src/relation-repository/types.ts new file mode 100644 index 000000000..036b5ad92 --- /dev/null +++ b/packages/database-next/src/relation-repository/types.ts @@ -0,0 +1,19 @@ +import { PrimaryKey, Values } from '../repository'; +import { Transactionable } from 'sequelize'; + +export type PrimaryKeyWithThroughValues = [PrimaryKey, Values]; + +export interface AssociatedOptions extends Transactionable { + pk?: + | PrimaryKey + | PrimaryKey[] + | PrimaryKeyWithThroughValues + | PrimaryKeyWithThroughValues[]; +} + +export type setAssociationOptions = + | PrimaryKey + | PrimaryKey[] + | PrimaryKeyWithThroughValues + | PrimaryKeyWithThroughValues[] + | AssociatedOptions; diff --git a/packages/database-next/src/repository.ts b/packages/database-next/src/repository.ts index 70c44f1d2..c1cdfc0d1 100644 --- a/packages/database-next/src/repository.ts +++ b/packages/database-next/src/repository.ts @@ -1,57 +1,123 @@ import { - Op, + Association, + BulkCreateOptions, + CreateOptions as SequelizeCreateOptions, + FindAndCountOptions as SequelizeAndCountOptions, + FindOptions as SequelizeFindOptions, Model, ModelCtor, - Association, - FindOptions, - BulkCreateOptions, - DestroyOptions as SequelizeDestroyOptions, - CreateOptions as SequelizeCreateOptions, - UpdateOptions as SequelizeUpdateOptions, + Op, + Transaction, } from 'sequelize'; -import { flatten } from 'flat'; + import { Collection } from './collection'; -import _ from 'lodash'; +import lodash, { omit } from 'lodash'; import { Database } from './database'; -import { updateAssociations } from './update-associations'; +import { updateAssociations, updateModelByValues } from './update-associations'; import { RelationField } from './fields'; +import FilterParser from './filter-parser'; +import { OptionsParser } from './options-parser'; +import { RelationRepository } from './relation-repository/relation-repository'; +import { HasOneRepository } from './relation-repository/hasone-repository'; +import { BelongsToRepository } from './relation-repository/belongs-to-repository'; +import { BelongsToManyRepository } from './relation-repository/belongs-to-many-repository'; +import { HasManyRepository } from './relation-repository/hasmany-repository'; +import { UpdateGuard } from './update-guard'; +import { transactionWrapperBuilder } from './transaction-decorator'; + +const debug = require('debug')('noco-database'); export interface IRepository {} -interface CreateManyOptions extends BulkCreateOptions {} - -interface FindManyOptions extends FindOptions { - filter?: any; - fields?: any; - appends?: any; - expect?: any; - page?: any; - pageSize?: any; - sort?: any; +interface CreateManyOptions extends BulkCreateOptions { + records: Values[]; } -interface FindOneOptions extends FindOptions { - filter?: any; - fields?: any; - appends?: any; - expect?: any; - sort?: any; +export interface TransactionAble { + transaction?: Transaction; } -interface CreateOptions extends SequelizeCreateOptions { - values?: any; - whitelist?: any; - blacklist?: any; +export interface FilterAble { + filter: Filter; } -interface UpdateOptions extends SequelizeUpdateOptions { - values?: any; - whitelist?: any; - blacklist?: any; +export type PrimaryKey = string | number; +export type PK = PrimaryKey | PrimaryKey[]; + +export type Filter = any; +export type Appends = string[]; +export type Except = string[]; +export type Fields = string[]; +export type Sort = string[]; + +export type WhiteList = string[]; +export type BlackList = string[]; +export type AssociationKeysToBeUpdate = string[]; + +export type Values = { + [key: string]: string | number | Values | Array; +}; + +export interface CountOptions + extends Omit, + TransactionAble { + fields?: Fields; + filter?: Filter; } -interface DestroyOptions extends SequelizeDestroyOptions { - filter?: any; +export interface FilterByPK { + filterByPk?: PrimaryKey; +} + +export interface FindOptions + extends SequelizeFindOptions, + CommonFindOptions, + FilterByPK {} + +export interface CommonFindOptions { + filter?: Filter; + fields?: Fields; + appends?: Appends; + except?: Except; + sort?: Sort; +} + +interface FindOneOptions extends FindOptions, CommonFindOptions {} + +export interface DestroyOptions extends TransactionAble { + filter?: Filter; + filterByPk?: PrimaryKey | PrimaryKey[]; + truncate?: boolean; +} + +interface FindAndCountOptions + extends Omit { + // 数据过滤 + filter?: Filter; + // 输出结果显示哪些字段 + fields?: Fields; + // 输出结果不显示哪些字段 + except?: Except; + // 附加字段,用于控制关系字段的输出 + appends?: Appends; + // 排序,字段前面加上 “-” 表示降序 + sort?: Sort; +} + +export interface CreateOptions extends TransactionAble { + values?: Values; + whitelist?: WhiteList; + blacklist?: BlackList; + updateAssociationValues?: AssociationKeysToBeUpdate; +} + +export interface UpdateOptions extends TransactionAble { + values: Values; + filter?: Filter; + filterByPk?: PrimaryKey; + whitelist?: WhiteList; + blacklist?: BlackList; + updateAssociationValues?: AssociationKeysToBeUpdate; } interface RelatedQueryOptions { @@ -69,101 +135,43 @@ interface RelatedQueryOptions { }; } -type Identity = string | number; +const transaction = transactionWrapperBuilder(function () { + return (this).collection.model.sequelize.transaction(); +}); -class RelatedQuery { - options: RelatedQueryOptions; - sourceInstance: Model; +class RelationRepositoryBuilder { + collection: Collection; + associationName: string; + association: Association; - constructor(options: RelatedQueryOptions) { - this.options = options; + builderMap = { + HasOne: HasOneRepository, + BelongsTo: BelongsToRepository, + BelongsToMany: BelongsToManyRepository, + HasMany: HasManyRepository, + }; + + constructor(collection: Collection, associationName: string) { + this.collection = collection; + this.associationName = associationName; + this.association = this.collection.model.associations[this.associationName]; } - async getSourceInstance() { - if (this.sourceInstance) { - return this.sourceInstance; - } - const { idOrInstance, collection } = this.options.source; - if (idOrInstance instanceof Model) { - return (this.sourceInstance = idOrInstance); - } - this.sourceInstance = await collection.model.findByPk(idOrInstance); - return this.sourceInstance; + protected builder() { + return this.builderMap; } - async findMany(options?: any) { - const { collection } = this.options.target; - return await collection.repository.findMany(options); + of(id: string | number): R { + const klass = this.builder()[this.association.associationType]; + return new klass(this.collection, this.associationName, id); } - - async findOne(options?: any) { - const { collection } = this.options.target; - return await collection.repository.findOne(options); - } - - async create(values?: any, options?: any) { - const { association } = this.options.target; - const createAccessor = association.accessors.create; - const source = await this.getSourceInstance(); - const instance = await source[createAccessor](values, options); - if (!instance) { - return; - } - await updateAssociations(instance, values); - return instance; - } - - async update(values: any, options?: Identity | Model | UpdateOptions) { - const { association, collection } = this.options.target; - if (options instanceof Model) { - return await collection.repository.update(values, options); - } - const { field } = this.options; - if (field.type === 'hasOne' || field.type === 'belongsTo') { - const getAccessor = association.accessors.get; - const source = await this.getSourceInstance(); - const instance = await source[getAccessor](); - return await collection.repository.update(values, instance); - } - // TODO - return await collection.repository.update(values, options); - } - - async destroy(options?: any) { - const { association, collection } = this.options.target; - const { field } = this.options; - if (field.type === 'hasOne' || field.type === 'belongsTo') { - const getAccessor = association.accessors.get; - const source = await this.getSourceInstance(); - const instance = await source[getAccessor](); - if (!instance) { - return; - } - return await collection.repository.destroy(instance.id); - } - return await collection.repository.destroy(options); - } - - async set(options?: any) {} - - async add(options?: any) {} - - async remove(options?: any) {} - - async toggle(options?: any) {} - - async sync(options?: any) {} } -class HasOneQuery extends RelatedQuery {} - -class HasManyQuery extends RelatedQuery {} - -class BelongsToQuery extends RelatedQuery {} - -class BelongsToManyQuery extends RelatedQuery {} - -export class Repository implements IRepository { +export class Repository< + TModelAttributes extends {} = any, + TCreationAttributes extends {} = TModelAttributes, +> implements IRepository +{ database: Database; collection: Collection; model: ModelCtor; @@ -174,280 +182,274 @@ export class Repository implements IRepository { this.model = collection.model; } - async findMany(options?: FindManyOptions) { + /** + * return count by filter + */ + async count(countOptions?: CountOptions): Promise { + let options = countOptions ? lodash.clone(countOptions) : {}; + + const transaction = await this.getTransaction(options); + + if (countOptions?.filter) { + options = { + ...options, + ...this.parseFilter(countOptions.filter), + }; + } + + const count = await this.collection.model.count({ + ...options, + distinct: true, + transaction, + }); + + return count as any; + } + + /** + * find + * @param options + */ + async find(options?: FindOptions) { const model = this.collection.model; + const transaction = await this.getTransaction(options); const opts = { subQuery: false, ...this.buildQueryOptions(options), }; - let rows = []; - if (opts.include) { + + if (opts.include && opts.include.length > 0) { const ids = ( await model.findAll({ ...opts, includeIgnoreAttributes: false, attributes: [model.primaryKeyAttribute], group: `${model.name}.${model.primaryKeyAttribute}`, + transaction, }) - ).map((item) => item[model.primaryKeyAttribute]); - if (ids.length > 0) { - rows = await model.findAll({ - ...opts, - where: { - [model.primaryKeyAttribute]: { - [Op.in]: ids, - }, - }, - }); - } - } else { - rows = await model.findAll({ - ...opts, + ).map((row) => row.get(model.primaryKeyAttribute)); + + const where = { + [model.primaryKeyAttribute]: { + [Op.in]: ids, + }, + }; + + return await model.findAll({ + ...omit(opts, ['limit', 'offset']), + where, + transaction, }); } - const count = await model.count({ + + return await model.findAll({ ...opts, - distinct: opts.include ? true : undefined, + transaction, }); - return { count, rows }; } - async findOne(options?: FindOneOptions) { - const model = this.collection.model; - const opts = { - subQuery: false, - ...this.buildQueryOptions(options), + /** + * find and count + * @param options + */ + async findAndCount( + options?: FindAndCountOptions, + ): Promise<[Model[], number]> { + const transaction = await this.getTransaction(options); + options = { + ...options, + transaction, }; - let data: Model; - if (opts.include) { - const item = await model.findOne({ - ...opts, - includeIgnoreAttributes: false, - attributes: [model.primaryKeyAttribute], - group: `${model.name}.${model.primaryKeyAttribute}`, - }); - if (!item) { - return; - } - data = await model.findOne({ - ...opts, - where: item.toJSON(), - }); - } else { - data = await model.findOne({ - ...opts, - }); - } - return data; + + return [await this.find(options), await this.count(options)]; } - async create(values?: any, options?: CreateOptions) { - const instance = await this.model.create(values, options); + /** + * Find By Id + * + */ + findById(id: PrimaryKey) { + return this.collection.model.findByPk(id); + } + + /** + * Find one record from database + * + * @param options + */ + async findOne(options?: FindOneOptions) { + const transaction = await this.getTransaction(options); + + const rows = await this.find({ ...options, limit: 1, transaction }); + return rows.length == 1 ? rows[0] : null; + } + + /** + * Save instance to database + * + * @param values + * @param options + */ + @transaction() + async create(options: CreateOptions): Promise { + const transaction = await this.getTransaction(options); + + const guard = UpdateGuard.fromOptions(this.model, options); + const values = guard.sanitize(options.values || {}); + + const instance = await this.model.create(values, { transaction }); + if (!instance) { return; } - await updateAssociations(instance, values, options); - return instance; - } - async createMany(records: any[], options?: CreateManyOptions) { - const instances = await this.collection.model.bulkCreate(records, options); - const promises = instances.map((instance, index) => { - return updateAssociations(instance, records[index]); + await updateAssociations(instance, values, { + transaction, }); - return Promise.all(promises); - } - async update(values: any, options: Identity | Model | UpdateOptions) { - if (options instanceof Model) { - await options.update(values); - await updateAssociations(options, values); - return options; - } - let instance: Model; - if (typeof options === 'string' || typeof options === 'number') { - instance = await this.model.findByPk(options); - } else { - // TODO - instance = await this.findOne(options); - } - await instance.update(values); - await updateAssociations(instance, values); return instance; } - async destroy(options: Identity | Identity[] | DestroyOptions) { - if (typeof options === 'number' || typeof options === 'string') { - return await this.model.destroy({ - where: { - [this.model.primaryKeyAttribute]: options, - }, + /** + * Save Many instances to database + * + * @param records + * @param options + */ + @transaction() + async createMany(options: CreateManyOptions) { + const transaction = await this.getTransaction(options); + const { records } = options; + const instances = await this.collection.model.bulkCreate(records, { + ...options, + transaction, + }); + + for (let i = 0; i < instances.length; i++) { + await updateAssociations(instances[i], records[i], { transaction }); + } + + return instances; + } + + /** + * Update model value + * + * @param values + * @param options + */ + @transaction() + async update(options: UpdateOptions) { + const transaction = await this.getTransaction(options); + const guard = UpdateGuard.fromOptions(this.model, options); + + const values = guard.sanitize(options.values); + + const queryOptions = this.buildQueryOptions(options); + + const instances = await this.find({ + ...queryOptions, + transaction, + }); + + for (const instance of instances) { + await updateModelByValues(instance, values, { + sanitized: true, + transaction, }); } - if (Array.isArray(options)) { + + return true; + } + + @transaction((args, transaction) => { + return { + filterByPk: args[0], + transaction, + }; + }) + async destroy(options?: PrimaryKey | PrimaryKey[] | DestroyOptions) { + const transaction = await this.getTransaction(options); + + options = options; + + let filterByPk = options.filterByPk; + + if (filterByPk) { + if (!Array.isArray(filterByPk)) { + filterByPk = [filterByPk]; + } + return await this.model.destroy({ where: { [this.model.primaryKeyAttribute]: { - [Op.in]: options, + [Op.in]: filterByPk, }, }, + transaction, }); } - const opts = this.buildQueryOptions(options); - return await this.model.destroy(opts); - } - // TODO - async sort() {} - - relatedQuery(name: string) { - return { - for: (sourceIdOrInstance: any) => { - const field = this.collection.getField(name) as RelationField; - const database = this.collection.context.database; - const collection = database.getCollection(field.target); - const options: RelatedQueryOptions = { - field, - database: database, - source: { - collection: this.collection, - idOrInstance: sourceIdOrInstance, - }, - target: { - collection, - association: this.collection.model.associations[name] as any, - }, - }; - switch (field.type) { - case 'hasOne': - return new HasOneQuery(options); - case 'hasMany': - return new HasManyQuery(options); - case 'belongsTo': - return new BelongsToQuery(options); - case 'belongsToMany': - return new BelongsToManyQuery(options); - } - }, - }; - } - - buildQueryOptions(options: any) { - const opts = this.parseFilter(options.filter); - return { ...options, ...opts }; - } - - parseFilter(filter?: any) { - if (!filter) { - return {}; - } - const model = this.collection.model; - if (typeof filter === 'number' || typeof filter === 'string') { - return { - where: { - [model.primaryKeyAttribute]: filter, - }, - }; - } - const operators = this.database.operators; - const obj = flatten(filter || {}); - const include = {}; - const where = {}; - let skipPrefix = null; - const filter2 = {}; - for (const [key, value] of Object.entries(obj)) { - _.set(filter2, key, value); - } - for (let [key, value] of Object.entries(obj)) { - if (skipPrefix && key.startsWith(skipPrefix)) { - continue; - } - let keys = key.split('.'); - const associations = model.associations; - const paths = []; - const origins = []; - while (keys.length) { - const k = keys.shift(); - origins.push(k); - if (k.startsWith('$')) { - if (operators.has(k)) { - const opKey = operators.get(k); - if (typeof opKey === 'symbol') { - paths.push(opKey); - continue; - } else if (typeof opKey === 'function') { - skipPrefix = origins.join('.'); - // console.log({ skipPrefix }, filter2, _.get(filter2, origins)); - value = opKey(_.get(filter2, origins)); - break; - } - } else { - paths.push(k); - continue; - } - } - if (/\d+/.test(k)) { - paths.push(k); - continue; - } - if (!associations[k]) { - paths.push(k); - continue; - } - const associationKeys = []; - associationKeys.push(k); - _.set(include, k, { - association: k, - attributes: [], - }); - let target = associations[k].target; - while (target) { - const attr = keys.shift(); - if (target.rawAttributes[attr]) { - associationKeys.push(attr); - target = null; - } else if (target.associations[attr]) { - associationKeys.push(attr); - const assoc = []; - associationKeys.forEach((associationKey, index) => { - if (index > 0) { - assoc.push('include'); - } - assoc.push(associationKey); - }); - _.set(include, assoc, { - association: attr, - attributes: [], - }); - target = target.associations[attr].target; - } - } - if (associationKeys.length > 1) { - paths.push(`$${associationKeys.join('.')}$`); - } else { - paths.push(k); - } - } - console.log(paths, value); - const values = _.get(where, paths); - if ( - values && - typeof values === 'object' && - value && - typeof value === 'object' - ) { - value = { ...value, ...values }; - } - _.set(where, paths, value); - } - const toInclude = (items) => { - return Object.values(items).map((item: any) => { - if (item.include) { - item.include = toInclude(item.include); - } - return item; + if (options.filter) { + const instances = await this.find({ + filter: options.filter, + transaction, }); - }; - return { where, include: toInclude(include) }; + + return await this.destroy({ + filterByPk: instances.map( + (instance) => instance[this.model.primaryKeyAttribute], + ), + transaction, + }); + } + + if (options.truncate) { + return await this.model.destroy({ + truncate: true, + transaction, + }); + } + } + + /** + * @param association target association + */ + relation( + association: string, + ): RelationRepositoryBuilder { + return new RelationRepositoryBuilder(this.collection, association); + } + + protected buildQueryOptions(options: any) { + const parser = new OptionsParser( + this.collection.model, + this.collection.context.database, + options, + ); + const params = parser.toSequelizeParams(); + debug('sequelize query params %o', params); + return { ...options, ...params }; + } + + protected parseFilter(filter: Filter) { + const parser = new FilterParser( + this.collection.model, + this.collection.context.database, + filter, + ); + return parser.toSequelizeParams(); + } + + protected async getTransaction(options: any, autoGen = false) { + if (lodash.isPlainObject(options) && options.transaction) { + return options.transaction; + } + + if (autoGen) { + return await this.model.sequelize.transaction(); + } + + return null; } } diff --git a/packages/database-next/src/transaction-decorator.ts b/packages/database-next/src/transaction-decorator.ts new file mode 100644 index 000000000..e199ebeb1 --- /dev/null +++ b/packages/database-next/src/transaction-decorator.ts @@ -0,0 +1,62 @@ +import lodash from 'lodash'; + +export function transactionWrapperBuilder(transactionGenerator) { + return function transaction(transactionInjector?) { + return (target, name, descriptor) => { + const oldValue = descriptor.value; + + descriptor.value = async function () { + let transaction; + let newTransaction = false; + + if (arguments.length > 0 && typeof arguments[0] === 'object') { + transaction = arguments[0]['transaction']; + } + + if (!transaction) { + transaction = await transactionGenerator.apply(this); + newTransaction = true; + } + + // 需要将 newTransaction 注入到被装饰函数参数内 + if (newTransaction) { + try { + let callArguments; + if (lodash.isPlainObject(arguments[0])) { + callArguments = { + ...arguments[0], + transaction, + }; + } else if (transactionInjector) { + callArguments = transactionInjector(arguments, transaction); + } else if ( + lodash.isNull(arguments[0]) || + lodash.isUndefined(arguments[0]) + ) { + callArguments = { + transaction, + }; + } else { + throw new Error( + `please provide transactionInjector for ${name} call`, + ); + } + + const results = await oldValue.apply(this, [callArguments]); + + await transaction.commit(); + + return results; + } catch (err) { + await transaction.rollback(); + throw err; + } + } else { + return oldValue.apply(this, arguments); + } + }; + + return descriptor; + }; + }; +} diff --git a/packages/database-next/src/update-associations.ts b/packages/database-next/src/update-associations.ts index e5018700a..1fe35869b 100644 --- a/packages/database-next/src/update-associations.ts +++ b/packages/database-next/src/update-associations.ts @@ -5,7 +5,13 @@ import { DataTypes, Utils, Association, + HasOne, + BelongsTo, + BelongsToMany, + HasMany, } from 'sequelize'; +import { UpdateGuard } from './update-guard'; +import { TransactionAble } from './repository'; function isUndefinedOrNull(value: any) { return typeof value === 'undefined' || value === null; @@ -15,39 +21,171 @@ function isStringOrNumber(value: any) { return typeof value === 'string' || typeof value === 'number'; } +export function modelAssociations(instance: Model) { + return (instance.constructor).associations; +} + +export function belongsToManyAssociations( + instance: Model, +): Array { + const associations = modelAssociations(instance); + return Object.entries(associations) + .filter((entry) => { + const [key, association] = entry; + return association.associationType == 'BelongsToMany'; + }) + .map((association) => { + return association[1]; + }); +} + +export function modelAssociationByKey( + instance: Model, + key: string, +): Association { + return modelAssociations(instance)[key] as Association; +} + +type UpdateValue = { [key: string]: any }; + +interface UpdateOptions extends TransactionAble { + filter?: any; + filterByPk?: number | string; + // 字段白名单 + whitelist?: string[]; + // 字段黑名单 + blacklist?: string[]; + // 关系数据默认会新建并建立关联处理,如果是已存在的数据只关联,但不更新关系数据 + // 如果需要更新关联数据,可以通过 updateAssociationValues 指定 + updateAssociationValues?: string[]; + sanitized?: boolean; + sourceModel?: Model; +} + +export async function updateModelByValues( + instance: Model, + values: UpdateValue, + options?: UpdateOptions, +) { + if (!options?.sanitized) { + const guard = new UpdateGuard(); + //@ts-ignore + guard.setModel(instance.constructor); + guard.setBlackList(options.blacklist); + guard.setWhiteList(options.whitelist); + guard.setAssociationKeysToBeUpdate(options.updateAssociationValues); + values = guard.sanitize(values); + } + + await instance.update(values); + await updateAssociations(instance, values, options); +} + +export async function updateThroughTableValue( + instance: Model, + throughName: string, + throughValues: any, + source: Model, + transaction = null, +) { + // update through table values + for (const belongsToMany of belongsToManyAssociations(instance)) { + // @ts-ignore + const throughModel = belongsToMany.through.model; + const throughModelName = throughModel.name; + + if (throughModelName === throughModelName) { + const where = { + [belongsToMany.foreignKey]: instance.get(belongsToMany.sourceKey), + [belongsToMany.otherKey]: source.get(belongsToMany.targetKey), + }; + + return await throughModel.update(throughValues, { + where, + transaction, + }); + } + } +} + +/** + * update association of instance by values + * @param instance + * @param values + * @param options + */ export async function updateAssociations( instance: Model, values: any, options: any = {}, ) { - const { transaction = await instance.sequelize.transaction() } = options; - // @ts-ignore - for (const key of Object.keys(instance.constructor.associations)) { - // 如果 key 不存在才跳过 - if (!Object.keys(values||{}).includes(key)) { - continue; - } - await updateAssociation(instance, key, values[key], { - ...options, - transaction, - }); + // if no values set, return + if (!values) { + return; } - if (!options.transaction) { + + let newTransaction = false; + let transaction = options.transaction; + + if (!transaction) { + newTransaction = true; + transaction = await instance.sequelize.transaction(); + } + + for (const key of Object.keys(modelAssociations(instance))) { + if (values[key]) { + await updateAssociation(instance, key, values[key], { + ...options, + transaction, + }); + } + } + + // update through table values + for (const belongsToMany of belongsToManyAssociations(instance)) { + // @ts-ignore + const throughModel = belongsToMany.through.model; + const throughModelName = throughModel.name; + + if (values[throughModelName] && options.sourceModel) { + const where = { + [belongsToMany.foreignKey]: instance.get(belongsToMany.sourceKey), + [belongsToMany.otherKey]: options.sourceModel.get( + belongsToMany.targetKey, + ), + }; + + await throughModel.update(values[throughModel.name], { + where, + transaction, + }); + } + } + + if (newTransaction) { await transaction.commit(); } } +/** + * update model association by key + * @param instance + * @param key + * @param value + * @param options + */ export async function updateAssociation( instance: Model, key: string, value: any, options: any = {}, ) { - // @ts-ignore - const association = instance.constructor.associations[key] as Association; + const association = modelAssociationByKey(instance, key); + if (!association) { return false; } + switch (association.associationType) { case 'HasOne': case 'BelongsTo': @@ -58,32 +196,48 @@ export async function updateAssociation( } } +/** + * update belongsTo and HasOne + * @param model + * @param key + * @param value + * @param options + */ export async function updateSingleAssociation( model: Model, key: string, value: any, options: any = {}, ) { - // @ts-ignore - const association = model.constructor.associations[key] as Association; + const association = modelAssociationByKey(model, key); + if (!association) { return false; } + if (!['undefined', 'string', 'number', 'object'].includes(typeof value)) { return false; } + const { transaction = await model.sequelize.transaction() } = options; + try { - // @ts-ignore + // set method of association const setAccessor = association.accessors.set; - if (isUndefinedOrNull(value)) { + + const removeAssociation = async () => { await model[setAccessor](null, { transaction }); model.setDataValue(key, null); if (!options.transaction) { await transaction.commit(); } return true; + }; + + if (isUndefinedOrNull(value)) { + return await removeAssociation(); } + if (isStringOrNumber(value)) { await model[setAccessor](value, { transaction }); if (!options.transaction) { @@ -99,7 +253,7 @@ export async function updateSingleAssociation( } return true; } - // @ts-ignore + const createAccessor = association.accessors.create; let dataKey: string; let M: ModelCtor; @@ -111,6 +265,7 @@ export async function updateSingleAssociation( M = association.source; dataKey = M.primaryKeyAttribute; } + if (isStringOrNumber(value[dataKey])) { let instance: any = await M.findOne({ where: { @@ -128,6 +283,11 @@ export async function updateSingleAssociation( return true; } } + + if (value[dataKey] === null) { + return await removeAssociation(); + } + const instance = await model[createAccessor](value, { transaction }); await updateAssociations(instance, value, { transaction, ...options }); model.setDataValue(key, instance); @@ -146,38 +306,52 @@ export async function updateSingleAssociation( } } +/** + * update multiple association of model by value + * @param model + * @param key + * @param value + * @param options + */ export async function updateMultipleAssociation( model: Model, key: string, value: any, options: any = {}, ) { - // @ts-ignore - const association = model.constructor.associations[key] as Association; + const association = ( + modelAssociationByKey(model, key) + ); + if (!association) { return false; } + if (!['undefined', 'string', 'number', 'object'].includes(typeof value)) { return false; } + const { transaction = await model.sequelize.transaction() } = options; + try { - // @ts-ignore const setAccessor = association.accessors.set; - // @ts-ignore + const createAccessor = association.accessors.create; if (isUndefinedOrNull(value)) { await model[setAccessor](null, { transaction }); model.setDataValue(key, null); return; } + if (isStringOrNumber(value)) { await model[setAccessor](value, { transaction }); return; } + if (!Array.isArray(value)) { value = [value]; } + const list1 = []; // to be setted const list2 = []; // to be added for (const item of value) { @@ -194,23 +368,46 @@ export async function updateMultipleAssociation( list2.push(item); } } + + // associate targets in lists1 await model[setAccessor](list1, { transaction }); + const list3 = []; for (const item of list2) { const pk = association.target.primaryKeyAttribute; + + const through = (association).through + ? (association).through.model.name + : null; + + const accessorOptions = { + transaction, + }; + + const throughValue = item[through]; + + if (throughValue) { + accessorOptions['through'] = throughValue; + } + if (isUndefinedOrNull(item[pk])) { - const instance = await model[createAccessor](item, { transaction }); + // create new record + const instance = await model[createAccessor](item, accessorOptions); await updateAssociations(instance, item, { transaction, ...options }); list3.push(instance); } else { - const instance = await association.target.findByPk(item[pk], { transaction }); - // @ts-ignore + // set & update record + const instance = await association.target.findByPk(item[pk], { + transaction, + }); const addAccessor = association.accessors.add; - await model[addAccessor](item[pk], { transaction }); + + await model[addAccessor](item[pk], accessorOptions); await updateAssociations(instance, item, { transaction, ...options }); list3.push(instance); } } + model.setDataValue(key, list1.concat(list3)); if (!options.transaction) { await transaction.commit(); diff --git a/packages/database-next/src/update-guard.ts b/packages/database-next/src/update-guard.ts new file mode 100644 index 000000000..d0574dc7d --- /dev/null +++ b/packages/database-next/src/update-guard.ts @@ -0,0 +1,172 @@ +import { flatten } from 'flat'; +import lodash, { keys } from 'lodash'; + +import { Collection } from './collection'; +import { BelongsTo, HasOne, Model, ModelCtor } from 'sequelize'; +import { AssociationKeysToBeUpdate, BlackList, WhiteList } from './repository'; + +type UpdateValueItem = string | number | UpdateValues; + +type UpdateValues = { + [key: string]: UpdateValueItem | Array; +}; + +export class UpdateGuard { + model: ModelCtor; + private associationKeysToBeUpdate: AssociationKeysToBeUpdate; + private blackList: BlackList; + private whiteList: WhiteList; + + setModel(model: ModelCtor) { + this.model = model; + } + + setAssociationKeysToBeUpdate( + associationKeysToBeUpdate: AssociationKeysToBeUpdate, + ) { + this.associationKeysToBeUpdate = associationKeysToBeUpdate; + } + + setWhiteList(whiteList: WhiteList) { + this.whiteList = whiteList; + } + + setBlackList(blackList: BlackList) { + this.blackList = blackList; + } + + /** + * Sanitize values by whitelist blacklist + * @param values + */ + sanitize(values: UpdateValues) { + values = lodash.clone(values); + + if (!this.model) { + throw new Error('please set model first'); + } + + const associations = this.model.associations; + const associationsValues = lodash.pick(values, Object.keys(associations)); + + // build params of association update guard + const listOfAssociation = (list, association) => { + if (list) { + list = list + .filter((whiteListKey) => whiteListKey.startsWith(`${association}.`)) + .map((whiteListKey) => whiteListKey.replace(`${association}.`, '')); + + if (list.length == 0) { + return undefined; + } + + return list; + } + + return undefined; + }; + + // sanitize association values + Object.keys(associationsValues).forEach((association) => { + let associationValues = associationsValues[association]; + + const filterAssociationToBeUpdate = (value) => { + const associationKeysToBeUpdate = this.associationKeysToBeUpdate || []; + + if (associationKeysToBeUpdate.includes(association)) { + return value; + } + + const associationObj = associations[association]; + + const associationKeyName = + associationObj.associationType == 'BelongsTo' || + associationObj.associationType == 'HasOne' + ? (associationObj).targetKey + : associationObj.target.primaryKeyAttribute; + + if (value[associationKeyName]) { + return lodash.pick(value, [ + associationKeyName, + ...Object.keys(associationObj.target.associations), + ]); + } + + return value; + }; + + const sanitizeValue = (value) => { + const associationUpdateGuard = new UpdateGuard(); + associationUpdateGuard.setModel(associations[association].target); + + ['whiteList', 'blackList', 'associationKeysToBeUpdate'].forEach( + (optionKey) => { + associationUpdateGuard[`set${lodash.upperFirst(optionKey)}`]( + listOfAssociation(this[optionKey], association), + ); + }, + ); + + return associationUpdateGuard.sanitize( + filterAssociationToBeUpdate(value), + ); + }; + + if (Array.isArray(associationValues)) { + associationValues = associationValues.map((value) => { + if (typeof value == 'string' || typeof value == 'number') { + return value; + } else { + return sanitizeValue(value); + } + }); + } else if ( + typeof associationValues === 'object' && + associationValues !== null + ) { + associationValues = sanitizeValue(associationValues); + } + + // set association values to sanitized value + values[association] = associationValues; + }); + + let valuesKeys = Object.keys(values); + + // handle whitelist + if (this.whiteList) { + valuesKeys = valuesKeys.filter((valueKey) => { + return ( + this.whiteList.findIndex((whiteKey) => { + const keyPaths = whiteKey.split('.'); + return keyPaths[0] === valueKey; + }) !== -1 + ); + }); + } + + // handle blacklist + if (this.blackList) { + valuesKeys = valuesKeys.filter( + (valueKey) => !this.blackList.includes(valueKey), + ); + } + + const result = valuesKeys.reduce((obj, key) => { + lodash.set(obj, key, values[key]); + return obj; + }, {}); + + return result; + } + + static fromOptions(model, options) { + const guard = new UpdateGuard(); + guard.setModel(model); + guard.setWhiteList(options.whitelist); + guard.setBlackList(options.blacklist); + guard.setAssociationKeysToBeUpdate(options.updateAssociationValues); + + return guard; + } +} diff --git a/packages/database-next/src/utils.ts b/packages/database-next/src/utils.ts index ad5a96438..e3e576fe6 100644 --- a/packages/database-next/src/utils.ts +++ b/packages/database-next/src/utils.ts @@ -1,18 +1,10 @@ -export default { - fiter: { - and: [ - { a: 'a' }, - { b: 'b' }, - { c: 'c' }, - { 'assoc.a': 'abc1' }, - { 'assoc.b': 'abc2' }, - { 'assoc.c': 'abc3' }, - { - and: [ - { 'assoc.a': 'abc1' }, - { 'assoc.b': 'abc2' }, - ], - }, - ], - }, -}; +let IDX = 36, + HEX = ''; +while (IDX--) HEX += IDX.toString(36); + +export function uid(len?: number) { + let str = '', + num = len || 11; + while (num--) str += HEX[(Math.random() * 36) | 0]; + return str; +} diff --git a/packages/server/src/application.ts b/packages/server/src/application.ts index 02854236e..de5fe0f15 100644 --- a/packages/server/src/application.ts +++ b/packages/server/src/application.ts @@ -12,6 +12,7 @@ import { registerMiddlewares, } from './helper'; import { i18n, InitOptions } from 'i18next'; +import { applyMixins, AsyncEmitter } from '@nocobase/utils'; export interface ResourcerOptions { prefix?: string; @@ -51,10 +52,10 @@ interface ActionsOptions { resourceNames?: string[]; } -export class Application< - StateT = DefaultState, - ContextT = DefaultContext -> extends Koa { +export class Application + extends Koa + implements AsyncEmitter +{ public readonly db: Database; public readonly resourcer: Resourcer; @@ -160,57 +161,6 @@ export class Application< await this.emitAsync('plugins.afterLoad'); } - async emitAsync(event: string | symbol, ...args: any[]): Promise { - // @ts-ignore - const events = this._events; - let callbacks = events[event]; - if (!callbacks) { - return false; - } - // helper function to reuse as much code as possible - const run = (cb) => { - switch (args.length) { - // fast cases - case 0: - cb = cb.call(this); - break; - case 1: - cb = cb.call(this, args[0]); - break; - case 2: - cb = cb.call(this, args[0], args[1]); - break; - case 3: - cb = cb.call(this, args[0], args[1], args[2]); - break; - // slower - default: - cb = cb.apply(this, args); - } - - if (cb && (cb instanceof Promise || typeof cb.then === 'function')) { - return cb; - } - - return Promise.resolve(true); - }; - - if (typeof callbacks === 'function') { - await run(callbacks); - } else if (typeof callbacks === 'object') { - callbacks = callbacks.slice().filter(Boolean); - await callbacks.reduce((prev, next) => { - return prev.then((res) => { - return run(next).then((result) => - Promise.resolve(res.concat(result)), - ); - }); - }, Promise.resolve([])); - } - - return true; - } - async parse(argv = process.argv) { await this.load(); return this.cli.parseAsync(argv); @@ -219,6 +169,9 @@ export class Application< async destroy() { await this.db.close(); } + + emitAsync: (event: string | symbol, ...args: any[]) => Promise; } +applyMixins(Application, [AsyncEmitter]); export default Application; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 7ade55b32..4391fd880 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,2 +1,4 @@ +export * from './mixin'; +export * from './mixin/AsyncEmitter'; export * from './merge'; export * from './umiConfig'; diff --git a/packages/utils/src/mixin/AsyncEmitter.ts b/packages/utils/src/mixin/AsyncEmitter.ts new file mode 100644 index 000000000..41736fa9f --- /dev/null +++ b/packages/utils/src/mixin/AsyncEmitter.ts @@ -0,0 +1,52 @@ +export class AsyncEmitter { + async emitAsync(event: string | symbol, ...args: any[]): Promise { + // @ts-ignore + const events = this._events; + let callbacks = events[event]; + if (!callbacks) { + return false; + } + // helper function to reuse as much code as possible + const run = (cb) => { + switch (args.length) { + // fast cases + case 0: + cb = cb.call(this); + break; + case 1: + cb = cb.call(this, args[0]); + break; + case 2: + cb = cb.call(this, args[0], args[1]); + break; + case 3: + cb = cb.call(this, args[0], args[1], args[2]); + break; + // slower + default: + cb = cb.apply(this, args); + } + + if (cb && (cb instanceof Promise || typeof cb.then === 'function')) { + return cb; + } + + return Promise.resolve(true); + }; + + if (typeof callbacks === 'function') { + await run(callbacks); + } else if (typeof callbacks === 'object') { + callbacks = callbacks.slice().filter(Boolean); + await callbacks.reduce((prev, next) => { + return prev.then((res) => { + return run(next).then((result) => + Promise.resolve(res.concat(result)), + ); + }); + }, Promise.resolve([])); + } + + return true; + } +} diff --git a/packages/utils/src/mixin/index.ts b/packages/utils/src/mixin/index.ts new file mode 100644 index 000000000..ca3e182a2 --- /dev/null +++ b/packages/utils/src/mixin/index.ts @@ -0,0 +1,12 @@ +export function applyMixins(derivedCtor: any, constructors: any[]) { + constructors.forEach((baseCtor) => { + Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => { + Object.defineProperty( + derivedCtor.prototype, + name, + Object.getOwnPropertyDescriptor(baseCtor.prototype, name) || + Object.create(null), + ); + }); + }); +} diff --git a/tsconfig.jest.json b/tsconfig.jest.json index 06724af57..94891b6ad 100755 --- a/tsconfig.jest.json +++ b/tsconfig.jest.json @@ -5,13 +5,8 @@ "sourceMap": true, "target": "ES6", "paths": { - "@nocobase/*": [ - "./packages/*/src" - ] + "@nocobase/*": ["./packages/*/src"] } }, - "exclude": [ - "./packages/*/esm", - "./packages/*/lib" - ] -} \ No newline at end of file + "exclude": ["./packages/*/esm", "./packages/*/lib"] +}