feat: Remote Joiner (#4098)

This commit is contained in:
Carlos R. L. Rodrigues
2023-06-29 10:29:01 -03:00
committed by GitHub
parent 9dcdc0041a
commit 499c3478c9
20 changed files with 2227 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
---
"@medusajs/orchestration": patch
"@medusajs/types": patch
"@medusajs/utils": patch
---
feat: Remote Joiner

View File

@@ -0,0 +1,13 @@
module.exports = {
globals: {
"ts-jest": {
tsConfig: "tsconfig.json",
isolatedModules: false,
},
},
transform: {
"^.+\\.[jt]s?$": "ts-jest",
},
testEnvironment: `node`,
moduleFileExtensions: [`js`, `ts`],
}

View File

@@ -0,0 +1,36 @@
{
"name": "@medusajs/orchestration",
"version": "0.0.1",
"description": "Medusa utilities to orchestrate modules",
"main": "dist/index.js",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/orchestration"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"author": "Medusa",
"license": "MIT",
"devDependencies": {
"@medusajs/types": "^1.8.7",
"cross-env": "^5.2.1",
"jest": "^25.5.4",
"ts-jest": "^25.5.1",
"typescript": "^4.4.4"
},
"dependencies": {
"@medusajs/utils": "^1.9.1",
"graphql": "^16.6.0"
},
"scripts": {
"prepare": "cross-env NODE_ENV=production yarn run build",
"build": "tsc --build",
"watch": "tsc --build --watch",
"test": "jest"
}
}

View File

@@ -0,0 +1,189 @@
export const remoteJoinerData = {
user: [
{
id: 1,
email: "johndoe@example.com",
name: "John Doe",
fullname: "John Doe full name",
products: [
{
id: 1,
product_id: 102,
},
],
nested: {
lala: "lala",
multiple: [
{
abc: 1,
},
{
abc: 2,
},
],
},
},
{
id: 2,
email: "janedoe@example.com",
name: "Jane Doe",
products: [
{
id: 2,
product_id: [101, 102],
},
],
nested: {
lala: "lele",
multiple: [
{
a: 33,
},
{
a: 44,
},
],
},
},
{
id: 3,
email: "aaa@example.com",
name: "aaa bbb",
fullname: "3333 Doe full name",
nested: {
lala: "lolo",
multiple: [
{
a: 555,
},
{
a: 555,
},
],
},
},
{
id: 4,
email: "444444@example.com",
name: "a4444 44 44",
fullname: "444 Doe full name",
products: [
{
id: 4,
product_id: 103,
},
],
nested: {
lala: "lulu",
multiple: [
{
a: 6666,
},
{
a: 7777,
},
],
},
},
],
product: {
rows: [
{
id: 101,
name: "Product 1",
handler: "product-1-handler",
user_id: 2,
},
{
id: 102,
name: "Product 2",
handler: "product-2-handler",
user_id: 1,
},
{
id: 103,
name: "Product 3",
handler: "product-3-handler",
user_id: 3,
},
],
limit: 3,
skip: 0,
},
variant: [
{
id: 991,
name: "Product variant 1",
product_id: 101,
},
{
id: 992,
name: "Product variant 2",
product_id: 101,
},
{
id: 993,
name: "Product variant 33",
product_id: 103,
},
],
order_variant: [
{
order_id: 201,
product_id: 101,
variant_id: 991,
quantity: 1,
},
{
order_id: 201,
product_id: 101,
variant_id: 992,
quantity: 5,
},
{
order_id: 205,
product_id: 101,
variant_id: 992,
quantity: 4,
},
{
order_id: 205,
product_id: 103,
variant_id: 993,
quantity: 1,
},
],
order: [
{
id: 201,
number: "ORD-001",
date: "2023-04-01T12:00:00Z",
products: [
{
product_id: 101,
variant_id: 991,
quantity: 1,
},
{
product_id: 101,
variant_id: 992,
quantity: 5,
},
],
user_id: 4,
},
{
id: 205,
number: "ORD-202",
date: "2023-04-01T12:00:00Z",
products: [
{
product_id: [101, 103],
variant_id: 993,
quantity: 4,
},
],
user_id: 1,
},
],
}

View File

@@ -0,0 +1,118 @@
import { JoinerServiceConfig } from "@medusajs/types"
import { remoteJoinerData } from "./../../__fixtures__/joiner/data"
export const serviceConfigs: JoinerServiceConfig[] = [
{
serviceName: "User",
primaryKeys: ["id"],
relationships: [
{
foreignKey: "products.product_id",
serviceName: "Product",
primaryKey: "id",
alias: "product",
},
],
extends: [
{
serviceName: "Variant",
resolve: {
foreignKey: "user_id",
serviceName: "User",
primaryKey: "id",
alias: "user",
},
},
],
},
{
serviceName: "Product",
primaryKeys: ["id", "sku"],
relationships: [
{
foreignKey: "user_id",
serviceName: "User",
primaryKey: "id",
alias: "user",
},
],
},
{
serviceName: "Variant",
primaryKeys: ["id"],
relationships: [
{
foreignKey: "product_id",
serviceName: "Product",
primaryKey: "id",
alias: "product",
},
{
foreignKey: "variant_id",
primaryKey: "id",
serviceName: "Order",
alias: "orders",
inverse: true, // In an inverted relationship the foreign key is on Order and the primary key is on variant
},
],
},
{
serviceName: "Order",
primaryKeys: ["id"],
relationships: [
{
foreignKey: "product_id",
serviceName: "Product",
primaryKey: "id",
alias: "product",
},
{
foreignKey: "products.variant_id,product_id",
serviceName: "Variant",
primaryKey: "id,product_id",
alias: "variant",
},
{
foreignKey: "user_id",
serviceName: "User",
primaryKey: "id",
alias: "user",
},
],
},
]
export const mockServiceList = (serviceName) => {
return jest.fn().mockImplementation((data) => {
const src = {
userService: remoteJoinerData.user,
productService: remoteJoinerData.product,
variantService: remoteJoinerData.variant,
orderService: remoteJoinerData.order,
}
let resultset = JSON.parse(JSON.stringify(src[serviceName]))
if (
serviceName === "userService" &&
!data.fields?.some((field) => field.includes("multiple"))
) {
resultset = resultset.map((item) => {
delete item.nested.multiple
return item
})
}
return {
data: resultset,
path: serviceName === "productService" ? "rows" : undefined,
}
})
}
export const serviceMock = {
orderService: mockServiceList("orderService"),
userService: mockServiceList("userService"),
productService: mockServiceList("productService"),
variantService: mockServiceList("variantService"),
}

View File

@@ -0,0 +1,239 @@
import GraphQLParser from "../../joiner/graphql-ast"
describe("RemoteJoiner.parseQuery", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it("Simple query with fields", async () => {
const graphqlQuery = `
query {
order {
id
number
date
}
}
`
const parser = new GraphQLParser(graphqlQuery)
const rjQuery = parser.parseQuery()
expect(rjQuery).toEqual({
service: "order",
fields: ["id", "number", "date"],
expands: [],
})
})
it("Simple query with fields and arguments", async () => {
const graphqlQuery = `
query {
order(
id: "ord_123",
another_arg: 987,
complexArg: {
id: "123",
name: "test",
nestedArg: {
nest_id: "abc",
num: 123
}
}
) {
id
number
date
}
}
`
const parser = new GraphQLParser(graphqlQuery)
const rjQuery = parser.parseQuery()
expect(rjQuery).toEqual({
service: "order",
fields: ["id", "number", "date"],
expands: [],
args: [
{
name: "id",
value: "ord_123",
},
{
name: "another_arg",
value: 987,
},
{
name: "complexArg",
value: {
id: "123",
name: "test",
nestedArg: {
nest_id: "abc",
num: 123,
},
},
},
],
})
})
it("Nested query with fields", async () => {
const graphqlQuery = `
query {
order {
id
number
date
products {
product_id
variant_id
order
variant {
name
sku
}
}
}
}
`
const parser = new GraphQLParser(graphqlQuery)
const rjQuery = parser.parseQuery()
expect(rjQuery).toEqual({
service: "order",
fields: ["id", "number", "date", "products"],
expands: [
{
property: "products",
fields: ["product_id", "variant_id", "order", "variant"],
},
{
property: "products.variant",
fields: ["name", "sku"],
},
],
})
})
it("Nested query with fields and arguments", async () => {
const graphqlQuery = `
query {
order (order_id: "ord_123") {
id
number
date
products (limit: 10) {
product_id
variant_id
order
variant (complexArg: { id: "123", name: "test", nestedArg: { nest_id: "abc", num: 123 } }, region_id: "reg_123") {
name
sku
}
}
}
}
`
const parser = new GraphQLParser(graphqlQuery)
const rjQuery = parser.parseQuery()
expect(rjQuery).toEqual({
service: "order",
fields: ["id", "number", "date", "products"],
expands: [
{
property: "products",
fields: ["product_id", "variant_id", "order", "variant"],
args: [
{
name: "limit",
value: 10,
},
],
},
{
property: "products.variant",
fields: ["name", "sku"],
args: [
{
name: "complexArg",
value: {
id: "123",
name: "test",
nestedArg: {
nest_id: "abc",
num: 123,
},
},
},
{
name: "region_id",
value: "reg_123",
},
],
},
],
args: [
{
name: "order_id",
value: "ord_123",
},
],
})
})
it("Nested query with fields and arguments using variables", async () => {
const graphqlQuery = `
query($orderId: ID, $anotherArg: String, $randomVariable: nonValidatedType) {
order (order_id: $orderId, anotherArg: $anotherArg) {
id
number
date
products (randomValue: $randomVariable) {
product_id
variant_id
order
}
}
}
`
const parser = new GraphQLParser(graphqlQuery, {
orderId: 123,
randomVariable: { complex: { num: 12343, str: "str_123" } },
anotherArg: "any string",
})
const rjQuery = parser.parseQuery()
expect(rjQuery).toEqual({
service: "order",
fields: ["id", "number", "date", "products"],
expands: [
{
property: "products",
fields: ["product_id", "variant_id", "order"],
args: [
{
name: "randomValue",
value: {
complex: {
num: 12343,
str: "str_123",
},
},
},
],
},
],
args: [
{
name: "order_id",
value: 123,
},
{
name: "anotherArg",
value: "any string",
},
],
})
})
})

