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

Calculated fields #1539

Merged
merged 14 commits into from
Jul 19, 2024
88 changes: 87 additions & 1 deletion packages/framework-common-helpers/src/instances.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Class } from '@boostercloud/framework-types'
import { Class, ReadModelInterface } from '@boostercloud/framework-types'

/**
* Creates an instance of the given class from the given raw object.
Expand Down Expand Up @@ -43,3 +43,89 @@
export function createInstances<T>(instanceClass: Class<T>, rawObjects: Array<Record<string, any>>): T[] {
return rawObjects.map((rawObject) => createInstance(instanceClass, rawObject))
}

/**
* Creates an instance of the read model class with the calculated properties included
* @param instanceClass The read model class
* @param raw The raw read model data
* @param propertiesToInclude The properties to include in the response
* @private
*/
export async function createInstanceWithCalculatedProperties<T extends ReadModelInterface>(
instanceClass: { new (...args: any[]): T },
raw: Partial<T>,
propertiesToInclude: string[]
): Promise<{ [key: string]: any }> {
const instance = new instanceClass()
Object.assign(instance, raw)
const result: { [key: string]: any } = {}

const propertiesMap = buildPropertiesMap(propertiesToInclude)

await processProperties(instance, result, propertiesMap)

return result
}

/**
* Builds a map of properties to include in the response
* @param properties The properties to include in the response
* @private
*/
function buildPropertiesMap(properties: string[]): any {
const map: any = {}
properties.forEach((property) => {
const parts = property.split('.')
let current = map
parts.forEach((part) => {
const isArray = part.endsWith('[]')
const key = isArray ? part.slice(0, -2) : part
if (!current[key]) {
current[key] = isArray ? { __isArray: true, __children: {} } : {}

Check warning

Code scanning / CodeQL

Prototype-polluting assignment Medium

This assignment may alter Object.prototype if a malicious '__proto__' string is injected from
library input
.
}
current = isArray ? current[key].__children : current[key]
})
})
return map
}

/**
* Processes the properties of the source object and adds them to the result object
* @param source The source object
* @param result The result object
* @param propertiesMap The map of properties to include in the response
* @private
*/
async function processProperties(source: any, result: any, propertiesMap: any): Promise<void> {
for (const key of Object.keys(propertiesMap)) {
if (key === '__isArray' || key === '__children') continue

if (source[key] !== undefined) {
if (propertiesMap[key].__isArray) {
result[key] = []
for (const item of source[key]) {
const newItem: any = {}
await processProperties(item, newItem, propertiesMap[key].__children)
if (Object.keys(newItem).length > 0) {
result[key].push(newItem)
}
}
} else if (typeof propertiesMap[key] === 'object' && Object.keys(propertiesMap[key]).length > 0) {
const value = source[key]
const resolvedValue = isPromise(value) ? await value : value
result[key] = {}
await processProperties(resolvedValue, result[key], propertiesMap[key])
if (Object.keys(result[key]).length === 0) {
delete result[key]
}
} else {
const value = source[key]
result[key] = isPromise(value) ? await value : value
}
}
}
}

function isPromise(obj: any): obj is Promise<any> {
return !!obj && (typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function'
}
80 changes: 74 additions & 6 deletions packages/framework-core/src/booster-read-models-reader.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
TraceActionTypes,
AnyClass,
BoosterConfig,
FilterFor,
GraphQLOperation,
InvalidParameterError,
NotFoundError,
ProjectionFor,
ReadModelInterface,
ReadModelListResult,
ReadModelMetadata,
ReadModelRequestEnvelope,
ReadOnlyNonEmptyArray,
SortFor,
SubscriptionEnvelope,
ProjectionFor,
TraceActionTypes,
} from '@boostercloud/framework-types'
import { createInstance, createInstances, getLogger } from '@boostercloud/framework-common-helpers'
import {
createInstance,
createInstances,
createInstanceWithCalculatedProperties,
getLogger,
} from '@boostercloud/framework-common-helpers'
import { Booster } from './booster'
import { applyReadModelRequestBeforeFunctions } from './services/filter-helpers'
import { ReadModelSchemaMigrator } from './read-model-schema-migrator'
import { Trace } from './instrumentation'
import { PropertyMetadata } from '@boostercloud/metadata-booster'

