Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow define projection fields in read model queries #1481

Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f759db3
Allow define projection fields in read model queries
gonzalojaubert Oct 25, 2023
7bfd4c8
rush change
gonzalojaubert Oct 25, 2023
9cee23e
Merge remote-tracking branch 'origin/main' into allow_define_projecti…
Mar 21, 2024
ed4d1ca
Update pnmp-lock.yaml after rebasing
Mar 22, 2024
03dc926
Merge remote-tracking branch 'origin/main' into allow_define_projecti…
Mar 22, 2024
898c0f4
Merge remote-tracking branch 'origin/main' into allow_define_projecti…
Mar 26, 2024
3c33c14
Merge remote-tracking branch 'origin/main' into allow_define_projecti…
Apr 12, 2024
fec44b8
Update pnpm-lock.yaml after merge
Apr 12, 2024
b66b4e7
Merge remote-tracking branch 'origin/main' into allow_define_projecti…
Apr 16, 2024
ae6a361
Update pnpm-lock.yaml after merge
Apr 16, 2024
e2d6ac5
Refactor ProjectionFor type
Apr 22, 2024
1ce8758
Remove skipInstance and deprecate schema migration
Apr 24, 2024
72d6ddb
Change deprecation comment for read model schema migration
Apr 26, 2024
42af24b
Add select projections when querying read models via GraphQL
May 17, 2024
c9d61ac
Code cleanup
May 19, 2024
4007436
Refactor select for local provider
May 20, 2024
09d7db6
Update documentation
May 20, 2024
afd9d2b
Fix integration test execution for local provider
May 22, 2024
34ddffa
Add support for selecting fields in arrays which are not top-level pr…
May 28, 2024
f498f42
Remove unnecessary console.error
May 28, 2024
c4b7ca6
Update docs
May 28, 2024
acf2ae2
Add documentation to new helper functions
May 28, 2024
caa9de0
Fix prototype-polluting assignment warning
May 29, 2024
c511853
Revert "Fix prototype-polluting assignment warning"
May 29, 2024
e38a5b6
Fix prototype-polluting assignment warning
May 29, 2024
3e806b3
Add clarification comment
May 30, 2024
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
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
95 changes: 92 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,19 @@ 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) {
select = select
.map((field: string) => {
return field.split('.').slice(1).join('.')
})
.filter((str: string) => str.trim().length > 0) as ProjectionFor<unknown>
}
MarcAstr0 marked this conversation as resolved.
Show resolved Hide resolved
}
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 +221,78 @@ export class GraphQLGenerator {
sortBy: {},
}
}

private static getFields(info: GraphQLResolveInfo): string[] {
let fields: string[] = []

const isList = (type: any): boolean => {
if (type instanceof GraphQLNonNull) {
return type.ofType instanceof GraphQLList
}
return type instanceof GraphQLList
}

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) {
console.error(`Field ${fieldName} not found on type ${parentType.name}. Skipping.`) // @TODO: remove
MarcAstr0 marked this conversation as resolved.
Show resolved Hide resolved
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
}
}
MarcAstr0 marked this conversation as resolved.
Show resolved Hide resolved

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 +305,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
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,19 @@ import { BoosterReadModelsReader } from '../../../src/booster-read-models-reader
import { GraphQLGenerator } from '../../../src/services/graphql/graphql-generator'
import {
BoosterConfig,
ReadModelInterface,
Level,
EventParametersFilterByEntity,
EventSearchRequest,
EventSearchResponse,
Level,
ReadModelInterface,
ReadModelRequestArgs,
ReadModelRequestProperties,
} from '@boostercloud/framework-types'
import { expect } from '../../expect'
import { GraphQLQueryGenerator } from '../../../src/services/graphql/graphql-query-generator'
import { GraphQLMutationGenerator } from '../../../src/services/graphql/graphql-mutation-generator'
import { GraphQLSubscriptionGenerator } from '../../../src/services/graphql/graphql-subcriptions-generator'
import { random, internet, lorem } from 'faker'
import { internet, lorem, random } from 'faker'
import { BoosterEventsReader } from '../../../src/booster-events-reader'

import { GraphQLResolverContext } from '../../../src/services/graphql/common'
Expand Down Expand Up @@ -154,7 +154,9 @@ describe('GraphQL generator', () => {
rawContext: {},
},
}
mockResolverInfo = {}
mockResolverInfo = {
fieldNodes: [],
}
})

describe('readModelResolverBuilder', () => {
Expand Down Expand Up @@ -185,9 +187,10 @@ describe('GraphQL generator', () => {
afterCursor: undefined,
paginatedVersion: false,
version: 1,
select: undefined,
}

await returnedFunction('', {}, mockResolverContext, {} as any)
await returnedFunction('', {}, mockResolverContext, { fieldNodes: [] } as any)

expect(fakeSearch).to.have.been.calledOnceWithExactly(expectedFetchPayload)
})
Expand Down Expand Up @@ -444,6 +447,7 @@ describe('GraphQL generator', () => {
afterCursor: undefined,
paginatedVersion: false,
version: 1,
select: undefined,
})
})

Expand Down
Loading
Loading