feat: database next (#130)

* FIX: database test with sqlite

* more types

* filter test

* split filter parser

* filter test

* filter test: hasMany

* define inverse association for belongsTo & hasMany

* chore: console.log

* repository count method

* chore: Collection

* repository filter & appends & fields & expect

* repository: sort option

* chore: test

* add: test

* find & findAndCount

* chore: test

* database-next: update guard

* database-next: update guard associationKeysToBeUpdate

* chore: comment

* update-guard OneToOne Association

* has one repository

* support through table value

* belongs to many repository

* has many repository

* has many repository find

* fix: has many find and count

* clean code

* add count method

* chore: multiple relation

* chore: single relation

* repository find

* relation repository builder

* repository count

* repository count test

* fix test

* close db afterEach test

* sort with associations

* repository update

* has many repository: destroy

* belongs to many repository: destroy

* add transaction decorator

* belongs to many with transaction

* has many with transaction

* clean types

* clean types

* clean types

* repository transaction

* fix test

* single relation repository with transaction

* single relation repository with transaction

* fix: test

* fix: option parser fields append

* fix: typo

* fix: string type

* fix: import

* collection field methods

* cleanup

* collection sync

* fix: import

* fix: test

* collection update field

* collection update options

* database hook

* database test

* database event test

* update database event

* add async emmit mixin

* async model event

* database import

* fix: model hook type

* fix: collection event

* recall model.init on collection update

* skip redefine collection test

* skip collection model update

* add model hook class

* global model event support

* chore

* chore

* change utils import

* add field types

* database import

* more import test

* test case

* fix: through model init...

* bugfix

* fix

* update database import

* collection sync by foreachModel

* fix collection model sync

* update

* add field types

* custom operator

* sqlite array field

* postgresql array field

* array query escape

* mysql array operators

* date operators

* array field sqlite fix

* association operator

* date operator empty & notEmpty

* fix: fields import

* fix array field nested association

* filter parse prepare

* fix test

* string field empty

* add date operator test

* field option types

* fix typo

* fix: operator name conflict

* rename function

Co-authored-by: Chareice <chareice@live.com>
This commit is contained in:
chenos 2021-12-06 21:12:54 +08:00 committed by GitHub
parent bac1912b66
commit c2ff7882bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
88 changed files with 6912 additions and 828 deletions

View File

@ -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');
});
});

View File

@ -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 (<any>(
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 (<any>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 (<any>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();
});

View File

@ -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();
});
});

View File

@ -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();
});
});

View File

@ -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<any>({
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<any>({
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();
});
});

View File

@ -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();
});

View File

@ -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<any>();
@ -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<any>({ sort: 3 });
@ -66,5 +62,4 @@ describe('string field', () => {
expect(t3.get('sort')).toBe(1);
expect(t4.get('sort')).toBe(2);
});
});

View File

@ -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',

View File

@ -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',
);
});
});

View File

@ -0,0 +1,6 @@
import { extend } from '../../../database';
export default extend({
name: 'tests',
fields: [{ type: 'string', name: 'n0' }],
});

View File

@ -0,0 +1,6 @@
import { extend } from '../../../database';
export default extend({
name: 'tests',
fields: [{ type: 'string', name: 'n1' }],
});

View File

@ -0,0 +1,6 @@
import { extend } from '../../../database';
export default {
name: 'tests',
fields: [{ type: 'string', name: 'n2' }],
};

View File

@ -0,0 +1,6 @@
import { extend } from '../../../database';
export default extend({
name: 'images',
fields: [{ type: 'string', name: 'url' }],
});

View File

@ -0,0 +1,6 @@
import { extend } from '../../../database';
export default extend({
name: 'images',
fields: [{ type: 'string', name: 'url2' }],
});

View File

@ -0,0 +1,6 @@
import { extend } from '../../../database';
export default extend({
name: 'tags',
fields: [{ type: 'string', name: 'color' }],
});

View File

@ -0,0 +1,6 @@
import { extend } from '../../../database';
export default extend({
name: 'tags',
fields: [{ type: 'string', name: 'color2' }],
});

View File

@ -0,0 +1,4 @@
export default {
name: 'posts',
fields: [{ type: 'string', name: 'title' }],
};

View File

@ -0,0 +1,4 @@
module.exports = {
name: 'tags',
fields: [{ type: 'string', name: 'name' }],
};

View File

@ -0,0 +1,9 @@
{
"name": "users",
"fields": [
{
"type": "string",
"name": "name"
}
]
}

View File

@ -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));

View File

@ -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);
});
});

View File

@ -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'));
});
});

View File

@ -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'));
});
});

View File

@ -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'));
});
});

View File

@ -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');
});
});

View File

@ -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();
});
});

View File

@ -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();
});
});

View File

@ -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();
});
});

View File

