Skip to content

Commit

Permalink
Allow define projection fields in read model queries (#1481)
Browse files Browse the repository at this point in the history
* Allow define projection fields in read model queries

* rush change

* Update pnmp-lock.yaml after rebasing

* Update pnpm-lock.yaml after merge

* Update pnpm-lock.yaml after merge

* Refactor ProjectionFor type

* Remove skipInstance and deprecate schema migration

* Change deprecation comment for read model schema migration

* Add select projections when querying read models via GraphQL

* Code cleanup

* Refactor select for local provider

* Update documentation

* Fix integration test execution for local provider

* Add support for selecting fields in arrays which are not top-level properties of the read model

* Remove unnecessary console.error

* Update docs

* Add documentation to new helper functions

* Fix prototype-polluting assignment warning

* Revert "Fix prototype-polluting assignment warning"

This reverts commit caa9de0.

* Fix prototype-polluting assignment warning

* Add clarification comment

---------

Co-authored-by: Castro, Mario <mariocs@optum.com>
  • Loading branch information
gonzalojaubert and Castro, Mario authored May 30, 2024
1 parent 8aa3453 commit f747847
Show file tree
Hide file tree
Showing 25 changed files with 1,215 additions and 422 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@boostercloud/framework-core",
"comment": "Allow define projection fields in read model queries",
"type": "minor"
}
],
"packageName": "@boostercloud/framework-core"
}
596 changes: 218 additions & 378 deletions common/config/rush/pnpm-lock.yaml

Large diffs are not rendered by default.

13 changes: 10 additions & 3 deletions packages/framework-core/src/booster-read-models-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ReadOnlyNonEmptyArray,
SortFor,
SubscriptionEnvelope,
ProjectionFor,
} from '@boostercloud/framework-types'
import { createInstance, createInstances, getLogger } from '@boostercloud/framework-common-helpers'
import { Booster } from './booster'
Expand Down Expand Up @@ -71,7 +72,8 @@ export class BoosterReadModelsReader {
readModelTransformedRequest.sortBy,
readModelTransformedRequest.limit,
readModelTransformedRequest.afterCursor,
readModelTransformedRequest.paginatedVersion
readModelTransformedRequest.paginatedVersion,
readModelTransformedRequest.select
)
}

