Skip to content

Commit

Permalink
feat: Added by option to aggregated date fields so group by DAY, …
Browse files Browse the repository at this point in the history
…`MONTH` or `YEAR` (TypeORM)
  • Loading branch information
TriPSs committed Jan 27, 2023
1 parent 5a31466 commit 0993b93
Show file tree
Hide file tree
Showing 11 changed files with 267 additions and 66 deletions.
14 changes: 7 additions & 7 deletions packages/core/src/helpers/aggregate.helpers.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { AggregateQuery, AggregateResponse, NumberAggregate } from '../interfaces'
import { AggregateQuery, AggregateQueryField, AggregateResponse, NumberAggregate } from '../interfaces'
import { QueryFieldMap } from './query.helpers'

const convertAggregateQueryFields = <From, To>(
fieldMap: QueryFieldMap<From, To>,
fields?: (keyof From)[]
): (keyof To)[] | undefined => {
fields?: AggregateQueryField<From>[]
): AggregateQueryField<To>[] | undefined => {
if (!fields) {
return undefined
}

return fields.map((fromField) => {
const otherKey = fieldMap[fromField]
return fields.map(({ field, args }) => {
const otherKey = fieldMap[field]
if (!otherKey) {
throw new Error(`No corresponding field found for '${fromField as string}' when transforming aggregateQuery`)
throw new Error(`No corresponding field found for '${field as string}' when transforming aggregateQuery`)
}
return otherKey as keyof To
return { field: otherKey, args } as AggregateQueryField<To>
})
}

Expand Down
30 changes: 24 additions & 6 deletions packages/core/src/interfaces/aggregate-query.interface.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
export enum GroupBy {
DAY = 'DAY',
MONTH = 'MONTH',
YEAR = 'YEAR'
}

export type AggregateQueryField<DTO, ARGS = unknown> = {
field: keyof DTO
args: ARGS
}

export type AggregateQueryCountField<DTO> = AggregateQueryField<DTO>
export type AggregateQuerySumField<DTO> = AggregateQueryField<DTO>
export type AggregateQueryAvgField<DTO> = AggregateQueryField<DTO>
export type AggregateQueryMaxField<DTO> = AggregateQueryField<DTO>
export type AggregateQueryMinField<DTO> = AggregateQueryField<DTO>
export type AggregateQueryGroupByField<DTO> = AggregateQueryField<DTO, { by: GroupBy }>

export type AggregateQuery<DTO> = {
count?: (keyof DTO)[]
sum?: (keyof DTO)[]
avg?: (keyof DTO)[]
max?: (keyof DTO)[]
min?: (keyof DTO)[]
groupBy?: (keyof DTO)[]
count?: AggregateQueryCountField<DTO>[]
sum?: AggregateQuerySumField<DTO>[]
avg?: AggregateQueryAvgField<DTO>[]
max?: AggregateQueryMaxField<DTO>[]
min?: AggregateQueryMinField<DTO>[]
groupBy?: AggregateQueryGroupByField<DTO>[]
}
1 change: 0 additions & 1 deletion packages/query-graphql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
"url": "https://github.com/tripss/nestjs-query/issues"
},
"dependencies": {
"graphql-fields": "^2.0.3",
"lodash.omit": "^4.5.0",
"lower-case-first": "^2.0.2",
"pluralize": "^8.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,33 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'
import { GqlExecutionContext } from '@nestjs/graphql'
import { AggregateQuery } from '@ptc-org/nestjs-query-core'
import { GraphQLResolveInfo } from 'graphql'
import graphqlFields from 'graphql-fields'

const EXCLUDED_FIELDS = ['__typename']
import { QueryResolveTree, simplifyResolveInfo } from './graphql-resolve-info.utils'

const QUERY_OPERATORS: (keyof AggregateQuery<unknown>)[] = ['groupBy', 'count', 'avg', 'sum', 'min', 'max']

export const AggregateQueryParam = createParamDecorator(<DTO>(data: unknown, ctx: ExecutionContext) => {
const info = GqlExecutionContext.create(ctx).getInfo<GraphQLResolveInfo>()
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const fields = graphqlFields(info, {}, { excludedFields: EXCLUDED_FIELDS }) as Record<
keyof AggregateQuery<DTO>,
Record<keyof DTO, unknown>
>
return QUERY_OPERATORS.filter((operator) => !!fields[operator]).reduce((query, operator) => {
const queryFields = Object.keys(fields[operator]) as (keyof DTO)[]
if (queryFields && queryFields.length) {
return { ...query, [operator]: queryFields }
const simpleResolverInfo = simplifyResolveInfo<DTO>(info)

return QUERY_OPERATORS.reduce((query, operator) => {
if (simpleResolverInfo.fields[operator]) {
const simpleOperator = simpleResolverInfo.fields[operator] as QueryResolveTree<DTO> | undefined
const operatorFields = Object.keys(simpleOperator.fields || {})

if (operatorFields && operatorFields.length > 0) {
return {
...query,
[operator]: operatorFields.map((operatorField) => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
field: simpleOperator.fields[operatorField].name,
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
args: simpleOperator.fields[operatorField].args
}))
}
}
}

return query
}, {} as AggregateQuery<DTO>)
})
103 changes: 103 additions & 0 deletions packages/query-graphql/src/decorators/graphql-resolve-info.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { ASTNode, FieldNode, getArgumentValues, getNamedType, GraphQLField, GraphQLUnionType, isCompositeType } from 'graphql'

import type { CursorConnectionType, OffsetConnectionType } from '../types'
import type { Query } from '@ptc-org/nestjs-query-core'
import type { GraphQLCompositeType, GraphQLResolveInfo as ResolveInfo, SelectionNode } from 'graphql'

type QueryResolveFields<DTO> = {
[key in keyof DTO]: QueryResolveTree<
// If the key is a array get the type of the array
DTO[key] extends ArrayLike<unknown> ? DTO[key][number] : DTO[key]
>
}

export interface QueryResolveTree<DTO> {
name: string
alias: string
args?: Query<DTO>
fields: QueryResolveFields<DTO>
}

function getFieldFromAST<TContext>(
fieldNode: ASTNode,
parentType: GraphQLCompositeType
): GraphQLField<GraphQLCompositeType, TContext> | undefined {
if (fieldNode.kind === 'Field') {
if (!(parentType instanceof GraphQLUnionType)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return parentType.getFields()[fieldNode.name.value]
} else {
// XXX: TODO: Handle GraphQLUnionType
}
}
return undefined
}

function parseFieldNodes<DTO>(
inASTs: ReadonlyArray<SelectionNode> | SelectionNode,
resolveInfo: ResolveInfo,
initTree: QueryResolveFields<DTO> | null,
parentType: GraphQLCompositeType
): QueryResolveTree<DTO> | QueryResolveFields<DTO> {
const asts: ReadonlyArray<FieldNode> = Array.isArray(inASTs) ? inASTs : [inASTs]

return asts.reduce((tree, fieldNode) => {
const alias: string = fieldNode?.alias?.value ?? fieldNode.name.value

const field = getFieldFromAST(fieldNode, parentType)
if (field == null) {
return tree
}
const fieldGqlTypeOrUndefined = getNamedType(field.type)
if (!fieldGqlTypeOrUndefined) {
return tree
}

const parsedField = {
name: fieldNode.name.value,
alias,
args: getArgumentValues(field, fieldNode, resolveInfo.variableValues),

fields:
fieldNode.selectionSet && isCompositeType(fieldGqlTypeOrUndefined)
? parseFieldNodes(
fieldNode.selectionSet.selections,
resolveInfo,
{} as QueryResolveFields<DTO>,
fieldGqlTypeOrUndefined
)
: {}
} as QueryResolveTree<DTO>

if (tree === null) {
return parsedField
} else {
tree[alias] = parsedField
}

return tree
}, initTree)
}

function isOffsetPaging<DTO>(info: unknown): info is QueryResolveTree<OffsetConnectionType<DTO>> {
return typeof (info as QueryResolveTree<OffsetConnectionType<DTO>>).fields.nodes !== 'undefined'
}

function isCursorPaging<DTO>(info: unknown): info is QueryResolveTree<CursorConnectionType<DTO>> {
return typeof (info as QueryResolveTree<CursorConnectionType<DTO>>).fields.edges !== 'undefined'
}

export function simplifyResolveInfo<DTO>(resolveInfo: ResolveInfo): QueryResolveTree<DTO> {
const simpleInfo = parseFieldNodes(resolveInfo.fieldNodes, resolveInfo, null, resolveInfo.parentType) as
| QueryResolveTree<DTO>
| QueryResolveTree<OffsetConnectionType<DTO>>
| QueryResolveTree<CursorConnectionType<DTO>>

if (isOffsetPaging(simpleInfo)) {
return simpleInfo.fields.nodes as QueryResolveTree<DTO>
} else if (isCursorPaging(simpleInfo)) {
return simpleInfo.fields.edges.fields.node as QueryResolveTree<DTO>
}

return simpleInfo as QueryResolveTree<DTO>
}
5 changes: 3 additions & 2 deletions packages/query-graphql/src/resolvers/aggregate.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getDTONames } from '../common'
import { AggregateQueryParam, AuthorizerFilter, ResolverMethodOpts, ResolverQuery } from '../decorators'
import { AuthorizerInterceptor } from '../interceptors'
import { AggregateArgsType, AggregateResponseType } from '../types'
import { GroupByAggregateMixin } from './aggregate/group-by-aggregate.resolver'
import { transformAndValidate } from './helpers'
import { BaseServiceResolver, ResolverClass, ServiceResolver } from './resolver.interface'

Expand Down Expand Up @@ -36,7 +37,7 @@ export const Aggregateable =
const { baseNameLower } = getDTONames(DTOClass)
const commonResolverOpts = omit(opts, 'dtoName', 'one', 'many', 'QueryArgs', 'Connection')
const queryName = `${baseNameLower}Aggregate`
const AR = AggregateResponseType(DTOClass)
const [AR, GroupbyType] = AggregateResponseType(DTOClass)

@ArgsType()
class AA extends AggregateArgsType(DTOClass) {}
Expand Down Expand Up @@ -64,7 +65,7 @@ export const Aggregateable =
}
}

