From 0953bdfe841e8b9126f0866394d74c6ffc72821c Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Tue, 12 Sep 2023 03:43:25 -0300 Subject: [PATCH] feat(orchestration): Remote Joiner field aliases (#5013) * initial commit * chore: unit tests and forward arguments --- .../src/__mocks__/joiner/mock_data.ts | 9 + .../__tests__/joiner/remote-joiner-data.ts | 309 ++++++++++++++++-- .../orchestration/src/joiner/remote-joiner.ts | 288 +++++++++++----- packages/types/src/joiner/index.ts | 8 + 4 files changed, 498 insertions(+), 116 deletions(-) diff --git a/packages/orchestration/src/__mocks__/joiner/mock_data.ts b/packages/orchestration/src/__mocks__/joiner/mock_data.ts index 1da74c70d6..a298c44ccf 100644 --- a/packages/orchestration/src/__mocks__/joiner/mock_data.ts +++ b/packages/orchestration/src/__mocks__/joiner/mock_data.ts @@ -56,6 +56,9 @@ export const serviceConfigs: JoinerServiceConfig[] = [ alias: { name: "variant", }, + fieldAlias: { + user_shortcut: "product.user", + }, primaryKeys: ["id"], relationships: [ { @@ -75,6 +78,12 @@ export const serviceConfigs: JoinerServiceConfig[] = [ }, { serviceName: "order", + fieldAlias: { + product_user_alias: { + path: "products.product.user", + forwardArgumentsOnPath: ["products.product"], + }, + }, primaryKeys: ["id"], relationships: [ { diff --git a/packages/orchestration/src/__tests__/joiner/remote-joiner-data.ts b/packages/orchestration/src/__tests__/joiner/remote-joiner-data.ts index aee4989580..0e714e4d99 100644 --- a/packages/orchestration/src/__tests__/joiner/remote-joiner-data.ts +++ b/packages/orchestration/src/__tests__/joiner/remote-joiner-data.ts @@ -38,31 +38,40 @@ const container = { }, } as MedusaContainer -const fetchServiceDataCallback = async ( - expand: RemoteExpandProperty, - pkField: string, - ids?: (unknown | unknown[])[], - relationship?: any -) => { - const serviceConfig = expand.serviceConfig - const moduleRegistryName = !serviceConfig.serviceName.endsWith("Service") - ? lowerCaseFirst(serviceConfig.serviceName) + "Service" - : serviceConfig.serviceName +const callbacks = jest.fn() +const fetchServiceDataCallback = jest.fn( + async ( + expand: RemoteExpandProperty, + pkField: string, + ids?: (unknown | unknown[])[], + relationship?: any + ) => { + const serviceConfig = expand.serviceConfig + const moduleRegistryName = !serviceConfig.serviceName.endsWith("Service") + ? lowerCaseFirst(serviceConfig.serviceName) + "Service" + : serviceConfig.serviceName - const service = container.resolve(moduleRegistryName) - const methodName = relationship?.inverse - ? `getBy${toPascalCase(pkField)}` - : "list" + const service = container.resolve(moduleRegistryName) + const methodName = relationship?.inverse + ? `getBy${toPascalCase(pkField)}` + : "list" - return await service[methodName]({ - fields: expand.fields, - args: expand.args, - expands: expand.expands, - options: { - [pkField]: ids, - }, - }) -} + callbacks({ + service: serviceConfig.serviceName, + fieds: expand.fields, + args: expand.args, + }) + + return await service[methodName]({ + fields: expand.fields, + args: expand.args, + expands: expand.expands, + options: { + [pkField]: ids, + }, + }) + } +) describe("RemoteJoiner", () => { let joiner: RemoteJoiner @@ -161,13 +170,11 @@ describe("RemoteJoiner", () => { } const data = await joiner.query(query) - expect(data).toEqual([ { email: "johndoe@example.com", products: [ { - id: 1, product_id: 102, product: { name: "Product 2", @@ -180,7 +187,6 @@ describe("RemoteJoiner", () => { email: "janedoe@example.com", products: [ { - id: 2, product_id: [101, 102], product: [ { @@ -202,7 +208,6 @@ describe("RemoteJoiner", () => { email: "444444@example.com", products: [ { - id: 4, product_id: 103, product: { name: "Product 3", @@ -272,7 +277,6 @@ describe("RemoteJoiner", () => { email: "444444@example.com", products: [ { - id: 4, product_id: 103, product: { name: "Product 3", @@ -307,7 +311,6 @@ describe("RemoteJoiner", () => { email: "johndoe@example.com", products: [ { - id: 1, product_id: 102, product: { name: "Product 2", @@ -489,4 +492,252 @@ describe("RemoteJoiner", () => { }, ]) }) + + it("Should query an field alias and cleanup unused nested levels", async () => { + const query = RemoteJoiner.parseQuery(` + query { + order { + product_user_alias { + email + } + } + } + `) + const data = await joiner.query(query) + + expect(data).toEqual([ + expect.objectContaining({ + product_user_alias: [ + { + email: "janedoe@example.com", + id: 2, + }, + { + email: "janedoe@example.com", + id: 2, + }, + ], + }), + expect.objectContaining({ + product_user_alias: [ + { + email: "janedoe@example.com", + id: 2, + }, + { + email: "aaa@example.com", + id: 3, + }, + ], + }), + ]) + expect(data[0].products[0].product).toEqual(undefined) + }) + + it("Should query an field alias and keep queried nested levels", async () => { + const query = RemoteJoiner.parseQuery(` + query { + order { + product_user_alias { + email + } + products { + product { + name + } + } + } + } + `) + const data = await joiner.query(query) + + expect(data).toEqual([ + expect.objectContaining({ + product_user_alias: [ + { + email: "janedoe@example.com", + id: 2, + }, + { + email: "janedoe@example.com", + id: 2, + }, + ], + }), + expect.objectContaining({ + product_user_alias: [ + { + email: "janedoe@example.com", + id: 2, + }, + { + email: "aaa@example.com", + id: 3, + }, + ], + }), + ]) + expect(data[0].products[0].product).toEqual({ + name: "Product 1", + id: 101, + user_id: 2, + }) + expect(data[0].products[0].product.user).toEqual(undefined) + }) + + it("Should query an field alias and merge requested fields on alias and on the relationship", async () => { + const query = RemoteJoiner.parseQuery(` + query { + order { + product_user_alias { + email + } + products { + product { + user { + name + } + } + } + } + } + `) + const data = await joiner.query(query) + + expect(data).toEqual([ + expect.objectContaining({ + product_user_alias: [ + { + name: "Jane Doe", + id: 2, + email: "janedoe@example.com", + }, + { + name: "Jane Doe", + id: 2, + email: "janedoe@example.com", + }, + ], + }), + expect.objectContaining({ + product_user_alias: [ + { + name: "Jane Doe", + id: 2, + email: "janedoe@example.com", + }, + { + name: "aaa bbb", + id: 3, + email: "aaa@example.com", + }, + ], + }), + ]) + expect(data[0].products[0].product).toEqual({ + id: 101, + user_id: 2, + user: { + name: "Jane Doe", + id: 2, + email: "janedoe@example.com", + }, + }) + }) + + it("Should query multiple aliases and pass the arguments where defined on 'forwardArgumentsOnPath'", async () => { + const query = RemoteJoiner.parseQuery(` + query { + order { + id + product_user_alias (arg: { random: 123 }) { + name + } + products { + variant { + user_shortcut(arg: 123) { + email + } + } + } + } + } + `) + const data = await joiner.query(query) + + expect(callbacks.mock.calls).toEqual([ + [ + { + service: "order", + fieds: ["id", "product_user_alias", "products"], + }, + ], + [ + { + service: "variantService", + fieds: ["user_shortcut", "id", "product_id"], + }, + ], + [ + { + service: "product", + fieds: ["id", "user_id"], + args: [ + { + name: "arg", + value: { + random: 123, + }, + }, + ], + }, + ], + [ + { + service: "user", + fieds: ["name", "id"], + }, + ], + [ + { + service: "product", + fieds: ["id", "user_id"], + }, + ], + [ + { + service: "user", + fieds: ["email", "id"], + }, + ], + ]) + + expect(data[1]).toEqual( + expect.objectContaining({ + product_user_alias: [ + { + id: 2, + name: "Jane Doe", + }, + { + id: 3, + name: "aaa bbb", + }, + ], + }) + ) + + expect(data[0].products[0]).toEqual({ + variant_id: 991, + product_id: 101, + variant: { + id: 991, + product_id: 101, + user_shortcut: { + email: "janedoe@example.com", + id: 2, + }, + }, + }) + }) }) diff --git a/packages/orchestration/src/joiner/remote-joiner.ts b/packages/orchestration/src/joiner/remote-joiner.ts index 14f08b2fbe..acf2f250e0 100644 --- a/packages/orchestration/src/joiner/remote-joiner.ts +++ b/packages/orchestration/src/joiner/remote-joiner.ts @@ -8,7 +8,7 @@ import { RemoteNestedExpands, } from "@medusajs/types" -import { isDefined } from "@medusajs/utils" +import { isDefined, isString } from "@medusajs/utils" import GraphQLParser from "./graphql-ast" const BASE_PATH = "_root" @@ -26,6 +26,12 @@ export type RemoteFetchDataCallback = ( export class RemoteJoiner { private serviceConfigCache: Map = new Map() + private implodeMapping: { + location: string[] + property: string + path: string[] + }[] = [] + private static filterFields( data: any, fields: string[], @@ -76,9 +82,7 @@ export class RemoteJoiner { } private static getNestedItems(items: any[], property: string): any[] { - return items - .flatMap((item) => item[property]) - .filter((item) => item !== undefined) + return items.flatMap((item) => item?.[property]) } private static createRelatedDataMap( @@ -265,6 +269,8 @@ export class RemoteJoiner { } else { uniqueIds = Array.from(new Set(uniqueIds.flat())) } + + uniqueIds = uniqueIds.filter((id) => id !== undefined) } if (relationship) { @@ -295,57 +301,110 @@ export class RemoteJoiner { return response } + private handleFieldAliases( + items: any[], + parsedExpands: Map + ) { + const getChildren = (item: any, prop: string) => { + if (Array.isArray(item)) { + return item.flatMap((currentItem) => currentItem[prop]) + } else { + return item[prop] + } + } + const removeChildren = (item: any, prop: string) => { + if (Array.isArray(item)) { + item.forEach((currentItem) => delete currentItem[prop]) + } else { + delete item[prop] + } + } + + const cleanup: [any, string][] = [] + for (const alias of this.implodeMapping) { + const propPath = alias.path + + let itemsLocation = items + for (const locationProp of alias.location) { + propPath.shift() + itemsLocation = RemoteJoiner.getNestedItems(itemsLocation, locationProp) + } + + itemsLocation.forEach((locationItem) => { + if (!locationItem) { + return + } + + let currentItems = locationItem + let parentRemoveItems: any = null + + const curPath: string[] = [BASE_PATH].concat(alias.location) + for (const prop of propPath) { + if (currentItems === undefined) { + break + } + + curPath.push(prop) + + const config = parsedExpands.get(curPath.join(".")) as any + if (config?.isAliasMapping && parentRemoveItems === null) { + parentRemoveItems = [currentItems, prop] + } + + currentItems = getChildren(currentItems, prop) + } + + if (Array.isArray(currentItems)) { + if (currentItems.length < 2) { + locationItem[alias.property] = currentItems.shift() + } else { + locationItem[alias.property] = currentItems + } + } else { + locationItem[alias.property] = currentItems + } + + if (parentRemoveItems !== null) { + cleanup.push(parentRemoveItems) + } + }) + } + + for (const parentRemoveItems of cleanup) { + const [remItems, path] = parentRemoveItems + removeChildren(remItems, path) + } + } + private async handleExpands( items: any[], - query: RemoteJoinerQuery, parsedExpands: Map ): Promise { if (!parsedExpands) { return } - const resolvedPaths = new Set() - const stack: [any[], Partial, string][] = [ - [items, query, BASE_PATH], - ] - while (stack.length > 0) { - const [currentItems, currentQuery, basePath] = stack.pop()! + for (const [expandedPath, expand] of parsedExpands.entries()) { + if (expandedPath === BASE_PATH) { + continue + } - for (const [expandedPath, expand] of parsedExpands.entries()) { - const isParentPath = expandedPath.startsWith(basePath) + let nestedItems = items + const expandedPathLevels = expandedPath.split(".") - if (!isParentPath || resolvedPaths.has(expandedPath)) { - continue - } + for (let idx = 1; idx < expandedPathLevels.length - 1; idx++) { + nestedItems = RemoteJoiner.getNestedItems( + nestedItems, + expandedPathLevels[idx] + ) + } - resolvedPaths.add(expandedPath) - const property = expand.property || "" - - let curItems = currentItems - const expandedPathLevels = expandedPath.split(".") - for (let idx = 1; idx < expandedPathLevels.length - 1; idx++) { - curItems = RemoteJoiner.getNestedItems( - curItems, - expandedPathLevels[idx] - ) - } - - await this.expandProperty(curItems, expand.parentConfig!, expand) - const nestedItems = RemoteJoiner.getNestedItems(currentItems, property) - - if (nestedItems.length > 0) { - const relationship = expand.serviceConfig - let nextProp = currentQuery - if (relationship) { - const relQuery = { - service: relationship.serviceName, - } - nextProp = relQuery - } - stack.push([nestedItems, nextProp, expandedPath]) - } + if (nestedItems.length > 0) { + await this.expandProperty(nestedItems, expand.parentConfig!, expand) } } + + this.handleFieldAliases(items, parsedExpands) } private async expandProperty( @@ -379,11 +438,9 @@ export class RemoteJoiner { const idsToFetch: any[] = [] items.forEach((item) => { - const values = fieldsArray - .map((field) => item[field]) - .filter((value) => value !== undefined) + const values = fieldsArray.map((field) => item?.[field]) - if (values.length === fieldsArray.length && !item[relationship.alias]) { + if (values.length === fieldsArray.length && !item?.[relationship.alias]) { if (fieldsArray.length === 1) { if (!idsToFetch.includes(values[0])) { idsToFetch.push(values[0]) @@ -424,30 +481,30 @@ export class RemoteJoiner { ) items.forEach((item) => { - if (!item[relationship.alias]) { - const itemKey = fieldsArray.map((field) => item[field]).join(",") + if (!item || item[relationship.alias]) { + return + } - if (Array.isArray(item[field])) { - item[relationship.alias] = item[field] - .map((id) => { - if (relationship.isList && !Array.isArray(relatedDataMap[id])) { - relatedDataMap[id] = - relatedDataMap[id] !== undefined ? [relatedDataMap[id]] : [] - } + const itemKey = fieldsArray.map((field) => item[field]).join(",") - return relatedDataMap[id] - }) - .filter((relatedItem) => relatedItem !== undefined) - } else { - if (relationship.isList && !Array.isArray(relatedDataMap[itemKey])) { - relatedDataMap[itemKey] = - relatedDataMap[itemKey] !== undefined - ? [relatedDataMap[itemKey]] - : [] + if (Array.isArray(item[field])) { + item[relationship.alias] = item[field].map((id) => { + if (relationship.isList && !Array.isArray(relatedDataMap[id])) { + relatedDataMap[id] = + relatedDataMap[id] !== undefined ? [relatedDataMap[id]] : [] } - item[relationship.alias] = relatedDataMap[itemKey] + return relatedDataMap[id] + }) + } else { + if (relationship.isList && !Array.isArray(relatedDataMap[itemKey])) { + relatedDataMap[itemKey] = + relatedDataMap[itemKey] !== undefined + ? [relatedDataMap[itemKey]] + : [] } + + item[relationship.alias] = relatedDataMap[itemKey] } }) } @@ -479,21 +536,65 @@ export class RemoteJoiner { const parsedExpands = new Map() parsedExpands.set(BASE_PATH, initialService) + let forwardArgumentsOnPath: string[] = [] for (const expand of expands || []) { const properties = expand.property.split(".") - let currentServiceConfig = serviceConfig as any + let currentServiceConfig = serviceConfig const currentPath: string[] = [] for (const prop of properties) { + const fieldAlias = currentServiceConfig.fieldAlias ?? {} + + if (fieldAlias[prop]) { + const alias = fieldAlias[prop] as any + + const path = isString(alias) ? alias : alias.path + const fullPath = currentPath.concat(path.split(".")) + + forwardArgumentsOnPath = forwardArgumentsOnPath.concat( + (alias?.forwardArgumentsOnPath || []).map( + (forPath) => + BASE_PATH + "." + currentPath.concat(forPath).join(".") + ) + ) + + this.implodeMapping.push({ + location: currentPath, + property: prop, + path: fullPath, + }) + + const extMapping = expands as unknown[] + + const middlePath = path.split(".").slice(0, -1) + let curMiddlePath = currentPath + for (const path of middlePath) { + curMiddlePath = curMiddlePath.concat(path) + extMapping.push({ + args: expand.args, + property: curMiddlePath.join("."), + isAliasMapping: true, + }) + } + + extMapping.push({ + ...expand, + property: fullPath.join("."), + isAliasMapping: true, + }) + continue + } + const fullPath = [BASE_PATH, ...currentPath, prop].join(".") - const relationship = currentServiceConfig.relationships.find( + const relationship = currentServiceConfig.relationships?.find( (relation) => relation.alias === prop ) let fields: string[] | undefined = fullPath === BASE_PATH + "." + expand.property - ? expand.fields + ? expand.fields ?? [] : undefined + const args = fullPath === BASE_PATH + "." + expand.property ? expand.args @@ -504,29 +605,29 @@ export class RemoteJoiner { parsedExpands.get([BASE_PATH, ...currentPath].join(".")) || query if (parentExpand) { - if (parentExpand.fields) { - const relField = relationship.inverse - ? relationship.primaryKey - : relationship.foreignKey.split(".").pop()! + const relField = relationship.inverse + ? relationship.primaryKey + : relationship.foreignKey.split(".").pop()! - parentExpand.fields = parentExpand.fields - .concat(relField.split(",")) - .filter((field) => field !== relationship.alias) + parentExpand.fields ??= [] - parentExpand.fields = [...new Set(parentExpand.fields)] - } + parentExpand.fields = parentExpand.fields + .concat(relField.split(",")) + .filter((field) => field !== relationship.alias) + + parentExpand.fields = [...new Set(parentExpand.fields)] if (fields) { const relField = relationship.inverse ? relationship.foreignKey.split(".").pop()! : relationship.primaryKey fields = fields.concat(relField.split(",")) - - fields = [...new Set(fields)] } } - currentServiceConfig = this.getServiceConfig(relationship.serviceName) + currentServiceConfig = this.getServiceConfig( + relationship.serviceName + )! if (!currentServiceConfig) { throw new Error( @@ -535,16 +636,33 @@ export class RemoteJoiner { } } + const isAliasMapping = (expand as any).isAliasMapping if (!parsedExpands.has(fullPath)) { const parentPath = [BASE_PATH, ...currentPath].join(".") + parsedExpands.set(fullPath, { property: prop, serviceConfig: currentServiceConfig, fields, - args, + args: isAliasMapping + ? forwardArgumentsOnPath.includes(fullPath) + ? args + : undefined + : args, + isAliasMapping: isAliasMapping, parent: parentPath, parentConfig: parsedExpands.get(parentPath).serviceConfig, }) + } else { + const exp = parsedExpands.get(fullPath) + + if (forwardArgumentsOnPath.includes(fullPath) && args) { + exp.args = (exp.args || []).concat(args) + } + + if (fields) { + exp.fields = (exp.fields || []).concat(fields) + } } currentPath.push(prop) @@ -585,7 +703,7 @@ export class RemoteJoiner { targetExpand = targetExpand.expands[key] ??= {} } - targetExpand.fields = expand.fields + targetExpand.fields = [...new Set(expand.fields)] targetExpand.args = expand.args mergedExpands.delete(path) @@ -648,11 +766,7 @@ export class RemoteJoiner { const data = response.path ? response.data[response.path!] : response.data - await this.handleExpands( - Array.isArray(data) ? data : [data], - queryObj, - parsedExpands - ) + await this.handleExpands(Array.isArray(data) ? data : [data], parsedExpands) return response.data } diff --git a/packages/types/src/joiner/index.ts b/packages/types/src/joiner/index.ts index 2a8d2fe55e..77d91c59b2 100644 --- a/packages/types/src/joiner/index.ts +++ b/packages/types/src/joiner/index.ts @@ -16,6 +16,14 @@ export interface JoinerServiceConfigAlias { export interface JoinerServiceConfig { serviceName: string alias?: JoinerServiceConfigAlias | JoinerServiceConfigAlias[] // Property name to use as entrypoint to the service + fieldAlias?: Record< + string, + | string + | { + path: string + forwardArgumentsOnPath: string[] + } + > // alias for deeper nested relationships (e.g. { 'price': 'prices.calculated_price_set.amount' }) primaryKeys: string[] relationships?: JoinerRelationship[] extends?: {