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:
Carlos R. L. Rodrigues
2023-08-30 11:31:32 -03:00
committed by GitHub
parent bc4c9e0d32
commit 4d16acf5f0
97 changed files with 3540 additions and 424 deletions

View File

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

View File

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

View File

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