feat(orchestration): Remote Joiner field aliases (#5013)

* initial commit

* chore: unit tests and forward arguments
This commit is contained in:
Carlos R. L. Rodrigues authored and GitHub committed 2023-09-12 08:43:25 +02:00
1 parent 834da5c41a
commit 0953bdfe84
4 files changed
+447 -65

No files matched your search

@@ -56,6 +56,9 @@ export const serviceConfigs: JoinerServiceConfig[] = [
alias: { alias: {
name: "variant", name: "variant",
}, },
fieldAlias: {
user_shortcut: "product.user",
},
primaryKeys: ["id"], primaryKeys: ["id"],
relationships: [ relationships: [
{ {
@@ -75,6 +78,12 @@ export const serviceConfigs: JoinerServiceConfig[] = [
}, },
{ {
serviceName: "order", serviceName: "order",
fieldAlias: {
product_user_alias: {
path: "products.product.user",
forwardArgumentsOnPath: ["products.product"],
},
},
primaryKeys: ["id"], primaryKeys: ["id"],
relationships: [ relationships: [
{ {
@@ -38,7 +38,9 @@ const container = {
}, },
} as MedusaContainer } as MedusaContainer
const fetchServiceDataCallback = async ( const callbacks = jest.fn()
const fetchServiceDataCallback = jest.fn(
async (
expand: RemoteExpandProperty, expand: RemoteExpandProperty,
pkField: string, pkField: string,
ids?: (unknown | unknown[])[], ids?: (unknown | unknown[])[],
@@ -54,6 +56,12 @@ const fetchServiceDataCallback = async (
? `getBy${toPascalCase(pkField)}` ? `getBy${toPascalCase(pkField)}`
: "list" : "list"
callbacks({
service: serviceConfig.serviceName,
fieds: expand.fields,
args: expand.args,
})
return await service[methodName]({ return await service[methodName]({
fields: expand.fields, fields: expand.fields,
args: expand.args, args: expand.args,
@@ -63,6 +71,7 @@ const fetchServiceDataCallback = async (
}, },
}) })
} }
)
describe("RemoteJoiner", () => { describe("RemoteJoiner", () => {
let joiner: RemoteJoiner let joiner: RemoteJoiner
@@ -161,13 +170,11 @@ describe("RemoteJoiner", () => {
} }
const data = await joiner.query(query) const data = await joiner.query(query)
expect(data).toEqual([ expect(data).toEqual([
{ {
email: "johndoe@example.com", email: "johndoe@example.com",
products: [ products: [
{ {
id: 1,
product_id: 102, product_id: 102,
product: { product: {
name: "Product 2", name: "Product 2",
@@ -180,7 +187,6 @@ describe("RemoteJoiner", () => {
email: "janedoe@example.com", email: "janedoe@example.com",
products: [ products: [
{ {
id: 2,
product_id: [101, 102], product_id: [101, 102],
product: [ product: [
{ {
@@ -202,7 +208,6 @@ describe("RemoteJoiner", () => {
email: "444444@example.com", email: "444444@example.com",
products: [ products: [
{ {
id: 4,
product_id: 103, product_id: 103,
product: { product: {
name: "Product 3", name: "Product 3",
@@ -272,7 +277,6 @@ describe("RemoteJoiner", () => {
email: "444444@example.com", email: "444444@example.com",
products: [ products: [
{ {
id: 4,
product_id: 103, product_id: 103,
product: { product: {
name: "Product 3", name: "Product 3",
@@ -307,7 +311,6 @@ describe("RemoteJoiner", () => {
email: "johndoe@example.com", email: "johndoe@example.com",
products: [ products: [
{ {
id: 1,
product_id: 102, product_id: 102,
product: { product: {
name: "Product 2", 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,
},
},
})
})
}) })
@@ -8,7 +8,7 @@ import {
RemoteNestedExpands, RemoteNestedExpands,
} from "@medusajs/types" } from "@medusajs/types"
import { isDefined } from "@medusajs/utils" import { isDefined, isString } from "@medusajs/utils"
import GraphQLParser from "./graphql-ast" import GraphQLParser from "./graphql-ast"
const BASE_PATH = "_root" const BASE_PATH = "_root"
@@ -26,6 +26,12 @@ export type RemoteFetchDataCallback = (
export class RemoteJoiner { export class RemoteJoiner {
private serviceConfigCache: Map<string, JoinerServiceConfig> = new Map() private serviceConfigCache: Map<string, JoinerServiceConfig> = new Map()
private implodeMapping: {
location: string[]
property: string
path: string[]
}[] = []
private static filterFields( private static filterFields(
data: any, data: any,
fields: string[], fields: string[],
@@ -76,9 +82,7 @@ export class RemoteJoiner {
} }
private static getNestedItems(items: any[], property: string): any[] { private static getNestedItems(items: any[], property: string): any[] {
return items return items.flatMap((item) => item?.[property])
.flatMap((item) => item[property])
.filter((item) => item !== undefined)
} }
private static createRelatedDataMap( private static createRelatedDataMap(
@@ -265,6 +269,8 @@ export class RemoteJoiner {
} else { } else {
uniqueIds = Array.from(new Set(uniqueIds.flat())) uniqueIds = Array.from(new Set(uniqueIds.flat()))
} }
uniqueIds = uniqueIds.filter((id) => id !== undefined)
} }
if (relationship) { if (relationship) {
@@ -295,57 +301,110 @@ export class RemoteJoiner {
return response return response
} }
private handleFieldAliases(
items: any[],
parsedExpands: Map<string, RemoteExpandProperty>
) {
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( private async handleExpands(
items: any[], items: any[],
query: RemoteJoinerQuery,
parsedExpands: Map<string, RemoteExpandProperty> parsedExpands: Map<string, RemoteExpandProperty>
): Promise<void> { ): Promise<void> {
if (!parsedExpands) { if (!parsedExpands) {
return return
} }
const resolvedPaths = new Set<string>()
const stack: [any[], Partial<RemoteJoinerQuery>, string][] = [
[items, query, BASE_PATH],
]
while (stack.length > 0) {
const [currentItems, currentQuery, basePath] = stack.pop()!
for (const [expandedPath, expand] of parsedExpands.entries()) { for (const [expandedPath, expand] of parsedExpands.entries()) {
const isParentPath = expandedPath.startsWith(basePath) if (expandedPath === BASE_PATH) {
if (!isParentPath || resolvedPaths.has(expandedPath)) {
continue continue
} }
resolvedPaths.add(expandedPath) let nestedItems = items
const property = expand.property || ""
let curItems = currentItems
const expandedPathLevels = expandedPath.split(".") const expandedPathLevels = expandedPath.split(".")
for (let idx = 1; idx < expandedPathLevels.length - 1; idx++) { for (let idx = 1; idx < expandedPathLevels.length - 1; idx++) {
curItems = RemoteJoiner.getNestedItems( nestedItems = RemoteJoiner.getNestedItems(
curItems, nestedItems,
expandedPathLevels[idx] expandedPathLevels[idx]
) )
} }
await this.expandProperty(curItems, expand.parentConfig!, expand)
const nestedItems = RemoteJoiner.getNestedItems(currentItems, property)
if (nestedItems.length > 0) { if (nestedItems.length > 0) {
const relationship = expand.serviceConfig await this.expandProperty(nestedItems, expand.parentConfig!, expand)
let nextProp = currentQuery
if (relationship) {
const relQuery = {
service: relationship.serviceName,
}
nextProp = relQuery
}
stack.push([nestedItems, nextProp, expandedPath])
}
} }
} }
this.handleFieldAliases(items, parsedExpands)
} }
private async expandProperty( private async expandProperty(
@@ -379,11 +438,9 @@ export class RemoteJoiner {
const idsToFetch: any[] = [] const idsToFetch: any[] = []
items.forEach((item) => { items.forEach((item) => {
const values = fieldsArray const values = fieldsArray.map((field) => item?.[field])
.map((field) => item[field])
.filter((value) => value !== undefined)
if (values.length === fieldsArray.length && !item[relationship.alias]) { if (values.length === fieldsArray.length && !item?.[relationship.alias]) {
if (fieldsArray.length === 1) { if (fieldsArray.length === 1) {
if (!idsToFetch.includes(values[0])) { if (!idsToFetch.includes(values[0])) {
idsToFetch.push(values[0]) idsToFetch.push(values[0])
@@ -424,12 +481,14 @@ export class RemoteJoiner {
) )
items.forEach((item) => { items.forEach((item) => {
if (!item[relationship.alias]) { if (!item || item[relationship.alias]) {
return
}
const itemKey = fieldsArray.map((field) => item[field]).join(",") const itemKey = fieldsArray.map((field) => item[field]).join(",")
if (Array.isArray(item[field])) { if (Array.isArray(item[field])) {
item[relationship.alias] = item[field] item[relationship.alias] = item[field].map((id) => {
.map((id) => {
if (relationship.isList && !Array.isArray(relatedDataMap[id])) { if (relationship.isList && !Array.isArray(relatedDataMap[id])) {
relatedDataMap[id] = relatedDataMap[id] =
relatedDataMap[id] !== undefined ? [relatedDataMap[id]] : [] relatedDataMap[id] !== undefined ? [relatedDataMap[id]] : []
@@ -437,7 +496,6 @@ export class RemoteJoiner {
return relatedDataMap[id] return relatedDataMap[id]
}) })
.filter((relatedItem) => relatedItem !== undefined)
} else { } else {
if (relationship.isList && !Array.isArray(relatedDataMap[itemKey])) { if (relationship.isList && !Array.isArray(relatedDataMap[itemKey])) {
relatedDataMap[itemKey] = relatedDataMap[itemKey] =
@@ -448,7 +506,6 @@ export class RemoteJoiner {
item[relationship.alias] = relatedDataMap[itemKey] item[relationship.alias] = relatedDataMap[itemKey]
} }
}
}) })
} }
@@ -479,21 +536,65 @@ export class RemoteJoiner {
const parsedExpands = new Map<string, any>() const parsedExpands = new Map<string, any>()
parsedExpands.set(BASE_PATH, initialService) parsedExpands.set(BASE_PATH, initialService)
let forwardArgumentsOnPath: string[] = []
for (const expand of expands || []) { for (const expand of expands || []) {
const properties = expand.property.split(".") const properties = expand.property.split(".")
let currentServiceConfig = serviceConfig as any let currentServiceConfig = serviceConfig
const currentPath: string[] = [] const currentPath: string[] = []
for (const prop of properties) { 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 fullPath = [BASE_PATH, ...currentPath, prop].join(".")
const relationship = currentServiceConfig.relationships.find( const relationship = currentServiceConfig.relationships?.find(
(relation) => relation.alias === prop (relation) => relation.alias === prop
) )
let fields: string[] | undefined = let fields: string[] | undefined =
fullPath === BASE_PATH + "." + expand.property fullPath === BASE_PATH + "." + expand.property
? expand.fields ? expand.fields ?? []
: undefined : undefined
const args = const args =
fullPath === BASE_PATH + "." + expand.property fullPath === BASE_PATH + "." + expand.property
? expand.args ? expand.args
@@ -504,29 +605,29 @@ export class RemoteJoiner {
parsedExpands.get([BASE_PATH, ...currentPath].join(".")) || query parsedExpands.get([BASE_PATH, ...currentPath].join(".")) || query
if (parentExpand) { if (parentExpand) {
if (parentExpand.fields) {
const relField = relationship.inverse const relField = relationship.inverse
? relationship.primaryKey ? relationship.primaryKey
: relationship.foreignKey.split(".").pop()! : relationship.foreignKey.split(".").pop()!
parentExpand.fields ??= []
parentExpand.fields = parentExpand.fields parentExpand.fields = parentExpand.fields
.concat(relField.split(",")) .concat(relField.split(","))
.filter((field) => field !== relationship.alias) .filter((field) => field !== relationship.alias)
parentExpand.fields = [...new Set(parentExpand.fields)] parentExpand.fields = [...new Set(parentExpand.fields)]
}
if (fields) { if (fields) {
const relField = relationship.inverse const relField = relationship.inverse
? relationship.foreignKey.split(".").pop()! ? relationship.foreignKey.split(".").pop()!
: relationship.primaryKey : relationship.primaryKey
fields = fields.concat(relField.split(",")) fields = fields.concat(relField.split(","))
fields = [...new Set(fields)]
} }
} }
currentServiceConfig = this.getServiceConfig(relationship.serviceName) currentServiceConfig = this.getServiceConfig(
relationship.serviceName
)!
if (!currentServiceConfig) { if (!currentServiceConfig) {
throw new Error( throw new Error(
@@ -535,16 +636,33 @@ export class RemoteJoiner {
} }
} }
const isAliasMapping = (expand as any).isAliasMapping
if (!parsedExpands.has(fullPath)) { if (!parsedExpands.has(fullPath)) {
const parentPath = [BASE_PATH, ...currentPath].join(".") const parentPath = [BASE_PATH, ...currentPath].join(".")
parsedExpands.set(fullPath, { parsedExpands.set(fullPath, {
property: prop, property: prop,
serviceConfig: currentServiceConfig, serviceConfig: currentServiceConfig,
fields, fields,
args, args: isAliasMapping
? forwardArgumentsOnPath.includes(fullPath)
? args
: undefined
: args,
isAliasMapping: isAliasMapping,
parent: parentPath, parent: parentPath,
parentConfig: parsedExpands.get(parentPath).serviceConfig, 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) currentPath.push(prop)
@@ -585,7 +703,7 @@ export class RemoteJoiner {
targetExpand = targetExpand.expands[key] ??= {} targetExpand = targetExpand.expands[key] ??= {}
} }
targetExpand.fields = expand.fields targetExpand.fields = [...new Set(expand.fields)]
targetExpand.args = expand.args targetExpand.args = expand.args
mergedExpands.delete(path) mergedExpands.delete(path)
@@ -648,11 +766,7 @@ export class RemoteJoiner {
const data = response.path ? response.data[response.path!] : response.data const data = response.path ? response.data[response.path!] : response.data
await this.handleExpands( await this.handleExpands(Array.isArray(data) ? data : [data], parsedExpands)
Array.isArray(data) ? data : [data],
queryObj,
parsedExpands
)
return response.data return response.data
} }
+8
View File
@@ -16,6 +16,14 @@ export interface JoinerServiceConfigAlias {
export interface JoinerServiceConfig { export interface JoinerServiceConfig {
serviceName: string serviceName: string
alias?: JoinerServiceConfigAlias | JoinerServiceConfigAlias[] // Property name to use as entrypoint to the service 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[] primaryKeys: string[]
relationships?: JoinerRelationship[] relationships?: JoinerRelationship[]
extends?: { extends?: {