View File

@@ -0,0 +1,491 @@
import { MedusaContainer, RemoteExpandProperty } from "@medusajs/types"
import { lowerCaseFirst, toPascalCase } from "@medusajs/utils"
import { remoteJoinerData } from "../../__fixtures__/joiner/data"
import { serviceConfigs, serviceMock } from "../../__mocks__/joiner/mock_data"
import { RemoteJoiner } from "../../joiner"
const container = {
resolve: (serviceName) => {
return {
list: (...args) => {
return serviceMock[serviceName].apply(this, args)
},
getByVariantId: (options) => {
if (serviceName !== "orderService") {
return
}
let orderVar = JSON.parse(
JSON.stringify(remoteJoinerData.order_variant)
)
if (options.expands?.order) {
orderVar = orderVar.map((item) => {
item.order = JSON.parse(
JSON.stringify(
remoteJoinerData.order.find((o) => o.id === item.order_id)
)
)
return item
})
}
return {
data: orderVar,
}
},
}
},
} as MedusaContainer
const fetchServiceDataCallback = async (
expand: RemoteExpandProperty,
pkField: string,
ids?: (unknown | unknown[])[],
relationship?: any
) => {
const serviceConfig = expand.serviceConfig
const moduleRegistryName =
lowerCaseFirst(serviceConfig.serviceName) + "Service"
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,
},
})
}
describe("RemoteJoiner", () => {
let joiner: RemoteJoiner
beforeAll(() => {
joiner = new RemoteJoiner(serviceConfigs, fetchServiceDataCallback)
})
beforeEach(() => {
jest.clearAllMocks()
})
it("Simple query of a service, its id and no fields specified", async () => {
const query = {
service: "User",
args: [
{
name: "id",
value: "1",
},
],
fields: ["id", "name", "email"],
}
const data = await joiner.query(query)
expect(data).toEqual([
{
id: 1,
name: "John Doe",
email: "johndoe@example.com",
},
{
id: 2,
name: "Jane Doe",
email: "janedoe@example.com",
},
{
id: 3,
name: "aaa bbb",
email: "aaa@example.com",
},
{
id: 4,
name: "a4444 44 44",
email: "444444@example.com",
},
])
})
it("Simple query of a service where the returned data contains multiple properties", async () => {
const query = RemoteJoiner.parseQuery(`
query {
product {
id
name
}
}
`)
const data = await joiner.query(query)
expect(data).toEqual({
rows: [
{
id: 101,
name: "Product 1",
},
{
id: 102,
name: "Product 2",
},
{
id: 103,
name: "Product 3",
},
],
limit: 3,
skip: 0,
})
})
it("Query of a service, expanding a property and restricting the fields expanded", async () => {
const query = {
service: "User",
args: [
{
name: "id",
value: "1",
},
],
fields: ["username", "email", "products"],
expands: [
{
property: "products.product",
fields: ["name"],
},
],
}
const data = await joiner.query(query)
expect(data).toEqual([
{
email: "johndoe@example.com",
products: [
{
id: 1,
product_id: 102,
product: {
name: "Product 2",
id: 102,
},
},
],
},
{
email: "janedoe@example.com",
products: [
{
id: 2,
product_id: [101, 102],
product: [
{
name: "Product 1",
id: 101,
},
{
name: "Product 2",
id: 102,
},
],
},
],
},
{
email: "aaa@example.com",
},
{
email: "444444@example.com",
products: [
{
id: 4,
product_id: 103,
product: {
name: "Product 3",
id: 103,
},
},
],
},
])
})
it("Query a service expanding multiple nested properties", async () => {
const query = {
service: "Order",
fields: ["number", "date", "products"],
expands: [
{
property: "products",
fields: ["product"],
},
{
property: "products.product",
fields: ["name"],
},
{
property: "user",
fields: ["fullname", "email", "products"],
},
{
property: "user.products.product",
fields: ["name"],
},
],
args: [
{
name: "id",
value: "3",
},
],
}
const data = await joiner.query(query)
expect(data).toEqual([
{
number: "ORD-001",
date: "2023-04-01T12:00:00Z",
products: [
{
product_id: 101,
product: {
name: "Product 1",
id: 101,
},
},
{
product_id: 101,
product: {
name: "Product 1",
id: 101,
},
},
],
user_id: 4,
user: {
fullname: "444 Doe full name",
email: "444444@example.com",
products: [
{
id: 4,
product_id: 103,
product: {
name: "Product 3",
id: 103,
},
},
],
id: 4,
},
},
{
number: "ORD-202",
date: "2023-04-01T12:00:00Z",
products: [
{
product_id: [101, 103],
product: [
{
name: "Product 1",
id: 101,
},
{
name: "Product 3",
id: 103,
},
],
},
],
user_id: 1,
user: {
fullname: "John Doe full name",
email: "johndoe@example.com",
products: [
{
id: 1,
product_id: 102,
product: {
name: "Product 2",
id: 102,
},
},
],
id: 1,
},
},
])
})
it("Query a service expanding an inverse relation", async () => {
const query = RemoteJoiner.parseQuery(`
query {
variant {
id
name
orders {
order {
number
products {
quantity
product {
name
}
variant {
name
}
}
}
}
}
}
`)
const data = await joiner.query(query)
expect(data).toEqual([
{
id: 991,
name: "Product variant 1",
orders: {
order: {
number: "ORD-001",
products: [
{
product_id: 101,
variant_id: 991,
quantity: 1,
product: {
name: "Product 1",
id: 101,
},
variant: {
name: "Product variant 1",
id: 991,
product_id: 101,
},
},
{
product_id: 101,
variant_id: 992,
quantity: 5,
product: {
name: "Product 1",
id: 101,
},
variant: {
name: "Product variant 2",
id: 992,
product_id: 101,
},
},
],
id: 201,
},
variant_id: 991,
order_id: 201,
},
},
{
id: 992,
name: "Product variant 2",
orders: [
{
order: {
number: "ORD-001",
products: [
{
product_id: 101,
variant_id: 991,
quantity: 1,
product: {
name: "Product 1",
id: 101,
},
variant: {
name: "Product variant 1",
id: 991,
product_id: 101,
},
},
{
product_id: 101,
variant_id: 992,
quantity: 5,
product: {
name: "Product 1",
id: 101,
},
variant: {
name: "Product variant 2",
id: 992,
product_id: 101,
},
},
],
id: 201,
},
variant_id: 992,
order_id: 201,
},
{
order: {
number: "ORD-202",
products: [
{
product_id: [101, 103],
variant_id: 993,
quantity: 4,
product: [
{
name: "Product 1",
id: 101,
},
{
name: "Product 3",
id: 103,
},
],
},
],
id: 205,
},
variant_id: 992,
order_id: 205,
},
],
},
{
id: 993,
name: "Product variant 33",
orders: {
order: {
number: "ORD-202",
products: [
{
product_id: [101, 103],
variant_id: 993,
quantity: 4,
product: [
{
name: "Product 1",
id: 101,
},
{
name: "Product 3",
id: 103,
},
],
},
],
id: 205,
},
variant_id: 993,
order_id: 205,
},
},
])
})
})

