diff --git a/packages/medusa/src/interfaces/__tests__/base-service.spec.ts b/packages/medusa/src/interfaces/__tests__/transaction-base-service.spec.ts similarity index 56% rename from packages/medusa/src/interfaces/__tests__/base-service.spec.ts rename to packages/medusa/src/interfaces/__tests__/transaction-base-service.spec.ts index 9166759fac..65c5b62608 100644 --- a/packages/medusa/src/interfaces/__tests__/base-service.spec.ts +++ b/packages/medusa/src/interfaces/__tests__/transaction-base-service.spec.ts @@ -1,12 +1,15 @@ -import BaseService from "../base-service" -import { In, Not } from "typeorm" +import { EntityManager } from "typeorm" import { MockManager } from "medusa-test-utils" +import { TransactionBaseService } from "../transaction-base-service" -describe("BaseService", () => { +describe("TransactionBaseService", () => { it("should cloned the child class withTransaction", () => { - class Child extends BaseService { + class Child extends TransactionBaseService { + protected manager_!: EntityManager + protected transactionManager_!: EntityManager + constructor(protected readonly container) { - super(container, {}); + super(container); this.container = container } @@ -32,30 +35,4 @@ describe("BaseService", () => { expect(child2.getTransactionManager()).toBeTruthy() expect((child2.getTransactionManager() as any)?.testProp).toBe('testProp') }) - - describe("buildQuery_", () => { - const baseService = new BaseService({}, {}) - - it("successfully creates query", () => { - const q = baseService.buildQuery_( - { - id: "1234", - test1: ["123", "12", "1"], - test2: Not("this"), - }, - { - relations: ["1234"], - } - ) - - expect(q).toEqual({ - where: { - id: "1234", - test1: In(["123", "12", "1"]), - test2: Not("this"), - }, - relations: ["1234"], - }) - }) - }) -}) +}) \ No newline at end of file diff --git a/packages/medusa/src/interfaces/base-service.ts b/packages/medusa/src/interfaces/base-service.ts deleted file mode 100644 index 18560465f2..0000000000 --- a/packages/medusa/src/interfaces/base-service.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { MedusaError } from "medusa-core-utils" -import { EntityManager, FindOperator, In, Raw } from "typeorm" -import { IsolationLevel } from "typeorm/driver/types/IsolationLevel" -import { FindConfig } from "../types/common" - -type Selector = { [key in keyof TEntity]?: unknown } - -/** - * Common functionality for Services - * @interface - */ -class BaseService< - TChild extends BaseService, - TContainer = unknown -> { - protected transactionManager_: EntityManager | undefined - protected manager_: EntityManager - private readonly container_: TContainer - - constructor( - container: TContainer, - protected readonly configModule: Record - ) { - this.container_ = container - } - - withTransaction(): this - withTransaction(transactionManager: EntityManager): TChild - withTransaction(transactionManager?: EntityManager): this | TChild { - if (!transactionManager) { - return this - } - - const cloned = new (this.constructor)< - TChild, - TContainer - >( - { - ...this.container_, - manager: transactionManager, - }, - this.configModule - ) - - cloned.transactionManager_ = transactionManager - - return cloned as TChild - } - - /** - * Used to build TypeORM queries. - * @param selector The selector - * @param config The config - * @return The QueryBuilderConfig - */ - buildQuery_( - selector: Selector, - config: FindConfig = {} - ): FindConfig & { - where: { [key in keyof TEntity]?: unknown } - withDeleted?: boolean - } { - const build = ( - obj: Record - ): { [key in keyof TEntity]?: unknown } => { - return Object.entries(obj).reduce((acc, [key, value]: any) => { - // Undefined values indicate that they have no significance to the query. - // If the query is looking for rows where a column is not set it should use null instead of undefined - if (typeof value === "undefined") { - return acc - } - - const subquery: { - operator: "<" | ">" | "<=" | ">=" - value: unknown - }[] = [] - - switch (true) { - case value instanceof FindOperator: - acc[key] = value - break - case Array.isArray(value): - acc[key] = In([...(value as unknown[])]) - break - case value !== null && typeof value === "object": - Object.entries(value as Record).map( - ([modifier, val]) => { - switch (modifier) { - case "lt": - subquery.push({ operator: "<", value: val }) - break - case "gt": - subquery.push({ operator: ">", value: val }) - break - case "lte": - subquery.push({ operator: "<=", value: val }) - break - case "gte": - subquery.push({ operator: ">=", value: val }) - break - default: - acc[key] = value - break - } - } - ) - - if (subquery.length) { - acc[key] = Raw( - (a) => - subquery - .map((s, index) => `${a} ${s.operator} :${index}`) - .join(" AND "), - subquery.map((s) => s.value) - ) - } - break - default: - acc[key] = value - break - } - - return acc - }, {} as { [key in keyof TEntity]?: unknown }) - } - - const query: FindConfig & { - where: { [key in keyof TEntity]?: unknown } - withDeleted?: boolean - } = { - where: build(selector), - } - - if ("deleted_at" in selector) { - query.withDeleted = true - } - - if ("skip" in config) { - query.skip = config.skip - } - - if ("take" in config) { - query.take = config.take - } - - if ("relations" in config) { - query.relations = config.relations - } - - if ("select" in config) { - query.select = config.select - } - - if ("order" in config) { - query.order = config.order - } - - return query - } - - /** - * Confirms whether a given raw id is valid. Fails if the provided - * id is null or undefined. The validate function takes an optional config - * param, to support checking id prefix and length. - * @param rawId - the id to validate. - * @param config - optional config - * @returns the rawId given that nothing failed - */ - validateId_( - rawId: string, - config: { prefix?: string; length?: number } = {} - ): string { - const { prefix, length } = config - if (!rawId) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Failed to validate id: ${rawId}` - ) - } - - if (prefix || length) { - const [pre, rand] = rawId.split("_") - if (prefix && pre !== prefix) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `The provided id: ${rawId} does not adhere to prefix constraint: ${prefix}` - ) - } - - if (length && length !== rand.length) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `The provided id: ${rawId} does not adhere to length constraint: ${length}` - ) - } - } - - return rawId - } - - shouldRetryTransaction( - err: { code: string } | Record - ): boolean { - if (!(err as { code: string })?.code) { - return false - } - const code = (err as { code: string })?.code - return code === "40001" || code === "40P01" - } - - /** - * Wraps some work within a transactional block. If the service already has - * a transaction manager attached this will be reused, otherwise a new - * transaction manager is created. - * @param work - the transactional work to be done - * @param isolationOrErrorHandler - the isolation level to be used for the work. - * @param maybeErrorHandlerOrDontFail Potential error handler - * @return the result of the transactional work - */ - async atomicPhase_( - work: (transactionManager: EntityManager) => Promise, - isolationOrErrorHandler?: - | IsolationLevel - | ((error: unknown) => Promise), - maybeErrorHandlerOrDontFail?: (error: unknown) => Promise - ): Promise { - let errorHandler = maybeErrorHandlerOrDontFail - let isolation: - | IsolationLevel - | ((error: unknown) => Promise) - | undefined - | null = isolationOrErrorHandler - let dontFail = false - if (typeof isolationOrErrorHandler === "function") { - isolation = null - errorHandler = isolationOrErrorHandler - dontFail = !!maybeErrorHandlerOrDontFail - } - - if (this.transactionManager_) { - const doWork = async (m: EntityManager): Promise => { - this.manager_ = m - this.transactionManager_ = m - try { - return await work(m) - } catch (error) { - if (errorHandler) { - const queryRunner = this.transactionManager_.queryRunner - if (queryRunner && queryRunner.isTransactionActive) { - await queryRunner.rollbackTransaction() - } - - await errorHandler(error) - } - throw error - } - } - - return doWork(this.transactionManager_) - } else { - const temp = this.manager_ - const doWork = async (m: EntityManager): Promise => { - this.manager_ = m - this.transactionManager_ = m - try { - const result = await work(m) - this.manager_ = temp - this.transactionManager_ = undefined - return result - } catch (error) { - this.manager_ = temp - this.transactionManager_ = undefined - throw error - } - } - - if (isolation) { - let result - try { - result = await this.manager_.transaction( - isolation as IsolationLevel, - (m) => doWork(m) - ) - return result - } catch (error) { - if (this.shouldRetryTransaction(error)) { - return this.manager_.transaction(isolation as IsolationLevel, (m) => - doWork(m) - ) - } else { - if (errorHandler) { - await errorHandler(error) - } - throw error - } - } - } - - try { - return await this.manager_.transaction((m) => doWork(m)) - } catch (error) { - if (errorHandler) { - const result = await errorHandler(error) - if (dontFail) { - return result - } - } - - throw error - } - } - } - - /** - * Dedicated method to set metadata. - * @param obj - the entity to apply metadata to. - * @param metadata - the metadata to set - * @return resolves to the updated result. - */ - setMetadata_( - obj: { metadata: Record }, - metadata: Record - ): Record { - const existing = obj.metadata || {} - const newData = {} - for (const [key, value] of Object.entries(metadata)) { - if (typeof key !== "string") { - throw new MedusaError( - MedusaError.Types.INVALID_ARGUMENT, - "Key type is invalid. Metadata keys must be strings" - ) - } - newData[key] = value - } - - return { - ...existing, - ...newData, - } - } -} -export default BaseService diff --git a/packages/medusa/src/interfaces/index.ts b/packages/medusa/src/interfaces/index.ts index 7552d3c149..9472f26f30 100644 --- a/packages/medusa/src/interfaces/index.ts +++ b/packages/medusa/src/interfaces/index.ts @@ -1,4 +1,4 @@ export * from "./tax-calculation-strategy" export * from "./cart-completion-strategy" export * from "./tax-service" -export * from "./base-service" +export * from "./transaction-base-service" diff --git a/packages/medusa/src/interfaces/transaction-base-service.ts b/packages/medusa/src/interfaces/transaction-base-service.ts new file mode 100644 index 0000000000..96d472bb23 --- /dev/null +++ b/packages/medusa/src/interfaces/transaction-base-service.ts @@ -0,0 +1,149 @@ +import { EntityManager } from "typeorm" +import { IsolationLevel } from "typeorm/driver/types/IsolationLevel" + +export abstract class TransactionBaseService< + TChild extends TransactionBaseService, + TContainer = unknown +> { + protected abstract manager_: EntityManager + protected abstract transactionManager_: EntityManager | undefined + + protected constructor( + protected readonly container: TContainer, + protected readonly configModule?: Record + ) {} + + withTransaction(transactionManager?: EntityManager): this | TChild { + if (!transactionManager) { + return this + } + + const cloned = new (this.constructor)( + { + ...this.container, + manager: transactionManager, + }, + this.configModule + ) + + cloned.transactionManager_ = transactionManager + + return cloned as TChild + } + + protected shouldRetryTransaction_( + err: { code: string } | Record + ): boolean { + if (!(err as { code: string })?.code) { + return false + } + const code = (err as { code: string })?.code + return code === "40001" || code === "40P01" + } + + /** + * Wraps some work within a transactional block. If the service already has + * a transaction manager attached this will be reused, otherwise a new + * transaction manager is created. + * @param work - the transactional work to be done + * @param isolationOrErrorHandler - the isolation level to be used for the work. + * @param maybeErrorHandlerOrDontFail Potential error handler + * @return the result of the transactional work + */ + protected async atomicPhase_( + work: (transactionManager: EntityManager) => Promise, + isolationOrErrorHandler?: + | IsolationLevel + | ((error: TError) => Promise), + maybeErrorHandlerOrDontFail?: ( + error: TError + ) => Promise + ): Promise { + let errorHandler = maybeErrorHandlerOrDontFail + let isolation: + | IsolationLevel + | ((error: TError) => Promise) + | undefined + | null = isolationOrErrorHandler + let dontFail = false + if (typeof isolationOrErrorHandler === "function") { + isolation = null + errorHandler = isolationOrErrorHandler + dontFail = !!maybeErrorHandlerOrDontFail + } + + if (this.transactionManager_) { + const doWork = async (m: EntityManager): Promise => { + this.manager_ = m + this.transactionManager_ = m + try { + return await work(m) + } catch (error) { + if (errorHandler) { + const queryRunner = this.transactionManager_.queryRunner + if (queryRunner && queryRunner.isTransactionActive) { + await queryRunner.rollbackTransaction() + } + + await errorHandler(error) + } + throw error + } + } + + return await doWork(this.transactionManager_) + } else { + const temp = this.manager_ + const doWork = async (m: EntityManager): Promise => { + this.manager_ = m + this.transactionManager_ = m + try { + const result = await work(m) + this.manager_ = temp + this.transactionManager_ = undefined + return result + } catch (error) { + this.manager_ = temp + this.transactionManager_ = undefined + throw error + } + } + + if (isolation && this.manager_) { + let result + try { + result = await this.manager_.transaction( + isolation as IsolationLevel, + (m) => doWork(m) + ) + return result + } catch (error) { + if (this.shouldRetryTransaction_(error)) { + return this.manager_.transaction( + isolation as IsolationLevel, + (m): Promise => doWork(m) + ) + } else { + if (errorHandler) { + await errorHandler(error) + } + throw error + } + } + } + + try { + return await this.manager_.transaction((m) => doWork(m)) + } catch (error) { + if (errorHandler) { + const result = await errorHandler(error) + if (dontFail) { + return result as TResult + } + } + + throw error + } + } + } +} diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index 6e5d0c4284..69973d4371 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -1,6 +1,5 @@ import _ from "lodash" import { MedusaError, Validator } from "medusa-core-utils" -import { BaseService } from "medusa-interfaces" import { DeepPartial, EntityManager, In } from "typeorm" import { IPriceSelectionStrategy } from "../interfaces/price-selection-strategy" import { Address } from "../models/address" @@ -37,6 +36,8 @@ import InventoryService from "./inventory" import CustomShippingOptionService from "./custom-shipping-option" import LineItemAdjustmentService from "./line-item-adjustment" import { LineItemRepository } from "../repositories/line-item" +import { TransactionBaseService } from "../interfaces" +import { buildQuery, setMetadata, validateId } from "../utils" type InjectedDependencies = { manager: EntityManager @@ -70,14 +71,16 @@ type TotalsConfig = { /* Provides layer to manipulate carts. * @implements BaseService */ -class CartService extends BaseService { - static Events = { +class CartService extends TransactionBaseService { + static readonly Events = { CUSTOMER_UPDATED: "cart.customer_updated", CREATED: "cart.created", UPDATED: "cart.updated", } - protected readonly manager_: EntityManager + protected manager_: EntityManager + protected transactionManager_: EntityManager | undefined + protected readonly shippingMethodRepository_: typeof ShippingMethodRepository protected readonly cartRepository_: typeof CartRepository protected readonly addressRepository_: typeof AddressRepository @@ -124,7 +127,8 @@ class CartService extends BaseService { lineItemAdjustmentService, priceSelectionStrategy, }: InjectedDependencies) { - super() + // eslint-disable-next-line prefer-rest-params + super(arguments[0]) this.manager_ = manager this.shippingMethodRepository_ = shippingMethodRepository @@ -150,41 +154,6 @@ class CartService extends BaseService { this.priceSelectionStrategy_ = priceSelectionStrategy } - withTransaction(transactionManager: EntityManager): CartService { - if (!transactionManager) { - return this - } - - const cloned = new CartService({ - manager: transactionManager, - taxProviderService: this.taxProviderService_, - cartRepository: this.cartRepository_, - lineItemRepository: this.lineItemRepository_, - eventBusService: this.eventBus_, - paymentProviderService: this.paymentProviderService_, - paymentSessionRepository: this.paymentSessionRepository_, - shippingMethodRepository: this.shippingMethodRepository_, - productService: this.productService_, - productVariantService: this.productVariantService_, - regionService: this.regionService_, - lineItemService: this.lineItemService_, - shippingOptionService: this.shippingOptionService_, - customerService: this.customerService_, - discountService: this.discountService_, - totalsService: this.totalsService_, - addressRepository: this.addressRepository_, - giftCardService: this.giftCardService_, - inventoryService: this.inventoryService_, - customShippingOptionService: this.customShippingOptionService_, - lineItemAdjustmentService: this.lineItemAdjustmentService_, - priceSelectionStrategy: this.priceSelectionStrategy_, - }) - - cloned.transactionManager_ = transactionManager - - return cloned - } - protected transformQueryForTotals_( config: FindConfig ): FindConfig & { totalsToSelect: TotalField[] } { @@ -292,7 +261,7 @@ class CartService extends BaseService { this.cartRepository_ ) - const query = this.buildQuery_(selector, config) + const query = buildQuery(selector, config) return await cartRepo.find(query) } ) @@ -315,12 +284,12 @@ class CartService extends BaseService { const cartRepo = transactionManager.getCustomRepository( this.cartRepository_ ) - const validatedId = this.validateId_(cartId) + const validatedId = validateId(cartId) const { select, relations, totalsToSelect } = this.transformQueryForTotals_(options) - const query = this.buildQuery_( + const query = buildQuery( { id: validatedId }, { ...options, select, relations } ) @@ -714,16 +683,20 @@ class CartService extends BaseService { * @param shouldAdd - flag to indicate, if we should add or remove * @return void */ - async adjustFreeShipping_(cart: Cart, shouldAdd: boolean): Promise { + protected async adjustFreeShipping_( + cart: Cart, + shouldAdd: boolean + ): Promise { + const transactionManager = this.transactionManager_ ?? this.manager_ + if (cart.shipping_methods?.length) { - const shippingMethodRepository = - this.transactionManager_.getCustomRepository( - this.shippingMethodRepository_ - ) + const shippingMethodRepository = transactionManager.getCustomRepository( + this.shippingMethodRepository_ + ) // if any free shipping discounts, we ensure to update shipping method amount if (shouldAdd) { - return shippingMethodRepository.update( + await shippingMethodRepository.update( { id: In( cart.shipping_methods.map((shippingMethod) => shippingMethod.id) @@ -855,8 +828,8 @@ class CartService extends BaseService { ) } - if ("metadata" in data) { - cart.metadata = this.setMetadata_(cart, data.metadata) + if (data?.metadata) { + cart.metadata = setMetadata(cart, data.metadata) } if ("context" in data) { @@ -1352,7 +1325,7 @@ class CartService extends BaseService { * @param cartOrCartId - the id of the cart to set payment session for * @return the result of the update operation. */ - async setPaymentSessions(cartOrCartId: Cart | string): Promise { + async setPaymentSessions(cartOrCartId: Cart | string): Promise { return await this.atomicPhase_( async (transactionManager: EntityManager) => { const psRepo = transactionManager.getCustomRepository( @@ -1679,6 +1652,8 @@ class CartService extends BaseService { regionId?: string, customer_id?: string ): Promise { + const transactionManager = this.transactionManager_ ?? this.manager_ + // If the cart contains items, we update the price of the items // to match the updated region or customer id (keeping the old // value if it exists) @@ -1693,7 +1668,7 @@ class CartService extends BaseService { await Promise.all( cart.items.map(async (item) => { const availablePrice = await this.priceSelectionStrategy_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .calculateVariantPrice(item.variant_id, { region_id: region.id, currency_code: region.currency_code, @@ -1708,14 +1683,14 @@ class CartService extends BaseService { availablePrice.calculatedPrice !== null ) { return this.lineItemService_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .update(item.id, { has_shipping: false, unit_price: availablePrice.calculatedPrice, }) } else { await this.lineItemService_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .delete(item.id) return } @@ -1737,6 +1712,8 @@ class CartService extends BaseService { regionId: string, countryCode: string | null ): Promise { + const transactionManager = this.transactionManager_ ?? this.manager_ + if (cart.completed_at || cart.payment_authorized_at) { throw new MedusaError( MedusaError.Types.NOT_ALLOWED, @@ -1750,7 +1727,7 @@ class CartService extends BaseService { .retrieve(regionId, { relations: ["countries"], }) - const addrRepo = this.transactionManager_.getCustomRepository( + const addrRepo = transactionManager.getCustomRepository( this.addressRepository_ ) cart.region = region @@ -1828,7 +1805,7 @@ class CartService extends BaseService { // new shipping method if (cart.shipping_methods && cart.shipping_methods.length) { await this.shippingOptionService_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .deleteShippingMethods(cart.shipping_methods) } @@ -1841,7 +1818,7 @@ class CartService extends BaseService { cart.gift_cards = [] if (cart.payment_sessions && cart.payment_sessions.length) { - const paymentSessionRepo = this.transactionManager_.getCustomRepository( + const paymentSessionRepo = transactionManager.getCustomRepository( this.paymentSessionRepository_ ) await paymentSessionRepo.delete({ @@ -1859,7 +1836,7 @@ class CartService extends BaseService { * @param cartId - the id of the cart to delete * @return the deleted cart or undefined if the cart was not found. */ - async delete(cartId: string): Promise { + async delete(cartId: string): Promise { return await this.atomicPhase_( async (transactionManager: EntityManager) => { const cart = await this.retrieve(cartId, { @@ -1913,7 +1890,7 @@ class CartService extends BaseService { this.cartRepository_ ) - const validatedId = this.validateId_(cartId) + const validatedId = validateId(cartId) if (typeof key !== "string") { throw new MedusaError( MedusaError.Types.INVALID_ARGUMENT, @@ -1972,20 +1949,22 @@ class CartService extends BaseService { } protected async refreshAdjustments_(cart: Cart): Promise { + const transactionManager = this.transactionManager_ ?? this.manager_ + const nonReturnLineIDs = cart.items .filter((item) => !item.is_return) .map((i) => i.id) // delete all old non return line item adjustments await this.lineItemAdjustmentService_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .delete({ item_id: nonReturnLineIDs, }) // potentially create/update line item adjustments await this.lineItemAdjustmentService_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .createAdjustments(cart) } @@ -2001,7 +1980,7 @@ class CartService extends BaseService { const cartRepo = transactionManager.getCustomRepository( this.cartRepository_ ) - const validatedId = this.validateId_(cartId) + const validatedId = validateId(cartId) if (typeof key !== "string") { throw new MedusaError( diff --git a/packages/medusa/src/types/cart.ts b/packages/medusa/src/types/cart.ts index 249c0c9af0..dfba20b82f 100644 --- a/packages/medusa/src/types/cart.ts +++ b/packages/medusa/src/types/cart.ts @@ -71,5 +71,5 @@ export type CartUpdateProps = { discounts?: Discount[] customer_id?: string context?: object - metadata?: object + metadata?: Record } diff --git a/packages/medusa/src/types/common.ts b/packages/medusa/src/types/common.ts index 98bd6fde7d..da52f14025 100644 --- a/packages/medusa/src/types/common.ts +++ b/packages/medusa/src/types/common.ts @@ -13,6 +13,22 @@ export type PartialPick = { [P in K]?: T[P] } +export type Writable = { -readonly [key in keyof T]: T[key] } + +export type ExtendedFindConfig = FindConfig & { + where: Partial> + withDeleted?: boolean +} + +export type Selector = { + [key in keyof TEntity]?: + | TEntity[key] + | TEntity[key][] + | DateComparisonOperator + | StringComparisonOperator + | NumericalComparisonOperator +} + export type TotalField = | "shipping_total" | "discount_total" diff --git a/packages/medusa/src/utils/__tests__/build-query.spec.ts b/packages/medusa/src/utils/__tests__/build-query.spec.ts new file mode 100644 index 0000000000..f9a4819390 --- /dev/null +++ b/packages/medusa/src/utils/__tests__/build-query.spec.ts @@ -0,0 +1,28 @@ +import { In, Not } from "typeorm" +import { buildQuery } from "../build-query" + +describe('buildQuery', () => { + describe("buildQuery_", () => { + it("successfully creates query", () => { + const q = buildQuery( + { + id: "1234", + test1: ["123", "12", "1"], + test2: Not("this"), + }, + { + relations: ["1234"], + } + ) + + expect(q).toEqual({ + where: { + id: "1234", + test1: In(["123", "12", "1"]), + test2: Not("this"), + }, + relations: ["1234"], + }) + }) + }) +}) \ No newline at end of file diff --git a/packages/medusa/src/utils/build-query.ts b/packages/medusa/src/utils/build-query.ts new file mode 100644 index 0000000000..f61123741d --- /dev/null +++ b/packages/medusa/src/utils/build-query.ts @@ -0,0 +1,113 @@ +import { + ExtendedFindConfig, + FindConfig, + Selector, + Writable, +} from "../types/common" +import { FindOperator, In, Raw } from "typeorm" + +/** +* Used to build TypeORM queries. +* @param selector The selector +* @param config The config +* @return The QueryBuilderConfig +*/ +export function buildQuery( + selector: Selector, + config: FindConfig = {} +): ExtendedFindConfig { + const build = ( + obj: Selector + ): Partial> => { + return Object.entries(obj).reduce((acc, [key, value]: any) => { + // Undefined values indicate that they have no significance to the query. + // If the query is looking for rows where a column is not set it should use null instead of undefined + if (typeof value === "undefined") { + return acc + } + + const subquery: { + operator: "<" | ">" | "<=" | ">=" + value: unknown + }[] = [] + + switch (true) { + case value instanceof FindOperator: + acc[key] = value + break + case Array.isArray(value): + acc[key] = In([...(value as unknown[])]) + break + case value !== null && typeof value === "object": + Object.entries(value).map(([modifier, val]) => { + switch (modifier) { + case "lt": + subquery.push({ operator: "<", value: val }) + break + case "gt": + subquery.push({ operator: ">", value: val }) + break + case "lte": + subquery.push({ operator: "<=", value: val }) + break + case "gte": + subquery.push({ operator: ">=", value: val }) + break + default: + acc[key] = value + break + } + }) + + if (subquery.length) { + acc[key] = Raw( + (a) => + subquery + .map((s, index) => `${a} ${s.operator} :${index}`) + .join(" AND "), + subquery.map((s) => s.value) + ) + } + break + default: + acc[key] = value + break + } + + return acc + }, {} as Partial>) + } + + const query: FindConfig & { + where: Partial> + withDeleted?: boolean + } = { + where: build(selector), + } + + if ("deleted_at" in selector) { + query.withDeleted = true + } + + if ("skip" in config) { + query.skip = config.skip + } + + if ("take" in config) { + query.take = config.take + } + + if ("relations" in config) { + query.relations = config.relations + } + + if ("select" in config) { + query.select = config.select + } + + if ("order" in config) { + query.order = config.order + } + + return query +} \ No newline at end of file diff --git a/packages/medusa/src/utils/index.ts b/packages/medusa/src/utils/index.ts new file mode 100644 index 0000000000..5d7ec9889f --- /dev/null +++ b/packages/medusa/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './build-query' +export * from './set-metadata' +export * from './validate-id' \ No newline at end of file diff --git a/packages/medusa/src/utils/set-metadata.ts b/packages/medusa/src/utils/set-metadata.ts new file mode 100644 index 0000000000..d27cddc5ed --- /dev/null +++ b/packages/medusa/src/utils/set-metadata.ts @@ -0,0 +1,29 @@ +import { MedusaError } from "medusa-core-utils/dist" + +/** +* Dedicated method to set metadata. +* @param obj - the entity to apply metadata to. +* @param metadata - the metadata to set +* @return resolves to the updated result. +*/ +export function setMetadata( + obj: { metadata: Record }, + metadata: Record +): Record { + const existing = obj.metadata || {} + const newData = {} + for (const [key, value] of Object.entries(metadata)) { + if (typeof key !== "string") { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Key type is invalid. Metadata keys must be strings" + ) + } + newData[key] = value + } + + return { + ...existing, + ...newData, + } +} \ No newline at end of file diff --git a/packages/medusa/src/utils/validate-id.ts b/packages/medusa/src/utils/validate-id.ts new file mode 100644 index 0000000000..da6102e69a --- /dev/null +++ b/packages/medusa/src/utils/validate-id.ts @@ -0,0 +1,41 @@ +/** +* Confirms whether a given raw id is valid. Fails if the provided +* id is null or undefined. The validate function takes an optional config +* param, to support checking id prefix and length. +* @param rawId - the id to validate. +* @param config - optional config +* @returns the rawId given that nothing failed +*/ +import { MedusaError } from "medusa-core-utils/dist" + +export function validateId( + rawId: string, + config: { prefix?: string; length?: number } = {} +): string { + const { prefix, length } = config + if (!rawId) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Failed to validate id: ${rawId}` + ) + } + + if (prefix || length) { + const [pre, rand] = rawId.split("_") + if (prefix && pre !== prefix) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `The provided id: ${rawId} does not adhere to prefix constraint: ${prefix}` + ) + } + + if (length && length !== rand.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `The provided id: ${rawId} does not adhere to length constraint: ${length}` + ) + } + } + + return rawId +} \ No newline at end of file