Skip to content

Commit cae47d9

Browse files
authored
feat: add check for uniqueness when creating links with isList=false (medusajs#11767)
1 parent 879c623 commit cae47d9

File tree

5 files changed

+145
-26
lines changed

5 files changed

+145
-26
lines changed

.changeset/late-shirts-begin.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@medusajs/link-modules": patch
3+
"@medusajs/modules-sdk": patch
4+
"@medusajs/utils": patch
5+
---
6+
7+
feat: add check for uniqueness when creating links with isList=false

integration-tests/modules/__tests__/link-modules/define-link.spec.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ medusaIntegrationTestRunner({
4646
entity: "Currency",
4747
primaryKey: "code",
4848
foreignKey: "currency_code",
49+
isList: true,
4950
alias: "currency",
5051
args: {
5152
methodSuffix: "Currencies",
@@ -58,6 +59,7 @@ medusaIntegrationTestRunner({
5859
primaryKey: "id",
5960
foreignKey: "region_id",
6061
alias: "region",
62+
isList: false,
6163
args: {
6264
methodSuffix: "Regions",
6365
},
@@ -88,9 +90,9 @@ medusaIntegrationTestRunner({
8890
serviceName: "region",
8991
entity: "Region",
9092
fieldAlias: {
91-
currency: {
93+
currencies: {
9294
path: "currency_link.currency",
93-
isList: false,
95+
isList: true,
9496
forwardArgumentsOnPath: ["currency_link.currency"],
9597
},
9698
},
@@ -100,7 +102,7 @@ medusaIntegrationTestRunner({
100102
primaryKey: "region_id",
101103
foreignKey: "id",
102104
alias: "currency_link",
103-
isList: false,
105+
isList: true,
104106
},
105107
},
106108
],
@@ -145,6 +147,7 @@ medusaIntegrationTestRunner({
145147
entity: "ProductVariant",
146148
primaryKey: "id",
147149
foreignKey: "product_variant_id",
150+
isList: true,
148151
alias: "product_variant",
149152
args: {
150153
methodSuffix: "ProductVariants",
@@ -156,6 +159,7 @@ medusaIntegrationTestRunner({
156159
entity: "Region",
157160
primaryKey: "id",
158161
foreignKey: "region_id",
162+
isList: false,
159163
alias: "region",
160164
args: {
161165
methodSuffix: "Regions",
@@ -187,9 +191,9 @@ medusaIntegrationTestRunner({
187191
serviceName: "region",
188192
entity: "Region",
189193
fieldAlias: {
190-
product_variant: {
194+
product_variants: {
191195
path: "product_variant_link.product_variant",
192-
isList: false,
196+
isList: true,
193197
forwardArgumentsOnPath: [
194198
"product_variant_link.product_variant",
195199
],
@@ -201,7 +205,7 @@ medusaIntegrationTestRunner({
201205
primaryKey: "region_id",
202206
foreignKey: "id",
203207
alias: "product_variant_link",
204-
isList: false,
208+
isList: true,
205209
},
206210
},
207211
],
@@ -249,6 +253,7 @@ medusaIntegrationTestRunner({
249253
entity: "Currency",
250254
primaryKey: "code",
251255
foreignKey: "currency_code",
256+
isList: true,
252257
alias: "currency",
253258
args: {
254259
methodSuffix: "Currencies",
@@ -260,6 +265,7 @@ medusaIntegrationTestRunner({
260265
entity: "Region",
261266
primaryKey: "id",
262267
foreignKey: "region_id",
268+
isList: false,
263269
alias: "region",
264270
args: {
265271
methodSuffix: "Regions",
@@ -291,9 +297,9 @@ medusaIntegrationTestRunner({
291297
serviceName: "region",
292298
entity: "Region",
293299
fieldAlias: {
294-
currency: {
300+
currencies: {
295301
path: "currency_link.currency",
296-
isList: false,
302+
isList: true,
297303
forwardArgumentsOnPath: ["currency_link.currency"],
298304
},
299305
},
@@ -303,7 +309,7 @@ medusaIntegrationTestRunner({
303309
primaryKey: "region_id",
304310
foreignKey: "id",
305311
alias: "currency_link",
306-
isList: false,
312+
isList: true,
307313
},
308314
},
309315
],
@@ -347,6 +353,7 @@ medusaIntegrationTestRunner({
347353
entity: "Currency",
348354
primaryKey: "code",
349355
foreignKey: "currency_code",
356+
isList: true,
350357
alias: "currency",
351358
args: {
352359
methodSuffix: "Currencies",
@@ -358,6 +365,7 @@ medusaIntegrationTestRunner({
358365
entity: "Region",
359366
primaryKey: "id",
360367
foreignKey: "region_id",
368+
isList: true,
361369
alias: "region",
362370
args: {
363371
methodSuffix: "Regions",
@@ -389,9 +397,9 @@ medusaIntegrationTestRunner({
389397
serviceName: "region",
390398
entity: "Region",
391399
fieldAlias: {
392-
currency: {
400+
currencies: {
393401
path: "currency_link.currency",
394-
isList: false,
402+
isList: true,
395403
forwardArgumentsOnPath: ["currency_link.currency"],
396404
},
397405
},
@@ -401,7 +409,7 @@ medusaIntegrationTestRunner({
401409
primaryKey: "region_id",
402410
foreignKey: "id",
403411
alias: "currency_link",
404-
isList: false,
412+
isList: true,
405413
},
406414
},
407415
],

packages/core/modules-sdk/src/link.ts

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import {
55
ModuleJoinerRelationship,
66
} from "@medusajs/types"
77

8-
import { isObject, Modules, promiseAll, toPascalCase } from "@medusajs/utils"
8+
import {
9+
isObject,
10+
MedusaError,
11+
Modules,
12+
promiseAll,
13+
toPascalCase,
14+
} from "@medusajs/utils"
915
import { MedusaModule } from "./medusa-module"
1016
import { convertRecordsToLinkDefinition } from "./utils/convert-data-to-link-definition"
1117
import { linkingErrorMessage } from "./utils/linking-error"
@@ -380,18 +386,88 @@ export class Link {
380386
const allLinks = Array.isArray(link) ? link : [link]
381387
const serviceLinks = new Map<
382388
string,
383-
[string | string[], string, Record<string, unknown>?][]
389+
{
390+
linksToCreate: [string | string[], string, Record<string, unknown>?][]
391+
linksToValidateForUniqueness: {
392+
filters: { [key: string]: string }[]
393+
services: string[]
394+
}
395+
}
384396
>()
385397

386398
for (const link of allLinks) {
387399
const service = this.getLinkModuleOrThrow(link)
400+
const relationships = service.__joinerConfig.relationships
388401
const { moduleA, moduleB, moduleBKey, primaryKeys } =
389402
this.getLinkDataConfig(link)
390403

391404
if (!serviceLinks.has(service.__definition.key)) {
392-
serviceLinks.set(service.__definition.key, [])
405+
serviceLinks.set(service.__definition.key, {
406+
/**
407+
* Tuple of foreign key and the primary keys that must be
408+
* persisted to the pivot table for representing the
409+
* link
410+
*/
411+
linksToCreate: [],
412+
413+
/**
414+
* An array of objects to validate for uniqueness before persisting
415+
* data to the pivot table. When a link uses "isList: false", we
416+
* have to limit a relationship with this entity to be a one-to-one
417+
* or one-to-many
418+
*/
419+
linksToValidateForUniqueness: {
420+
filters: [],
421+
services: [],
422+
},
423+
})
393424
}
394425

426+
relationships?.forEach((relationship) => {
427+
const linksToValidateForUniqueness = serviceLinks.get(
428+
service.__definition.key
429+
)!.linksToValidateForUniqueness!
430+
431+
linksToValidateForUniqueness.services.push(relationship.serviceName)
432+
433+
/**
434+
* When isList is set on false on the relationship, then it means
435+
* we have a one-to-one or many-to-one relationship with the
436+
* other side and we have limit duplicate entries from other
437+
* entity. For example:
438+
*
439+
* - A brand has a many to one relationship with a product.
440+
* - A product can have only one brand. Aka (brand.isList = false)
441+
* - A brand can have multiple products. Aka (products.isList = true)
442+
*
443+
* A result of this, we have to ensure that a product_id can only appear
444+
* once in the pivot table that is used for tracking "brand<>products"
445+
* relationship.
446+
*/
447+
if (relationship.isList === false) {
448+
const otherSide = relationships.find(
449+
(other) => other.foreignKey !== relationship.foreignKey
450+
)
451+
if (!otherSide) {
452+
return
453+
}
454+
455+
if (moduleBKey === otherSide.foreignKey) {
456+
linksToValidateForUniqueness.filters.push({
457+
[otherSide.foreignKey]: link[moduleB][moduleBKey],
458+
})
459+
} else {
460+
primaryKeys.forEach((pk) => {
461+
if (pk === otherSide.foreignKey) {
462+
linksToValidateForUniqueness.filters.push({
463+
[otherSide.foreignKey]: link[moduleA][pk],
464+
})
465+
}
466+
})
467+
}
468+
}
469+
})
470+
395471
const pkValue =
396472
primaryKeys.length === 1
397473
? link[moduleA][primaryKeys[0]]
@@ -403,15 +479,39 @@ export class Link {
403479
fields.push(link.data)
404480
}
405481

406-
serviceLinks.get(service.__definition.key)?.push(fields as any)
482+
serviceLinks
483+
.get(service.__definition.key)
484+
?.linksToCreate.push(fields as any)
407485
}
408486

409-
const promises: Promise<unknown[]>[] = []
487+
for (const [serviceName, data] of serviceLinks) {
488+
if (data.linksToValidateForUniqueness.filters.length) {
489+
const service = this.modulesMap.get(serviceName)!
490+
const existingLinks = await service.list(
491+
{
492+
$or: data.linksToValidateForUniqueness.filters,
493+
},
494+
{
495+
take: 1,
496+
}
497+
)
410498

411-
for (const [serviceName, links] of serviceLinks) {
412-
const service = this.modulesMap.get(serviceName)!
499+
if (existingLinks.length > 0) {
500+
const serviceA = data.linksToValidateForUniqueness.services[0]
501+
const serviceB = data.linksToValidateForUniqueness.services[1]
413502

414-
promises.push(service.create(links))
503+
throw new MedusaError(
504+
MedusaError.Types.INVALID_DATA,
505+
`Cannot create multiple links between '${serviceA}' and '${serviceB}'`
506+
)
507+
}
508+
}
509+
}
510+
511+
const promises: Promise<unknown[]>[] = []
512+
for (const [serviceName, data] of serviceLinks) {
513+
const service = this.modulesMap.get(serviceName)!
514+
promises.push(service.create(data.linksToCreate))
415515
}
416516

417517
return (await promiseAll(promises)).flat()

packages/core/utils/src/modules-sdk/define-link.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ function buildFieldAlias(fieldAliases?: Shortcut | Shortcut[]) {
125125
}
126126

127127
function prepareServiceConfig(
128-
input: DefineLinkInputSource | DefineReadOnlyLinkInputSource
128+
input: DefineLinkInputSource | DefineReadOnlyLinkInputSource,
129+
defaultOptions?: { isList?: boolean }
129130
) {
130131
let serviceConfig = {} as ModuleLinkableKeyConfig
131132

@@ -137,7 +138,7 @@ function prepareServiceConfig(
137138
alias: source.alias ?? camelToSnakeCase(source.field ?? ""),
138139
field: input.field ?? source.field,
139140
primaryKey: source.primaryKey,
140-
isList: false,
141+
isList: defaultOptions?.isList ?? false,
141142
deleteCascade: false,
142143
module: source.serviceName,
143144
entity: source.entity,
@@ -152,7 +153,7 @@ function prepareServiceConfig(
152153
alias: source.alias ?? camelToSnakeCase(source.field ?? ""),
153154
field: input.field ?? source.field,
154155
primaryKey: source.primaryKey,
155-
isList: input.isList ?? false,
156+
isList: input.isList ?? defaultOptions?.isList ?? false,
156157
deleteCascade: input.deleteCascade ?? false,
157158
module: source.serviceName,
158159
entity: source.entity,
@@ -183,8 +184,8 @@ export function defineLink(
183184
rightService: DefineLinkInputSource | DefineReadOnlyLinkInputSource,
184185
linkServiceOptions?: ExtraOptions | ReadOnlyExtraOptions
185186
): DefineLinkExport {
186-
const serviceAObj = prepareServiceConfig(leftService)
187-
const serviceBObj = prepareServiceConfig(rightService)
187+
const serviceAObj = prepareServiceConfig(leftService, { isList: true })
188+
const serviceBObj = prepareServiceConfig(rightService, { isList: false })
188189

189190
if (linkServiceOptions?.readOnly) {
190191
return defineReadOnlyLink(
@@ -373,6 +374,7 @@ ${serviceBObj.module}: {
373374
methodSuffix: serviceAMethodSuffix,
374375
},
375376
deleteCascade: serviceAObj.deleteCascade,
377+
isList: serviceAObj.isList,
376378
},
377379
{
378380
serviceName: serviceBObj.module,
@@ -384,6 +386,7 @@ ${serviceBObj.module}: {
384386
methodSuffix: serviceBMethodSuffix,
385387
},
386388
deleteCascade: serviceBObj.deleteCascade,
389+
isList: serviceBObj.isList,
387390
},
388391
],
389392
extends: [

packages/modules/link-modules/src/services/link.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import {
33
InjectManager,
44
InjectTransactionManager,
55
MedusaContext,
6+
MikroOrmBaseRepository,
67
ModulesSdkUtils,
78
} from "@medusajs/framework/utils"
89

910
type InjectedDependencies = {
10-
linkRepository: any
11+
linkRepository: MikroOrmBaseRepository
1112
}
1213

1314
export default class LinkService<TEntity> {

0 commit comments

Comments
 (0)