View File

@@ -0,0 +1,287 @@
import { MedusaContainer, RemoteExpandProperty } from "@medusajs/types"
import { lowerCaseFirst, toPascalCase } from "@medusajs/utils"
import { serviceConfigs, serviceMock } from "../../__mocks__/joiner/mock_data"
import { RemoteJoiner } from "./../../joiner"
const container = {
resolve: (serviceName) => {
return {
list: (...args) => {
return serviceMock[serviceName].apply(this, args)
},
}
},
} as MedusaContainer
const fetchServiceDataCallback = async (
expand: RemoteExpandProperty,
pkField: string,
ids?: (unknown | unknown[])[],
relationship?: any
) => {
const serviceConfig = expand.serviceConfig
const moduleRegistryName =
lowerCaseFirst(serviceConfig.serviceName) + "Service"
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,
},
})
}
describe("RemoteJoiner", () => {
let joiner: RemoteJoiner
beforeAll(() => {
joiner = new RemoteJoiner(serviceConfigs, fetchServiceDataCallback)
})
beforeEach(() => {
jest.clearAllMocks()
})
it("Simple query of a service, its id and no fields specified", async () => {
const query = {
service: "User",
args: [
{
name: "id",
value: "1",
},
],
fields: ["id", "name", "email"],
}
await joiner.query(query)
expect(serviceMock.userService).toHaveBeenCalledTimes(1)
expect(serviceMock.userService).toHaveBeenCalledWith({
args: [],
fields: ["id", "name", "email"],
options: { id: ["1"] },
})
})
it("Transforms main service name into PascalCase", async () => {
const query = {
service: "user",
fields: ["id"],
}
await joiner.query(query)
expect(serviceMock.userService).toHaveBeenCalledTimes(1)
})
it("Simple query of a service, its id and a few fields specified", async () => {
const query = {
service: "User",
args: [
{
name: "id",
value: "1",
},
],
fields: ["username", "email"],
}
await joiner.query(query)
expect(serviceMock.userService).toHaveBeenCalledTimes(1)
expect(serviceMock.userService).toHaveBeenCalledWith({
args: [],
fields: ["username", "email"],
options: { id: ["1"] },
})
})
it("Query of a service, expanding a property and restricting the fields expanded", async () => {
const query = {
service: "user",
fields: ["username", "email", "products"],
args: [
{
name: "id",
value: "1",
},
],
expands: [
{
property: "products",
fields: ["product"],
},
{
property: "products.product",
fields: ["name"],
},
],
}
await joiner.query(query)
expect(serviceMock.userService).toHaveBeenCalledTimes(1)
expect(serviceMock.userService).toHaveBeenCalledWith({
args: [],
fields: ["username", "email", "products"],
expands: {
products: {
args: undefined,
fields: ["product_id"],
},
},
options: { id: ["1"] },
})
expect(serviceMock.productService).toHaveBeenCalledTimes(1)
expect(serviceMock.productService).toHaveBeenCalledWith({
fields: ["name", "id"],
options: { id: expect.arrayContaining([101, 102, 103]) },
})
})
it("Query a service using more than 1 argument, expanding a property with another argument", async () => {
const query = {
service: "User",
args: [
{
name: "id",
value: "1",
},
{
name: "role",
value: "admin",
},
],
fields: ["username", "email", "products"],
expands: [
{
property: "products",
fields: ["product"],
},
{
property: "products.product",
fields: ["name"],
args: [
{
name: "limit",
value: "5",
},
],
},
],
}
await joiner.query(query)
expect(serviceMock.userService).toHaveBeenCalledTimes(1)
expect(serviceMock.userService).toHaveBeenCalledWith({
args: [
{
name: "role",
value: "admin",
},
],
fields: ["username", "email", "products"],
expands: {
products: {
args: undefined,
fields: ["product_id"],
},
},
options: { id: ["1"] },
})
expect(serviceMock.productService).toHaveBeenCalledTimes(1)
expect(serviceMock.productService).toHaveBeenCalledWith({
fields: ["name", "id"],
options: { id: expect.arrayContaining([101, 102, 103]) },
args: [
{
name: "limit",
value: "5",
},
],
})
})
it("Query a service expanding multiple nested properties", async () => {
const query = {
service: "Order",
fields: ["number", "date", "products"],
expands: [
{
property: "products",
fields: ["product"],
},
{
property: "products.product",
fields: ["handler"],
},
{
property: "user",
fields: ["fullname", "email", "products"],
},
{
property: "user.products",
fields: ["product"],
},
{
property: "user.products.product",
fields: ["name"],
},
],
args: [
{
name: "id",
value: "3",
},
],
}
await joiner.query(query)
expect(serviceMock.orderService).toHaveBeenCalledTimes(1)
expect(serviceMock.orderService).toHaveBeenCalledWith({
args: [],
fields: ["number", "date", "products", "user_id"],
expands: {
products: {
args: undefined,
fields: ["product_id"],
},
},
options: { id: ["3"] },
})
expect(serviceMock.userService).toHaveBeenCalledTimes(1)
expect(serviceMock.userService).toHaveBeenCalledWith({
fields: ["fullname", "email", "products", "id"],
args: undefined,
expands: {
products: {
args: undefined,
fields: ["product_id"],
},
},
options: { id: [4, 1] },
})
expect(serviceMock.productService).toHaveBeenCalledTimes(2)
expect(serviceMock.productService).toHaveBeenNthCalledWith(1, {
fields: ["name", "id"],
options: { id: expect.arrayContaining([103, 102]) },
})
expect(serviceMock.productService).toHaveBeenNthCalledWith(2, {
fields: ["handler", "id"],
options: { id: expect.arrayContaining([101, 103]) },
})
})
})

