feat: repository aggregate method (#1829)

* feat: repository aggregate method

* chore: aggregate with distinct

* feat: has many repository aggregation

* feat: belongs to  many repository aggregation

* chore: using model aggregate method

* chore: aggregate with association filter

* chore: aggregate type

* chore: type error
This commit is contained in:
ChengLei Shao 2023-05-11 15:16:33 +08:00 committed by GitHub
parent a6c7b417de
commit f8fa36d016
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 411 additions and 2 deletions

View File

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

View File

@ -45,7 +45,9 @@ export function getConfigByEnv() {
function customLogger(queryString, queryObject) { function customLogger(queryString, queryObject) {
console.log(queryString); // outputs a string 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 { export function mockDatabase(options: IDatabaseOptions = {}): MockDatabase {

View File

@ -1,7 +1,15 @@
import lodash from 'lodash'; import lodash from 'lodash';
import { BelongsToMany, Op, Transaction } from 'sequelize'; import { BelongsToMany, Op, Transaction } from 'sequelize';
import { Model } from '../model'; 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 { updateThroughTableValue } from '../update-associations';
import { FindAndCountOptions, MultipleRelationRepository } from './multiple-relation-repository'; import { FindAndCountOptions, MultipleRelationRepository } from './multiple-relation-repository';
import { transaction } from './relation-repository'; import { transaction } from './relation-repository';
@ -38,6 +46,30 @@ interface IBelongsToManyRepository<M extends Model> {
} }
export class BelongsToManyRepository extends MultipleRelationRepository implements IBelongsToManyRepository<any> { export class BelongsToManyRepository extends MultipleRelationRepository implements IBelongsToManyRepository<any> {
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() @transaction()
async create(options?: CreateBelongsToManyOptions): Promise<any> { async create(options?: CreateBelongsToManyOptions): Promise<any> {
if (Array.isArray(options.values)) { if (Array.isArray(options.values)) {

View File

@ -2,6 +2,7 @@ import { omit } from 'lodash';
import { HasMany, Op } from 'sequelize'; import { HasMany, Op } from 'sequelize';
import { Model } from '../model'; import { Model } from '../model';
import { import {
AggregateOptions,
CreateOptions, CreateOptions,
DestroyOptions, DestroyOptions,
FindOneOptions, FindOneOptions,
@ -53,6 +54,22 @@ export class HasManyRepository extends MultipleRelationRepository implements IHa
return await targetRepository.find(findOptions); 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) => { @transaction((args, transaction) => {
return { return {
filterByTk: args[0], filterByTk: args[0],

View File

@ -9,6 +9,7 @@ import {
FindOptions as SequelizeFindOptions, FindOptions as SequelizeFindOptions,
ModelStatic, ModelStatic,
Op, Op,
Sequelize,
Transactionable, Transactionable,
UpdateOptions as SequelizeUpdateOptions, UpdateOptions as SequelizeUpdateOptions,
WhereOperators, WhereOperators,
@ -202,6 +203,13 @@ class RelationRepositoryBuilder<R extends RelationRepository> {
} }
} }
export interface AggregateOptions {
method: 'avg' | 'count' | 'min' | 'max' | 'sum';
field?: string;
filter?: Filter;
distinct?: boolean;
}
export class Repository<TModelAttributes extends {} = any, TCreationAttributes extends {} = TModelAttributes> export class Repository<TModelAttributes extends {} = any, TCreationAttributes extends {} = TModelAttributes>
implements IRepository implements IRepository
{ {
@ -259,6 +267,59 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
return count; return count;
} }
async aggregate(options: AggregateOptions & { optionsTransformer?: (options: any) => any }): Promise<any> {
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 * find
* @param options * @param options