diff --git a/packages/core/src/interfaces/aggregate-having-filter.interface.ts b/packages/core/src/interfaces/aggregate-having-filter.interface.ts new file mode 100644 index 000000000..c51255013 --- /dev/null +++ b/packages/core/src/interfaces/aggregate-having-filter.interface.ts @@ -0,0 +1,10 @@ +import { CommonFieldComparisonType } from '@rezonate/nestjs-query-core' + +export type HavingFilter = { + count?: CommonFieldComparisonType + distinctCount?: CommonFieldComparisonType + sum?: CommonFieldComparisonType + avg?: CommonFieldComparisonType + max?: CommonFieldComparisonType + min?: CommonFieldComparisonType +} diff --git a/packages/core/src/interfaces/index.ts b/packages/core/src/interfaces/index.ts index 9f4924d78..b57fb1217 100644 --- a/packages/core/src/interfaces/index.ts +++ b/packages/core/src/interfaces/index.ts @@ -15,3 +15,4 @@ export * from './query.inteface' export * from './sort-field.interface' export * from './update-many-response.interface' export * from './update-one-options.interface' +export * from './aggregate-having-filter.interface' diff --git a/packages/core/src/services/query.service.ts b/packages/core/src/services/query.service.ts index 2a57b7f66..a1faa4870 100644 --- a/packages/core/src/services/query.service.ts +++ b/packages/core/src/services/query.service.ts @@ -11,6 +11,7 @@ import { FindByIdOptions, FindRelationOptions, GetByIdOptions, + HavingFilter, ModifyRelationOptions, Query, UpdateManyResponse, @@ -34,8 +35,9 @@ export interface QueryService, U = DeepPartial> { * Perform an aggregate query * @param filter * @param aggregate + * @param having */ - aggregate(filter: Filter, aggregate: AggregateQuery): Promise[]> + aggregate(filter: Filter, aggregate: AggregateQuery, having?: HavingFilter): Promise[]> /** * Count the number of records that match the filter. diff --git a/packages/query-graphql/src/resolvers/aggregate.resolver.ts b/packages/query-graphql/src/resolvers/aggregate.resolver.ts index 2680298b5..48fcfcda9 100644 --- a/packages/query-graphql/src/resolvers/aggregate.resolver.ts +++ b/packages/query-graphql/src/resolvers/aggregate.resolver.ts @@ -60,7 +60,7 @@ export const Aggregateable = authFilter?: Filter ): Promise[]> { const qa = await transformAndValidate(AA, args) - return this.service.aggregate(mergeFilter(qa.filter || {}, authFilter ?? {}), query) + return this.service.aggregate(mergeFilter(qa.filter || {}, authFilter ?? {}), query, qa.having) } } diff --git a/packages/query-graphql/src/types/aggregate/aggregate-args.type.ts b/packages/query-graphql/src/types/aggregate/aggregate-args.type.ts index f0c0a8e03..f237c550e 100644 --- a/packages/query-graphql/src/types/aggregate/aggregate-args.type.ts +++ b/packages/query-graphql/src/types/aggregate/aggregate-args.type.ts @@ -1,12 +1,13 @@ import { ArgsType, Field } from '@nestjs/graphql' -import { Class, Filter } from '@rezonate/nestjs-query-core' +import { Class, Filter, HavingFilter } from '@rezonate/nestjs-query-core' import { Type } from 'class-transformer' import { ValidateNested } from 'class-validator' -import { AggregateFilterType } from '../query' +import { AggregateFilterType, AggregateHavingFilterType } from '../query' export interface AggregateArgsType { filter?: Filter + having?: HavingFilter } /** @@ -16,6 +17,7 @@ export interface AggregateArgsType { // eslint-disable-next-line @typescript-eslint/no-redeclare -- intentional export function AggregateArgsType(DTOClass: Class): Class> { const F = AggregateFilterType(DTOClass) + const HF = AggregateHavingFilterType(DTOClass) @ArgsType() class AggregateArgs implements AggregateArgsType { @@ -23,6 +25,11 @@ export function AggregateArgsType(DTOClass: Class): Class F, { nullable: true, description: 'Filter to find records to aggregate on' }) filter?: Filter + + @Type(() => HF) + @ValidateNested() + @Field(() => HF, { nullable: true, description: 'having filter to find records to aggregate on' }) + having?: HavingFilter } return AggregateArgs diff --git a/packages/query-graphql/src/types/query/having.filter.type.ts b/packages/query-graphql/src/types/query/having.filter.type.ts new file mode 100644 index 000000000..2e635ff34 --- /dev/null +++ b/packages/query-graphql/src/types/query/having.filter.type.ts @@ -0,0 +1,136 @@ +import { Field, InputType } from '@nestjs/graphql' +import { + Class, + CommonFieldComparisonType, + FilterComparisons, + FilterFieldComparison, + HavingFilter, + MapReflector +} from '@rezonate/nestjs-query-core' +import { Type } from 'class-transformer' +import { ValidateNested } from 'class-validator' +import { getDTONames, getGraphqlObjectName } from '../../common' +import { FilterableFieldDescriptor, getFilterableFields } from "../../decorators"; +import { createFilterComparisonType } from './field-comparison' +import { upperCaseFirst } from 'upper-case-first' +import { HasRequiredFilter } from '../../decorators/has-required.filter' + +const reflector = new MapReflector('nestjs-query:having-filter-type') + +export interface HavingFilterConstructor { + hasRequiredFilters: boolean + + new (): HavingFilter +} +function createHavingFilterComparison(FieldType: Class, fieldName: string): Class> { + const name = `${fieldName}HavingFilterComparison` + + @InputType(name) + class HavingFilterComparison { + @Field(() => Number, { nullable: true }) + @Type(() => FieldType) + eq?: number + + @Field(() => Number, { nullable: true }) + @Type(() => FieldType) + neq?: number + + @Field(() => Number, { nullable: true }) + @Type(() => FieldType) + gt?: number + + @Field(() => Number, { nullable: true }) + @Type(() => FieldType) + gte?: number + + @Field(() => Number, { nullable: true }) + @Type(() => FieldType) + lt?: number + + @Field(() => Number, { nullable: true }) + @Type(() => FieldType) + lte?: number + } + + return HavingFilterComparison as Class> +} +function createHavingFilter(name: string, fields: FilterableFieldDescriptor[] ) { + + @InputType(name) + class HavingFilterAggFunc {} + + fields.forEach((field) => { + const { target, advancedOptions, propertyName } = field + + const FC = createHavingFilterComparison(target, `${name}${upperCaseFirst(propertyName)}`) + + Field(() => FC, { nullable: true })(HavingFilterAggFunc.prototype, propertyName) + Type(() => FC)(HavingFilterAggFunc.prototype, propertyName) + }) + + return HavingFilterAggFunc +} + +function getObjectTypeName(DTOClass: Class): string { + return getGraphqlObjectName(DTOClass, 'No fields found to create FilterType.') +} + +function getOrCreateHavingFilterType(TClass: Class, name: string): HavingFilterConstructor { + return reflector.memoize(TClass, name, () => { + const fields = getFilterableFields(TClass) + + if (!fields.length) { + throw new Error(`No fields found to create GraphQLHavingFilter for ${TClass.name}`) + } + + //TODO: need a better way of creating the filter object + @InputType(`${name}HavingComparison${Date.now()}`) + class GraphqlHavingFieldsFilterComparison {} + + const SumFilter = createHavingFilter(`${name}Sum`, fields) + const MinFilter = createHavingFilter(`${name}Min`, fields) + const MaxFilter = createHavingFilter(`${name}Max`, fields) + const CountFilter = createHavingFilter(`${name}Count`, fields) + const DistinctCountFilter = createHavingFilter(`${name}DistinctCount`, fields) + const AvgFilter = createHavingFilter(`${name}Avg`, fields) + + @InputType(name) + class GraphQLHavingFilter { + @ValidateNested() + @Field(() => SumFilter, { nullable: true }) + @Type(() => SumFilter) + sum?: FilterComparisons + + @ValidateNested() + @Field(() => MaxFilter, { nullable: true }) + @Type(() => MaxFilter) + max?: FilterComparisons + + @ValidateNested() + @Field(() => MinFilter, { nullable: true }) + @Type(() => MinFilter) + min?: FilterComparisons + + @ValidateNested() + @Field(() => CountFilter, { nullable: true }) + @Type(() => CountFilter) + count?: FilterComparisons + + @ValidateNested() + @Field(() => DistinctCountFilter, { nullable: true }) + @Type(() => DistinctCountFilter) + distinctCount?: FilterComparisons + + @ValidateNested() + @Field(() => AvgFilter, { nullable: true }) + @Type(() => AvgFilter) + avg?: FilterComparisons + } + + return GraphQLHavingFilter as HavingFilterConstructor + }) +} + +export function AggregateHavingFilterType(TClass: Class): HavingFilterConstructor { + return getOrCreateHavingFilterType(TClass, `${getObjectTypeName(TClass)}AggregateHavingFilter`) +} diff --git a/packages/query-graphql/src/types/query/index.ts b/packages/query-graphql/src/types/query/index.ts index 2baf4e280..be53b5960 100644 --- a/packages/query-graphql/src/types/query/index.ts +++ b/packages/query-graphql/src/types/query/index.ts @@ -1,4 +1,5 @@ export { AggregateFilterType, DeleteFilterType, FilterType, SubscriptionFilterType, UpdateFilterType } from './filter.type' +export { AggregateHavingFilterType } from './having.filter.type' export { CursorPagingType, NonePagingType, OffsetPagingType, PagingStrategies, PagingTypes } from './paging' export { CursorQueryArgsType, diff --git a/packages/query-typeorm/src/query/aggregate.builder.ts b/packages/query-typeorm/src/query/aggregate.builder.ts index 6435114b4..27b90cf1e 100644 --- a/packages/query-typeorm/src/query/aggregate.builder.ts +++ b/packages/query-typeorm/src/query/aggregate.builder.ts @@ -1,8 +1,14 @@ import { BadRequestException } from '@nestjs/common' -import { AggregateFields, AggregateQuery, AggregateResponse } from '@rezonate/nestjs-query-core' +import { + AggregateFields, + AggregateQuery, + AggregateResponse, + FilterFieldComparison, + HavingFilter +} from '@rezonate/nestjs-query-core' import { camelCase } from 'camel-case' -import { EntityMetadata, Repository, SelectQueryBuilder } from 'typeorm' -import { Entries } from '@rezonate/nestjs-query-graphql/src/decorators' +import { Repository, SelectQueryBuilder } from 'typeorm' +import { EntityComparisonField, SQLComparisonBuilder } from './sql-comparison.builder' enum AggregateFuncs { AVG = 'AVG', @@ -20,8 +26,10 @@ const AGG_REGEXP = /(AVG|SUM|COUNT|DISTINCT_COUNT|MAX|MIN|GROUP_BY)_(.*)/ * Builds a WHERE clause from a Filter. */ export class AggregateBuilder { - constructor(readonly repo: Repository) { - } + constructor( + readonly repo: Repository, + readonly sqlComparisonBuilder: SQLComparisonBuilder = new SQLComparisonBuilder() + ) {} // eslint-disable-next-line @typescript-eslint/no-shadow public static async asyncConvertToAggregateResponse( @@ -108,7 +116,7 @@ export class AggregateBuilder { */ public getCorrectedField(alias: string, f: AggregateFields[0]) { return this.getFieldWithRelations(f).map(({ field, metadata, relationField }) => { - const col = alias || relationField ? `${relationField ? relationField : alias}.${field}` : (field) + const col = alias || relationField ? `${relationField ? relationField : alias}.${field}` : field const meta = metadata.findColumnWithPropertyName(`${field}`) if (meta && metadata.connection.driver.normalizeType(meta) === 'datetime') { @@ -147,8 +155,11 @@ export class AggregateBuilder { return [] } return this.getFieldsWithRelations(fields).map(({ field, relationField }) => { - const col = alias || relationField ? `${relationField ? relationField : alias}.${field as string}` : (field as string) - return [`${func}(${col})`, AggregateBuilder.getAggregateAlias(func, relationField ? `${relationField}.${field}` : `${field}`)] + const col = alias || relationField ? `${relationField ? relationField : alias}.${field}` : field + return [ + `${func}(${col})`, + AggregateBuilder.getAggregateAlias(func, relationField ? `${relationField}.${field}` : `${field}`) + ] }) } @@ -157,8 +168,11 @@ export class AggregateBuilder { return [] } return this.getFieldsWithRelations(fields).map(({ field, relationField }) => { - const col = alias || relationField ? `${relationField ? relationField : alias}.${field as string}` : (field as string) - return [`COUNT (DISTINCT ${col})`, AggregateBuilder.getAggregateAlias(func, relationField ? `${relationField}.${field}` : `${field}`)] + const col = alias || relationField ? `${relationField ? relationField : alias}.${field}` : field + return [ + `COUNT (DISTINCT ${col})`, + AggregateBuilder.getAggregateAlias(func, relationField ? `${relationField}.${field}` : `${field}`) + ] }) } @@ -180,15 +194,18 @@ export class AggregateBuilder { } private getFieldsWithRelations(fields: AggregateFields) { - return fields.flatMap(field => this.getFieldWithRelations(field)) + return fields.flatMap((field) => this.getFieldWithRelations(field)) } private getFieldWithRelations(field: AggregateFields[0]) { - if (typeof field !== 'object') return [{ - field: field as string, - metadata: this.repo.metadata, - relationField: null - }] + if (typeof field !== 'object') + return [ + { + field: field as string, + metadata: this.repo.metadata, + relationField: null + } + ] const entries: [string, string[]][] = Object.entries(field) return entries.flatMap(([key, relationField]) => { @@ -198,4 +215,45 @@ export class AggregateBuilder { }) }) } + + public buildHavingFilter>(qb: Qb, having: HavingFilter, alias?: string): Qb { + const aggFuncMapper = new Map, (col: string) => string>([ + ['avg', (columnName: string) => `AVG(${columnName})`], + ['min', (columnName: string) => `MIN(${columnName})`], + ['max', (columnName: string) => `MAX(${columnName})`], + ['sum', (columnName: string) => `SUM(${columnName})`], + ['count', (columnName: string) => `COUNT(${columnName})`], + ['distinctCount', (columnName: string) => `COUNT(DISTINCT ${columnName})`] + ]) + + Object.entries(having).forEach(([aggFunc, filter]) => { + Object.keys(filter).forEach((field) => { + const cmp = filter[field] as FilterFieldComparison + const opts = Object.keys(cmp) as (keyof FilterFieldComparison)[] + + let column: string + + if (!alias) { + column = aggFuncMapper.get(aggFunc as keyof HavingFilter)(field) + } else { + column = aggFuncMapper.get(aggFunc as keyof HavingFilter)(`${alias}.${field}`) + } + + const sqlComparisons = opts.map((cmpType) => + this.sqlComparisonBuilder.build( + column as keyof Entity, + cmpType, + cmp[cmpType] as EntityComparisonField, + alias, + true + ) + ) + sqlComparisons.map(({ sql, params }) => { + qb.andHaving(sql, params) + }) + }) + }) + + return qb + } } diff --git a/packages/query-typeorm/src/query/filter-query.builder.ts b/packages/query-typeorm/src/query/filter-query.builder.ts index aaca218b4..4d322d0c1 100644 --- a/packages/query-typeorm/src/query/filter-query.builder.ts +++ b/packages/query-typeorm/src/query/filter-query.builder.ts @@ -3,6 +3,7 @@ import { AggregateQuery, Filter, getFilterFields, + HavingFilter, Paging, Query, SortDirection, @@ -24,6 +25,7 @@ import { SoftDeleteQueryBuilder } from 'typeorm/query-builder/SoftDeleteQueryBui import { AggregateBuilder } from './aggregate.builder' import { WhereBuilder } from './where.builder' import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata' +import { ObjectLiteral } from 'typeorm/common/ObjectLiteral' /** * @internal @@ -38,6 +40,10 @@ interface Groupable extends QueryBuilder { addGroupBy(groupBy: string): this } +interface Havingable extends QueryBuilder { + andHaving(having: string, parameters?: ObjectLiteral): this +} + /** * @internal * @@ -112,20 +118,26 @@ export class FilterQueryBuilder { return qb } - public aggregate(query: Query, aggregate: AggregateQuery): SelectQueryBuilder { + public aggregate( + query: Query, + aggregate: AggregateQuery, + having?: HavingFilter + ): SelectQueryBuilder { const hasRelations = this.filterHasRelations(query.filter) const hasAggregatedRelations = this.aggregateHasRelations(aggregate) const tableColumns = this.repo.metadata.columns - const relationsMap = { ...(hasRelations ? this.getReferencedRelationsRecursive(this.repo.metadata, query.filter) : {}), ...(hasAggregatedRelations ? this.getAggregatedRelations(aggregate) : {}) } + const relationsMap = { + ...(hasRelations ? this.getReferencedRelationsRecursive(this.repo.metadata, query.filter) : {}), + ...(hasAggregatedRelations ? this.getAggregatedRelations(aggregate) : {}) + } let qb = this.createQueryBuilder() - qb = hasRelations || hasAggregatedRelations - ? this.applyRelationJoinsRecursive(qb, relationsMap) - : qb + qb = hasRelations || hasAggregatedRelations ? this.applyRelationJoinsRecursive(qb, relationsMap) : qb qb = this.applyAggregate(qb, aggregate, qb.alias) qb = this.applyFilter(qb, tableColumns, query.filter, [], qb.alias) qb = this.applyAggregateSorting(qb, aggregate.groupBy, qb.alias) qb = this.applyAggregateGroupBy(qb, aggregate.groupBy, qb.alias) + qb = this.applyAggregateHaving(qb, having, qb.alias) return qb } @@ -251,6 +263,13 @@ export class FilterQueryBuilder { }, qb) } + public applyAggregateHaving>(qb: Qb, having?: HavingFilter, alias?: string): Qb { + if (!having) { + return qb + } + return this.aggregateBuilder.buildHavingFilter(qb, having, alias) + } + public applyAggregateSorting>(qb: T, groupBy?: AggregateFields, alias?: string): T { if (!groupBy) { return qb diff --git a/packages/query-typeorm/src/query/sql-comparison.builder.ts b/packages/query-typeorm/src/query/sql-comparison.builder.ts index 6a115b1ed..8aea4ab2c 100644 --- a/packages/query-typeorm/src/query/sql-comparison.builder.ts +++ b/packages/query-typeorm/src/query/sql-comparison.builder.ts @@ -50,14 +50,21 @@ export class SQLComparisonBuilder { * @param cmp - the FilterComparisonOperator (eq, neq, gt, etc...) * @param val - the value to compare to * @param alias - alias for the field. + * @param customField */ build( field: F, cmp: FilterComparisonOperators, val: EntityComparisonField, - alias?: string + alias?: string, + customField = false ): CmpSQLType { - const col = alias ? `${alias}.${field as string}` : `${field as string}` + let col: string + if (!customField) { + col = alias ? `${alias}.${field as string}` : `${field as string}` + } else { + col = `${field as string}` + } const normalizedCmp = (cmp as string).toLowerCase() if (this.comparisonMap[normalizedCmp]) { // comparison operator (e.b. =, !=, >, <) diff --git a/packages/query-typeorm/src/services/typeorm-query.service.ts b/packages/query-typeorm/src/services/typeorm-query.service.ts index ba8f3963f..897d9a0f4 100644 --- a/packages/query-typeorm/src/services/typeorm-query.service.ts +++ b/packages/query-typeorm/src/services/typeorm-query.service.ts @@ -10,12 +10,12 @@ import { Filter, Filterable, FindByIdOptions, - GetByIdOptions, + GetByIdOptions, HavingFilter, Query, QueryService, UpdateManyResponse, UpdateOneOptions -} from '@rezonate/nestjs-query-core' +} from "@rezonate/nestjs-query-core"; import { DeleteResult, FindOptionsWhere, Repository } from 'typeorm' import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity' import { UpdateResult } from 'typeorm/query-builder/result/UpdateResult' @@ -81,9 +81,13 @@ export class TypeOrmQueryService return this.filterQueryBuilder.select(query, repo).getMany() } - async aggregate(filter: Filter, aggregate: AggregateQuery): Promise[]> { + async aggregate( + filter: Filter, + aggregate: AggregateQuery, + having?: HavingFilter + ): Promise[]> { return AggregateBuilder.asyncConvertToAggregateResponse( - this.filterQueryBuilder.aggregate({ filter }, aggregate).getRawMany>() + this.filterQueryBuilder.aggregate({ filter }, aggregate, having).getRawMany>() ) }