View File

@@ -0,0 +1 @@
export * from "./joiner"

View File

@@ -0,0 +1,154 @@
import { RemoteJoinerQuery } from "@medusajs/types"
import {
ArgumentNode,
DocumentNode,
FieldNode,
Kind,
OperationDefinitionNode,
SelectionSetNode,
ValueNode,
parse,
} from "graphql"
interface Argument {
name: string
value?: unknown
}
interface Entity {
property: string
fields: string[]
args?: Argument[]
}
class GraphQLParser {
private ast: DocumentNode
constructor(input: string, private variables?: { [key: string]: unknown }) {
this.ast = parse(input)
this.variables = variables || {}
}
private parseValueNode(valueNode: ValueNode): unknown {
switch (valueNode.kind) {
case Kind.VARIABLE:
const variableName = valueNode.name.value
return this.variables ? this.variables[variableName] : undefined
case Kind.INT:
return parseInt(valueNode.value, 10)
case Kind.FLOAT:
return parseFloat(valueNode.value)
case Kind.BOOLEAN:
return Boolean(valueNode.value)
case Kind.STRING:
case Kind.ENUM:
return valueNode.value
case Kind.NULL:
return null
case Kind.LIST:
return valueNode.values.map((v) => this.parseValueNode(v))
case Kind.OBJECT:
let obj = {}
for (const field of valueNode.fields) {
obj[field.name.value] = this.parseValueNode(field.value)
}
return obj
default:
return undefined
}
}
private parseArguments(
args: readonly ArgumentNode[]
): Argument[] | undefined {
if (!args.length) {
return
}
return args.map((arg) => {
const value = this.parseValueNode(arg.value)
return {
name: arg.name.value,
value: value,
}
})
}
private extractEntities(
node: SelectionSetNode,
parentName = "",
mainService = ""
): Entity[] {
const entities: Entity[] = []
node.selections.forEach((selection) => {
if (selection.kind === "Field") {
const fieldNode = selection as FieldNode
if (fieldNode.selectionSet) {
const entityName = parentName
? `${parentName}.${fieldNode.name.value}`
: fieldNode.name.value
const nestedEntity: Entity = {
property: entityName.replace(`${mainService}.`, ""),
fields: fieldNode.selectionSet.selections.map(
(field) => (field as FieldNode).name.value
),
args: this.parseArguments(fieldNode.arguments!),
}
entities.push(nestedEntity)
entities.push(
...this.extractEntities(
fieldNode.selectionSet,
entityName,
mainService
)
)
}
}
})
return entities
}
public parseQuery(): RemoteJoinerQuery {
const queryDefinition = this.ast.definitions.find(
(definition) => definition.kind === "OperationDefinition"
) as OperationDefinitionNode
if (!queryDefinition) {
throw new Error("No query found")
}
const rootFieldNode = queryDefinition.selectionSet
.selections[0] as FieldNode
const remoteJoinConfig: RemoteJoinerQuery = {
service: rootFieldNode.name.value,
fields: [],
expands: [],
}
if (rootFieldNode.arguments) {
remoteJoinConfig.args = this.parseArguments(rootFieldNode.arguments)
}
if (rootFieldNode.selectionSet) {
remoteJoinConfig.fields = rootFieldNode.selectionSet.selections.map(
(field) => (field as FieldNode).name.value
)
remoteJoinConfig.expands = this.extractEntities(
rootFieldNode.selectionSet,
rootFieldNode.name.value,
rootFieldNode.name.value
)
}
return remoteJoinConfig
}
}
export default GraphQLParser

