feat(link-modules,modules-sdk, utils, types, products) - Remote Link and Link modules (#4695)
What:
- Definition of all Modules links
- `link-modules` package to manage the creation of all pre-defined link or custom ones
```typescript
import { initialize as iniInventory } from "@medusajs/inventory";
import { initialize as iniProduct } from "@medusajs/product";
import {
initialize as iniLinks,
runMigrations as migrateLinks
} from "@medusajs/link-modules";
await Promise.all([iniInventory(), iniProduct()]);
await migrateLinks(); // create tables based on previous loaded modules
await iniLinks(); // load link based on previous loaded modules
await iniLinks(undefined, [
{
serviceName: "product_custom_translation_service_link",
isLink: true,
databaseConfig: {
tableName: "product_transalations",
},
alias: [
{
name: "translations",
},
],
primaryKeys: ["id", "product_id", "translation_id"],
relationships: [
{
serviceName: Modules.PRODUCT,
primaryKey: "id",
foreignKey: "product_id",
alias: "product",
},
{
serviceName: "custom_translation_service",
primaryKey: "id",
foreignKey: "translation_id",
alias: "transalation",
deleteCascade: true,
},
],
extends: [
{
serviceName: Modules.PRODUCT,
relationship: {
serviceName: "product_custom_translation_service_link",
primaryKey: "product_id",
foreignKey: "id",
alias: "translations",
isList: true,
},
},
{
serviceName: "custom_translation_service",
relationship: {
serviceName: "product_custom_translation_service_link",
primaryKey: "product_id",
foreignKey: "id",
alias: "product_link",
},
},
],
},
]); // custom links
```
Remote Link
```typescript
import { RemoteLink, Modules } from "@medusajs/modules-sdk";
// [...] initialize modules and links
const remoteLink = new RemoteLink();
// upsert the relationship
await remoteLink.create({ // one (object) or many (array)
[Modules.PRODUCT]: {
variant_id: "var_abc",
},
[Modules.INVENTORY]: {
inventory_item_id: "iitem_abc",
},
data: { // optional additional fields
required_quantity: 5
}
});
// dismiss (doesn't cascade)
await remoteLink.dismiss({ // one (object) or many (array)
[Modules.PRODUCT]: {
variant_id: "var_abc",
},
[Modules.INVENTORY]: {
inventory_item_id: "iitem_abc",
},
});
// delete
await remoteLink.delete({
// every key is a module
[Modules.PRODUCT]: {
// every key is a linkable field
variant_id: "var_abc", // single or multiple values
},
});
// restore
await remoteLink.restore({
// every key is a module
[Modules.PRODUCT]: {
// every key is a linkable field
variant_id: "var_abc", // single or multiple values
},
});
```
Co-authored-by: Riqwan Thamir <5105988+riqwan@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
bc4c9e0d32
commit
4d16acf5f0
@@ -274,4 +274,85 @@ describe("RemoteJoiner.parseQuery", () => {
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("Nested query with fields and directives", async () => {
|
||||
const graphqlQuery = `
|
||||
query {
|
||||
order(regularArgs: 123) {
|
||||
id
|
||||
number @include(if: "date > '2020-01-01'")
|
||||
date
|
||||
products {
|
||||
product_id
|
||||
variant_id
|
||||
variant @count {
|
||||
name @lowerCase
|
||||
sku @include(if: "name == 'test'")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
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", "variant"],
|
||||
directives: {
|
||||
variant: [
|
||||
{
|
||||
name: "count",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
property: "products.variant",
|
||||
fields: ["name", "sku"],
|
||||
directives: {
|
||||
name: [
|
||||
{
|
||||
name: "lowerCase",
|
||||
},
|
||||
],
|
||||
sku: [
|
||||
{
|
||||
name: "include",
|
||||
args: [
|
||||
{
|
||||
name: "if",
|
||||
value: "name == 'test'",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
args: [
|
||||
{
|
||||
name: "regularArgs",
|
||||
value: 123,
|
||||
},
|
||||
],
|
||||
directives: {
|
||||
number: [
|
||||
{
|
||||
name: "include",
|
||||
args: [
|
||||
{
|
||||
name: "if",
|
||||
value: "date > '2020-01-01'",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RemoteJoinerQuery } from "@medusajs/types"
|
||||
import {
|
||||
ArgumentNode,
|
||||
DirectiveNode,
|
||||
DocumentNode,
|
||||
FieldNode,
|
||||
Kind,
|
||||
@@ -15,18 +16,24 @@ interface Argument {
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
interface Directive {
|
||||
name: string
|
||||
args?: Argument[]
|
||||
}
|
||||
|
||||
interface Entity {
|
||||
property: string
|
||||
fields: string[]
|
||||
args?: Argument[]
|
||||
directives?: { [field: string]: Directive[] }
|
||||
}
|
||||
|
||||
class GraphQLParser {
|
||||
private ast: DocumentNode
|
||||
|
||||
constructor(input: string, private variables?: { [key: string]: unknown }) {
|
||||
constructor(input: string, private variables: Record<string, unknown> = {}) {
|
||||
this.ast = parse(input)
|
||||
this.variables = variables || {}
|
||||
this.variables = variables
|
||||
}
|
||||
|
||||
private parseValueNode(valueNode: ValueNode): unknown {
|
||||
@@ -75,6 +82,33 @@ class GraphQLParser {
|
||||
})
|
||||
}
|
||||
|
||||
private parseDirectives(directives: readonly DirectiveNode[]): Directive[] {
|
||||
return directives.map((directive) => ({
|
||||
name: directive.name.value,
|
||||
args: this.parseArguments(directive.arguments || []),
|
||||
}))
|
||||
}
|
||||
|
||||
private createDirectivesMap(selectionSet: SelectionSetNode):
|
||||
| {
|
||||
[field: string]: Directive[]
|
||||
}
|
||||
| undefined {
|
||||
const directivesMap: { [field: string]: Directive[] } = {}
|
||||
let hasDirectives = false
|
||||
selectionSet.selections.forEach((field) => {
|
||||
const fieldName = (field as FieldNode).name.value
|
||||
const fieldDirectives = this.parseDirectives(
|
||||
(field as FieldNode).directives || []
|
||||
)
|
||||
if (fieldDirectives.length > 0) {
|
||||
hasDirectives = true
|
||||
directivesMap[fieldName] = fieldDirectives
|
||||
}
|
||||
})
|
||||
return hasDirectives ? directivesMap : undefined
|
||||
}
|
||||
|
||||
private extractEntities(
|
||||
node: SelectionSetNode,
|
||||
parentName = "",
|
||||
@@ -98,7 +132,8 @@ class GraphQLParser {
|
||||
fields: fieldNode.selectionSet.selections.map(
|
||||
(field) => (field as FieldNode).name.value
|
||||
),
|
||||
args: this.parseArguments(fieldNode.arguments!),
|
||||
args: this.parseArguments(fieldNode.arguments || []),
|
||||
directives: this.createDirectivesMap(fieldNode.selectionSet),
|
||||
}
|
||||
|
||||
entities.push(nestedEntity)
|
||||
@@ -126,8 +161,8 @@ class GraphQLParser {
|
||||
|
||||
const rootFieldNode = queryDefinition.selectionSet
|
||||
.selections[0] as FieldNode
|
||||
|
||||
const propName = rootFieldNode.name.value
|
||||
|
||||
const remoteJoinConfig: RemoteJoinerQuery = {
|
||||
alias: propName,
|
||||
fields: [],
|
||||
@@ -142,7 +177,9 @@ class GraphQLParser {
|
||||
remoteJoinConfig.fields = rootFieldNode.selectionSet.selections.map(
|
||||
(field) => (field as FieldNode).name.value
|
||||
)
|
||||
|
||||
remoteJoinConfig.directives = this.createDirectivesMap(
|
||||
rootFieldNode.selectionSet
|
||||
)
|
||||
remoteJoinConfig.expands = this.extractEntities(
|
||||
rootFieldNode.selectionSet,
|
||||
propName,
|
||||
|
||||
@@ -2,14 +2,27 @@ import {
|
||||
JoinerRelationship,
|
||||
JoinerServiceConfig,
|
||||
JoinerServiceConfigAlias,
|
||||
ModuleJoinerConfig,
|
||||
RemoteExpandProperty,
|
||||
RemoteJoinerQuery,
|
||||
RemoteNestedExpands,
|
||||
} from "@medusajs/types"
|
||||
|
||||
import { isDefined } from "@medusajs/utils"
|
||||
import GraphQLParser from "./graphql-ast"
|
||||
|
||||
const BASE_PATH = "_root"
|
||||
|
||||
export type RemoteFetchDataCallback = (
|
||||
expand: RemoteExpandProperty,
|
||||
keyField: string,
|
||||
ids?: (unknown | unknown[])[],
|
||||
relationship?: any
|
||||
) => Promise<{
|
||||
data: unknown[] | { [path: string]: unknown }
|
||||
path?: string
|
||||
}>
|
||||
|
||||
export class RemoteJoiner {
|
||||
private serviceConfigCache: Map<string, JoinerServiceConfig> = new Map()
|
||||
|
||||
@@ -18,7 +31,7 @@ export class RemoteJoiner {
|
||||
fields: string[],
|
||||
expands?: RemoteNestedExpands
|
||||
): Record<string, unknown> {
|
||||
if (!fields) {
|
||||
if (!fields || !data) {
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -78,44 +91,29 @@ export class RemoteJoiner {
|
||||
}, {})
|
||||
}
|
||||
|
||||
static parseQuery(graphqlQuery: string, variables?: any): RemoteJoinerQuery {
|
||||
static parseQuery(
|
||||
graphqlQuery: string,
|
||||
variables?: Record<string, unknown>
|
||||
): RemoteJoinerQuery {
|
||||
const parser = new GraphQLParser(graphqlQuery, variables)
|
||||
return parser.parseQuery()
|
||||
}
|
||||
|
||||
constructor(
|
||||
private serviceConfigs: JoinerServiceConfig[],
|
||||
private remoteFetchData: (
|
||||
expand: RemoteExpandProperty,
|
||||
keyField: string,
|
||||
ids?: (unknown | unknown[])[],
|
||||
relationship?: any
|
||||
) => Promise<{
|
||||
data: unknown[] | { [path: string]: unknown }
|
||||
path?: string
|
||||
}>
|
||||
private serviceConfigs: ModuleJoinerConfig[],
|
||||
private remoteFetchData: RemoteFetchDataCallback
|
||||
) {
|
||||
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 {
|
||||
public setFetchDataCallback(remoteFetchData: RemoteFetchDataCallback): void {
|
||||
this.remoteFetchData = remoteFetchData
|
||||
}
|
||||
|
||||
private buildReferences(serviceConfigs: JoinerServiceConfig[]) {
|
||||
private buildReferences(serviceConfigs: ModuleJoinerConfig[]) {
|
||||
const expandedRelationships: Map<string, JoinerRelationship[]> = new Map()
|
||||
for (const service of serviceConfigs) {
|
||||
if (this.serviceConfigCache.has(service.serviceName)) {
|
||||
if (this.serviceConfigCache.has(service.serviceName!)) {
|
||||
throw new Error(`Service "${service.serviceName}" is already defined.`)
|
||||
}
|
||||
|
||||
@@ -124,38 +122,42 @@ export class RemoteJoiner {
|
||||
}
|
||||
|
||||
// add aliases
|
||||
if (!service.alias) {
|
||||
service.alias = [{ name: service.serviceName.toLowerCase() }]
|
||||
} else if (!Array.isArray(service.alias)) {
|
||||
service.alias = [service.alias]
|
||||
}
|
||||
|
||||
// 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 isReadOnlyDefinition =
|
||||
service.serviceName === undefined || service.isReadOnlyLink
|
||||
if (!isReadOnlyDefinition) {
|
||||
if (!service.alias) {
|
||||
service.alias = [{ name: service.serviceName!.toLowerCase() }]
|
||||
} else if (!Array.isArray(service.alias)) {
|
||||
service.alias = [service.alias]
|
||||
}
|
||||
|
||||
const args =
|
||||
service.args || alias.args
|
||||
? { ...service.args, ...alias.args }
|
||||
: undefined
|
||||
// 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}".`
|
||||
)
|
||||
}
|
||||
|
||||
service.relationships?.push({
|
||||
alias: alias.name,
|
||||
foreignKey: alias.name + "_id",
|
||||
primaryKey: "id",
|
||||
serviceName: service.serviceName,
|
||||
args,
|
||||
})
|
||||
this.cacheServiceConfig(serviceConfigs, undefined, alias.name)
|
||||
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)
|
||||
}
|
||||
|
||||
this.cacheServiceConfig(serviceConfigs, service.serviceName)
|
||||
|
||||
if (!service.extends) {
|
||||
continue
|
||||
}
|
||||
@@ -295,7 +297,7 @@ export class RemoteJoiner {
|
||||
Map<string, RemoteExpandProperty>,
|
||||
string,
|
||||
Set<string>
|
||||
][] = [[items, query, parsedExpands, "", new Set()]]
|
||||
][] = [[items, query, parsedExpands, BASE_PATH, new Set()]]
|
||||
|
||||
while (stack.length > 0) {
|
||||
const [
|
||||
@@ -307,9 +309,7 @@ export class RemoteJoiner {
|
||||
] = stack.pop()!
|
||||
|
||||
for (const [expandedPath, expand] of currentParsedExpands.entries()) {
|
||||
const isImmediateChildPath =
|
||||
expandedPath.startsWith(basePath) &&
|
||||
expandedPath.split(".").length === basePath.split(".").length + 1
|
||||
const isImmediateChildPath = basePath === expand.parent
|
||||
|
||||
if (!isImmediateChildPath || resolvedPaths.has(expandedPath)) {
|
||||
continue
|
||||
@@ -433,7 +433,8 @@ export class RemoteJoiner {
|
||||
item[relationship.alias] = item[field]
|
||||
.map((id) => {
|
||||
if (relationship.isList && !Array.isArray(relatedDataMap[id])) {
|
||||
relatedDataMap[id] = [relatedDataMap[id]]
|
||||
relatedDataMap[id] =
|
||||
relatedDataMap[id] !== undefined ? [relatedDataMap[id]] : []
|
||||
}
|
||||
|
||||
return relatedDataMap[id]
|
||||
@@ -441,7 +442,10 @@ export class RemoteJoiner {
|
||||
.filter((relatedItem) => relatedItem !== undefined)
|
||||
} else {
|
||||
if (relationship.isList && !Array.isArray(relatedDataMap[itemKey])) {
|
||||
relatedDataMap[itemKey] = [relatedDataMap[itemKey]]
|
||||
relatedDataMap[itemKey] =
|
||||
relatedDataMap[itemKey] !== undefined
|
||||
? [relatedDataMap[itemKey]]
|
||||
: []
|
||||
}
|
||||
|
||||
item[relationship.alias] = relatedDataMap[itemKey]
|
||||
@@ -539,13 +543,13 @@ export class RemoteJoiner {
|
||||
serviceConfig: currentServiceConfig,
|
||||
fields,
|
||||
args,
|
||||
parent: [BASE_PATH, ...currentPath].join("."),
|
||||
})
|
||||
}
|
||||
|
||||
currentPath.push(prop)
|
||||
}
|
||||
}
|
||||
|
||||
return parsedExpands
|
||||
}
|
||||
|
||||
@@ -566,7 +570,7 @@ export class RemoteJoiner {
|
||||
for (const [path, expand] of sortedParsedExpands.entries()) {
|
||||
const currentServiceName = expand.serviceConfig.serviceName
|
||||
|
||||
let parentPath = path.split(".").slice(0, -1).join(".")
|
||||
let parentPath = expand.parent
|
||||
|
||||
// Check if the parentPath was merged before
|
||||
while (mergedPaths.has(parentPath)) {
|
||||
@@ -580,6 +584,7 @@ export class RemoteJoiner {
|
||||
|
||||
if (parentExpand.serviceConfig.serviceName === currentServiceName) {
|
||||
const nestedKeys = path.split(".").slice(parentPath.split(".").length)
|
||||
|
||||
let targetExpand: any = parentExpand
|
||||
|
||||
for (let key of nestedKeys) {
|
||||
@@ -633,6 +638,7 @@ export class RemoteJoiner {
|
||||
const parsedExpands = this.parseExpands(
|
||||
{
|
||||
property: "",
|
||||
parent: "",
|
||||
serviceConfig: serviceConfig,
|
||||
fields: queryObj.fields,
|
||||
args: otherArgs,
|
||||
|
||||
Reference in New Issue
Block a user