@ -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<any>({
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(<number>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'),
});
});
});

View File

@ -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<HasManyRepository>('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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});
});

View File

@ -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<any>({ 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<any>({ 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<any>({ 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');
});
});
});

View File

@ -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);
});
});

View File

@ -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<string>;
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),
);
}
}

View File

@ -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<ModelOptions, 'name'> {
name: string;
tableName?: string;
fields?: any;
[key: string]: any;
fields?: FieldOptions[];
model?: string | ModelCtor<Model>;
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<string, any>;
model: ModelCtor<Model>;
repository: Repository;
isThrough?: boolean;
fields: Map<string, any> = new Map<string, any>();
model: ModelCtor<Model<TModelAttributes, TCreationAttributes>>;
repository: Repository<TModelAttributes, TCreationAttributes>;
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<string, any>();
this.model = class extends Model<any, any> {};
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<FieldOptions, 'name'>): Field {
return this.setField(name, options);
}
setFields(fields: any, reset = true) {
if (!fields) {
return this;
setField(name: string, options: Omit<FieldOptions, 'name'>): 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<any>(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) {
(<any>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 ((<any>association).through) {
modelNames.push((<any>association).through.model.name);
}
}
const models: ModelCtor<Model>[] = [];
// @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);
}
}
}

View File

@ -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<Model>;
}
interface MapOf<T> {
[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<string, ModelCtor<any>>();
repositories = new Map<string, RepositoryType>();
operators = new Map();
collections: Map<string, Collection>;
collections = new Map<string, Collection>();
pendingFields = new Map<string, RelationField[]>();
modelCollection = new Map<ModelCtor<any>, 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<Attributes = any, CreateAttributes = Attributes>(
options: CollectionOptions,
): Collection<Attributes, CreateAttributes> {
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<typeof Field>) {
for (const [type, fieldType] of Object.entries(fieldTypes)) {
this.fieldTypes.set(type, fieldType);
}
}
registerModels(models: any) {
registerModels(models: MapOf<ModelCtor<any>>) {
for (const [type, schemaType] of Object.entries(models)) {
this.models.set(type, schemaType);
}
}
registerRepositories(repositories: any) {
registerRepositories(repositories: MapOf<RepositoryType>) {
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<OperatorFunc>) {
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<Map<string, Collection>> {
const reader = new ImporterReader(options.directory, options.extensions);
const modules = await reader.read();
const result = new Map<string, Collection>();
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<boolean>;
}
export function extend(
collectionOptions: CollectionOptions,
mergeOptions?: MergeOptions,
) {
return {
collectionOptions,
mergeOptions,
extend: true,
};
}
applyMixins(Database, [AsyncEmitter]);
export default Database;

View File

@ -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';
}

View File

@ -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';
}

View File

@ -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<SequelizeBelongsToManyOptions, 'through'> {
type: 'belongsToMany';
through?: string;
}

View File

@ -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';
}

View File

@ -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';
}

View File

@ -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<ModelAttributeColumnOptions, 'type'> {
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';
}
}

View File

@ -1,8 +0,0 @@
import { DataTypes } from 'sequelize';
import { Field } from './field';
export class FloatField extends Field {
get dataType() {
return DataTypes.FLOAT;
}
}

View File

@ -0,0 +1,5 @@
import { Field } from './field';
export interface HasInverseField {
inverseField: () => Field;
}

View File

@ -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';
}

View File

@ -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';
}

View File

@ -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;

View File

@ -1,8 +0,0 @@
import { DataTypes } from 'sequelize';
import { Field } from './field';
export class IntegerField extends Field {
get dataType() {
return DataTypes.INTEGER;
}
}

View File

@ -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';
}

View File

@ -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';
}

View File

@ -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];
}

View File

@ -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;
}

View File

@ -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';
}

View File

@ -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';
}

View File

@ -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';
}

View File

@ -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';
}

View File

@ -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';
}

View File

@ -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<Model>;
filter: FilterType;
constructor(model: ModelCtor<any>, 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) };
}
}

View File

@ -0,0 +1,4 @@
export * from './database';
export * from './collection';
export * from './utils';
export { Database as default } from './database';

View File

@ -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<string>();
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 <keyof SequelizeHooks>hookType;
}
}
return false;
}
findModelName(hookArgs) {
for (const arg of hookArgs) {
if (arg instanceof Model) {
return (<Model>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);
};
}
}

View File