export class BoosterReadModelsReader {
public constructor(readonly config: BoosterConfig) {}
Expand Down Expand Up @@ -88,6 +95,23 @@ export class BoosterReadModelsReader {
select?: ProjectionFor<TReadModel>
): Promise<Array<TReadModel> | ReadModelListResult<TReadModel>> {
const readModelName = readModelClass.name

let selectWithDependencies: ProjectionFor<TReadModel> | undefined = undefined
const calculatedFieldsDependencies = this.getCalculatedFieldsDependencies(readModelClass)

if (select && Object.keys(calculatedFieldsDependencies).length > 0) {
const extendedSelect = new Set<string>(select)

select.forEach((field: any) => {
const topLevelField = field.split('.')[0].replace('[]', '')
if (calculatedFieldsDependencies[topLevelField]) {
calculatedFieldsDependencies[topLevelField].map((dependency) => extendedSelect.add(dependency))
}
})

selectWithDependencies = Array.from(extendedSelect) as ProjectionFor<TReadModel>
}

const searchResult = await this.config.provider.readModels.search<TReadModel>(
this.config,
readModelName,
Expand All @@ -96,13 +120,13 @@ export class BoosterReadModelsReader {
limit,
afterCursor,
paginatedVersion ?? false,
select
selectWithDependencies ?? select
)

const readModels = this.createReadModelInstances(searchResult, readModelClass)
if (select) {
return searchResult
return this.createReadModelInstancesWithCalculatedProperties(searchResult, readModelClass, select ?? [])
}
const readModels = this.createReadModelInstances(searchResult, readModelClass)
return this.migrateReadModels(readModels, readModelName)
}

Expand Down Expand Up @@ -133,6 +157,34 @@ export class BoosterReadModelsReader {
}
}

/**
* Creates instances of the read model class with the calculated properties included
* @param searchResult The search result
* @param readModelClass The read model class
* @param propertiesToInclude The properties to include in the response
* @private
*/
private async createReadModelInstancesWithCalculatedProperties<TReadModel extends ReadModelInterface>(
searchResult: Array<TReadModel> | ReadModelListResult<TReadModel>,
readModelClass: AnyClass,
propertiesToInclude: string[]
): Promise<Array<TReadModel> | ReadModelListResult<TReadModel>> {
const processInstance = async (raw: Partial<TReadModel>): Promise<TReadModel> => {
const instance = await createInstanceWithCalculatedProperties(readModelClass, raw, propertiesToInclude)
return instance as TReadModel
}

if (Array.isArray(searchResult)) {
return await Promise.all(searchResult.map(processInstance))
} else {
const processedItems = await Promise.all(searchResult.items.map(processInstance))
return {
...searchResult,
items: processedItems,
}
}
}

public async subscribe(
connectionID: string,
readModelRequest: ReadModelRequestEnvelope<ReadModelInterface>,
Expand Down Expand Up @@ -216,4 +268,20 @@ export class BoosterReadModelsReader {
}
return this.config.provider.readModels.subscribe(this.config, subscription)
}

/**
* Returns the dependencies of the calculated fields of a read model
* @param readModelClass The read model class
* @private
*/
private getCalculatedFieldsDependencies(readModelClass: AnyClass): Record<string, Array<string>> {
const readModelMetadata: ReadModelMetadata = this.config.readModels[readModelClass.name]

const dependenciesMap: Record<string, Array<string>> = {}
readModelMetadata?.properties.map((property: PropertyMetadata): void => {
dependenciesMap[property.name] = property.dependencies
})

return dependenciesMap
}
}
25 changes: 24 additions & 1 deletion packages/framework-core/src/decorators/read-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,35 @@ export function ReadModel(
}

const authorizer = BoosterAuthorizer.build(attributes) as ReadModelAuthorizer
const classMetadata = getClassMetadata(readModelClass)
const dynamicDependencies = Reflect.getMetadata('dynamic:dependencies', readModelClass) || {}
MarcAstr0 marked this conversation as resolved.
Show resolved Hide resolved

// Combine properties with dynamic dependencies
const properties = classMetadata.fields.map((field: any) => {
return {
...field,
dependencies: dynamicDependencies[field.name] || [],
}
})

config.readModels[readModelClass.name] = {
class: readModelClass,
properties: getClassMetadata(readModelClass).fields,
properties,
authorizer,
before: attributes.before ?? [],
}
})
}
}

/**
* Decorator to mark a property as a calculated field with dependencies.
* @param dependencies - An array of strings indicating the dependencies.
*/
export function CalculatedField(dependencies: string[]): PropertyDecorator {
return (target: object, propertyKey: string | symbol): void => {
const existingDependencies = Reflect.getMetadata('dynamic:dependencies', target.constructor) || {}
existingDependencies[propertyKey] = dependencies
Reflect.defineMetadata('dynamic:dependencies', existingDependencies, target.constructor)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ describe('BoosterReadModelReader', () => {
filters,
currentUser,
select: ['id'],
skipInstance: false,
} as any

const expectedReadModels = [new TestReadModel(), new TestReadModel()]
Expand Down Expand Up @@ -491,6 +492,7 @@ describe('BoosterReadModelReader', () => {
filters,
currentUser,
select: ['id'],
skipInstance: false,
} as any

const expectedResult = [new TestReadModel(), new TestReadModel()]
Expand Down
Loading
Loading