Expand All @@ -82,7 +84,8 @@ export class BoosterReadModelsReader {
sort?: SortFor<unknown>,
limit?: number,
afterCursor?: any,
paginatedVersion?: boolean
paginatedVersion?: boolean,
select?: ProjectionFor<TReadModel>
): Promise<Array<TReadModel> | ReadModelListResult<TReadModel>> {
const readModelName = readModelClass.name
const searchResult = await this.config.provider.readModels.search<TReadModel>(
Expand All @@ -92,9 +95,13 @@ export class BoosterReadModelsReader {
sort ?? {},
limit,
afterCursor,
paginatedVersion ?? false
paginatedVersion ?? false,
select
)

if (select) {
return searchResult
}
const readModels = this.createReadModelInstances(searchResult, readModelClass)
return this.migrateReadModels(readModels, readModelName)
}
Expand Down
3 changes: 3 additions & 0 deletions packages/framework-core/src/decorators/schema-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import 'reflect-metadata'

const migrationMethodsMetadataKey = 'booster:migrationsMethods'

/**
* **NOTE:** Using this decorator for read model migrations is deprecated. Prefer using `@DataMigration` instead.
*/
export function SchemaMigration(conceptClass: AnyClass): (schemaMigrationClass: AnyClass) => void {
return (schemaMigrationClass) => {
Booster.configureCurrentEnv((config) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/framework-core/src/read-model-schema-migrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { Trace } from './instrumentation'
export class ReadModelSchemaMigrator {
public constructor(private config: BoosterConfig) {}

/**
* **NOTE:** Read model schema migration is deprecated. Prefer data migration.
*/
@Trace(TraceActionTypes.READ_MODEL_SCHEMA_MIGRATOR_MIGRATE)
public async migrate<TMigratableReadModel extends ReadModelInterface>(
readModel: TMigratableReadModel,
Expand Down
129 changes: 126 additions & 3 deletions packages/framework-core/src/services/graphql/graphql-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
EventSearchParameters,
EventSearchRequest,
EventSearchResponse,
ProjectionFor,
QueryEnvelope,
ReadModelByIdRequestArgs,
ReadModelInterface,
Expand All @@ -15,7 +16,18 @@ import {
TimeKey,
} from '@boostercloud/framework-types'
import { getLogger } from '@boostercloud/framework-common-helpers'
import { GraphQLFieldResolver, GraphQLInputObjectType, GraphQLSchema } from 'graphql'
import {
FieldNode,
getNamedType,
GraphQLFieldResolver,
GraphQLInputObjectType,
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
GraphQLResolveInfo,
GraphQLSchema,
isObjectType,
} from 'graphql'
import { pluralize } from 'inflected'
import { BoosterCommandDispatcher } from '../../booster-command-dispatcher'
import { BoosterEventsReader } from '../../booster-events-reader'
Expand All @@ -26,6 +38,8 @@ import { GraphQLQueryGenerator } from './graphql-query-generator'
import { GraphQLSubscriptionGenerator } from './graphql-subcriptions-generator'
import { GraphQLTypeInformer } from './graphql-type-informer'
import { BoosterQueryDispatcher } from '../../booster-query-dispatcher'
import { SelectionSetNode } from 'graphql/language/ast'
import { GraphQLInputType, GraphQLNamedInputType } from 'graphql/type/definition'

export class GraphQLGenerator {
private static commandsDispatcher: BoosterCommandDispatcher
Expand Down Expand Up @@ -90,10 +104,20 @@ export class GraphQLGenerator {
): GraphQLFieldResolver<unknown, GraphQLResolverContext, ReadModelRequestArgs<ReadModelInterface>> {
return (parent, args, context, info) => {
let isPaginated = false
const fields: ProjectionFor<unknown> = this.getFields(info) as ProjectionFor<unknown>
let select: ProjectionFor<unknown> | undefined = fields.length > 0 ? fields : undefined
if (info?.fieldName === `List${pluralize(readModelClass.name)}`) {
isPaginated = true
if (select) {
// In paginated queries, the `items[].` field needs to be removed from the select fields before querying the database
select = select
.map((field: string) => {
return field.split('.').slice(1).join('.')
})
.filter((str: string) => str.trim().length > 0) as ProjectionFor<unknown>
}
}
const readModelEnvelope = toReadModelRequestEnvelope(readModelClass, args, context, isPaginated)
const readModelEnvelope = toReadModelRequestEnvelope(readModelClass, args, context, isPaginated, select)
return this.readModelsReader.search(readModelEnvelope)
}
}
Expand Down Expand Up @@ -198,13 +222,111 @@ export class GraphQLGenerator {
sortBy: {},
}
}

/**
* Extracts the fields from a GraphQLResolveInfo object. This object is part of a GraphQL request and the goal is to
* identify the fields in the request. For example, for the following GraphQL query request:
*
* ```graphql
* query {
* CartReadModel {
* id
* cartItems {
* id
* quantity
* }
* }
* }
* ```
*
* it will return the following list of fields: `['id', 'cartItems[].id', 'cartItems[].quantity']`
*
* @param {GraphQLResolveInfo} info - The GraphQLResolveInfo object.
* @returns {string[]} - The extracted fields.
* @private
*/
private static getFields(info: GraphQLResolveInfo): string[] {
let fields: string[] = []

/**
* Checks if a type is a list.
* @param {any} type - The type to check.
* @returns {boolean} - True if the type is a list, false otherwise.
*/
const isList = (type: any): boolean => {
if (type instanceof GraphQLNonNull) {
return type.ofType instanceof GraphQLList
}
return type instanceof GraphQLList
}

/**
* Extracts fields from a selection set.
* @param {SelectionSetNode} selectionSet - The selection set.
* @param {string[]} path - The current path.
* @param {GraphQLObjectType | any} parentType - The parent type.
* @returns {string[]} - The extracted fields.
*/
const extractFields = (
selectionSet: SelectionSetNode,
path: string[] = [],
parentType: GraphQLObjectType | any
): string[] => {
let subFields: string[] = []
if (selectionSet && selectionSet.selections) {
selectionSet.selections.forEach((selection: any) => {
const fieldName = selection.name.value
const field = parentType.getFields()[fieldName]

if (!field) {
return
}

const fieldType: GraphQLNamedInputType = getNamedType(field.type as GraphQLNamedInputType)
const currentPath = [...path, fieldName]

if (isList(field.type)) {
const elementType = getNamedType(field.type.ofType)
if (isObjectType(elementType)) {
currentPath[currentPath.length - 1] += '[]'
}
}

if (!selection.selectionSet) {
subFields.push(currentPath.join('.'))
} else {
const nextParentType = isObjectType(fieldType) ? fieldType : parentType
subFields = subFields.concat(extractFields(selection.selectionSet, currentPath, nextParentType))
}
})
}
return subFields
}

info.fieldNodes.forEach((fieldNode: FieldNode) => {
const firstField: string = fieldNode.name.value
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const firstFieldType = info.schema.getQueryType().getFields()[firstField].type
const rootType: GraphQLObjectType = getNamedType(
firstFieldType as GraphQLInputType
) as unknown as GraphQLObjectType

if (fieldNode.selectionSet) {
fields = fields.concat(extractFields(fieldNode.selectionSet, [], rootType))
}
})

return fields
}
}

function toReadModelRequestEnvelope(
readModelClass: Class<ReadModelInterface>,
args: ReadModelRequestArgs<ReadModelInterface>,
context: GraphQLResolverContext,
paginatedVersion = false
paginatedVersion = false,
select?: ProjectionFor<unknown> | undefined
): ReadModelRequestEnvelope<ReadModelInterface> {
return {
requestID: context.requestID,
Expand All @@ -217,6 +339,7 @@ function toReadModelRequestEnvelope(
afterCursor: args.afterCursor,
paginatedVersion,
version: 1, // TODO: How to pass the version through GraphQL?
select,
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,8 @@ describe('BoosterReadModelReader', () => {
{},
undefined,
undefined,
false
false,
undefined
)
expect(result).to.be.deep.equal(expectedReadModels)
})
Expand All @@ -408,7 +409,8 @@ describe('BoosterReadModelReader', () => {
{},
undefined,
undefined,
false
false,
undefined
)
expect(result).to.be.deep.equal(expectedReadModels)
expect(migratorStub).to.have.been.calledTwice
Expand Down Expand Up @@ -438,12 +440,84 @@ describe('BoosterReadModelReader', () => {
{},
undefined,
undefined,
true
true,
undefined
)
expect(result).to.be.deep.equal(searchResult)
expect(migratorStub).to.have.been.calledTwice
})

context('when there are projections fields', () => {
it('calls the provider search function with the right parameters', async () => {
const readModelWithProjectionRequestEnvelope: ReadModelRequestEnvelope<TestReadModel> = {
class: TestReadModel,
className: TestReadModel.name,
requestID: random.uuid(),
version: 1,
filters,
currentUser,
select: ['id'],
} as any

const expectedReadModels = [new TestReadModel(), new TestReadModel()]
const providerSearcherFunctionFake = fake.returns(expectedReadModels)
replace(config.provider.readModels, 'search', providerSearcherFunctionFake)

replace(Booster, 'config', config) // Needed because the function `Booster.readModel` references `this.config` from `searchFunction`

migratorStub.callsFake(async (readModel, readModelName) => readModel)

const result = await readModelReader.search(readModelWithProjectionRequestEnvelope)

expect(providerSearcherFunctionFake).to.have.been.calledOnceWithExactly(
match.any,
TestReadModel.name,
filters,
{},
undefined,
undefined,
false,
['id']
)
expect(result).to.be.deep.equal(expectedReadModels)
})

it('do not call migrates if select is set', async () => {
const readModelWithProjectionRequestEnvelope: ReadModelRequestEnvelope<TestReadModel> = {
class: TestReadModel,
className: TestReadModel.name,
requestID: random.uuid(),
version: 1,
filters,
currentUser,
select: ['id'],
} as any

const expectedResult = [new TestReadModel(), new TestReadModel()]
const providerSearcherFunctionFake = fake.returns(expectedResult)
replace(config.provider.readModels, 'search', providerSearcherFunctionFake)

replace(Booster, 'config', config) // Needed because the function `Booster.readModel` references `this.config` from `searchFunction`

migratorStub.callsFake(async (readModel, readModelName) => readModel)

const result = await readModelReader.search(readModelWithProjectionRequestEnvelope)

expect(providerSearcherFunctionFake).to.have.been.calledOnceWithExactly(
match.any,
TestReadModel.name,
filters,
{},
undefined,
undefined,
false,
['id']
)
expect(result).to.be.deep.equal(expectedResult)
expect(migratorStub).to.have.not.been.called
})
})

context('when there is only one before hook function', () => {
const fakeBeforeFn = fake(beforeFn)

Expand Down
3 changes: 2 additions & 1 deletion packages/framework-core/test/booster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ describe('the `Booster` class', () => {
{},
undefined,
undefined,
false
false,
undefined
)
})
it('has an instance method', async () => {
Expand Down
Loading

0 comments on commit f747847

Please sign in to comment.