@ -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}`)],
};
},
};

View File

@ -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,
};
},
};

View File

@ -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) };
},
};

View File

@ -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,
};
},
};

View File

@ -0,0 +1,6 @@
export default {
...require('./association').default,
...require('./date').default,
...require('./array').default,
...require('./empty').default,
};

View File

@ -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<any>;
filterParser: FilterParser;
constructor(model: ModelCtor<any>, 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<any> = 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;
}
}

View File

@ -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());
})();

View File

@ -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<M extends Model> {
find(options?: FindOptions): Promise<M[]>;
findAndCount(options?: FindAndCountOptions): Promise<[M[], number]>;
findOne(options?: FindOneOptions): Promise<M>;
// 新增并关联,存在中间表数据
create(options?: CreateBelongsToManyOptions): Promise<M>;
// 更新,存在中间表数据
update(options?: UpdateOptions): Promise<M>;
// 删除
destroy(
options?: number | string | number[] | string[] | DestroyOptions,
): Promise<Boolean>;
// 建立关联
set(options: PrimaryKey | PrimaryKey[] | AssociatedOptions): Promise<void>;
// 附加关联,存在中间表数据
add(options: PrimaryKey | PrimaryKey[] | AssociatedOptions): Promise<void>;
// 移除关联
remove(options: PrimaryKey | PrimaryKey[] | AssociatedOptions): Promise<void>;
toggle(
options: PrimaryKey | { pk?: PrimaryKey; transaction?: Transaction },
): Promise<void>;
}
export class BelongsToManyRepository
extends MultipleRelationRepository
implements IBelongsToManyRepository<any>
{
@transaction()
async create(options?: CreateBelongsToManyOptions): Promise<any> {
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<Boolean> {
const transaction = await this.getTransaction(options);
const association = <BelongsToMany>this.association;
const instancesToIds = (instances) => {
return instances.map((instance) =>
instance.get(this.target.primaryKeyAttribute),
);
};
// Through Table
const throughTableWhere: Array<any> = [
{
[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 = (<any>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 = (<AssociatedOptions>options).pk || [];
}
if (lodash.isString(options) || lodash.isNumber(options)) {
handleKeys = [<PrimaryKey>options];
} // if it is type primaryKeyWithThroughValues
else if (
lodash.isArray(options) &&
options.length == 2 &&
lodash.isPlainObject(options[0][1])
) {
handleKeys = [<PrimaryKeyWithThroughValues>options];
} else {
handleKeys = <PrimaryKey[] | PrimaryKeyWithThroughValues[]>options;
}
const sourceModel = await this.getSourceModel(transaction);
const setObj = (<any>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<void> {
await this.setTargets('add', options);
}
@transaction((args, transaction) => {
return {
pk: args[0],
transaction,
};
})
async set(
options:
| PrimaryKey
| PrimaryKey[]
| PrimaryKeyWithThroughValues
| PrimaryKeyWithThroughValues[]
| AssociatedOptions,
): Promise<void> {
await this.setTargets('set', options);
}
@transaction((args, transaction) => {
return {
pk: args[0],
transaction,
};
})
async toggle(
options: PrimaryKey | { pk?: PrimaryKey; transaction?: Transaction },
): Promise<void> {
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({
...(<any>options),
transaction,
});
} else {
await this.add({
...(<any>options),
transaction,
});
}
return;
}
throughName() {
return this.throughModel().name;
}
throughModel() {
return (<any>this.association).through.model;
}
}

View File

@ -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<M extends Model> {
// 不需要 findOnefind 就是 findOne
find(options?: BelongsToFindOptions): Promise<M>;
// 新增并关联,如果存在关联,解除之后,与新数据建立关联
create(options?: CreateOptions): Promise<M>;
// 更新
update(options?: UpdateOptions): Promise<M>;
// 删除
destroy(): Promise<Boolean>;
// 建立关联
set(primaryKey: any): Promise<void>;
// 移除关联
remove(): Promise<void>;
}
export class BelongsToRepository
extends SingleRelationRepository
implements IBelongsToRepository<any> {}

View File

@ -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<M extends Model> {
find(options?: FindOptions): Promise<M>;
findAndCount(options?: FindAndCountOptions): Promise<[M[], number]>;
findOne(options?: FindOneOptions): Promise<M>;
// 新增并关联
create(options?: CreateOptions): Promise<M>;
// 更新
update(options?: UpdateOptions): Promise<M>;
// 删除
destroy(options?: PK | DestroyOptions): Promise<Boolean>;
// 建立关联
set(options: PrimaryKey | PrimaryKey[] | AssociatedOptions): Promise<void>;
// 附加关联
add(options: PrimaryKey | PrimaryKey[] | AssociatedOptions): Promise<void>;
// 移除关联
remove(options: PrimaryKey | PrimaryKey[] | AssociatedOptions): Promise<void>;
}
export class HasManyRepository
extends MultipleRelationRepository
implements IHasManyRepository<any>
{
@transaction((args, transaction) => {
return {
filterByPk: args[0],
transaction,
};
})
async destroy(options?: PK | DestroyOptions): Promise<Boolean> {
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 = (<any>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<void> {
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<void> {
const transaction = await this.getTransaction(options);
const sourceModel = await this.getSourceModel(transaction);
await sourceModel[this.accessors().add](this.handleKeyOfAdd(options), {
transaction,
});
}
accessors() {
return (<HasMany>this.association).accessors;
}
}

View File

@ -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<M extends Model> {
// 不需要 findOnefind 就是 findOne
find(options?: HasOneFindOptions): Promise<Model<any>>;
// 新增并关联,如果存在关联,解除之后,与新数据建立关联
create(options?: CreateOptions): Promise<M>;
// 更新
update(options?): Promise<M>;
// 删除
destroy(): Promise<Boolean>;
// 建立关联
set(primaryKey: any): Promise<void>;
// 移除关联
remove(): Promise<void>;
}
export class HasOneRepository<M extends Model>
extends SingleRelationRepository
implements IHasOneRepository<M> {}

View File

@ -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<any> {
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<any> {
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<void> {
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<any> {
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<Boolean> {
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 <MultiAssociationAccessors>super.accessors();
}
}

View File

@ -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<any>;
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 (<BelongsTo | HasOne | HasMany | BelongsToMany>this.association)
.accessors;
}
async create(options?: CreateOptions): Promise<any> {
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<Transaction | null> {
if (lodash.isPlainObject(options) && options.transaction) {
return options.transaction;
}
if (autoGen) {
return await this.source.model.sequelize.transaction();
}
return null;
}
}

View File

@ -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<void> {
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<void> {
const transaction = await this.getTransaction(options);
let handleKey = lodash.isPlainObject(options)
? (<SetOption>options).pk
: options;
const sourceModel = await this.getSourceModel(transaction);
return await sourceModel[this.accessors().set](handleKey, {
transaction,
});
}
async find(options?: SingleRelationFindOption): Promise<Model<any>> {
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<Boolean> {
const transaction = await this.getTransaction(options);
const target = await this.find({
transaction,
});
await target.destroy({
transaction,
});
return true;
}
@transaction()
async update(options: UpdateOptions): Promise<any> {
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 <SingleAssociationAccessors>super.accessors();
}
}

View File

@ -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;

View File

@ -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<number | string | Values>;
};
export interface CountOptions
extends Omit<SequelizeCreateOptions, 'distinct' | 'where' | 'include'>,
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<SequelizeAndCountOptions, 'where' | 'include' | 'order'> {
// 数据过滤
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 (<Repository>this).collection.model.sequelize.transaction();
});
class RelatedQuery {
options: RelatedQueryOptions;
sourceInstance: Model;
class RelationRepositoryBuilder<R extends RelationRepository> {
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<Model>;
@ -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<number> {
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<any>(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<Model> {
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<any>(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 = <DestroyOptions>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<R extends RelationRepository>(
association: string,
): RelationRepositoryBuilder<R> {
return new RelationRepositoryBuilder<R>(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;
}
}

View File

@ -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;
};
};
}

View File

@ -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 (<typeof Model>instance.constructor).associations;
}
export function belongsToManyAssociations(
instance: Model,
): Array<BelongsToMany> {
const associations = modelAssociations(instance);
return Object.entries(associations)
.filter((entry) => {
const [key, association] = entry;
return association.associationType == 'BelongsToMany';
})
.map((association) => {
return <BelongsToMany>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 = <HasOne | BelongsTo>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<Model>;
@ -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 = <BelongsToMany | HasMany>(
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 = (<any>association).through
? (<any>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();

View File

@ -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<UpdateValueItem>;
};
export class UpdateGuard {
model: ModelCtor<any>;
private associationKeysToBeUpdate: AssociationKeysToBeUpdate;
private blackList: BlackList;
private whiteList: WhiteList;
setModel(model: ModelCtor<any>) {
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'
? (<any>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;
}
}

View File

@ -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;
}

View File

@ -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<StateT = DefaultState, ContextT = DefaultContext>
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<boolean> {
// @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<boolean>;
}
applyMixins(Application, [AsyncEmitter]);
export default Application;

View File

@ -1,2 +1,4 @@
export * from './mixin';
export * from './mixin/AsyncEmitter';
export * from './merge';
export * from './umiConfig';

View File

@ -0,0 +1,52 @@
export class AsyncEmitter {
async emitAsync(event: string | symbol, ...args: any[]): Promise<boolean> {
// @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;
}
}

View File

@ -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),
);
});
});
}

View File

@ -5,13 +5,8 @@
"sourceMap": true,
"target": "ES6",
"paths": {
"@nocobase/*": [
"./packages/*/src"
]
"@nocobase/*": ["./packages/*/src"]
}
},
"exclude": [
"./packages/*/esm",
"./packages/*/lib"
]
"exclude": ["./packages/*/esm", "./packages/*/lib"]
}