Skip to content
This repository was archived by the owner on Apr 24, 2025. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/core/src/interfaces/aggregate-having-filter.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { CommonFieldComparisonType } from '@rezonate/nestjs-query-core'

export type HavingFilter<DTO> = {
count?: CommonFieldComparisonType<DTO>
distinctCount?: CommonFieldComparisonType<DTO>
sum?: CommonFieldComparisonType<DTO>
avg?: CommonFieldComparisonType<DTO>
max?: CommonFieldComparisonType<DTO>
min?: CommonFieldComparisonType<DTO>
}
1 change: 1 addition & 0 deletions packages/core/src/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
4 changes: 3 additions & 1 deletion packages/core/src/services/query.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
FindByIdOptions,
FindRelationOptions,
GetByIdOptions,
HavingFilter,
ModifyRelationOptions,
Query,
UpdateManyResponse,
Expand All @@ -34,8 +35,9 @@ export interface QueryService<DTO, C = DeepPartial<DTO>, U = DeepPartial<DTO>> {
* Perform an aggregate query
* @param filter
* @param aggregate
* @param having
*/
aggregate(filter: Filter<DTO>, aggregate: AggregateQuery<DTO>): Promise<AggregateResponse<DTO>[]>
aggregate(filter: Filter<DTO>, aggregate: AggregateQuery<DTO>, having?: HavingFilter<DTO>): Promise<AggregateResponse<DTO>[]>

/**
* Count the number of records that match the filter.
Expand Down
2 changes: 1 addition & 1 deletion packages/query-graphql/src/resolvers/aggregate.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const Aggregateable =
authFilter?: Filter<DTO>
): Promise<AggregateResponse<DTO>[]> {
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)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<DTO> {
filter?: Filter<DTO>
having?: HavingFilter<DTO>
}

/**
Expand All @@ -16,13 +17,19 @@ export interface AggregateArgsType<DTO> {
// eslint-disable-next-line @typescript-eslint/no-redeclare -- intentional
export function AggregateArgsType<DTO>(DTOClass: Class<DTO>): Class<AggregateArgsType<DTO>> {
const F = AggregateFilterType(DTOClass)
const HF = AggregateHavingFilterType(DTOClass)

@ArgsType()
class AggregateArgs implements AggregateArgsType<DTO> {
@Type(() => F)
@ValidateNested()
@Field(() => F, { nullable: true, description: 'Filter to find records to aggregate on' })
filter?: Filter<DTO>

@Type(() => HF)
@ValidateNested()
@Field(() => HF, { nullable: true, description: 'having filter to find records to aggregate on' })
having?: HavingFilter<DTO>
}

return AggregateArgs
Expand Down
136 changes: 136 additions & 0 deletions packages/query-graphql/src/types/query/having.filter.type.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
hasRequiredFilters: boolean

new (): HavingFilter<T>
}
function createHavingFilterComparison<T>(FieldType: Class<T>, fieldName: string): Class<CommonFieldComparisonType<T>> {
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<CommonFieldComparisonType<T>>
}
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<DTO>(DTOClass: Class<DTO>): string {
return getGraphqlObjectName(DTOClass, 'No fields found to create FilterType.')
}

function getOrCreateHavingFilterType<T>(TClass: Class<T>, name: string): HavingFilterConstructor<T> {
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<T>

@ValidateNested()
@Field(() => MaxFilter, { nullable: true })
@Type(() => MaxFilter)
max?: FilterComparisons<T>

@ValidateNested()
@Field(() => MinFilter, { nullable: true })
@Type(() => MinFilter)
min?: FilterComparisons<T>

@ValidateNested()
@Field(() => CountFilter, { nullable: true })
@Type(() => CountFilter)
count?: FilterComparisons<T>

@ValidateNested()
@Field(() => DistinctCountFilter, { nullable: true })
@Type(() => DistinctCountFilter)
distinctCount?: FilterComparisons<T>

@ValidateNested()
@Field(() => AvgFilter, { nullable: true })
@Type(() => AvgFilter)
avg?: FilterComparisons<T>
}

return GraphQLHavingFilter as HavingFilterConstructor<T>
})
}

export function AggregateHavingFilterType<T>(TClass: Class<T>): HavingFilterConstructor<T> {
return getOrCreateHavingFilterType(TClass, `${getObjectTypeName(TClass)}AggregateHavingFilter`)
}
1 change: 1 addition & 0 deletions packages/query-graphql/src/types/query/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
90 changes: 74 additions & 16 deletions packages/query-typeorm/src/query/aggregate.builder.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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<Entity> {
constructor(readonly repo: Repository<Entity>) {
}
constructor(
readonly repo: Repository<Entity>,
readonly sqlComparisonBuilder: SQLComparisonBuilder<Entity> = new SQLComparisonBuilder<Entity>()
) {}

// eslint-disable-next-line @typescript-eslint/no-shadow
public static async asyncConvertToAggregateResponse<Entity>(
Expand Down Expand Up @@ -108,7 +116,7 @@ export class AggregateBuilder<Entity> {
*/
public getCorrectedField(alias: string, f: AggregateFields<Entity>[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') {
Expand Down Expand Up @@ -147,8 +155,11 @@ export class AggregateBuilder<Entity> {
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}`)
]
})
}

Expand All @@ -157,8 +168,11 @@ export class AggregateBuilder<Entity> {
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}`)
]
})
}

Expand All @@ -180,15 +194,18 @@ export class AggregateBuilder<Entity> {
}

private getFieldsWithRelations(fields: AggregateFields<Entity>) {
return fields.flatMap(field => this.getFieldWithRelations(field))
return fields.flatMap((field) => this.getFieldWithRelations(field))
}

private getFieldWithRelations(field: AggregateFields<Entity>[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]) => {
Expand All @@ -198,4 +215,45 @@ export class AggregateBuilder<Entity> {
})
})
}

public buildHavingFilter<Qb extends SelectQueryBuilder<Entity>>(qb: Qb, having: HavingFilter<Entity>, alias?: string): Qb {
const aggFuncMapper = new Map<keyof HavingFilter<Entity>, (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<Entity[keyof Entity]>
const opts = Object.keys(cmp) as (keyof FilterFieldComparison<Entity[keyof Entity]>)[]

let column: string

if (!alias) {
column = aggFuncMapper.get(aggFunc as keyof HavingFilter<Entity>)(field)
} else {
column = aggFuncMapper.get(aggFunc as keyof HavingFilter<Entity>)(`${alias}.${field}`)
}

const sqlComparisons = opts.map((cmpType) =>
this.sqlComparisonBuilder.build(
column as keyof Entity,
cmpType,
cmp[cmpType] as EntityComparisonField<Entity, keyof Entity>,
alias,
true
)
)
sqlComparisons.map(({ sql, params }) => {
qb.andHaving(sql, params)
})
})
})

return qb
}
}
Loading