feat: add hasMany flag to enforce in app link uniqueness (#12039)

* feat: add createMultiple flag to enforce inApp link uniqueness

* changes

* mocks

* default

* many to many

---------

Co-authored-by: Carlos R. L. Rodrigues <rodrigolr@gmail.com>
This commit is contained in:
Harminder Virk
2025-04-02 10:46:51 +02:00
committed by GitHub
co-authored by Carlos R. L. Rodrigues
parent f441362f4a
commit d3e725a907
25 changed files with 316 additions and 70 deletions
@@ -21,5 +21,6 @@ export const InventoryModule = {
},
},
list: jest.fn(async () => []),
softDelete: jest.fn(() => {}),
}
@@ -65,5 +65,6 @@ export const InventoryStockLocationLink = {
foreignKeyData?: string
) => {}
),
list: jest.fn(async () => []),
softDelete: jest.fn(() => {}),
}
@@ -71,5 +71,6 @@ export const ProductInventoryLinkModule = {
foreignKeyData?: string
) => {}
),
list: jest.fn(async () => []),
softDelete: jest.fn(() => {}),
}
@@ -18,5 +18,6 @@ export const ProductModule = {
alias: [],
},
list: jest.fn(async () => []),
softDelete: jest.fn(() => {}),
}
@@ -18,5 +18,6 @@ export const StockLocationModule = {
alias: [],
},
list: jest.fn(async () => []),
softDelete: jest.fn(() => {}),
}
+40 -44
View File
@@ -389,7 +389,7 @@ export class Link {
{
linksToCreate: [string | string[], string, Record<string, unknown>?][]
linksToValidateForUniqueness: {
filters: { [key: string]: string }[]
filters: { [key: string]: any }[]
services: string[]
}
}
@@ -418,55 +418,51 @@ export class Link {
*/
linksToValidateForUniqueness: {
filters: [],
services: [],
services: relationships?.map((r) => r.serviceName) ?? [],
},
})
}
relationships?.forEach((relationship) => {
const linksToValidateForUniqueness = serviceLinks.get(
service.__definition.key
)!.linksToValidateForUniqueness!
/**
* 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.
*/
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 modA = relationships?.[0]!
const modB = relationships?.[1]!
if (!modA.hasMany || !modB.hasMany) {
if (!modA.hasMany && !modB.hasMany) {
linksToValidateForUniqueness.filters.push({
$or: [
{ [modA.foreignKey]: link[moduleA][modA.foreignKey] },
{ [modB.foreignKey]: link[moduleB][modB.foreignKey] },
],
})
} else if (!modA.hasMany) {
linksToValidateForUniqueness.filters.push({
[modA.foreignKey]: { $ne: link[moduleA][modA.foreignKey] },
[modB.foreignKey]: link[moduleB][modB.foreignKey],
})
} else if (!modB.hasMany) {
linksToValidateForUniqueness.filters.push({
[modB.foreignKey]: { $ne: link[moduleB][modB.foreignKey] },
[modA.foreignKey]: link[moduleA][modA.foreignKey],
})
}
})
}
const pkValue =
primaryKeys.length === 1
+6 -1
View File
@@ -209,7 +209,7 @@ export type ModuleJoinerConfig = Omit<
isList?: boolean
}
> // alias for deeper nested relationships (e.g. { 'price': 'prices.calculated_price_set.amount' })
relationship: ModuleJoinerRelationship
relationship: Omit<ModuleJoinerRelationship, "hasMany">
}[]
serviceName?: string
primaryKeys?: string[]
@@ -248,6 +248,11 @@ export declare type ModuleJoinerRelationship = JoinerRelationship & {
* If true, the link joiner will cascade deleting the relationship
*/
deleteCascade?: boolean
/**
* Allow multiple relationships to exist for this
* entity
*/
hasMany?: boolean
}
export type ModuleExports<T = Constructor<any>> = {
@@ -85,6 +85,7 @@ type ModuleLinkableKeyConfig = {
deleteCascade?: boolean
primaryKey: string
alias: string
hasMany?: boolean
shortcut?: Shortcut | Shortcut[]
}
@@ -125,8 +126,7 @@ function buildFieldAlias(fieldAliases?: Shortcut | Shortcut[]) {
}
function prepareServiceConfig(
input: DefineLinkInputSource | DefineReadOnlyLinkInputSource,
defaultOptions?: { isList?: boolean }
input: DefineLinkInputSource | DefineReadOnlyLinkInputSource
) {
let serviceConfig = {} as ModuleLinkableKeyConfig
@@ -138,7 +138,8 @@ function prepareServiceConfig(
alias: source.alias ?? camelToSnakeCase(source.field ?? ""),
field: input.field ?? source.field,
primaryKey: source.primaryKey,
isList: defaultOptions?.isList ?? false,
isList: false,
hasMany: false,
deleteCascade: false,
module: source.serviceName,
entity: source.entity,
@@ -148,12 +149,15 @@ function prepareServiceConfig(
? input.linkable.toJSON()
: input.linkable
const hasMany = !!input.isList
serviceConfig = {
key: source.linkable,
alias: source.alias ?? camelToSnakeCase(source.field ?? ""),
field: input.field ?? source.field,
primaryKey: source.primaryKey,
isList: input.isList ?? defaultOptions?.isList ?? false,
isList: input.isList ?? false,
hasMany,
deleteCascade: input.deleteCascade ?? false,
module: source.serviceName,
entity: source.entity,
@@ -184,8 +188,8 @@ export function defineLink(
rightService: DefineLinkInputSource | DefineReadOnlyLinkInputSource,
linkServiceOptions?: ExtraOptions | ReadOnlyExtraOptions
): DefineLinkExport {
const serviceAObj = prepareServiceConfig(leftService, { isList: true })
const serviceBObj = prepareServiceConfig(rightService, { isList: false })
const serviceAObj = prepareServiceConfig(leftService)
const serviceBObj = prepareServiceConfig(rightService)
if (linkServiceOptions?.readOnly) {
return defineReadOnlyLink(
@@ -374,7 +378,7 @@ ${serviceBObj.module}: {
methodSuffix: serviceAMethodSuffix,
},
deleteCascade: serviceAObj.deleteCascade,
isList: serviceAObj.isList,
hasMany: serviceAObj.hasMany,
},
{
serviceName: serviceBObj.module,
@@ -386,7 +390,7 @@ ${serviceBObj.module}: {
methodSuffix: serviceBMethodSuffix,
},
deleteCascade: serviceBObj.deleteCascade,
isList: serviceBObj.isList,
hasMany: serviceBObj.hasMany,
},
],
extends: [