Files
medusa-store/packages/medusa/src/utils/repository.ts
2024-01-07 13:58:30 +01:00

382 lines
12 KiB
TypeScript

import { promiseAll } from "@medusajs/utils"
import { flatten, groupBy, map, merge } from "lodash"
import {
EntityMetadata,
ObjectLiteral,
Repository,
SelectQueryBuilder,
} from "typeorm"
import { ExtendedFindConfig } from "../types/common"
// Regex matches all '.' except the rightmost
export const positiveLookaheadDotReplacer = new RegExp(/\.(?=[^.]*\.)/, "g")
// Replace all '.' with '__' to avoid typeorm's automatic aliasing
export const dotReplacer = new RegExp(/\./, "g")
/**
* Custom query entity, it is part of the creation of a custom findWithRelationsAndCount needs.
* Allow to query the relations for the specified entity ids
*
* @param repository
* @param entityIds
* @param groupedRelations
* @param withDeleted
* @param select
* @param customJoinBuilders
*/
export async function queryEntityWithIds<T extends ObjectLiteral>({
repository,
entityIds,
groupedRelations,
withDeleted = false,
select = [],
customJoinBuilders = [],
}: {
repository: Repository<T>
entityIds: string[]
groupedRelations: { [toplevel: string]: string[] }
withDeleted?: boolean
select?: (keyof T)[]
customJoinBuilders?: ((
qb: SelectQueryBuilder<T>,
alias: string,
toplevel: string
) => false | undefined)[]
}): Promise<T[]> {
const alias = repository.metadata.name.toLowerCase()
return await promiseAll(
Object.entries(groupedRelations).map(
async ([toplevel, topLevelRelations]) => {
let querybuilder = repository.createQueryBuilder(alias)
if (select?.length) {
querybuilder.select(
(select as string[])
.filter(function (s) {
return s.startsWith(toplevel) || !s.includes(".")
})
.map((column) => {
// In case the column is the toplevel relation, we need to replace the dot with a double underscore if it also contains top level relations
if (column.includes(toplevel)) {
return topLevelRelations.some((rel) => column.includes(rel))
? column.replace(positiveLookaheadDotReplacer, "__")
: column
}
return `${alias}.${column}`
})
)
}
let shouldAttachDefault: boolean | undefined = true
for (const customJoinBuilder of customJoinBuilders) {
const result = customJoinBuilder(querybuilder, alias, toplevel)
if (result === undefined) {
continue
}
shouldAttachDefault = shouldAttachDefault && result
}
if (shouldAttachDefault) {
const regexp = new RegExp(`^${toplevel}\\.\\w+$`)
const joinMethod = (select as string[]).filter(
(key) => !!key.match(regexp)
).length
? "leftJoin"
: "leftJoinAndSelect"
querybuilder = querybuilder[joinMethod](
`${alias}.${toplevel}`,
toplevel
)
}
for (const rel of topLevelRelations) {
const [_, rest] = rel.split(".")
if (!rest) {
continue
}
const regexp = new RegExp(`^${rel}\\.\\w+$`)
const joinMethod = (select as string[]).filter(
(key) => !!key.match(regexp)
).length
? "leftJoin"
: "leftJoinAndSelect"
querybuilder = querybuilder[joinMethod](
rel.replace(positiveLookaheadDotReplacer, "__"),
rel.replace(dotReplacer, "__")
)
}
querybuilder = querybuilder.where(`${alias}.id IN (:...entitiesIds)`, {
entitiesIds: entityIds,
})
if (withDeleted) {
querybuilder.withDeleted()
}
return querybuilder.getMany()
}
)
).then(flatten)
}
/**
* Custom query entity without relations, it is part of the creation of a custom findWithRelationsAndCount needs.
* Allow to query the entities without taking into account the relations. The relations will be queried separately
* using the queryEntityWithIds util
*
* @param repository
* @param optionsWithoutRelations
* @param shouldCount
* @param customJoinBuilders
*/
export async function queryEntityWithoutRelations<T extends ObjectLiteral>({
repository,
optionsWithoutRelations,
shouldCount = false,
customJoinBuilders = [],
}: {
repository: Repository<T>
optionsWithoutRelations: Omit<ExtendedFindConfig<T>, "relations">
shouldCount: boolean
customJoinBuilders: ((
qb: SelectQueryBuilder<T>,
alias: string
) => Promise<{ relation: string; preventOrderJoin: boolean } | void>)[]
}): Promise<[T[], number]> {
const alias = repository.metadata.name.toLowerCase()
const qb = repository.createQueryBuilder(alias).select([`${alias}.id`])
if (optionsWithoutRelations.where) {
qb.where(optionsWithoutRelations.where)
}
const shouldJoins: { relation: string; shouldJoin: boolean }[] = []
for (const customJoinBuilder of customJoinBuilders) {
const result = await customJoinBuilder(qb, alias)
if (result) {
shouldJoins.push({
relation: result.relation,
shouldJoin: !result.preventOrderJoin,
})
}
}
applyOrdering({
repository,
order: (optionsWithoutRelations.order as any) ?? {},
qb,
alias,
shouldJoin: (relationToJoin) => {
return shouldJoins.every(
({ relation, shouldJoin }) =>
relation !== relationToJoin ||
(relation === relationToJoin && shouldJoin)
)
},
})
if (optionsWithoutRelations.withDeleted) {
qb.withDeleted()
}
/*
* Deduplicate tuples for join + ordering (e.g. variants.prices.amount) since typeorm doesnt
* know how to manage it by itself
*/
const expressionMapAllOrderBys = qb.expressionMap.allOrderBys
if (
expressionMapAllOrderBys &&
Object.keys(expressionMapAllOrderBys).length
) {
const orderBysString = Object.keys(expressionMapAllOrderBys)
.map((column) => {
return `${column} ${expressionMapAllOrderBys[column]}`
})
.join(", ")
qb.addSelect(
`row_number() OVER (PARTITION BY ${alias}.id ORDER BY ${orderBysString}) AS rownum`
)
} else {
qb.addSelect(`1 AS rownum`)
}
/*
* In typeorm SelectQueryBuilder, the orderBy is removed from the original query when there is pagination
* and join involved together.
*
* This workaround allows us to include the order as part of the original query (including joins) before
* selecting the distinct ids of the main alias entity. The distinct ids deduplication
* is managed by the rownum column added to the select below.
*
* see: node_modules/typeorm/query-builder/SelectQueryBuilder.js(1973)
*/
const outerQb = new SelectQueryBuilder(qb.connection, (qb as any).queryRunner)
.select(`${qb.escape(`${alias}_id`)}`)
.from(`(${qb.getQuery()})`, alias)
.where(`${alias}.rownum = 1`)
.setParameters(qb.getParameters())
.setNativeParameters(qb.expressionMap.nativeParameters)
.offset(optionsWithoutRelations.skip)
.limit(optionsWithoutRelations.take)
const mapToEntities = (array: any) => {
return array.map((rawProduct) => ({
id: rawProduct[`${alias}_id`],
})) as unknown as T[]
}
let entities: T[]
let count = 0
if (shouldCount) {
const outerQbCount = new SelectQueryBuilder(
qb.connection,
(qb as any).queryRunner
)
.select(`COUNT(1)`, `count`)
.from(`(${qb.getQuery()})`, alias)
.where(`${alias}.rownum = 1`)
.setParameters(qb.getParameters())
.setNativeParameters(qb.expressionMap.nativeParameters)
.orderBy()
.groupBy()
.offset(undefined)
.limit(undefined)
.skip(undefined)
.take(undefined)
const result = await promiseAll([
outerQb.getRawMany(),
outerQbCount.getRawOne(),
])
entities = mapToEntities(result[0])
count = Number(result[1].count)
} else {
const result = await outerQb.getRawMany()
entities = mapToEntities(result)
}
return [entities, count]
}
/**
* Grouped the relation to the top level entity
* @param relations
*/
export function getGroupedRelations(relations: string[]): {
[toplevel: string]: string[]
} {
const groupedRelations: { [toplevel: string]: string[] } = {}
for (const rel of relations) {
const [topLevel] = rel.split(".")
if (groupedRelations[topLevel]) {
groupedRelations[topLevel].push(rel)
} else {
groupedRelations[topLevel] = [rel]
}
}
return groupedRelations
}
/**
* Merged the entities and relations that composed by the result of queryEntityWithIds and queryEntityWithoutRelations
* call
* @param entitiesAndRelations
*/
export function mergeEntitiesWithRelations<T>(
entitiesAndRelations: Array<Partial<T>>
): T[] {
const entitiesAndRelationsById = groupBy(entitiesAndRelations, "id")
return map(entitiesAndRelationsById, (entityAndRelations) =>
merge({}, ...entityAndRelations)
)
}
/**
* Apply the appropriate order depending on the requirements
* @param repository
* @param order The field on which to apply the order (e.g { "variants.prices.amount": "DESC" })
* @param qb
* @param alias
* @param shouldJoin In case a join is already applied elsewhere and therefore you want to avoid to re joining the data in that case you can return false for specific relations
*/
export function applyOrdering<T extends ObjectLiteral>({
repository,
order,
qb,
alias,
shouldJoin,
}: {
repository: Repository<T>
order: Record<string, "ASC" | "DESC">
qb: SelectQueryBuilder<T>
alias: string
shouldJoin: (relation: string) => boolean
}) {
const toSelect: string[] = []
const parsed = Object.entries(order).reduce(
(acc, [orderPath, orderDirection]) => {
// If the orderPath (e.g variants.prices.amount) includes a point it means that it is to access
// a child relation of an unknown depth
if (orderPath.includes(".")) {
// We are spliting the path and separating the relations from the property to order. (e.g relations ["variants", "prices"] and property "amount"
const relationsToJoin = orderPath.split(".")
const propToOrder = relationsToJoin.pop()
// For each relation we will retrieve the metadata in order to use the right property name from the relation registered in the entity.
// Each time we will return the child (i.e the relation) and the inverse metadata (corresponding to the child metadata from the parent point of view)
// In order for the next child to know its parent
relationsToJoin.reduce(
([parent, parentMetadata], child) => {
// Find the relation metadata from the parent entity
const relationMetadata = (
parentMetadata as EntityMetadata
).relations.find(
(relationMetadata) => relationMetadata.propertyName === child
)
// The consumer can refuse to apply a join on a relation if the join has already been applied before calling this util
const shouldApplyJoin = shouldJoin(child)
if (shouldApplyJoin) {
qb.leftJoin(`${parent}.${relationMetadata!.propertyPath}`, child)
}
// Return the child relation to be the parent for the next one, as well as the metadata corresponding the child in order
// to find the next relation metadata for the next child
return [child, relationMetadata!.inverseEntityMetadata]
},
[alias, repository.metadata]
)
// The key for variants.prices.amount will be "prices.amount" since we are ordering on the join added to its parent "variants" in this example
const key = `${
relationsToJoin[relationsToJoin.length - 1]
}.${propToOrder}`
acc[key] = orderDirection
toSelect.push(key)
return acc
}
const key = `${alias}.${orderPath}`
// Prevent ambiguous column error when top level entity id is ordered
if (orderPath !== "id") {
toSelect.push(key)
}
acc[key] = orderDirection
return acc
},
{}
)
qb.addSelect(toSelect)
qb.orderBy(parsed)
}