feat(modules-sdk): Remote Query (#4463)

* feat: Remote Query
This commit is contained in:
Carlos R. L. Rodrigues
2023-07-19 15:35:36 -03:00
committed by GitHub
parent 95c538c675
commit 5a8a889c6d
57 changed files with 1286 additions and 423 deletions

View File

@@ -17,7 +17,7 @@
"author": "Medusa",
"license": "MIT",
"devDependencies": {
"@medusajs/types": "^1.8.10",
"@medusajs/types": "^1.8.11",
"cross-env": "^5.2.1",
"jest": "^25.5.4",
"ts-jest": "^25.5.1",

View File

@@ -3,22 +3,36 @@ import { remoteJoinerData } from "./../../__fixtures__/joiner/data"
export const serviceConfigs: JoinerServiceConfig[] = [
{
serviceName: "User",
serviceName: "user",
primaryKeys: ["id"],
args: {
methodSuffix: "User",
},
alias: [
{
name: "me",
args: {
extraArgument: 123,
},
},
{
name: "customer",
},
],
relationships: [
{
foreignKey: "products.product_id",
serviceName: "Product",
serviceName: "product",
primaryKey: "id",
alias: "product",
},
],
extends: [
{
serviceName: "Variant",
resolve: {
serviceName: "variantService",
relationship: {
foreignKey: "user_id",
serviceName: "User",
serviceName: "user",
primaryKey: "id",
alias: "user",
},
@@ -26,55 +40,58 @@ export const serviceConfigs: JoinerServiceConfig[] = [
],
},
{
serviceName: "Product",
serviceName: "product",
primaryKeys: ["id", "sku"],
relationships: [
{
foreignKey: "user_id",
serviceName: "User",
serviceName: "user",
primaryKey: "id",
alias: "user",
},
],
},
{
serviceName: "Variant",
serviceName: "variantService",
alias: {
name: "variant",
},
primaryKeys: ["id"],
relationships: [
{
foreignKey: "product_id",
serviceName: "Product",
serviceName: "product",
primaryKey: "id",
alias: "product",
},
{
foreignKey: "variant_id",
primaryKey: "id",
serviceName: "Order",
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",
serviceName: "order",
primaryKeys: ["id"],
relationships: [
{
foreignKey: "product_id",
serviceName: "Product",
serviceName: "product",
primaryKey: "id",
alias: "product",
},
{
foreignKey: "products.variant_id,product_id",
serviceName: "Variant",
serviceName: "variantService",
primaryKey: "id,product_id",
alias: "variant",
},
{
foreignKey: "user_id",
serviceName: "User",
serviceName: "user",
primaryKey: "id",
alias: "user",
},

View File

@@ -19,7 +19,7 @@ describe("RemoteJoiner.parseQuery", () => {
const rjQuery = parser.parseQuery()
expect(rjQuery).toEqual({
service: "order",
alias: "order",
fields: ["id", "number", "date"],
expands: [],
})
@@ -50,7 +50,7 @@ describe("RemoteJoiner.parseQuery", () => {
const rjQuery = parser.parseQuery()
expect(rjQuery).toEqual({
service: "order",
alias: "order",
fields: ["id", "number", "date"],
expands: [],
args: [
@@ -77,6 +77,44 @@ describe("RemoteJoiner.parseQuery", () => {
})
})
it("Simple query with mapping fields to services", 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({
alias: "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", async () => {
const graphqlQuery = `
query {
@@ -100,7 +138,7 @@ describe("RemoteJoiner.parseQuery", () => {
const rjQuery = parser.parseQuery()
expect(rjQuery).toEqual({
service: "order",
alias: "order",
fields: ["id", "number", "date", "products"],
expands: [
{
@@ -138,7 +176,7 @@ describe("RemoteJoiner.parseQuery", () => {
const rjQuery = parser.parseQuery()
expect(rjQuery).toEqual({
service: "order",
alias: "order",
fields: ["id", "number", "date", "products"],
expands: [
{
@@ -205,7 +243,7 @@ describe("RemoteJoiner.parseQuery", () => {
const rjQuery = parser.parseQuery()
expect(rjQuery).toEqual({
service: "order",
alias: "order",
fields: ["id", "number", "date", "products"],
expands: [
{

View File

@@ -45,8 +45,9 @@ const fetchServiceDataCallback = async (
relationship?: any
) => {
const serviceConfig = expand.serviceConfig
const moduleRegistryName =
lowerCaseFirst(serviceConfig.serviceName) + "Service"
const moduleRegistryName = !serviceConfig.serviceName.endsWith("Service")
? lowerCaseFirst(serviceConfig.serviceName) + "Service"
: serviceConfig.serviceName
const service = container.resolve(moduleRegistryName)
const methodName = relationship?.inverse
@@ -74,7 +75,7 @@ describe("RemoteJoiner", () => {
it("Simple query of a service, its id and no fields specified", async () => {
const query = {
service: "User",
service: "user",
args: [
{
name: "id",
@@ -143,7 +144,7 @@ describe("RemoteJoiner", () => {
it("Query of a service, expanding a property and restricting the fields expanded", async () => {
const query = {
service: "User",
service: "user",
args: [
{
name: "id",
@@ -215,7 +216,7 @@ describe("RemoteJoiner", () => {
it("Query a service expanding multiple nested properties", async () => {
const query = {
service: "Order",
service: "order",
fields: ["number", "date", "products"],
expands: [
{

View File

@@ -49,7 +49,7 @@ describe("RemoteJoiner", () => {
it("Simple query of a service, its id and no fields specified", async () => {
const query = {
service: "User",
service: "user",
args: [
{
name: "id",
@@ -69,20 +69,62 @@ describe("RemoteJoiner", () => {
})
})
it("Transforms main service name into PascalCase", async () => {
it("Simple query of a service by its alias", async () => {
const query = {
service: "user",
alias: "customer",
fields: ["id"],
args: [
{
name: "id",
value: "1",
},
],
}
await joiner.query(query)
expect(serviceMock.userService).toHaveBeenCalledTimes(1)
expect(serviceMock.userService).toHaveBeenCalledWith({
args: [],
fields: ["id"],
options: { id: ["1"] },
})
})
it("Simple query of a service by its alias with extra arguments", async () => {
const query = {
alias: "me",
fields: ["id"],
args: [
{
name: "id",
value: 1,
},
{
name: "arg1",
value: "abc",
},
],
}
await joiner.query(query)
expect(serviceMock.userService).toHaveBeenCalledTimes(1)
expect(serviceMock.userService).toHaveBeenCalledWith({
args: [
{
name: "arg1",
value: "abc",
},
],
fields: ["id"],
options: { id: [1] },
})
})
it("Simple query of a service, its id and a few fields specified", async () => {
const query = {
service: "User",
service: "user",
args: [
{
name: "id",
@@ -148,7 +190,7 @@ describe("RemoteJoiner", () => {
it("Query a service using more than 1 argument, expanding a property with another argument", async () => {
const query = {
service: "User",
service: "user",
args: [
{
name: "id",
@@ -213,7 +255,7 @@ describe("RemoteJoiner", () => {
it("Query a service expanding multiple nested properties", async () => {
const query = {
service: "Order",
service: "order",
fields: ["number", "date", "products"],
expands: [
{

View File

@@ -86,28 +86,29 @@ class GraphQLParser {
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
)
)
if (!fieldNode.selectionSet) {
return
}
const propName = fieldNode.name.value
const entityName = parentName ? `${parentName}.${propName}` : propName
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
)
)
}
})
@@ -126,8 +127,9 @@ class GraphQLParser {
const rootFieldNode = queryDefinition.selectionSet
.selections[0] as FieldNode
const propName = rootFieldNode.name.value
const remoteJoinConfig: RemoteJoinerQuery = {
service: rootFieldNode.name.value,
alias: propName,
fields: [],
expands: [],
}
@@ -140,10 +142,11 @@ class GraphQLParser {
remoteJoinConfig.fields = rootFieldNode.selectionSet.selections.map(
(field) => (field as FieldNode).name.value
)
remoteJoinConfig.expands = this.extractEntities(
rootFieldNode.selectionSet,
rootFieldNode.name.value,
rootFieldNode.name.value
propName,
propName
)
}

View File

@@ -1,16 +1,16 @@
import {
JoinerRelationship,
JoinerServiceConfig,
JoinerServiceConfigAlias,
RemoteExpandProperty,
RemoteJoinerQuery,
RemoteNestedExpands,
} from "@medusajs/types"
import { isDefined, toPascalCase } from "@medusajs/utils"
import { isDefined } 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(
@@ -84,37 +84,77 @@ export class RemoteJoiner {
}
constructor(
serviceConfigs: JoinerServiceConfig[],
private serviceConfigs: JoinerServiceConfig[],
private remoteFetchData: (
expand: RemoteExpandProperty,
pkField: string,
keyField: string,
ids?: (unknown | unknown[])[],
relationship?: any
) => Promise<{
data: unknown[] | { [path: string]: unknown[] }
data: unknown[] | { [path: string]: unknown }
path?: string
}>
) {
this.serviceConfigs = this.buildReferences(serviceConfigs)
}
public setFetchDataCallback(
remoteFetchData: (
expand: RemoteExpandProperty,
keyField: string,
ids?: (unknown | unknown[])[],
relationship?: any
) => Promise<{
data: unknown[] | { [path: string]: unknown }
path?: string
}>
): void {
this.remoteFetchData = remoteFetchData
}
private buildReferences(serviceConfigs: JoinerServiceConfig[]) {
const expandedRelationships: Map<string, JoinerRelationship[]> = new Map()
for (const service of serviceConfigs) {
// self-reference
const propName = service.serviceName.toLowerCase()
if (this.serviceConfigCache.has(service.serviceName)) {
throw new Error(`Service "${service.serviceName}" is already defined.`)
}
if (!service.relationships) {
service.relationships = []
}
service.relationships?.push({
alias: propName,
foreignKey: propName + "_id",
primaryKey: "id",
serviceName: service.serviceName,
})
// add aliases
if (!service.alias) {
service.alias = [{ name: service.serviceName.toLowerCase() }]
} else if (!Array.isArray(service.alias)) {
service.alias = [service.alias]
}
this.serviceConfigCache.set(service.serviceName, service)
// self-reference
for (const alias of service.alias) {
if (this.serviceConfigCache.has(`alias_${alias.name}}`)) {
const defined = this.serviceConfigCache.get(`alias_${alias.name}}`)
throw new Error(
`Cannot add alias "${alias.name}" for "${service.serviceName}". It is already defined for Service "${defined?.serviceName}".`
)
}
const args =
service.args || alias.args
? { ...service.args, ...alias.args }
: undefined
service.relationships?.push({
alias: alias.name,
foreignKey: alias.name + "_id",
primaryKey: "id",
serviceName: service.serviceName,
args,
})
this.cacheServiceConfig(serviceConfigs, undefined, alias.name)
}
this.cacheServiceConfig(serviceConfigs, service.serviceName)
if (!service.extends) {
continue
@@ -125,13 +165,13 @@ export class RemoteJoiner {
expandedRelationships.set(extend.serviceName, [])
}
expandedRelationships.get(extend.serviceName)!.push(extend.resolve)
expandedRelationships.get(extend.serviceName)!.push(extend.relationship)
}
}
for (const [serviceName, relationships] of expandedRelationships) {
if (!this.serviceConfigCache.has(serviceName)) {
throw new Error(`Service ${serviceName} not found`)
throw new Error(`Service "${serviceName}" was not found`)
}
const service = this.serviceConfigCache.get(serviceName)
@@ -141,16 +181,49 @@ export class RemoteJoiner {
return serviceConfigs
}
private findServiceConfig(
serviceName: string
private getServiceConfig(
serviceName?: string,
serviceAlias?: string
): JoinerServiceConfig | undefined {
if (!this.serviceConfigCache.has(serviceName)) {
const config = this.serviceConfigs.find(
(config) => config.serviceName === serviceName
)
this.serviceConfigCache.set(serviceName, config!)
if (serviceAlias) {
const name = `alias_${serviceAlias}`
return this.serviceConfigCache.get(name)
}
return this.serviceConfigCache.get(serviceName)
return this.serviceConfigCache.get(serviceName!)
}
private cacheServiceConfig(
serviceConfigs,
serviceName?: string,
serviceAlias?: string
): void {
if (serviceAlias) {
const name = `alias_${serviceAlias}`
if (!this.serviceConfigCache.has(name)) {
let aliasConfig: JoinerServiceConfigAlias | undefined
const config = serviceConfigs.find((conf) => {
const aliases = conf.alias as JoinerServiceConfigAlias[]
const hasArgs = aliases?.find((alias) => alias.name === serviceAlias)
aliasConfig = hasArgs
return hasArgs
})
if (config) {
const serviceConfig = { ...config }
if (aliasConfig) {
serviceConfig.args = { ...config?.args, ...aliasConfig?.args }
}
this.serviceConfigCache.set(name, serviceConfig)
}
}
return
}
const config = serviceConfigs.find(
(config) => config.serviceName === serviceName
)
this.serviceConfigCache.set(serviceName!, config)
}
private async fetchData(
@@ -159,7 +232,7 @@ export class RemoteJoiner {
ids?: (unknown | unknown[])[],
relationship?: any
): Promise<{
data: unknown[] | { [path: string]: unknown[] }
data: unknown[] | { [path: string]: unknown }
path?: string
}> {
let uniqueIds = Array.isArray(ids) ? ids : ids ? [ids] : undefined
@@ -218,7 +291,7 @@ export class RemoteJoiner {
const stack: [
any[],
RemoteJoinerQuery,
Partial<RemoteJoinerQuery>,
Map<string, RemoteExpandProperty>,
string,
Set<string>
@@ -245,23 +318,25 @@ export class RemoteJoiner {
resolvedPaths.add(expandedPath)
const property = expand.property || ""
const parentServiceConfig = this.findServiceConfig(currentQuery.service)
const parentServiceConfig = this.getServiceConfig(
currentQuery.service,
currentQuery.alias
)
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
const relationship = expand.serviceConfig
let nextProp = currentQuery
if (relationship) {
const relQuery = {
service: relationship.serviceName,
}
nextProp = relQuery
}
stack.push([
nestedItems,
@@ -356,9 +431,19 @@ export class RemoteJoiner {
if (Array.isArray(item[field])) {
item[relationship.alias] = item[field]
.map((id) => relatedDataMap[id])
.map((id) => {
if (relationship.isList && !Array.isArray(relatedDataMap[id])) {
relatedDataMap[id] = [relatedDataMap[id]]
}
return relatedDataMap[id]
})
.filter((relatedItem) => relatedItem !== undefined)
} else {
if (relationship.isList && !Array.isArray(relatedDataMap[itemKey])) {
relatedDataMap[itemKey] = [relatedDataMap[itemKey]]
}
item[relationship.alias] = relatedDataMap[itemKey]
}
}
@@ -439,9 +524,7 @@ export class RemoteJoiner {
}
}
currentServiceConfig = this.findServiceConfig(
relationship.serviceName
)
currentServiceConfig = this.getServiceConfig(relationship.serviceName)
if (!currentServiceConfig) {
throw new Error(
@@ -522,11 +605,17 @@ export class RemoteJoiner {
}
async query(queryObj: RemoteJoinerQuery): Promise<any> {
queryObj.service = toPascalCase(queryObj.service)
const serviceConfig = this.findServiceConfig(queryObj.service)
const serviceConfig = this.getServiceConfig(
queryObj.service,
queryObj.alias
)
if (!serviceConfig) {
throw new Error(`Service not found: ${queryObj.service}`)
if (queryObj.alias) {
throw new Error(`Service with alias "${queryObj.alias}" was not found.`)
}
throw new Error(`Service "${queryObj.service}" was not found.`)
}
let pkName = serviceConfig.primaryKeys[0]