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

* initial commit

* chore: unit tests and forward arguments
This commit is contained in:
Carlos R. L. Rodrigues
2023-09-12 03:43:25 -03:00
committed by GitHub
parent 834da5c41a
commit 0953bdfe84
4 changed files with 498 additions and 116 deletions

View File

@@ -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: [
{

View File

@@ -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,
},
},
})
})
})

View File

@@ -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<string, JoinerServiceConfig> = 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<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(
items: any[],
query: RemoteJoinerQuery,
parsedExpands: Map<string, RemoteExpandProperty>
): Promise<void> {
if (!parsedExpands) {
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()) {
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<string, any>()
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
}

View File

@@ -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?: {