feat: add check for uniqueness when creating links with isList=false (#11767)
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/link-modules": patch
|
||||||
|
"@medusajs/modules-sdk": patch
|
||||||
|
"@medusajs/utils": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat: add check for uniqueness when creating links with isList=false
|
||||||
@@ -46,6 +46,7 @@ medusaIntegrationTestRunner({
|
|||||||
entity: "Currency",
|
entity: "Currency",
|
||||||
primaryKey: "code",
|
primaryKey: "code",
|
||||||
foreignKey: "currency_code",
|
foreignKey: "currency_code",
|
||||||
|
isList: true,
|
||||||
alias: "currency",
|
alias: "currency",
|
||||||
args: {
|
args: {
|
||||||
methodSuffix: "Currencies",
|
methodSuffix: "Currencies",
|
||||||
@@ -58,6 +59,7 @@ medusaIntegrationTestRunner({
|
|||||||
primaryKey: "id",
|
primaryKey: "id",
|
||||||
foreignKey: "region_id",
|
foreignKey: "region_id",
|
||||||
alias: "region",
|
alias: "region",
|
||||||
|
isList: false,
|
||||||
args: {
|
args: {
|
||||||
methodSuffix: "Regions",
|
methodSuffix: "Regions",
|
||||||
},
|
},
|
||||||
@@ -88,9 +90,9 @@ medusaIntegrationTestRunner({
|
|||||||
serviceName: "region",
|
serviceName: "region",
|
||||||
entity: "Region",
|
entity: "Region",
|
||||||
fieldAlias: {
|
fieldAlias: {
|
||||||
currency: {
|
currencies: {
|
||||||
path: "currency_link.currency",
|
path: "currency_link.currency",
|
||||||
isList: false,
|
isList: true,
|
||||||
forwardArgumentsOnPath: ["currency_link.currency"],
|
forwardArgumentsOnPath: ["currency_link.currency"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -100,7 +102,7 @@ medusaIntegrationTestRunner({
|
|||||||
primaryKey: "region_id",
|
primaryKey: "region_id",
|
||||||
foreignKey: "id",
|
foreignKey: "id",
|
||||||
alias: "currency_link",
|
alias: "currency_link",
|
||||||
isList: false,
|
isList: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -145,6 +147,7 @@ medusaIntegrationTestRunner({
|
|||||||
entity: "ProductVariant",
|
entity: "ProductVariant",
|
||||||
primaryKey: "id",
|
primaryKey: "id",
|
||||||
foreignKey: "product_variant_id",
|
foreignKey: "product_variant_id",
|
||||||
|
isList: true,
|
||||||
alias: "product_variant",
|
alias: "product_variant",
|
||||||
args: {
|
args: {
|
||||||
methodSuffix: "ProductVariants",
|
methodSuffix: "ProductVariants",
|
||||||
@@ -156,6 +159,7 @@ medusaIntegrationTestRunner({
|
|||||||
entity: "Region",
|
entity: "Region",
|
||||||
primaryKey: "id",
|
primaryKey: "id",
|
||||||
foreignKey: "region_id",
|
foreignKey: "region_id",
|
||||||
|
isList: false,
|
||||||
alias: "region",
|
alias: "region",
|
||||||
args: {
|
args: {
|
||||||
methodSuffix: "Regions",
|
methodSuffix: "Regions",
|
||||||
@@ -187,9 +191,9 @@ medusaIntegrationTestRunner({
|
|||||||
serviceName: "region",
|
serviceName: "region",
|
||||||
entity: "Region",
|
entity: "Region",
|
||||||
fieldAlias: {
|
fieldAlias: {
|
||||||
product_variant: {
|
product_variants: {
|
||||||
path: "product_variant_link.product_variant",
|
path: "product_variant_link.product_variant",
|
||||||
isList: false,
|
isList: true,
|
||||||
forwardArgumentsOnPath: [
|
forwardArgumentsOnPath: [
|
||||||
"product_variant_link.product_variant",
|
"product_variant_link.product_variant",
|
||||||
],
|
],
|
||||||
@@ -201,7 +205,7 @@ medusaIntegrationTestRunner({
|
|||||||
primaryKey: "region_id",
|
primaryKey: "region_id",
|
||||||
foreignKey: "id",
|
foreignKey: "id",
|
||||||
alias: "product_variant_link",
|
alias: "product_variant_link",
|
||||||
isList: false,
|
isList: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -249,6 +253,7 @@ medusaIntegrationTestRunner({
|
|||||||
entity: "Currency",
|
entity: "Currency",
|
||||||
primaryKey: "code",
|
primaryKey: "code",
|
||||||
foreignKey: "currency_code",
|
foreignKey: "currency_code",
|
||||||
|
isList: true,
|
||||||
alias: "currency",
|
alias: "currency",
|
||||||
args: {
|
args: {
|
||||||
methodSuffix: "Currencies",
|
methodSuffix: "Currencies",
|
||||||
@@ -260,6 +265,7 @@ medusaIntegrationTestRunner({
|
|||||||
entity: "Region",
|
entity: "Region",
|
||||||
primaryKey: "id",
|
primaryKey: "id",
|
||||||
foreignKey: "region_id",
|
foreignKey: "region_id",
|
||||||
|
isList: false,
|
||||||
alias: "region",
|
alias: "region",
|
||||||
args: {
|
args: {
|
||||||
methodSuffix: "Regions",
|
methodSuffix: "Regions",
|
||||||
@@ -291,9 +297,9 @@ medusaIntegrationTestRunner({
|
|||||||
serviceName: "region",
|
serviceName: "region",
|
||||||
entity: "Region",
|
entity: "Region",
|
||||||
fieldAlias: {
|
fieldAlias: {
|
||||||
currency: {
|
currencies: {
|
||||||
path: "currency_link.currency",
|
path: "currency_link.currency",
|
||||||
isList: false,
|
isList: true,
|
||||||
forwardArgumentsOnPath: ["currency_link.currency"],
|
forwardArgumentsOnPath: ["currency_link.currency"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -303,7 +309,7 @@ medusaIntegrationTestRunner({
|
|||||||
primaryKey: "region_id",
|
primaryKey: "region_id",
|
||||||
foreignKey: "id",
|
foreignKey: "id",
|
||||||
alias: "currency_link",
|
alias: "currency_link",
|
||||||
isList: false,
|
isList: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -347,6 +353,7 @@ medusaIntegrationTestRunner({
|
|||||||
entity: "Currency",
|
entity: "Currency",
|
||||||
primaryKey: "code",
|
primaryKey: "code",
|
||||||
foreignKey: "currency_code",
|
foreignKey: "currency_code",
|
||||||
|
isList: true,
|
||||||
alias: "currency",
|
alias: "currency",
|
||||||
args: {
|
args: {
|
||||||
methodSuffix: "Currencies",
|
methodSuffix: "Currencies",
|
||||||
@@ -358,6 +365,7 @@ medusaIntegrationTestRunner({
|
|||||||
entity: "Region",
|
entity: "Region",
|
||||||
primaryKey: "id",
|
primaryKey: "id",
|
||||||
foreignKey: "region_id",
|
foreignKey: "region_id",
|
||||||
|
isList: true,
|
||||||
alias: "region",
|
alias: "region",
|
||||||
args: {
|
args: {
|
||||||
methodSuffix: "Regions",
|
methodSuffix: "Regions",
|
||||||
@@ -389,9 +397,9 @@ medusaIntegrationTestRunner({
|
|||||||
serviceName: "region",
|
serviceName: "region",
|
||||||
entity: "Region",
|
entity: "Region",
|
||||||
fieldAlias: {
|
fieldAlias: {
|
||||||
currency: {
|
currencies: {
|
||||||
path: "currency_link.currency",
|
path: "currency_link.currency",
|
||||||
isList: false,
|
isList: true,
|
||||||
forwardArgumentsOnPath: ["currency_link.currency"],
|
forwardArgumentsOnPath: ["currency_link.currency"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -401,7 +409,7 @@ medusaIntegrationTestRunner({
|
|||||||
primaryKey: "region_id",
|
primaryKey: "region_id",
|
||||||
foreignKey: "id",
|
foreignKey: "id",
|
||||||
alias: "currency_link",
|
alias: "currency_link",
|
||||||
isList: false,
|
isList: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import {
|
|||||||
ModuleJoinerRelationship,
|
ModuleJoinerRelationship,
|
||||||
} from "@medusajs/types"
|
} from "@medusajs/types"
|
||||||
|
|
||||||
import { isObject, Modules, promiseAll, toPascalCase } from "@medusajs/utils"
|
import {
|
||||||
|
isObject,
|
||||||
|
MedusaError,
|
||||||
|
Modules,
|
||||||
|
promiseAll,
|
||||||
|
toPascalCase,
|
||||||
|
} from "@medusajs/utils"
|
||||||
import { MedusaModule } from "./medusa-module"
|
import { MedusaModule } from "./medusa-module"
|
||||||
import { convertRecordsToLinkDefinition } from "./utils/convert-data-to-link-definition"
|
import { convertRecordsToLinkDefinition } from "./utils/convert-data-to-link-definition"
|
||||||
import { linkingErrorMessage } from "./utils/linking-error"
|
import { linkingErrorMessage } from "./utils/linking-error"
|
||||||
@@ -380,18 +386,88 @@ export class Link {
|
|||||||
const allLinks = Array.isArray(link) ? link : [link]
|
const allLinks = Array.isArray(link) ? link : [link]
|
||||||
const serviceLinks = new Map<
|
const serviceLinks = new Map<
|
||||||
string,
|
string,
|
||||||
[string | string[], string, Record<string, unknown>?][]
|
{
|
||||||
|
linksToCreate: [string | string[], string, Record<string, unknown>?][]
|
||||||
|
linksToValidateForUniqueness: {
|
||||||
|
filters: { [key: string]: string }[]
|
||||||
|
services: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
>()
|
>()
|
||||||
|
|
||||||
for (const link of allLinks) {
|
for (const link of allLinks) {
|
||||||
const service = this.getLinkModuleOrThrow(link)
|
const service = this.getLinkModuleOrThrow(link)
|
||||||
|
const relationships = service.__joinerConfig.relationships
|
||||||
const { moduleA, moduleB, moduleBKey, primaryKeys } =
|
const { moduleA, moduleB, moduleBKey, primaryKeys } =
|
||||||
this.getLinkDataConfig(link)
|
this.getLinkDataConfig(link)
|
||||||
|
|
||||||
if (!serviceLinks.has(service.__definition.key)) {
|
if (!serviceLinks.has(service.__definition.key)) {
|
||||||
serviceLinks.set(service.__definition.key, [])
|
serviceLinks.set(service.__definition.key, {
|
||||||
|
/**
|
||||||
|
* Tuple of foreign key and the primary keys that must be
|
||||||
|
* persisted to the pivot table for representing the
|
||||||
|
* link
|
||||||
|
*/
|
||||||
|
linksToCreate: [],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An array of objects to validate for uniqueness before persisting
|
||||||
|
* data to the pivot table. When a link uses "isList: false", we
|
||||||
|
* have to limit a relationship with this entity to be a one-to-one
|
||||||
|
* or one-to-many
|
||||||
|
*/
|
||||||
|
linksToValidateForUniqueness: {
|
||||||
|
filters: [],
|
||||||
|
services: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
relationships?.forEach((relationship) => {
|
||||||
|
const linksToValidateForUniqueness = serviceLinks.get(
|
||||||
|
service.__definition.key
|
||||||
|
)!.linksToValidateForUniqueness!
|
||||||
|
|
||||||
|
linksToValidateForUniqueness.services.push(relationship.serviceName)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When isList is set on false on the relationship, then it means
|
||||||
|
* we have a one-to-one or many-to-one relationship with the
|
||||||
|
* other side and we have limit duplicate entries from other
|
||||||
|
* entity. For example:
|
||||||
|
*
|
||||||
|
* - A brand has a many to one relationship with a product.
|
||||||
|
* - A product can have only one brand. Aka (brand.isList = false)
|
||||||
|
* - A brand can have multiple products. Aka (products.isList = true)
|
||||||
|
*
|
||||||
|
* A result of this, we have to ensure that a product_id can only appear
|
||||||
|
* once in the pivot table that is used for tracking "brand<>products"
|
||||||
|
* relationship.
|
||||||
|
*/
|
||||||
|
if (relationship.isList === false) {
|
||||||
|
const otherSide = relationships.find(
|
||||||
|
(other) => other.foreignKey !== relationship.foreignKey
|
||||||
|
)
|
||||||
|
if (!otherSide) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moduleBKey === otherSide.foreignKey) {
|
||||||
|
linksToValidateForUniqueness.filters.push({
|
||||||
|
[otherSide.foreignKey]: link[moduleB][moduleBKey],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
primaryKeys.forEach((pk) => {
|
||||||
|
if (pk === otherSide.foreignKey) {
|
||||||
|
linksToValidateForUniqueness.filters.push({
|
||||||
|
[otherSide.foreignKey]: link[moduleA][pk],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const pkValue =
|
const pkValue =
|
||||||
primaryKeys.length === 1
|
primaryKeys.length === 1
|
||||||
? link[moduleA][primaryKeys[0]]
|
? link[moduleA][primaryKeys[0]]
|
||||||
@@ -403,15 +479,39 @@ export class Link {
|
|||||||
fields.push(link.data)
|
fields.push(link.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceLinks.get(service.__definition.key)?.push(fields as any)
|
serviceLinks
|
||||||
|
.get(service.__definition.key)
|
||||||
|
?.linksToCreate.push(fields as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [serviceName, data] of serviceLinks) {
|
||||||
|
if (data.linksToValidateForUniqueness.filters.length) {
|
||||||
|
const service = this.modulesMap.get(serviceName)!
|
||||||
|
const existingLinks = await service.list(
|
||||||
|
{
|
||||||
|
$or: data.linksToValidateForUniqueness.filters,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
take: 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingLinks.length > 0) {
|
||||||
|
const serviceA = data.linksToValidateForUniqueness.services[0]
|
||||||
|
const serviceB = data.linksToValidateForUniqueness.services[1]
|
||||||
|
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
`Cannot create multiple links between '${serviceA}' and '${serviceB}'`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const promises: Promise<unknown[]>[] = []
|
const promises: Promise<unknown[]>[] = []
|
||||||
|
for (const [serviceName, data] of serviceLinks) {
|
||||||
for (const [serviceName, links] of serviceLinks) {
|
|
||||||
const service = this.modulesMap.get(serviceName)!
|
const service = this.modulesMap.get(serviceName)!
|
||||||
|
promises.push(service.create(data.linksToCreate))
|
||||||
promises.push(service.create(links))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await promiseAll(promises)).flat()
|
return (await promiseAll(promises)).flat()
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ function buildFieldAlias(fieldAliases?: Shortcut | Shortcut[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function prepareServiceConfig(
|
function prepareServiceConfig(
|
||||||
input: DefineLinkInputSource | DefineReadOnlyLinkInputSource
|
input: DefineLinkInputSource | DefineReadOnlyLinkInputSource,
|
||||||
|
defaultOptions?: { isList?: boolean }
|
||||||
) {
|
) {
|
||||||
let serviceConfig = {} as ModuleLinkableKeyConfig
|
let serviceConfig = {} as ModuleLinkableKeyConfig
|
||||||
|
|
||||||
@@ -137,7 +138,7 @@ function prepareServiceConfig(
|
|||||||
alias: source.alias ?? camelToSnakeCase(source.field ?? ""),
|
alias: source.alias ?? camelToSnakeCase(source.field ?? ""),
|
||||||
field: input.field ?? source.field,
|
field: input.field ?? source.field,
|
||||||
primaryKey: source.primaryKey,
|
primaryKey: source.primaryKey,
|
||||||
isList: false,
|
isList: defaultOptions?.isList ?? false,
|
||||||
deleteCascade: false,
|
deleteCascade: false,
|
||||||
module: source.serviceName,
|
module: source.serviceName,
|
||||||
entity: source.entity,
|
entity: source.entity,
|
||||||
@@ -152,7 +153,7 @@ function prepareServiceConfig(
|
|||||||
alias: source.alias ?? camelToSnakeCase(source.field ?? ""),
|
alias: source.alias ?? camelToSnakeCase(source.field ?? ""),
|
||||||
field: input.field ?? source.field,
|
field: input.field ?? source.field,
|
||||||
primaryKey: source.primaryKey,
|
primaryKey: source.primaryKey,
|
||||||
isList: input.isList ?? false,
|
isList: input.isList ?? defaultOptions?.isList ?? false,
|
||||||
deleteCascade: input.deleteCascade ?? false,
|
deleteCascade: input.deleteCascade ?? false,
|
||||||
module: source.serviceName,
|
module: source.serviceName,
|
||||||
entity: source.entity,
|
entity: source.entity,
|
||||||
@@ -183,8 +184,8 @@ export function defineLink(
|
|||||||
rightService: DefineLinkInputSource | DefineReadOnlyLinkInputSource,
|
rightService: DefineLinkInputSource | DefineReadOnlyLinkInputSource,
|
||||||
linkServiceOptions?: ExtraOptions | ReadOnlyExtraOptions
|
linkServiceOptions?: ExtraOptions | ReadOnlyExtraOptions
|
||||||
): DefineLinkExport {
|
): DefineLinkExport {
|
||||||
const serviceAObj = prepareServiceConfig(leftService)
|
const serviceAObj = prepareServiceConfig(leftService, { isList: true })
|
||||||
const serviceBObj = prepareServiceConfig(rightService)
|
const serviceBObj = prepareServiceConfig(rightService, { isList: false })
|
||||||
|
|
||||||
if (linkServiceOptions?.readOnly) {
|
if (linkServiceOptions?.readOnly) {
|
||||||
return defineReadOnlyLink(
|
return defineReadOnlyLink(
|
||||||
@@ -373,6 +374,7 @@ ${serviceBObj.module}: {
|
|||||||
methodSuffix: serviceAMethodSuffix,
|
methodSuffix: serviceAMethodSuffix,
|
||||||
},
|
},
|
||||||
deleteCascade: serviceAObj.deleteCascade,
|
deleteCascade: serviceAObj.deleteCascade,
|
||||||
|
isList: serviceAObj.isList,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
serviceName: serviceBObj.module,
|
serviceName: serviceBObj.module,
|
||||||
@@ -384,6 +386,7 @@ ${serviceBObj.module}: {
|
|||||||
methodSuffix: serviceBMethodSuffix,
|
methodSuffix: serviceBMethodSuffix,
|
||||||
},
|
},
|
||||||
deleteCascade: serviceBObj.deleteCascade,
|
deleteCascade: serviceBObj.deleteCascade,
|
||||||
|
isList: serviceBObj.isList,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
extends: [
|
extends: [
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import {
|
|||||||
InjectManager,
|
InjectManager,
|
||||||
InjectTransactionManager,
|
InjectTransactionManager,
|
||||||
MedusaContext,
|
MedusaContext,
|
||||||
|
MikroOrmBaseRepository,
|
||||||
ModulesSdkUtils,
|
ModulesSdkUtils,
|
||||||
} from "@medusajs/framework/utils"
|
} from "@medusajs/framework/utils"
|
||||||
|
|
||||||
type InjectedDependencies = {
|
type InjectedDependencies = {
|
||||||
linkRepository: any
|
linkRepository: MikroOrmBaseRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class LinkService<TEntity> {
|
export default class LinkService<TEntity> {
|
||||||
|
|||||||
Reference in New Issue
Block a user