return AggregateResolverBase
return GroupByAggregateMixin(DTOClass, GroupbyType)(AggregateResolverBase)
}
// eslint-disable-next-line @typescript-eslint/no-redeclare -- intentional
export const AggregateResolver = <DTO, QS extends QueryService<DTO, unknown, unknown> = QueryService<DTO, unknown, unknown>>(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Args, Parent, registerEnumType, Resolver } from '@nestjs/graphql'
import { AggregateResponse, Class, GroupBy, MapReflector, QueryService } from '@ptc-org/nestjs-query-core'

import { getGraphqlObjectName } from '../../common'
import { getFilterableFields, ResolverField } from '../../decorators'
import { ServiceResolver } from '../resolver.interface'

const reflector = new MapReflector('nestjs-query:aggregate-response-type')

registerEnumType(GroupBy, {
name: 'GroupBy', // this one is mandatory
description: 'Group by' // this one is optional
})

export const GroupByAggregateMixin =
<DTO>(DTOClass: Class<DTO>, AR: Class<AggregateResponse<DTO>>) =>
<B extends Class<ServiceResolver<DTO, QueryService<DTO, unknown, unknown>>>>(Base: B): B => {
const objName = getGraphqlObjectName(DTOClass, 'Unable to make AggregationResponseType.')

const aggName = `${objName}AggregateGroupBy`
return reflector.memoize(DTOClass, aggName, () => {
const fields = getFilterableFields(DTOClass).filter((field) => field.target === Date)

if (!fields.length) {
throw new Error(
`No fields found to create AggregationResponseType for ${DTOClass.name}. Ensure fields are annotated with @FilterableField`
)
}

return fields.reduce((RB, field) => {
@Resolver(() => AR, { isAbstract: true })
class ReadOneMixin extends RB {
@ResolverField(field.propertyName, () => field.target, { nullable: true })
[field.propertyName](
@Parent() dto: DTO,
@Args('by', {
type: () => GroupBy,
defaultValue: GroupBy.DAY,
nullable: true
})
by: string
): unknown {
return dto[field.propertyName]
}
}

return ReadOneMixin
}, Base)
})
}
4 changes: 2 additions & 2 deletions packages/query-graphql/src/resolvers/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common'
import { applyFilter, Class, Filter } from '@ptc-org/nestjs-query-core'
import { plainToClass } from 'class-transformer'
import { plainToInstance } from 'class-transformer'
import { validate } from 'class-validator'