View File

@@ -0,0 +1 @@
export * from "./remote-joiner"

View File

@@ -0,0 +1,575 @@
import {
JoinerRelationship,
JoinerServiceConfig,
RemoteExpandProperty,
RemoteJoinerQuery,
RemoteNestedExpands,
} from "@medusajs/types"
import { isDefined, toPascalCase } from "@medusajs/utils"
import GraphQLParser from "./graphql-ast"
const BASE_PATH = "_root"
export class RemoteJoiner {
private serviceConfigs: JoinerServiceConfig[]
private serviceConfigCache: Map<string, JoinerServiceConfig> = new Map()
private static filterFields(
data: any,
fields: string[],
expands?: RemoteNestedExpands
): Record<string, unknown> {
if (!fields) {
return data
}
const filteredData = fields.reduce((acc: any, field: string) => {
acc[field] = data?.[field]
return acc
}, {})
if (expands) {
for (const key in expands) {
const expand = expands[key]
if (expand) {
if (Array.isArray(data[key])) {
filteredData[key] = data[key].map((item: any) =>
RemoteJoiner.filterFields(item, expand.fields, expand.expands)
)
} else {
filteredData[key] = RemoteJoiner.filterFields(
data[key],
expand.fields,
expand.expands
)
}
}
}
}
return filteredData
}
private static getNestedItems(items: any[], property: string): any[] {
return items
.flatMap((item) => item[property])
.filter((item) => item !== undefined)
}
private static createRelatedDataMap(
relatedDataArray: any[],
joinFields: string[]
): Map<string, any> {
return relatedDataArray.reduce((acc, data) => {
const joinValues = joinFields.map((field) => data[field])
const key = joinValues.length === 1 ? joinValues[0] : joinValues.join(",")
let isArray = Array.isArray(acc[key])
if (isDefined(acc[key]) && !isArray) {
acc[key] = [acc[key]]
isArray = true
}
if (isArray) {
acc[key].push(data)
} else {
acc[key] = data
}
return acc
}, {})
}
static parseQuery(graphqlQuery: string, variables?: any): RemoteJoinerQuery {
const parser = new GraphQLParser(graphqlQuery, variables)
return parser.parseQuery()
}
constructor(
serviceConfigs: JoinerServiceConfig[],
private remoteFetchData: (
expand: RemoteExpandProperty,
pkField: string,
ids?: (unknown | unknown[])[],
relationship?: any
) => Promise<{
data: unknown[] | { [path: string]: unknown[] }
path?: string
}>
) {
this.serviceConfigs = this.buildReferences(serviceConfigs)
}
private buildReferences(serviceConfigs: JoinerServiceConfig[]) {
const expandedRelationships: Map<string, JoinerRelationship[]> = new Map()
for (const service of serviceConfigs) {
// self-reference
const propName = service.serviceName.toLowerCase()
if (!service.relationships) {
service.relationships = []
}
service.relationships?.push({
alias: propName,
foreignKey: propName + "_id",
primaryKey: "id",
serviceName: service.serviceName,
})
this.serviceConfigCache.set(service.serviceName, service)
if (!service.extends) {
continue
}
for (const extend of service.extends) {
if (!expandedRelationships.has(extend.serviceName)) {
expandedRelationships.set(extend.serviceName, [])
}
expandedRelationships.get(extend.serviceName)!.push(extend.resolve)
}
}
for (const [serviceName, relationships] of expandedRelationships) {
if (!this.serviceConfigCache.has(serviceName)) {
throw new Error(`Service ${serviceName} not found`)
}
const service = this.serviceConfigCache.get(serviceName)
service!.relationships?.push(...relationships)
}
return serviceConfigs
}
private findServiceConfig(
serviceName: string
): JoinerServiceConfig | undefined {
if (!this.serviceConfigCache.has(serviceName)) {
const config = this.serviceConfigs.find(
(config) => config.serviceName === serviceName
)
this.serviceConfigCache.set(serviceName, config!)
}
return this.serviceConfigCache.get(serviceName)
}
private async fetchData(
expand: RemoteExpandProperty,
pkField: string,
ids?: (unknown | unknown[])[],
relationship?: any
): Promise<{
data: unknown[] | { [path: string]: unknown[] }
path?: string
}> {
let uniqueIds = Array.isArray(ids) ? ids : ids ? [ids] : undefined
if (uniqueIds) {
const isCompositeKey = Array.isArray(uniqueIds[0])
if (isCompositeKey) {
const seen = new Set()
uniqueIds = uniqueIds.filter((idArray) => {
const key = JSON.stringify(idArray)
const isNew = !seen.has(key)
seen.add(key)
return isNew
})
} else {
uniqueIds = Array.from(new Set(uniqueIds.flat()))
}
}
if (relationship) {
pkField = relationship.inverse
? relationship.foreignKey.split(".").pop()!
: relationship.primaryKey
}
const response = await this.remoteFetchData(
expand,
pkField,
uniqueIds,
relationship
)
const isObj = isDefined(response.path)
const resData = isObj ? response.data[response.path!] : response.data
const filteredDataArray = resData.map((data: any) =>
RemoteJoiner.filterFields(data, expand.fields, expand.expands)
)
if (isObj) {
response.data[response.path!] = filteredDataArray
} else {
response.data = filteredDataArray
}
return response
}
private async handleExpands(
items: any[],
query: RemoteJoinerQuery,
parsedExpands: Map<string, RemoteExpandProperty>
): Promise<void> {
if (!parsedExpands) {
return
}
const stack: [
any[],
RemoteJoinerQuery,
Map<string, RemoteExpandProperty>,
string,
Set<string>
][] = [[items, query, parsedExpands, "", new Set()]]
while (stack.length > 0) {
const [
currentItems,
currentQuery,
currentParsedExpands,
basePath,
resolvedPaths,
] = stack.pop()!
for (const [expandedPath, expand] of currentParsedExpands.entries()) {
const isImmediateChildPath =
expandedPath.startsWith(basePath) &&
expandedPath.split(".").length === basePath.split(".").length + 1
if (!isImmediateChildPath || resolvedPaths.has(expandedPath)) {
continue
}
resolvedPaths.add(expandedPath)
const property = expand.property || ""
const parentServiceConfig = this.findServiceConfig(currentQuery.service)
await this.expandProperty(currentItems, parentServiceConfig!, expand)
const relationship = parentServiceConfig?.relationships?.find(
(relation) => relation.alias === property
)
const nestedItems = RemoteJoiner.getNestedItems(currentItems, property)
if (nestedItems.length > 0) {
const nextProp = relationship
? {
...currentQuery,
service: relationship.serviceName,
}
: currentQuery
stack.push([
nestedItems,
nextProp,
currentParsedExpands,
expandedPath,
new Set(),
])
}
}
}
}
private async expandProperty(
items: any[],
parentServiceConfig: JoinerServiceConfig,
expand?: RemoteExpandProperty
): Promise<void> {
if (!expand) {
return
}
const relationship = parentServiceConfig?.relationships?.find(
(relation) => relation.alias === expand.property
)
if (relationship) {
await this.expandRelationshipProperty(items, expand, relationship)
}
}
private async expandRelationshipProperty(
items: any[],
expand: RemoteExpandProperty,
relationship: JoinerRelationship
): Promise<void> {
const field = relationship.inverse
? relationship.primaryKey
: relationship.foreignKey.split(".").pop()!
const fieldsArray = field.split(",")
const idsToFetch: any[] = []
items.forEach((item) => {
const values = fieldsArray
.map((field) => item[field])
.filter((value) => value !== undefined)
if (values.length === fieldsArray.length && !item[relationship.alias]) {
if (fieldsArray.length === 1) {
if (!idsToFetch.includes(values[0])) {
idsToFetch.push(values[0])
}
} else {
// composite key
const valuesString = values.join(",")
if (!idsToFetch.some((id) => id.join(",") === valuesString)) {
idsToFetch.push(values)
}
}
}
})
if (idsToFetch.length === 0) {
return
}
const relatedDataArray = await this.fetchData(
expand,
field,
idsToFetch,
relationship
)
const joinFields = relationship.inverse
? relationship.foreignKey.split(",")
: relationship.primaryKey.split(",")
const relData = relatedDataArray.path
? relatedDataArray.data[relatedDataArray.path!]
: relatedDataArray.data
const relatedDataMap = RemoteJoiner.createRelatedDataMap(
relData,
joinFields
)
items.forEach((item) => {
if (!item[relationship.alias]) {
const itemKey = fieldsArray.map((field) => item[field]).join(",")
if (Array.isArray(item[field])) {
item[relationship.alias] = item[field]
.map((id) => relatedDataMap[id])
.filter((relatedItem) => relatedItem !== undefined)
} else {
item[relationship.alias] = relatedDataMap[itemKey]
}
}
})
}
private parseExpands(
initialService: RemoteExpandProperty,
query: RemoteJoinerQuery,
serviceConfig: JoinerServiceConfig,
expands: RemoteJoinerQuery["expands"]
): Map<string, RemoteExpandProperty> {
const parsedExpands = this.parseProperties(
initialService,
query,
serviceConfig,
expands
)
const groupedExpands = this.groupExpands(parsedExpands)
return groupedExpands
}
private parseProperties(
initialService: RemoteExpandProperty,
query: RemoteJoinerQuery,
serviceConfig: JoinerServiceConfig,
expands: RemoteJoinerQuery["expands"]
): Map<string, RemoteExpandProperty> {
const parsedExpands = new Map<string, any>()
parsedExpands.set(BASE_PATH, initialService)
for (const expand of expands || []) {
const properties = expand.property.split(".")
let currentServiceConfig = serviceConfig as any
const currentPath: string[] = []
for (const prop of properties) {
const fullPath = [BASE_PATH, ...currentPath, prop].join(".")
const relationship = currentServiceConfig.relationships.find(
(relation) => relation.alias === prop
)
let fields: string[] | undefined =
fullPath === BASE_PATH + "." + expand.property
? expand.fields
: undefined
const args =
fullPath === BASE_PATH + "." + expand.property
? expand.args
: undefined
if (relationship) {
const parentExpand =
parsedExpands.get([BASE_PATH, ...currentPath].join(".")) || query
if (parentExpand) {
if (parentExpand.fields) {
const relField = relationship.inverse
? relationship.primaryKey
: relationship.foreignKey.split(".").pop()!
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.findServiceConfig(
relationship.serviceName
)
if (!currentServiceConfig) {
throw new Error(
`Target service not found: ${relationship.serviceName}`
)
}
}
if (!parsedExpands.has(fullPath)) {
parsedExpands.set(fullPath, {
property: prop,
serviceConfig: currentServiceConfig,
fields,
args,
})
}
currentPath.push(prop)
}
}
return parsedExpands
}
private groupExpands(
parsedExpands: Map<string, RemoteExpandProperty>
): Map<string, RemoteExpandProperty> {
const sortedParsedExpands = new Map(
Array.from(parsedExpands.entries()).sort()
)
const mergedExpands = new Map<string, RemoteExpandProperty>(
sortedParsedExpands
)
const mergedPaths = new Map<string, string>()
let lastServiceName = ""
for (const [path, expand] of sortedParsedExpands.entries()) {
const currentServiceName = expand.serviceConfig.serviceName
let parentPath = path.split(".").slice(0, -1).join(".")
// Check if the parentPath was merged before
while (mergedPaths.has(parentPath)) {
parentPath = mergedPaths.get(parentPath)!
}
const canMerge = currentServiceName === lastServiceName
if (mergedExpands.has(parentPath) && canMerge) {
const parentExpand = mergedExpands.get(parentPath)!
if (parentExpand.serviceConfig.serviceName === currentServiceName) {
const nestedKeys = path.split(".").slice(parentPath.split(".").length)
let targetExpand: any = parentExpand
for (let key of nestedKeys) {
if (!targetExpand.expands) {
targetExpand.expands = {}
}
if (!targetExpand.expands[key]) {
targetExpand.expands[key] = {} as any
}
targetExpand = targetExpand.expands[key]
}
targetExpand.fields = expand.fields
targetExpand.args = expand.args
mergedPaths.set(path, parentPath)
}
} else {
lastServiceName = currentServiceName
}
}
return mergedExpands
}
async query(queryObj: RemoteJoinerQuery): Promise<any> {
queryObj.service = toPascalCase(queryObj.service)
const serviceConfig = this.findServiceConfig(queryObj.service)
if (!serviceConfig) {
throw new Error(`Service not found: ${queryObj.service}`)
}
let pkName = serviceConfig.primaryKeys[0]
const primaryKeyArg = queryObj.args?.find((arg) => {
const inc = serviceConfig.primaryKeys.includes(arg.name)
if (inc) {
pkName = arg.name
}
return inc
})
const otherArgs = queryObj.args?.filter(
(arg) => !serviceConfig.primaryKeys.includes(arg.name)
)
const parsedExpands = this.parseExpands(
{
property: "",
serviceConfig: serviceConfig,
fields: queryObj.fields,
args: otherArgs,
},
queryObj,
serviceConfig,
queryObj.expands!
)
const root = parsedExpands.get(BASE_PATH)!
const response = await this.fetchData(
root,
pkName,
primaryKeyArg?.value,
undefined
)
const data = response.path ? response.data[response.path!] : response.data
await this.handleExpands(
Array.isArray(data) ? data : [data],
queryObj,
parsedExpands
)
return response.data
}
}

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"lib": ["es5", "es6", "es2019"],
"target": "es5",
"outDir": "./dist",
"esModuleInterop": true,
"declaration": true,
"module": "commonjs",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
"noImplicitReturns": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"allowJs": true,
"skipLibCheck": true,
"downlevelIteration": true
},
"include": ["src"],
"exclude": [
"dist",
"./src/**/__tests__",
"./src/**/__mocks__",
"./src/**/__fixtures__",
"node_modules"
]
}

