diff --git a/packages/core/database/src/__tests__/repository/aggregation.test.ts b/packages/core/database/src/__tests__/repository/aggregation.test.ts new file mode 100644 index 000000000..c25cdfb95 --- /dev/null +++ b/packages/core/database/src/__tests__/repository/aggregation.test.ts @@ -0,0 +1,297 @@ +import { BelongsToManyRepository, HasManyRepository, mockDatabase } from '../../index'; +import Database from '../../database'; +import { Collection } from '../../collection'; + +describe('association aggregation', () => { + let db: Database; + + let User: Collection; + let Post: Collection; + let Tag: Collection; + + afterEach(async () => { + await db.close(); + }); + + beforeEach(async () => { + db = mockDatabase(); + await db.clean({ drop: true }); + + User = db.collection({ + name: 'users', + fields: [ + { type: 'string', name: 'name' }, + { type: 'integer', name: 'age' }, + { type: 'hasMany', name: 'posts' }, + { + type: 'belongsToMany', + name: 'tags', + }, + ], + }); + + Post = db.collection({ + name: 'posts', + fields: [ + { + type: 'string', + name: 'title', + }, + { + type: 'string', + name: 'category', + }, + { + type: 'integer', + name: 'readCount', + }, + { + type: 'belongsTo', + name: 'user', + }, + ], + }); + + Tag = db.collection({ + name: 'tags', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'integer', + name: 'score', + }, + ], + }); + + await db.sync(); + }); + + describe('belongs to many', () => { + beforeEach(async () => { + await User.repository.create({ + values: [ + { + name: 'u1', + age: 1, + tags: [ + { name: 't1', score: 1 }, + { name: 't2', score: 2 }, + ], + }, + { + name: 'u2', + age: 2, + tags: [ + { name: 't3', score: 3 }, + { name: 't4', score: 4 }, + { name: 't5', score: 4 }, + ], + }, + ], + }); + }); + + it('should sum field', async () => { + const user1 = await User.repository.findOne({ + filter: { + name: 'u1', + }, + }); + + const TagRepository = await db.getRepository('users.tags', user1.get('id')); + + const sumResult = await TagRepository.aggregate({ + field: 'score', + method: 'sum', + }); + + expect(sumResult).toEqual(3); + }); + + it('should sum with filter', async () => { + const user1 = await User.repository.findOne({ + filter: { + name: 'u2', + }, + }); + + const TagRepository = await db.getRepository('users.tags', user1.get('id')); + + const sumResult = await TagRepository.aggregate({ + field: 'score', + method: 'sum', + filter: { + score: 4, + }, + }); + + expect(sumResult).toEqual(8); + }); + + it('should sum with distinct', async () => { + const user1 = await User.repository.findOne({ + filter: { + name: 'u2', + }, + }); + + const TagRepository = await db.getRepository('users.tags', user1.get('id')); + + const sumResult = await TagRepository.aggregate({ + field: 'score', + method: 'sum', + distinct: true, + filter: { + score: 4, + }, + }); + + expect(sumResult).toEqual(4); + }); + it('should sum with association filter', async () => { + const sumResult = await User.repository.aggregate({ + field: 'age', + method: 'sum', + filter: { + 'tags.score': 4, + }, + }); + + expect(sumResult).toEqual(2); + }); + }); + + describe('has many', () => { + beforeEach(async () => { + await User.repository.create({ + values: [ + { + name: 'u1', + age: 1, + posts: [ + { title: 'p1', category: 'c1', readCount: 1 }, + { title: 'p2', category: 'c2', readCount: 2 }, + ], + }, + { + name: 'u2', + age: 2, + posts: [ + { title: 'p3', category: 'c3', readCount: 3 }, + { title: 'p4', category: 'c4', readCount: 4 }, + ], + }, + ], + }); + }); + + it('should sum field', async () => { + const user1 = await User.repository.findOne({ + filter: { + name: 'u1', + }, + }); + + const PostRepository = await db.getRepository('users.posts', user1.get('id')); + const sumResult = await PostRepository.aggregate({ + field: 'readCount', + method: 'sum', + }); + + expect(sumResult).toEqual(3); + }); + + it('should sum with filter', async () => { + const user1 = await User.repository.findOne({ + filter: { + name: 'u1', + }, + }); + + const PostRepository = await db.getRepository('users.posts', user1.get('id')); + const sumResult = await PostRepository.aggregate({ + field: 'readCount', + method: 'sum', + }); + + expect(sumResult).toEqual(3); + }); + }); +}); + +describe('Aggregation', () => { + let db: Database; + + let User: Collection; + afterEach(async () => { + await db.close(); + }); + + beforeEach(async () => { + db = mockDatabase(); + await db.clean({ drop: true }); + + User = db.collection({ + name: 'users', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'integer', + name: 'age', + }, + ], + }); + + await db.sync(); + + await User.repository.create({ + values: [ + { name: 'u1', age: 1 }, + { name: 'u2', age: 2 }, + { name: 'u3', age: 3 }, + { name: 'u4', age: 4 }, + { name: 'u5', age: 5 }, + { name: 'u5', age: 5 }, + ], + }); + }); + + describe('sum', () => { + it('should sum field', async () => { + const sumResult = await User.repository.aggregate({ + method: 'sum', + field: 'age', + }); + + expect(sumResult).toEqual(20); + }); + + it('should sum with distinct', async () => { + const sumResult = await User.repository.aggregate({ + method: 'sum', + field: 'age', + distinct: true, + }); + + expect(sumResult).toEqual(15); + }); + + it('should sum with filter', async () => { + const sumResult = await User.repository.aggregate({ + method: 'sum', + field: 'age', + filter: { + name: 'u5', + }, + }); + + expect(sumResult).toEqual(10); + }); + }); +}); diff --git a/packages/core/database/src/mock-database.ts b/packages/core/database/src/mock-database.ts index 7abc8b4b5..cc064f09f 100644 --- a/packages/core/database/src/mock-database.ts +++ b/packages/core/database/src/mock-database.ts @@ -45,7 +45,9 @@ export function getConfigByEnv() { function customLogger(queryString, queryObject) { console.log(queryString); // outputs a string - console.log(queryObject.bind); // outputs an array + if (queryObject.bind) { + console.log(queryObject.bind); // outputs an array + } } export function mockDatabase(options: IDatabaseOptions = {}): MockDatabase { diff --git a/packages/core/database/src/relation-repository/belongs-to-many-repository.ts b/packages/core/database/src/relation-repository/belongs-to-many-repository.ts index 73d03fd44..356dd05e2 100644 --- a/packages/core/database/src/relation-repository/belongs-to-many-repository.ts +++ b/packages/core/database/src/relation-repository/belongs-to-many-repository.ts @@ -1,7 +1,15 @@ import lodash from 'lodash'; import { BelongsToMany, Op, Transaction } from 'sequelize'; import { Model } from '../model'; -import { CreateOptions, DestroyOptions, FindOneOptions, FindOptions, TargetKey, UpdateOptions } from '../repository'; +import { + AggregateOptions, + CreateOptions, + DestroyOptions, + FindOneOptions, + FindOptions, + TargetKey, + UpdateOptions, +} from '../repository'; import { updateThroughTableValue } from '../update-associations'; import { FindAndCountOptions, MultipleRelationRepository } from './multiple-relation-repository'; import { transaction } from './relation-repository'; @@ -38,6 +46,30 @@ interface IBelongsToManyRepository { } export class BelongsToManyRepository extends MultipleRelationRepository implements IBelongsToManyRepository { + async aggregate(options: AggregateOptions) { + const targetRepository = this.targetCollection.repository; + + const sourceModel = await this.getSourceModel(); + + const association = this.association as any; + + return await targetRepository.aggregate({ + ...options, + optionsTransformer: (modelOptions) => { + modelOptions.include = modelOptions.include || []; + const throughWhere = {}; + throughWhere[association.foreignKey] = sourceModel.get(association.sourceKey); + + modelOptions.include.push({ + association: association.oneFromTarget, + required: true, + attributes: [], + where: throughWhere, + }); + }, + }); + } + @transaction() async create(options?: CreateBelongsToManyOptions): Promise { if (Array.isArray(options.values)) { diff --git a/packages/core/database/src/relation-repository/hasmany-repository.ts b/packages/core/database/src/relation-repository/hasmany-repository.ts index 09147c677..0234bc478 100644 --- a/packages/core/database/src/relation-repository/hasmany-repository.ts +++ b/packages/core/database/src/relation-repository/hasmany-repository.ts @@ -2,6 +2,7 @@ import { omit } from 'lodash'; import { HasMany, Op } from 'sequelize'; import { Model } from '../model'; import { + AggregateOptions, CreateOptions, DestroyOptions, FindOneOptions, @@ -53,6 +54,22 @@ export class HasManyRepository extends MultipleRelationRepository implements IHa return await targetRepository.find(findOptions); } + async aggregate(options: AggregateOptions) { + const targetRepository = this.targetCollection.repository; + const addFilter = { + [this.association.foreignKey]: this.sourceKeyValue, + }; + + const aggOptions = { + ...options, + filter: { + $and: [options.filter || {}, addFilter], + }, + }; + + return await targetRepository.aggregate(aggOptions); + } + @transaction((args, transaction) => { return { filterByTk: args[0], diff --git a/packages/core/database/src/repository.ts b/packages/core/database/src/repository.ts index 3bdc13dc9..585866a62 100644 --- a/packages/core/database/src/repository.ts +++ b/packages/core/database/src/repository.ts @@ -9,6 +9,7 @@ import { FindOptions as SequelizeFindOptions, ModelStatic, Op, + Sequelize, Transactionable, UpdateOptions as SequelizeUpdateOptions, WhereOperators, @@ -202,6 +203,13 @@ class RelationRepositoryBuilder { } } +export interface AggregateOptions { + method: 'avg' | 'count' | 'min' | 'max' | 'sum'; + field?: string; + filter?: Filter; + distinct?: boolean; +} + export class Repository implements IRepository { @@ -259,6 +267,59 @@ export class Repository any }): Promise { + const { method, field } = options; + + const queryOptions = this.buildQueryOptions({ + ...options, + fields: [], + }); + + options.optionsTransformer?.(queryOptions); + + const hasAssociationFilter = () => { + if (queryOptions.include && queryOptions.include.length > 0) { + const filterInclude = queryOptions.include.filter((include) => { + return ( + Object.keys(include.where || {}).length > 0 || + JSON.stringify(queryOptions?.filter)?.includes(include.association) + ); + }); + return filterInclude.length > 0; + } + return false; + }; + + if (hasAssociationFilter()) { + const primaryKeyField = this.model.primaryKeyAttribute; + const queryInterface = this.database.sequelize.getQueryInterface(); + + const findOptions = { + ...queryOptions, + raw: true, + includeIgnoreAttributes: false, + attributes: [ + [ + Sequelize.literal( + `DISTINCT ${queryInterface.quoteIdentifiers(`${this.collection.name}.${primaryKeyField}`)}`, + ), + primaryKeyField, + ], + ], + }; + + const ids = await this.model.findAll(findOptions); + + return await this.model.aggregate(field, method, { + ...lodash.omit(queryOptions, ['where', 'include']), + where: { + [primaryKeyField]: ids.map((node) => node[primaryKeyField]), + }, + }); + } + + return await this.model.aggregate(field, method, queryOptions); + } /** * find * @param options