import { SubscriptionArgsType, SubscriptionFilterInputType } from '../types'
Expand All @@ -10,7 +10,7 @@ export const transformAndValidate = async <T>(TClass: Class<T>, partial: T): Pro
if (partial instanceof TClass) {
return partial
}
const transformed = plainToClass(TClass, partial)
const transformed = plainToInstance(TClass, partial)
const validationErrors = await validate(transformed as unknown as Record<keyof never, unknown>)
if (validationErrors.length) {
throw new BadRequestException(validationErrors)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ function AggregatedType<DTO>(name: string, fields: FilterableFieldDescriptor[]):

export type AggregateResponseOpts = { prefix: string }

export function AggregateResponseType<DTO>(DTOClass: Class<DTO>, opts?: AggregateResponseOpts): Class<AggregateResponse<DTO>> {
export function AggregateResponseType<DTO>(
DTOClass: Class<DTO>,
opts?: AggregateResponseOpts
): [Class<AggregateResponse<DTO>>, Class<TypeAggregate<DTO>>] {
const objName = getGraphqlObjectName(DTOClass, 'Unable to make AggregationResponseType.')
const prefix = opts?.prefix ?? objName
const aggName = `${prefix}AggregateResponse`
Expand Down Expand Up @@ -91,6 +94,6 @@ export function AggregateResponseType<DTO>(DTOClass: Class<DTO>, opts?: Aggregat
max?: TypeAggregate<DTO>
}

return AggResponse
return [AggResponse, GroupType]
})
}
Loading

0 comments on commit 0993b93

Please sign in to comment.