View File

@@ -3,6 +3,7 @@ export * from "./cache"
export * from "./common"
export * from "./event-bus"
export * from "./inventory"
export * from "./joiner"
export * from "./modules-sdk"
export * from "./product"
export * from "./product-category"

View File

@@ -0,0 +1,51 @@
export type JoinerRelationship = {
alias: string
foreignKey: string
primaryKey: string
serviceName: string
inverse?: boolean // In an inverted relationship the foreign key is on the other service and the primary key is on the current service
}
export interface JoinerServiceConfig {
serviceName: string
primaryKeys: string[]
relationships?: JoinerRelationship[]
extends?: {
serviceName: string
resolve: JoinerRelationship
}[]
}
export interface JoinerArgument {
name: string
value?: any
field?: string
}
export interface RemoteJoinerQuery {
service: string
expands?: Array<{
property: string
fields: string[]
args?: JoinerArgument[]
relationships?: JoinerRelationship[]
}>
fields: string[]
args?: JoinerArgument[]
}
export interface RemoteNestedExpands {
[key: string]: {
fields: string[]
args?: JoinerArgument[]
expands?: RemoteNestedExpands
}
}
export interface RemoteExpandProperty {
property: string
serviceConfig: JoinerServiceConfig
fields: string[]
args?: JoinerArgument[]
expands?: RemoteNestedExpands
}

View File

@@ -34,6 +34,6 @@
"prepare": "cross-env NODE_ENV=production yarn run build",
"build": "tsc --build",
"watch": "tsc --build --watch",
"test": "jest --passWithNoTests src"
"test": "jest"
}
}

View File

@@ -2,6 +2,7 @@ export * from "./build-query"
export * from "./errors"
export * from "./generate-entity-id"
export * from "./get-config-file"
export * from "./handle-postgres-database-error"
export * from "./is-date"
export * from "./is-defined"
export * from "./is-email"
@@ -17,5 +18,6 @@ export * from "./wrap-handler"
export * from "./to-kebab-case"
export * from "./to-camel-case"
export * from "./stringify-circular"
export * from "./build-query"
export * from "./handle-postgres-database-error"
export * from "./to-kebab-case"
export * from "./to-pascal-case"
export * from "./wrap-handler"

View File

@@ -0,0 +1,5 @@
export function toPascalCase(s: string): string {
return s.replace(/(^\w|_\w)/g, (match) =>
match.replace(/_/g, "").toUpperCase()
)
}

View File

@@ -15,13 +15,15 @@
"strictFunctionTypes": true,
"noImplicitThis": true,
"allowJs": true,
"skipLibCheck": true
"skipLibCheck": true,
"downlevelIteration": true
},
"include": ["src"],
"exclude": [
"dist",
"./src/**/__tests__",
"./src/**/__mocks__",
"./src/**/__fixtures__",
"node_modules"
]
}

View File

@@ -6458,6 +6458,20 @@ __metadata:
languageName: unknown
linkType: soft
"@medusajs/orchestration@workspace:packages/orchestration":
version: 0.0.0-use.local
resolution: "@medusajs/orchestration@workspace:packages/orchestration"
dependencies:
"@medusajs/types": ^1.8.7
"@medusajs/utils": ^1.9.1
cross-env: ^5.2.1
graphql: ^16.6.0
jest: ^25.5.4
ts-jest: ^25.5.1
typescript: ^4.4.4
languageName: unknown
linkType: soft
"@medusajs/product@workspace:packages/product":
version: 0.0.0-use.local
resolution: "@medusajs/product@workspace:packages/product"
@@ -6504,7 +6518,7 @@ __metadata:
languageName: unknown
linkType: soft
"@medusajs/types@^1.8.8, @medusajs/types@^1.8.9, @medusajs/types@workspace:^, @medusajs/types@workspace:packages/types":
"@medusajs/types@^1.8.7, @medusajs/types@^1.8.8, @medusajs/types@^1.8.9, @medusajs/types@workspace:^, @medusajs/types@workspace:packages/types":
version: 0.0.0-use.local
resolution: "@medusajs/types@workspace:packages/types"
dependencies:
@@ -23781,6 +23795,13 @@ __metadata:
languageName: node
linkType: hard
"graphql@npm:^16.6.0":
version: 16.6.0
resolution: "graphql@npm:16.6.0"
checksum: 3a2c15ff58b69d017618d2b224fa6f3c4a7937e1f711c3a5e0948db536b4931e6e649560b53de7cc26735e027ceea6e2d0a6bb7c29fc4639b290313e3aa71618
languageName: node
linkType: hard
"growly@npm:^1.3.0":
version: 1.3.0
resolution: "growly@npm:1.3.0"