feat(product): Create (+ workflow), delete, restore (#4459)

* Feat: create product with product module

* feat: create product wip

* feat: create product wip

* feat: update product relation and generate image migration

* lint

* conitnue implementation

* continue implementation and add integration tests for produceService.create

* Add integration tests for product creation at the module level for the complete flow

* only use persist since write operations are always wrapped in a transaction which will be committed and flushed

* simplify the transaction wrapper to make future changes easier

* feat: move some utils to the utils package to simplify its usage

* tests: fix unit tests

* feat: create variants along side the product

* Add more integration tests an update migrations

* chore: Update actions workflow to include packages integration tests

* small types and utils cleanup

* chore: Add support for database debug option

* chore: Add missing types in package.json from types and util, validate that all the models are sync with medusa

* expose retrieve method

* fix types issues

* fix unit tests and move integration tests workflow with the plugins integration tests

* chore: remove migration function export from the definition to prevent them to be ran by the medusa cli just in case

* fix package.json script

* chore: workflows

* feat: start creating the create product workflow

* feat: add empty step for prices and sales channel

* tests: update scripts and action envs

* fix imports

* feat: Add proper soft deleted support + add product deletion service public api

* chore: update migrations

* chore: update migrations

* chore: update todo

* feat: Add product deletion to the create-product workflow as compensation

* chore: cleanup product utils

* feat: Add support for cascade soft-remove

* feat: refactor repository to take into account withDeleted

* fix integration tests

* Add support for force delete -> delete, cleanup repositories and improvements

* Add support for restoring a product and add integration tests

* cleaup + tests

* types

* fix integration tests

* remove unnecessary comments

* move specific mikro orm usage to the DAL

* Cleanup workflow functions

* Make deleted_at optional at the property level and add url index for the images

* address feedback + cleanup

* fix export

* merge migrations into one

* feat(product, types): added missing product variant methods (#4475)

* chore: added missing product variant methods

* chore: address PR feedback

* chore: catch undefined case for retrieve + specs for variant service

* chore: align TEntity + add changeset

* chore: revert changeset, TEntity to ProductVariant

* chore: write tests for pagination, unskip the test

* Create chilled-mice-deliver.md

* update integration fixtuers

* update pipeline node version

* rename github action

* fix pipeline

* feat(medusa, types): added missing category tests and service methods (#4499)

* chore: added missing category tests and service methods

* chore: added type changes to module service

* chore: address pr feedback

* update repositories manager usage and serialisation from the write public API

* move serializisation to the DAL

* rename template args

* chore: added collection methods for module and collection service (#4505)

* chore: added collection methods for module and collection service

* Create fresh-islands-teach.md

* chore: move retrieve entity to utils package

* chore: make products optional in DTO type

---------

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>

* feat(product): Apply transaction decorators to the services (#4512)

---------

Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>
Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
This commit is contained in:
Adrien de Peretti
2023-07-16 20:19:23 +02:00
committed by GitHub
co-authored by Oliver Windall Juhl Riqwan Thamir Carlos R. L. Rodrigues
parent 5b91a3503a
commit befc2f1c80
98 changed files with 5444 additions and 688 deletions
@@ -0,0 +1,159 @@
import { EntityManager } from "typeorm"
import {
IInventoryService,
MedusaContainer,
ProductTypes,
} from "@medusajs/types"
import { ulid } from "ulid"
import { MedusaError } from "@medusajs/utils"
import {
DistributedTransaction,
TransactionHandlerType,
TransactionOrchestrator,
TransactionPayload,
TransactionState,
TransactionStepsDefinition,
} from "../../../utils/transaction"
import { CreateProductVariantInput } from "../../../types/product-variant"
import {
attachInventoryItems,
createInventoryItems,
createProducts,
removeInventoryItems,
removeProducts,
} from "../../functions"
enum Actions {
createProduct = "createProduct",
createPrices = "createPrices",
attachToSalesChannel = "attachToSalesChannel",
createInventoryItems = "createInventoryItems",
attachInventoryItems = "attachInventoryItems",
}
const workflowSteps: TransactionStepsDefinition = {
next: {
action: Actions.createProduct,
saveResponse: true,
next: {
action: Actions.attachToSalesChannel,
saveResponse: true,
next: {
action: Actions.createPrices,
saveResponse: true,
next: {
action: Actions.createInventoryItems,
saveResponse: true,
next: {
action: Actions.attachInventoryItems,
noCompensation: true,
},
},
},
},
},
}
const createProductOrchestrator = new TransactionOrchestrator(
"create-product",
workflowSteps
)
type InjectedDependencies = {
manager: EntityManager
container: MedusaContainer
inventoryService?: IInventoryService
}
export async function createProductWorkflow(
dependencies: InjectedDependencies,
productId: string,
input: CreateProductVariantInput[]
): Promise<DistributedTransaction> {
const { manager, container } = dependencies
async function transactionHandler(
actionId: string,
type: TransactionHandlerType,
payload: TransactionPayload
) {
const command = {
[Actions.createProduct]: {
[TransactionHandlerType.INVOKE]: async (
data: ProductTypes.CreateProductDTO[]
) => {
return await createProducts({
container,
data,
})
},
[TransactionHandlerType.COMPENSATE]: async (
data: any[],
{ invoke }
) => {
const createdProducts = invoke[Actions.createProduct]
return await removeProducts({ container, data: createdProducts })
},
},
[Actions.createInventoryItems]: {
[TransactionHandlerType.INVOKE]: async (
data: CreateProductVariantInput[],
{ invoke }
) => {
const { [Actions.createProduct]: products } = invoke
return await createInventoryItems({
container,
manager,
data: products,
})
},
[TransactionHandlerType.COMPENSATE]: async (_, { invoke }) => {
const variantInventoryItemsData = invoke[Actions.createInventoryItems]
await removeInventoryItems({
container,
manager,
data: variantInventoryItemsData,
})
},
},
[Actions.attachInventoryItems]: {
[TransactionHandlerType.INVOKE]: async (
data: CreateProductVariantInput[],
{ invoke }
) => {
const { [Actions.createInventoryItems]: inventoryItemsResult } =
invoke
return await attachInventoryItems({
container,
manager,
data: inventoryItemsResult,
})
},
},
}
return command[actionId][type](payload.data, payload.context)
}
const orchestrator = createProductOrchestrator
const transaction = await orchestrator.beginTransaction(
ulid(),
transactionHandler,
input
)
await orchestrator.resume(transaction)
if (transaction.getState() !== TransactionState.DONE) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
transaction
.getErrors()
.map((err) => err.error?.message)
.join("\n")
)
}
return transaction
}
@@ -0,0 +1,34 @@
import {
InventoryItemDTO,
MedusaContainer,
ProductTypes,
} from "@medusajs/types"
import { EntityManager } from "typeorm"
export async function attachInventoryItems({
container,
manager,
data,
}: {
container: MedusaContainer
manager: EntityManager
data: {
variant: ProductTypes.ProductVariantDTO
inventoryItem: InventoryItemDTO
}[]
}) {
const productVariantInventoryService = container
.resolve("productVariantInventoryService")
.withTransaction(manager)
return await Promise.all(
data
.filter((d) => d)
.map(async ({ variant, inventoryItem }) => {
return await productVariantInventoryService.attachInventoryItem(
variant.id,
inventoryItem.id
)
})
)
}
@@ -0,0 +1,55 @@
import {
IInventoryService,
MedusaContainer,
ProductTypes,
} from "@medusajs/types"
import { EntityManager } from "typeorm"
export async function createInventoryItems({
container,
manager,
data,
}: {
container: MedusaContainer
manager: EntityManager
data: ProductTypes.ProductDTO[]
}) {
const inventoryService: IInventoryService =
container.resolve("inventoryService")
const context = { transactionManager: manager }
const variants = data.reduce(
(
acc: ProductTypes.ProductVariantDTO[],
product: ProductTypes.ProductDTO
) => {
return acc.concat(product.variants)
},
[]
)
return await Promise.all(
variants.map(async (variant) => {
if (!variant.manage_inventory) {
return
}
const inventoryItem = await inventoryService!.createInventoryItem(
{
sku: variant.sku!,
origin_country: variant.origin_country!,
hs_code: variant.hs_code!,
mid_code: variant.mid_code!,
material: variant.material!,
weight: variant.weight!,
length: variant.length!,
height: variant.height!,
width: variant.width!,
},
context
)
return { variant, inventoryItem }
})
)
}
@@ -0,0 +1,12 @@
import { MedusaContainer, ProductTypes } from "@medusajs/types"
export async function removeProducts({
container,
data,
}: {
container: MedusaContainer
data: ProductTypes.ProductDTO[]
}): Promise<ProductTypes.ProductDTO[]> {
const productModuleService = container.resolve("productModuleService")
return await productModuleService.softDelete(data.map((p) => p.id))
}
@@ -0,0 +1,5 @@
export * from "./create-prducts"
export * from "./remove-products"
export * from "./create-inventory-items"
export * from "./remove-inventory-items"
export * from "./attach-inventory-items"
@@ -0,0 +1,26 @@
import { InventoryItemDTO, MedusaContainer } from "@medusajs/types"
import { EntityManager } from "typeorm"
export async function removeInventoryItems({
container,
manager,
data,
}: {
container: MedusaContainer
manager: EntityManager
data: {
inventoryItem: InventoryItemDTO
}[]
}) {
const inventoryService = container.resolve("inventoryService")
const context = { transactionManager: manager }
return await Promise.all(
data.map(async ({ inventoryItem }) => {
return await inventoryService!.deleteInventoryItem(
inventoryItem.id,
context
)
})
)
}
@@ -0,0 +1,12 @@
import { MedusaContainer, ProductTypes } from "@medusajs/types"
export async function createProducts({
container,
data,
}: {
container: MedusaContainer
data: ProductTypes.CreateProductDTO[]
}) {
const productModuleService = container.resolve("productModuleService")
return await productModuleService.create(data)
}
@@ -1,14 +1,9 @@
import { FindOptions, RepositoryService } from "@medusajs/types"
import { BaseRepository } from "../../src/repositories/base"
class CustomRepository implements RepositoryService {
constructor() {}
find(options?: FindOptions): Promise<any[]> {
throw new Error("Method not implemented.")
}
findAndCount(options?: FindOptions): Promise<[any[], number]> {
throw new Error("Method not implemented.")
class CustomRepository extends BaseRepository {
constructor({ manager }) {
// @ts-ignore
super(...arguments)
}
}
@@ -0,0 +1,80 @@
import { ProductTypes } from "@medusajs/types"
import faker from "faker"
import { Image } from "@models"
export const buildProductOnlyData = ({
title,
description,
subtitle,
is_giftcard,
discountable,
thumbnail,
images,
status,
}: {
title?: string
description?: string
subtitle?: string
is_giftcard?: boolean
discountable?: boolean
thumbnail?: string
images?: { id?: string; url: string }[]
status?: ProductTypes.ProductStatus
} = {}) => {
return {
title: title ?? faker.commerce.productName(),
description: description ?? faker.commerce.productName(),
subtitle: subtitle ?? faker.commerce.productName(),
is_giftcard: is_giftcard ?? false,
discountable: discountable ?? true,
thumbnail: thumbnail as string,
status: status ?? ProductTypes.ProductStatus.PUBLISHED,
images: (images ?? []) as Image[],
}
}
export const buildProductAndRelationsData = ({
title,
description,
subtitle,
is_giftcard,
discountable,
thumbnail,
images,
status,
type,
tags,
options,
variants,
}: Partial<ProductTypes.CreateProductDTO>) => {
const defaultOptionTitle = faker.commerce.productName()
return {
title: title ?? faker.commerce.productName(),
description: description ?? faker.commerce.productName(),
subtitle: subtitle ?? faker.commerce.productName(),
is_giftcard: is_giftcard ?? false,
discountable: discountable ?? true,
thumbnail: thumbnail as string,
status: status ?? ProductTypes.ProductStatus.PUBLISHED,
images: (images ?? []) as Image[],
type: type ? { value: type } : { value: faker.commerce.productName() },
tags: tags ?? [{ value: "tag-1" }],
options: options ?? [
{
title: defaultOptionTitle,
},
],
variants: variants ?? [
{
title: faker.commerce.productName(),
sku: faker.commerce.productName(),
options: [
{
value: defaultOptionTitle + faker.commerce.productName(),
},
],
},
],
// TODO: add categories, must be created first
}
}
@@ -1,5 +1,7 @@
import { ProductTypes } from "@medusajs/types"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import {
Image,
Product,
ProductCategory,
ProductCollection,
@@ -7,9 +9,16 @@ import {
} from "@models"
import ProductOption from "../../../src/models/product-option"
export * from "./data/create-product"
export async function createProductAndTags(
manager: SqlEntityManager,
data: any[]
data: {
id?: string
title: string
status: ProductTypes.ProductStatus
tags?: { id: string; value: string }[]
}[]
) {
const products: any[] = data.map((productData) => {
return manager.create(Product, productData)
@@ -35,7 +44,11 @@ export async function createProductVariants(
export async function createCollections(
manager: SqlEntityManager,
collectionData: any[]
collectionData: {
id?: string
title: string
handle?: string
}[]
) {
const collections: any[] = collectionData.map((collectionData) => {
return manager.create(ProductCollection, collectionData)
@@ -48,10 +61,21 @@ export async function createCollections(
export async function createOptions(
manager: SqlEntityManager,
optionsData: any[]
optionsData: {
id?: string
product: { id: string }
title: string
value?: string
values?: {
id?: string
value: string
variant?: { id: string } & any
}[]
variant?: { id: string } & any
}[]
) {
const options: any[] = optionsData.map((o) => {
return manager.create(ProductOption, o)
const options: any[] = optionsData.map((option) => {
return manager.create(ProductOption, option)
})
await manager.persistAndFlush(options)
@@ -59,6 +83,19 @@ export async function createOptions(
return options
}
export async function createImages(
manager: SqlEntityManager,
imagesData: string[]
) {
const images: any[] = imagesData.map((img) => {
return manager.create(Image, { url: img })
})
await manager.persistAndFlush(images)
return images
}
export async function assignCategoriesToProduct(
manager: SqlEntityManager,
product: Product,
@@ -0,0 +1,44 @@
import { ProductTypes } from "@medusajs/types"
import faker from "faker"
export const buildProductVariantOnlyData = ({
title,
sku,
barcode,
ean,
upc,
allow_backorder,
inventory_quantity,
manage_inventory,
hs_code,
origin_country,
mid_code,
material,
weight,
length,
height,
width,
options,
metadata,
}: Partial<ProductTypes.CreateProductVariantOnlyDTO>) => {
return {
title: title ?? faker.commerce.productName(),
sku: sku ?? faker.commerce.productName(),
barcode,
ean,
upc,
allow_backorder,
inventory_quantity,
manage_inventory,
hs_code,
origin_country,
mid_code,
material,
weight,
length,
height,
width,
options,
metadata,
}
}
@@ -0,0 +1 @@
export * from "./data/create-variant"
@@ -1,11 +1,13 @@
import { MedusaModule } from "@medusajs/modules-sdk"
import { Product } from "@models"
import { initialize } from "../../src"
import * as CustomRepositories from "../__fixtures__/module"
import { ProductRepository } from "../__fixtures__/module"
import { createProductAndTags } from "../__fixtures__/product"
import { productsData } from "../__fixtures__/product/data"
import { DB_URL, TestDatabase } from "../utils"
import { buildProductAndRelationsData } from "../__fixtures__/product/data/create-product"
import { kebabCase } from "@medusajs/utils"
import { IProductModuleService } from "@medusajs/types"
const beforeEach_ = async () => {
await TestDatabase.setupDatabase()
@@ -18,12 +20,11 @@ const afterEach_ = async () => {
describe("Product module", function () {
describe("Using built-in data access layer", function () {
let module
let products: Product[]
let module: IProductModuleService
beforeEach(async () => {
const testManager = await beforeEach_()
products = await createProductAndTags(testManager, productsData)
await createProductAndTags(testManager, productsData)
module = await initialize({
database: {
@@ -46,13 +47,12 @@ describe("Product module", function () {
})
describe("Using custom data access layer", function () {
let module
let products: Product[]
let module: IProductModuleService
beforeEach(async () => {
const testManager = await beforeEach_()
products = await createProductAndTags(testManager, productsData)
await createProductAndTags(testManager, productsData)
module = await initialize({
database: {
@@ -78,12 +78,11 @@ describe("Product module", function () {
})
describe("Using custom data access layer and connection", function () {
let module
let products: Product[]
let module: IProductModuleService
beforeEach(async () => {
const testManager = await beforeEach_()
products = await createProductAndTags(testManager, productsData)
await createProductAndTags(testManager, productsData)
MedusaModule.clearInstances()
@@ -106,4 +105,244 @@ describe("Product module", function () {
expect(products).toHaveLength(0)
})
})
describe("create", function () {
let module: IProductModuleService
let images = ["image-1"]
beforeEach(async () => {
await beforeEach_()
MedusaModule.clearInstances()
module = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
},
})
})
afterEach(afterEach_)
it("should create a product", async () => {
const data = buildProductAndRelationsData({
images,
thumbnail: images[0],
})
const products = await module.create([data])
expect(products).toHaveLength(1)
expect(products[0].images).toHaveLength(1)
expect(products[0].options).toHaveLength(1)
expect(products[0].tags).toHaveLength(1)
expect(products[0].categories).toHaveLength(0)
expect(products[0].variants).toHaveLength(1)
expect(products[0]).toEqual(
expect.objectContaining({
id: expect.any(String),
title: data.title,
handle: kebabCase(data.title),
description: data.description,
subtitle: data.subtitle,
is_giftcard: data.is_giftcard,
discountable: data.discountable,
thumbnail: images[0],
status: data.status,
images: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
url: images[0],
}),
]),
options: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
title: data.options[0].title,
values: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
value: data.variants[0].options?.[0].value,
}),
]),
}),
]),
tags: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
value: data.tags[0].value,
}),
]),
type: expect.objectContaining({
id: expect.any(String),
value: data.type.value,
}),
variants: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
title: data.variants[0].title,
sku: data.variants[0].sku,
allow_backorder: false,
manage_inventory: true,
inventory_quantity: 100,
variant_rank: 0,
options: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
value: data.variants[0].options?.[0].value,
}),
]),
}),
]),
})
)
})
})
describe("softDelete", function () {
let module: IProductModuleService
let images = ["image-1"]
beforeEach(async () => {
await beforeEach_()
MedusaModule.clearInstances()
module = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
},
})
})
afterEach(afterEach_)
it("should soft delete a product and its cascaded relations", async () => {
const data = buildProductAndRelationsData({
images,
thumbnail: images[0],
})
const products = await module.create([data])
await module.softDelete([products[0].id])
const deletedProducts = await module.list(
{ id: products[0].id },
{
relations: [
"variants",
"variants.options",
"options",
"options.values",
],
withDeleted: true,
}
)
expect(deletedProducts).toHaveLength(1)
expect(deletedProducts[0].deleted_at).not.toBeNull()
for (const option of deletedProducts[0].options) {
expect(option.deleted_at).not.toBeNull()
}
const productOptionsValues = deletedProducts[0].options
.map((o) => o.values)
.flat()
for (const optionValue of productOptionsValues) {
expect(optionValue.deleted_at).not.toBeNull()
}
for (const variant of deletedProducts[0].variants) {
expect(variant.deleted_at).not.toBeNull()
}
const variantsOptions = deletedProducts[0].options
.map((o) => o.values)
.flat()
for (const option of variantsOptions) {
expect(option.deleted_at).not.toBeNull()
}
})
})
describe("restore", function () {
let module: IProductModuleService
let images = ["image-1"]
beforeEach(async () => {
await beforeEach_()
MedusaModule.clearInstances()
module = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
},
})
})
afterEach(afterEach_)
it("should restore a soft deleted product and its cascaded relations", async () => {
const data = buildProductAndRelationsData({
images,
thumbnail: images[0],
})
const products = await module.create([data])
await module.softDelete([products[0].id])
await module.restore([products[0].id])
const deletedProducts = await module.list(
{ id: products[0].id },
{
relations: [
"variants",
"variants.options",
"variants.options",
"options",
"options.values",
],
withDeleted: true,
}
)
expect(deletedProducts).toHaveLength(1)
expect(deletedProducts[0].deleted_at).toBeNull()
for (const option of deletedProducts[0].options) {
expect(option.deleted_at).toBeNull()
}
const productOptionsValues = deletedProducts[0].options
.map((o) => o.values)
.flat()
for (const optionValue of productOptionsValues) {
expect(optionValue.deleted_at).toBeNull()
}
for (const variant of deletedProducts[0].variants) {
expect(variant.deleted_at).toBeNull()
}
const variantsOptions = deletedProducts[0].options
.map((o) => o.values)
.flat()
for (const option of variantsOptions) {
expect(option.deleted_at).toBeNull()
}
})
})
})
@@ -10,7 +10,7 @@ import { productCategoriesData } from "../../../__fixtures__/product-category/da
jest.setTimeout(30000)
describe("ProductCategory Service", () => {
describe("Product category Service", () => {
let service: ProductCategoryService
let testManager: SqlEntityManager
let repositoryManager: SqlEntityManager
@@ -47,7 +47,7 @@ describe("ProductCategory Service", () => {
const productCategoryResults = await service.list(
{},
{
select: ["id", "parent_category_id"] as any,
select: ["id", "parent_category_id"],
}
)
@@ -128,7 +128,7 @@ describe("ProductCategory Service", () => {
include_descendants_tree: true,
},
{
select: ["id", "handle"] as any,
select: ["id", "handle"],
}
)
@@ -192,7 +192,7 @@ describe("ProductCategory Service", () => {
is_internal: false,
},
{
select: ["id", "handle"] as any,
select: ["id", "handle"],
}
)
@@ -230,4 +230,334 @@ describe("ProductCategory Service", () => {
])
})
})
describe("retrieve", () => {
const categoryOneId = "category-1"
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
productCategories = await createProductCategories(
testManager,
productCategoriesData
)
})
it("should return category for the given id", async () => {
const productCategoryResults = await service.retrieve(
categoryOneId,
)
expect(productCategoryResults).toEqual(
expect.objectContaining({
id: categoryOneId
})
)
})
it("should throw an error when category with id does not exist", async () => {
let error
try {
await service.retrieve("does-not-exist")
} catch (e) {
error = e
}
expect(error.message).toEqual('ProductCategory with id: does-not-exist was not found')
})
it("should throw an error when an id is not provided", async () => {
let error
try {
await service.retrieve(undefined as unknown as string)
} catch (e) {
error = e
}
expect(error.message).toEqual('"productCategoryId" must be defined')
})
it("should return category based on config select param", async () => {
const productCategoryResults = await service.retrieve(
categoryOneId,
{
select: ["id", "parent_category_id"],
}
)
expect(productCategoryResults).toEqual(
expect.objectContaining({
id: categoryOneId,
parent_category_id: "category-0",
})
)
})
it("should return category based on config relation param", async () => {
const productCategoryResults = await service.retrieve(
categoryOneId,
{
select: ["id", "parent_category_id"],
relations: ["parent_category"]
}
)
expect(productCategoryResults).toEqual(
expect.objectContaining({
id: categoryOneId,
category_children: [
expect.objectContaining({
id: 'category-1-a',
}),
expect.objectContaining({
id: 'category-1-b',
})
],
parent_category: expect.objectContaining({
id: "category-0"
})
})
)
})
})
describe("listAndCount", () => {
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
productCategories = await createProductCategories(
testManager,
productCategoriesData
)
})
it("should return categories and count based on take and skip", async () => {
let results = await service.listAndCount(
{},
{
take: 1,
}
)
expect(results[1]).toEqual(5)
expect(results[0]).toEqual([
expect.objectContaining({
id: "category-0",
}),
])
results = await service.listAndCount(
{},
{
take: 1,
skip: 1
}
)
expect(results[1]).toEqual(5)
expect(results[0]).toEqual([
expect.objectContaining({
id: "category-1",
}),
])
})
it("should return all product categories and count", async () => {
const productCategoryResults = await service.listAndCount(
{},
{
select: ["id", "parent_category_id"],
relations: ["parent_category"],
}
)
expect(productCategoryResults[1]).toEqual(5)
expect(productCategoryResults[0]).toEqual([
expect.objectContaining({
id: "category-0",
parent_category: null
}),
expect.objectContaining({
id: "category-1",
parent_category: expect.objectContaining({
id: "category-0",
}),
}),
expect.objectContaining({
id: "category-1-a",
parent_category: expect.objectContaining({
id: "category-1",
}),
}),
expect.objectContaining({
id: "category-1-b",
parent_category: expect.objectContaining({
id: "category-1",
}),
}),
expect.objectContaining({
id: "category-1-b-1",
parent_category: expect.objectContaining({
id: "category-1-b",
}),
}),
])
})
it("should only return categories that are scoped by parent_category_id", async () => {
let productCategoryResults = await service.listAndCount(
{ parent_category_id: null },
{
select: ["id"],
}
)
expect(productCategoryResults[1]).toEqual(1)
expect(productCategoryResults[0]).toEqual([
expect.objectContaining({
id: "category-0",
}),
])
productCategoryResults = await service.listAndCount({
parent_category_id: "category-0",
})
expect(productCategoryResults[1]).toEqual(1)
expect(productCategoryResults[0]).toEqual([
expect.objectContaining({
id: "category-1",
}),
])
productCategoryResults = await service.listAndCount({
parent_category_id: ["category-1-b", "category-0"],
})
expect(productCategoryResults[1]).toEqual(2)
expect(productCategoryResults[0]).toEqual([
expect.objectContaining({
id: "category-1",
}),
expect.objectContaining({
id: "category-1-b-1",
}),
])
})
it("should includes descendants when include_descendants_tree is true", async () => {
const productCategoryResults = await service.listAndCount(
{
parent_category_id: null,
include_descendants_tree: true,
},
{
select: ["id", "handle"],
}
)
expect(productCategoryResults[1]).toEqual(1)
const serializedObject = JSON.parse(
JSON.stringify(productCategoryResults[0])
)
expect(serializedObject).toEqual([
expect.objectContaining({
id: "category-0",
handle: "category-0",
mpath: "category-0.",
parent_category_id: null,
parent_category: null,
category_children: [
expect.objectContaining({
id: "category-1",
handle: "category-1",
mpath: "category-0.category-1.",
parent_category_id: "category-0",
parent_category: "category-0",
category_children: [
expect.objectContaining({
id: "category-1-a",
handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.",
parent_category_id: "category-1",
parent_category: "category-1",
category_children: [],
}),
expect.objectContaining({
id: "category-1-b",
handle: "category-1-b",
mpath: "category-0.category-1.category-1-b.",
parent_category_id: "category-1",
parent_category: "category-1",
category_children: [
expect.objectContaining({
id: "category-1-b-1",
handle: "category-1-b-1",
mpath:
"category-0.category-1.category-1-b.category-1-b-1.",
parent_category_id: "category-1-b",
parent_category: "category-1-b",
category_children: [],
}),
],
}),
],
}),
],
}),
])
})
it("should filter out children when include_descendants_tree is true", async () => {
const productCategoryResults = await service.listAndCount(
{
parent_category_id: null,
include_descendants_tree: true,
is_internal: false,
},
{
select: ["id", "handle"],
}
)
expect(productCategoryResults[1]).toEqual(1)
const serializedObject = JSON.parse(
JSON.stringify(productCategoryResults[0])
)
expect(serializedObject).toEqual([
expect.objectContaining({
id: "category-0",
handle: "category-0",
mpath: "category-0.",
parent_category_id: null,
parent_category: null,
category_children: [
expect.objectContaining({
id: "category-1",
handle: "category-1",
mpath: "category-0.category-1.",
parent_category_id: "category-0",
parent_category: "category-0",
category_children: [
expect.objectContaining({
id: "category-1-a",
handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.",
parent_category_id: "category-1",
parent_category: "category-1",
category_children: [],
}),
],
}),
],
}),
])
})
})
})
@@ -9,7 +9,7 @@ import { createCollections } from "../../../__fixtures__/product"
jest.setTimeout(30000)
describe("Product Service", () => {
describe("Product collection Service", () => {
let service: ProductCollectionService
let testManager: SqlEntityManager
let repositoryManager: SqlEntityManager
@@ -59,9 +59,9 @@ describe("Product Service", () => {
})
it("list product collections", async () => {
const tagsResults = await service.list()
const productCollectionResults = await service.list()
expect(tagsResults).toEqual([
expect(productCollectionResults).toEqual([
expect.objectContaining({
id: "test-1",
title: "col 1",
@@ -82,9 +82,9 @@ describe("Product Service", () => {
})
it("list product collections by id", async () => {
const tagsResults = await service.list({ id: data![0].id })
const productCollectionResults = await service.list({ id: data![0].id })
expect(tagsResults).toEqual([
expect(productCollectionResults).toEqual([
expect.objectContaining({
id: "test-1",
title: "col 1",
@@ -93,9 +93,9 @@ describe("Product Service", () => {
})
it("list product collections by title matching string", async () => {
const tagsResults = await service.list({ title: "col 3 extra" })
const productCollectionResults = await service.list({ title: "col 3 extra" })
expect(tagsResults).toEqual([
expect(productCollectionResults).toEqual([
expect.objectContaining({
id: "test-3",
title: "col 3 extra",
@@ -103,4 +103,174 @@ describe("Product Service", () => {
])
})
})
describe("listAndCount", () => {
const data = [
{
id: "test-1",
title: "col 1",
},
{
id: "test-2",
title: "col 2",
},
{
id: "test-3",
title: "col 3 extra",
},
{
id: "test-4",
title: "col 4 extra",
},
]
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
collectionsData = await createCollections(testManager, data)
})
it("should return all collections and count", async () => {
const [productCollectionResults, count] = await service.listAndCount()
const serialized = JSON.parse(JSON.stringify(productCollectionResults))
expect(serialized).toEqual([
expect.objectContaining({
id: "test-1",
title: "col 1",
}),
expect.objectContaining({
id: "test-2",
title: "col 2",
}),
expect.objectContaining({
id: "test-3",
title: "col 3 extra",
}),
expect.objectContaining({
id: "test-4",
title: "col 4 extra",
}),
])
})
it("should return count and collections based on filter data", async () => {
const [productCollectionResults, count] = await service.listAndCount({ id: data![0].id })
const serialized = JSON.parse(JSON.stringify(productCollectionResults))
expect(count).toEqual(1)
expect(serialized).toEqual([
expect.objectContaining({
id: "test-1",
title: "col 1",
}),
])
})
it("should return count and collections based on config data", async () => {
const [productCollectionResults, count] = await service.listAndCount({}, {
relations: ['products'],
select: ['title'],
take: 1,
skip: 1,
})
const serialized = JSON.parse(JSON.stringify(productCollectionResults))
expect(count).toEqual(4)
expect(serialized).toEqual([
{
id: "test-2",
title: "col 2",
products: []
},
])
})
})
describe("retrieve", () => {
const collectionData = {
id: "collection-1",
title: "collection 1",
}
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
await createCollections(testManager, [collectionData])
})
it("should return collection for the given id", async () => {
const productCollectionResults = await service.retrieve(
collectionData.id,
)
expect(productCollectionResults).toEqual(
expect.objectContaining({
id: collectionData.id
})
)
})
it("should throw an error when collection with id does not exist", async () => {
let error
try {
await service.retrieve("does-not-exist")
} catch (e) {
error = e
}
expect(error.message).toEqual('ProductCollection with id: does-not-exist was not found')
})
it("should throw an error when an id is not provided", async () => {
let error
try {
await service.retrieve(undefined as unknown as string)
} catch (e) {
error = e
}
expect(error.message).toEqual('"productCollectionId" must be defined')
})
it("should return collection based on config select param", async () => {
const productCollectionResults = await service.retrieve(
collectionData.id,
{
select: ["id", "title"],
}
)
const serialized = JSON.parse(JSON.stringify(productCollectionResults))
expect(serialized).toEqual(
{
id: collectionData.id,
title: collectionData.title,
}
)
})
it("should return collection based on config relation param", async () => {
const productCollectionResults = await service.retrieve(
collectionData.id,
{
select: ["id", "title"],
relations: ["products"]
}
)
const serialized = JSON.parse(JSON.stringify(productCollectionResults))
expect(serialized).toEqual(
{
id: collectionData.id,
title: collectionData.title,
products: []
}
)
})
})
})
@@ -0,0 +1,248 @@
import { IProductModuleService } from "@medusajs/types"
import { Product, ProductCategory } from "@models"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { ProductTypes } from "@medusajs/types"
import { initialize } from "../../../../src"
import { DB_URL, TestDatabase } from "../../../utils"
import { createProductCategories } from "../../../__fixtures__/product-category"
describe("ProductModuleService product categories", () => {
let service: IProductModuleService
let testManager: SqlEntityManager
let repositoryManager: SqlEntityManager
let productOne: Product
let productTwo: Product
let productCategoryOne: ProductCategory
let productCategoryTwo: ProductCategory
let productCategories: ProductCategory[]
beforeEach(async () => {
await TestDatabase.setupDatabase()
repositoryManager = await TestDatabase.forkManager()
service = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
},
})
testManager = await TestDatabase.forkManager()
productOne = testManager.create(Product, {
id: "product-1",
title: "product 1",
status: ProductTypes.ProductStatus.PUBLISHED,
})
productTwo = testManager.create(Product, {
id: "product-2",
title: "product 2",
status: ProductTypes.ProductStatus.PUBLISHED,
})
const productCategoriesData = [{
id: "test-1",
name: "category 1",
products: [productOne],
},{
id: "test-2",
name: "category",
products: [productTwo],
}]
productCategories = await createProductCategories(
testManager,
productCategoriesData
)
productCategoryOne = productCategories[0]
productCategoryTwo = productCategories[1]
await testManager.persistAndFlush([productCategoryOne, productCategoryTwo])
})
afterEach(async () => {
await TestDatabase.clearDatabase()
})
describe("listCategories", () => {
it("should return categories queried by ID", async () => {
const results = await service.listCategories({
id: productCategoryOne.id,
})
expect(results).toEqual([
expect.objectContaining({
id: productCategoryOne.id,
}),
])
})
it("should return categories based on the options and filter parameter", async () => {
let results = await service.listCategories(
{
id: productCategoryOne.id,
},
{
take: 1,
}
)
expect(results).toEqual([
expect.objectContaining({
id: productCategoryOne.id,
}),
])
results = await service.listCategories({}, { take: 1, skip: 1 })
expect(results).toEqual([
expect.objectContaining({
id: productCategoryTwo.id,
}),
])
})
it("should return only requested fields and relations for categories", async () => {
const results = await service.listCategories(
{
id: productCategoryOne.id,
},
{
select: ["id", "name", "products.title"],
relations: ["products"],
}
)
expect(results).toEqual([
expect.objectContaining({
id: "test-1",
name: "category 1",
products: [expect.objectContaining({
id: "product-1",
title: "product 1",
})],
}),
])
})
})
describe("listAndCountCategories", () => {
it("should return categories and count queried by ID", async () => {
const results = await service.listAndCountCategories({
id: productCategoryOne.id,
})
expect(results[1]).toEqual(1)
expect(results[0]).toEqual([
expect.objectContaining({
id: productCategoryOne.id,
}),
])
})
it("should return categories and count based on the options and filter parameter", async () => {
let results = await service.listAndCountCategories(
{
id: productCategoryOne.id,
},
{
take: 1,
}
)
expect(results[1]).toEqual(1)
expect(results[0]).toEqual([
expect.objectContaining({
id: productCategoryOne.id,
}),
])
results = await service.listAndCountCategories({}, { take: 1 })
expect(results[1]).toEqual(2)
results = await service.listAndCountCategories({}, { take: 1, skip: 1 })
expect(results[1]).toEqual(2)
expect(results[0]).toEqual([
expect.objectContaining({
id: productCategoryTwo.id,
}),
])
})
it("should return only requested fields and relations for categories", async () => {
const results = await service.listAndCountCategories(
{
id: productCategoryOne.id,
},
{
select: ["id", "name", "products.title"],
relations: ["products"],
}
)
expect(results[1]).toEqual(1)
expect(results[0]).toEqual([
expect.objectContaining({
id: "test-1",
name: "category 1",
products: [expect.objectContaining({
id: "product-1",
title: "product 1",
})],
}),
])
})
})
describe("retrieveCategory", () => {
it("should return the requested category", async () => {
const result = await service.retrieveCategory(productCategoryOne.id)
expect(result).toEqual(
expect.objectContaining({
id: "test-1",
name: "category 1",
}),
)
})
it("should return requested attributes when requested through config", async () => {
const result = await service.retrieveCategory(
productCategoryOne.id,
{
select: ["id", "name", "products.title"],
relations: ["products"],
}
)
expect(result).toEqual(
expect.objectContaining({
id: "test-1",
name: "category 1",
products: [expect.objectContaining({
id: "product-1",
title: "product 1",
})],
}),
)
})
it("should throw an error when a category with ID does not exist", async () => {
let error
try {
await service.retrieveCategory("does-not-exist")
} catch (e) {
error = e
}
expect(error.message).toEqual("ProductCategory with id: does-not-exist was not found")
})
})
})
@@ -0,0 +1,250 @@
import { IProductModuleService } from "@medusajs/types"
import { Product, ProductCollection } from "@models"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { ProductTypes } from "@medusajs/types"
import { initialize } from "../../../../src"
import { DB_URL, TestDatabase } from "../../../utils"
import { createCollections } from "../../../__fixtures__/product"
describe("ProductModuleService product collections", () => {
let service: IProductModuleService
let testManager: SqlEntityManager
let repositoryManager: SqlEntityManager
let productOne: Product
let productTwo: Product
let productCollectionOne: ProductCollection
let productCollectionTwo: ProductCollection
let productCollections: ProductCollection[]
beforeEach(async () => {
await TestDatabase.setupDatabase()
repositoryManager = await TestDatabase.forkManager()
service = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
},
})
testManager = await TestDatabase.forkManager()
productOne = testManager.create(Product, {
id: "product-1",
title: "product 1",
status: ProductTypes.ProductStatus.PUBLISHED,
})
productTwo = testManager.create(Product, {
id: "product-2",
title: "product 2",
status: ProductTypes.ProductStatus.PUBLISHED,
})
const productCollectionsData = [{
id: "test-1",
title: "collection 1",
products: [productOne],
},{
id: "test-2",
title: "collection",
products: [productTwo],
}]
productCollections = await createCollections(
testManager,
productCollectionsData
)
productCollectionOne = productCollections[0]
productCollectionTwo = productCollections[1]
await testManager.persistAndFlush([productCollectionOne, productCollectionTwo])
})
afterEach(async () => {
await TestDatabase.clearDatabase()
})
describe("listCollections", () => {
it("should return collections queried by ID", async () => {
const results = await service.listCollections({
id: productCollectionOne.id,
})
expect(results).toEqual([
expect.objectContaining({
id: productCollectionOne.id,
}),
])
})
it("should return collections based on the options and filter parameter", async () => {
let results = await service.listCollections(
{
id: productCollectionOne.id,
},
{
take: 1,
}
)
expect(results).toEqual([
expect.objectContaining({
id: productCollectionOne.id,
}),
])
results = await service.listCollections({}, { take: 1, skip: 1 })
expect(results).toEqual([
expect.objectContaining({
id: productCollectionTwo.id,
}),
])
})
it("should return only requested fields and relations for collections", async () => {
const results = await service.listCollections(
{
id: productCollectionOne.id,
},
{
select: ["id", "title", "products.title"],
relations: ["products"],
}
)
expect(results).toEqual([
expect.objectContaining({
id: "test-1",
title: "collection 1",
products: [
expect.objectContaining({
id: "product-1",
title: "product 1",
})
],
}),
])
})
})
describe("listAndCountCollections", () => {
it("should return collections and count queried by ID", async () => {
const results = await service.listAndCountCollections({
id: productCollectionOne.id,
})
expect(results[1]).toEqual(1)
expect(results[0]).toEqual([
expect.objectContaining({
id: productCollectionOne.id,
}),
])
})
it("should return collections and count based on the options and filter parameter", async () => {
let results = await service.listAndCountCollections(
{
id: productCollectionOne.id,
},
{
take: 1,
}
)
expect(results[1]).toEqual(1)
expect(results[0]).toEqual([
expect.objectContaining({
id: productCollectionOne.id,
}),
])
results = await service.listAndCountCollections({}, { take: 1 })
expect(results[1]).toEqual(2)
results = await service.listAndCountCollections({}, { take: 1, skip: 1 })
expect(results[1]).toEqual(2)
expect(results[0]).toEqual([
expect.objectContaining({
id: productCollectionTwo.id,
}),
])
})
it("should return only requested fields and relations for collections", async () => {
const results = await service.listAndCountCollections(
{
id: productCollectionOne.id,
},
{
select: ["id", "title", "products.title"],
relations: ["products"],
}
)
expect(results[1]).toEqual(1)
expect(results[0]).toEqual([
expect.objectContaining({
id: "test-1",
title: "collection 1",
products: [expect.objectContaining({
id: "product-1",
title: "product 1",
})],
}),
])
})
})
describe("retrieveCollection", () => {
it("should return the requested collection", async () => {
const result = await service.retrieveCollection(productCollectionOne.id)
expect(result).toEqual(
expect.objectContaining({
id: "test-1",
title: "collection 1",
}),
)
})
it("should return requested attributes when requested through config", async () => {
const result = await service.retrieveCollection(
productCollectionOne.id,
{
select: ["id", "title", "products.title"],
relations: ["products"],
}
)
expect(result).toEqual(
expect.objectContaining({
id: "test-1",
title: "collection 1",
products: [expect.objectContaining({
id: "product-1",
title: "product 1",
})],
}),
)
})
it("should throw an error when a collection with ID does not exist", async () => {
let error
try {
await service.retrieveCollection("does-not-exist")
} catch (e) {
error = e
}
expect(error.message).toEqual("ProductCollection with id: does-not-exist was not found")
})
})
})
@@ -0,0 +1,182 @@
import { initialize } from "../../../../src"
import { DB_URL, TestDatabase } from "../../../utils"
import { IProductModuleService } from "@medusajs/types"
import { Product, ProductVariant } from "@models"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { ProductTypes } from "@medusajs/types"
describe("ProductModuleService product variants", () => {
let service: IProductModuleService
let testManager: SqlEntityManager
let repositoryManager: SqlEntityManager
let variantOne: ProductVariant
let variantTwo: ProductVariant
let productOne: Product
let productTwo: Product
beforeEach(async () => {
await TestDatabase.setupDatabase()
repositoryManager = await TestDatabase.forkManager()
service = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
},
})
testManager = await TestDatabase.forkManager()
productOne = testManager.create(Product, {
id: "product-1",
title: "product 1",
status: ProductTypes.ProductStatus.PUBLISHED,
})
productTwo = testManager.create(Product, {
id: "product-2",
title: "product 2",
status: ProductTypes.ProductStatus.PUBLISHED,
})
variantOne = testManager.create(ProductVariant, {
id: "test-1",
title: "variant 1",
inventory_quantity: 10,
product: productOne,
})
variantTwo = testManager.create(ProductVariant, {
id: "test-2",
title: "variant",
inventory_quantity: 10,
product: productTwo,
})
await testManager.persistAndFlush([variantOne, variantTwo])
})
afterEach(async () => {
await TestDatabase.clearDatabase()
})
describe("listAndCountVariants", () => {
it("should return variants and count queried by ID", async () => {
const results = await service.listAndCountVariants({
id: variantOne.id,
})
expect(results[1]).toEqual(1)
expect(results[0]).toEqual([
expect.objectContaining({
id: variantOne.id,
}),
])
})
it("should return variants and count based on the options and filter parameter", async () => {
let results = await service.listAndCountVariants(
{
id: variantOne.id,
},
{
take: 1,
}
)
expect(results[1]).toEqual(1)
expect(results[0]).toEqual([
expect.objectContaining({
id: variantOne.id,
}),
])
results = await service.listAndCountVariants({}, { take: 1 })
expect(results[1]).toEqual(2)
results = await service.listAndCountVariants({}, { take: 1, skip: 1 })
expect(results[1]).toEqual(2)
expect(results[0]).toEqual([
expect.objectContaining({
id: variantTwo.id,
}),
])
})
it("should return only requested fields and relations for variants", async () => {
const results = await service.listAndCountVariants(
{
id: variantOne.id,
},
{
select: ["id", "title", "product.title"] as any,
relations: ["product"],
}
)
expect(results[1]).toEqual(1)
expect(results[0]).toEqual([
expect.objectContaining({
id: "test-1",
title: "variant 1",
product_id: "product-1",
// TODO: investigate why this is returning more than the expected results
product: expect.objectContaining({
id: "product-1",
title: "product 1",
}),
}),
])
})
})
describe("retrieveVariant", () => {
it("should return the requested variant", async () => {
const result = await service.retrieveVariant(variantOne.id)
expect(result).toEqual(
expect.objectContaining({
id: "test-1",
title: "variant 1",
}),
)
})
it("should return requested attributes when requested through config", async () => {
const result = await service.retrieveVariant(
variantOne.id,
{
select: ["id", "title", "product.title"] as any,
relations: ["product"],
}
)
expect(result).toEqual(
expect.objectContaining({
id: "test-1",
title: "variant 1",
product_id: "product-1",
product: expect.objectContaining({
id: "product-1",
title: "product 1",
}),
}),
)
})
it("should throw an error when a variant with ID does not exist", async () => {
let error
try {
await service.retrieveVariant("does-not-exist")
} catch (e) {
error = e
}
expect(error.message).toEqual("ProductVariant with id: does-not-exist was not found")
})
})
})
@@ -10,7 +10,7 @@ import { ProductTypes } from "@medusajs/types"
jest.setTimeout(30000)
describe("Product Service", () => {
describe("Product tag Service", () => {
let service: ProductTagService
let testManager: SqlEntityManager
let repositoryManager: SqlEntityManager
@@ -1,6 +1,6 @@
import { TestDatabase } from "../../../utils"
import { ProductVariantService } from "@services"
import { ProductVariantRepository } from "@repositories"
import { ProductService, ProductVariantService } from "@services"
import { ProductRepository, ProductVariantRepository } from "@repositories"
import { Product, ProductTag, ProductVariant } from "@models"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { Collection } from "@mikro-orm/core"
@@ -12,6 +12,7 @@ import {
createProductVariants,
} from "../../../__fixtures__/product"
import { productsData, variantsData } from "../../../__fixtures__/product/data"
import { buildProductVariantOnlyData } from "../../../__fixtures__/variant/data/create-variant"
describe("ProductVariant Service", () => {
let service: ProductVariantService
@@ -20,6 +21,7 @@ describe("ProductVariant Service", () => {
let variantOne: ProductVariant
let variantTwo: ProductVariant
let productOne: Product
const productVariantTestOne = "test-1"
beforeEach(async () => {
await TestDatabase.setupDatabase()
@@ -28,8 +30,17 @@ describe("ProductVariant Service", () => {
const productVariantRepository = new ProductVariantRepository({
manager: repositoryManager,
})
const productRepository = new ProductRepository({
manager: repositoryManager,
})
service = new ProductVariantService({ productVariantRepository })
const productService = new ProductService({
productRepository,
})
service = new ProductVariantService({
productService,
productVariantRepository,
})
})
afterEach(async () => {
@@ -47,7 +58,7 @@ describe("ProductVariant Service", () => {
})
variantOne = testManager.create(ProductVariant, {
id: "test-1",
id: productVariantTestOne,
title: "variant 1",
inventory_quantity: 10,
product: productOne,
@@ -95,7 +106,7 @@ describe("ProductVariant Service", () => {
it("passing populate, scopes the results of the response", async () => {
const results = await service.list(
{
id: "test-1",
id: productVariantTestOne,
},
{
select: ["id", "title", "product.title"] as any,
@@ -105,7 +116,7 @@ describe("ProductVariant Service", () => {
expect(results).toEqual([
expect.objectContaining({
id: "test-1",
id: productVariantTestOne,
title: "variant 1",
product: expect.objectContaining({
id: "product-1",
@@ -118,7 +129,7 @@ describe("ProductVariant Service", () => {
expect(JSON.parse(JSON.stringify(results))).toEqual([
{
id: "test-1",
id: productVariantTestOne,
title: "variant 1",
product_id: "product-1",
product: {
@@ -175,11 +186,148 @@ describe("ProductVariant Service", () => {
expect(JSON.parse(JSON.stringify(variants))).toEqual([
expect.objectContaining({
id: "test-1",
id: productVariantTestOne,
title: "variant title",
sku: "sku 1",
}),
])
})
})
describe("create", function () {
let products: Product[]
let productOptions!: ProductOption[]
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
products = (await createProductAndTags(
testManager,
productsData
)) as Product[]
productOptions = await createOptions(testManager, [
{
id: "test-option-1",
title: "size",
product: products[0],
},
])
})
it("should create a variant", async () => {
const data = buildProductVariantOnlyData({
options: [
{
option: productOptions[0],
value: "XS",
},
],
})
const variants = await service.create(products[0].id, [data])
expect(variants).toHaveLength(1)
expect(variants[0].options).toHaveLength(1)
expect(JSON.parse(JSON.stringify(variants[0]))).toEqual(
expect.objectContaining({
id: expect.any(String),
title: data.title,
sku: data.sku,
inventory_quantity: 100,
allow_backorder: false,
manage_inventory: true,
variant_rank: 0,
product: expect.objectContaining({
id: products[0].id,
}),
options: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
value: data.options![0].value,
}),
]),
})
)
})
})
describe("retrieve", () => {
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
productOne = testManager.create(Product, {
id: "product-1",
title: "product 1",
status: ProductTypes.ProductStatus.PUBLISHED,
})
variantOne = testManager.create(ProductVariant, {
id: productVariantTestOne,
title: "variant 1",
inventory_quantity: 10,
product: productOne,
})
await testManager.persistAndFlush([variantOne])
})
it("should return the requested variant", async () => {
const result = await service.retrieve(variantOne.id)
expect(result).toEqual(
expect.objectContaining({
id: productVariantTestOne,
title: "variant 1",
}),
)
})
it("should return requested attributes when requested through config", async () => {
const result = await service.retrieve(
variantOne.id,
{
select: ["id", "title", "product.title"] as any,
relations: ["product"],
}
)
expect(result).toEqual(
expect.objectContaining({
id: productVariantTestOne,
title: "variant 1",
product_id: "product-1",
product: expect.objectContaining({
id: "product-1",
title: "product 1",
}),
}),
)
})
it("should throw an error when a variant with ID does not exist", async () => {
let error
try {
await service.retrieve("does-not-exist")
} catch (e) {
error = e
}
expect(error.message).toEqual("ProductVariant with id: does-not-exist was not found")
})
it("should throw an error when an id is not provided", async () => {
let error
try {
await service.retrieve(undefined as unknown as string)
} catch (e) {
error = e
}
expect(error.message).toEqual('"productVariantId" must be defined')
})
})
})
@@ -1,17 +1,14 @@
import { TestDatabase } from "../../../utils"
import {
ProductService,
ProductTagService,
ProductVariantService,
} from "@services"
import { ProductService } from "@services"
import { ProductRepository } from "@repositories"
import { Product, ProductCategory, ProductVariant } from "@models"
import { Image, Product, ProductCategory, ProductVariant } from "@models"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { ProductDTO } from "@medusajs/types"
import { createProductCategories } from "../../../__fixtures__/product-category"
import {
assignCategoriesToProduct,
createImages,
createProductAndTags,
createProductVariants,
} from "../../../__fixtures__/product"
@@ -20,13 +17,8 @@ import {
productsData,
variantsData,
} from "../../../__fixtures__/product/data"
const productVariantService = {
list: jest.fn(),
} as unknown as ProductVariantService
const productTagService = {
list: jest.fn(),
} as unknown as ProductTagService
import { buildProductOnlyData } from "../../../__fixtures__/product/data/create-product"
import { kebabCase } from "@medusajs/utils"
jest.setTimeout(30000)
@@ -48,8 +40,6 @@ describe("Product Service", () => {
service = new ProductService({
productRepository,
productVariantService,
productTagService,
})
})
@@ -57,7 +47,74 @@ describe("Product Service", () => {
await TestDatabase.clearDatabase()
})
describe("create", function () {
let images: Image[] = []
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
images = await createImages(testManager, ["image-1"])
})
it("should create a product", async () => {
const data = buildProductOnlyData({
images,
thumbnail: images[0].url,
})
const products = await service.create([data])
expect(products).toHaveLength(1)
expect(JSON.parse(JSON.stringify(products[0]))).toEqual(
expect.objectContaining({
id: expect.any(String),
title: data.title,
handle: kebabCase(data.title),
description: data.description,
subtitle: data.subtitle,
is_giftcard: data.is_giftcard,
discountable: data.discountable,
thumbnail: images[0].url,
status: data.status,
images: expect.arrayContaining([
expect.objectContaining({
id: images[0].id,
url: images[0].url,
}),
]),
})
)
})
})
describe("list", () => {
describe("soft deleted", function () {
let deletedProduct
let product
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
const products = await createProductAndTags(testManager, productsData)
product = products[1]
deletedProduct = await service.softDelete([products[0].id])
})
it("should list all products that are not deleted", async () => {
const products = await service.list()
expect(products).toHaveLength(1)
expect(products[0].id).toEqual(product.id)
})
it("should list all products including the deleted", async () => {
const products = await service.list({}, { withDeleted: true })
expect(products).toHaveLength(2)
})
})
describe("relation: tags", () => {
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
@@ -65,7 +122,7 @@ describe("Product Service", () => {
products = await createProductAndTags(testManager, productsData)
})
it("filter by id and including relations", async () => {
it("should filter by id and including relations", async () => {
const productsResult = await service.list(
{
id: products[0].id,
@@ -95,7 +152,7 @@ describe("Product Service", () => {
})
})
it("filter by id and without relations", async () => {
it("should filter by id and without relations", async () => {
const productsResult = await service.list({
id: products[0].id,
})
@@ -137,7 +194,7 @@ describe("Product Service", () => {
)
})
it("filter by categories relation and scope fields", async () => {
it("should filter by categories relation and scope fields", async () => {
const products = await service.list(
{
id: workingProduct.id,
@@ -187,7 +244,7 @@ describe("Product Service", () => {
])
})
it("returns empty array when querying for a category that doesnt exist", async () => {
it("should returns empty array when querying for a category that doesnt exist", async () => {
const products = await service.list(
{
id: workingProduct.id,
@@ -215,7 +272,7 @@ describe("Product Service", () => {
variants = await createProductVariants(testManager, variantsData)
})
it("filter by id and including relations", async () => {
it("should filter by id and including relations", async () => {
const productsResult = await service.list(
{
id: products[0].id,
@@ -254,4 +311,52 @@ describe("Product Service", () => {
})
})
})
describe("softDelete", function () {
let images: Image[] = []
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
images = await createImages(testManager, ["image-1"])
})
it("should soft delete a product", async () => {
const data = buildProductOnlyData({
images,
thumbnail: images[0].url,
})
const products = await service.create([data])
const deleteProducts = await service.softDelete(products.map((p) => p.id))
expect(deleteProducts).toHaveLength(1)
expect(deleteProducts[0].deleted_at).not.toBeNull()
})
})
describe("restore", function () {
let images: Image[] = []
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
images = await createImages(testManager, ["image-1"])
})
it("should restore a soft deleted product", async () => {
const data = buildProductOnlyData({
images,
thumbnail: images[0].url,
})
const products = await service.create([data])
const product = products[0]
await service.softDelete([product.id])
const restoreProducts = await service.restore([product.id])
expect(restoreProducts).toHaveLength(1)
expect(restoreProducts[0].deleted_at).toBeNull()
})
})
})
+4 -4
View File
@@ -1,8 +1,8 @@
const { dropDatabase } = require("pg-god")
const DB_HOST = process.env.DB_HOST
const DB_USERNAME = process.env.DB_USERNAME
const DB_PASSWORD = process.env.DB_PASSWORD
const DB_HOST = process.env.DB_HOST ?? "localhost"
const DB_USERNAME = process.env.DB_USERNAME ?? "postgres"
const DB_PASSWORD = process.env.DB_PASSWORD ?? ""
const DB_NAME = process.env.DB_TEMP_NAME
const pgGodCredentials = {
@@ -16,7 +16,7 @@ afterAll(async () => {
await dropDatabase({ databaseName: DB_NAME }, pgGodCredentials)
} catch (e) {
console.error(
`This might fail if it is run during the unit tests since there is no database to drop. Otherwise, please check what is the issue. ${e}`
`This might fail if it is run during the unit tests since there is no database to drop. Otherwise, please check what is the issue. ${e.message}`
)
}
})
+1
View File
@@ -39,6 +39,7 @@
"@mikro-orm/cli": "5.7.12",
"@mikro-orm/migrations": "5.7.12",
"cross-env": "^5.2.1",
"faker": "^6.6.6",
"jest": "^25.5.4",
"medusa-test-utils": "^1.1.40",
"pg-god": "^1.0.12",
+4 -8
View File
@@ -5,18 +5,14 @@ import {
MODULE_PACKAGE_NAMES,
Modules,
} from "@medusajs/modules-sdk"
import { IProductModuleService } from "@medusajs/types"
import { IProductModuleService, ModulesSdkTypes } from "@medusajs/types"
import { moduleDefinition } from "../module-definition"
import {
InitializeModuleInjectableDependencies,
ProductServiceInitializeCustomDataLayerOptions,
ProductServiceInitializeOptions,
} from "../types"
import { InitializeModuleInjectableDependencies } from "../types"
export const initialize = async (
options?:
| ProductServiceInitializeOptions
| ProductServiceInitializeCustomDataLayerOptions
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
| ExternalModuleDeclaration,
injectedDependencies?: InitializeModuleInjectableDependencies
): Promise<IProductModuleService> => {
+7 -10
View File
@@ -6,24 +6,21 @@ import {
MODULE_RESOURCE_TYPE,
MODULE_SCOPE,
} from "@medusajs/modules-sdk"
import { MedusaError } from "@medusajs/utils"
import { MedusaError, ModulesSdkUtils } from "@medusajs/utils"
import { EntitySchema } from "@mikro-orm/core"
import * as ProductModels from "@models"
import {
ProductServiceInitializeCustomDataLayerOptions,
ProductServiceInitializeOptions,
} from "../types"
import { createConnection, loadDatabaseConfig } from "../utils"
import { createConnection } from "../utils"
import { ModulesSdkTypes } from "@medusajs/types"
export default async (
{
options,
container,
}: LoaderOptions<
| ProductServiceInitializeOptions
| ProductServiceInitializeCustomDataLayerOptions
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
>,
moduleDeclaration?: InternalModuleDeclaration
): Promise<void> => {
@@ -35,11 +32,11 @@ export default async (
}
const customManager = (
options as ProductServiceInitializeCustomDataLayerOptions
options as ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
)?.manager
if (!customManager) {
const dbData = loadDatabaseConfig(options)
const dbData = ModulesSdkUtils.loadDatabaseConfig("product", options)
await loadDefault({ database: dbData, container })
} else {
container.register({
+22 -12
View File
@@ -3,36 +3,39 @@ import { LoaderOptions } from "@medusajs/modules-sdk"
import { asClass } from "awilix"
import {
ProductCategoryService,
ProductCollectionService,
ProductImageService,
ProductModuleService,
ProductOptionService,
ProductService,
ProductTagService,
ProductTypeService,
ProductVariantService,
ProductCollectionService,
} from "@services"
import * as DefaultRepositories from "@repositories"
import {
BaseRepository,
ProductCategoryRepository,
ProductCollectionRepository,
ProductImageRepository,
ProductOptionRepository,
ProductRepository,
ProductTagRepository,
ProductTypeRepository,
ProductVariantRepository,
} from "@repositories"
import {
ProductServiceInitializeCustomDataLayerOptions,
ProductServiceInitializeOptions,
} from "../types"
import { Constructor, DAL } from "@medusajs/types"
import { Constructor, DAL, ModulesSdkTypes } from "@medusajs/types"
import { lowerCaseFirst } from "@medusajs/utils"
export default async ({
container,
options,
}: LoaderOptions<
| ProductServiceInitializeOptions
| ProductServiceInitializeCustomDataLayerOptions
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
>): Promise<void> => {
const customRepositories = (
options as ProductServiceInitializeCustomDataLayerOptions
options as ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
)?.repositories
container.register({
@@ -42,6 +45,9 @@ export default async ({
productVariantService: asClass(ProductVariantService).singleton(),
productTagService: asClass(ProductTagService).singleton(),
productCollectionService: asClass(ProductCollectionService).singleton(),
productImageService: asClass(ProductImageService).singleton(),
productTypeService: asClass(ProductTypeService).singleton(),
productOptionService: asClass(ProductOptionService).singleton(),
})
if (customRepositories) {
@@ -53,13 +59,17 @@ export default async ({
function loadDefaultRepositories({ container }) {
container.register({
productRepository: asClass(ProductRepository).singleton(),
productVariantRepository: asClass(ProductVariantRepository).singleton(),
productTagRepository: asClass(ProductTagRepository).singleton(),
baseRepository: asClass(BaseRepository).singleton(),
productImageRepository: asClass(ProductImageRepository).singleton(),
productCategoryRepository: asClass(ProductCategoryRepository).singleton(),
productCollectionRepository: asClass(
ProductCollectionRepository
).singleton(),
productRepository: asClass(ProductRepository).singleton(),
productTagRepository: asClass(ProductTagRepository).singleton(),
productTypeRepository: asClass(ProductTypeRepository).singleton(),
productOptionRepository: asClass(ProductOptionRepository).singleton(),
productVariantRepository: asClass(ProductVariantRepository).singleton(),
})
}
@@ -212,6 +212,15 @@
"name": "product_collection",
"schema": "public",
"indexes": [
{
"columnNames": [
"deleted_at"
],
"composite": false,
"keyName": "IDX_product_collection_deleted_at",
"primary": false,
"unique": false
},
{
"keyName": "IDX_product_collection_handle_unique",
"columnNames": [
@@ -234,6 +243,80 @@
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"url": {
"name": "url",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "json"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "image",
"schema": "public",
"indexes": [
{
"columnNames": [
"url"
],
"composite": false,
"keyName": "IDX_product_image_url",
"primary": false,
"unique": false
},
{
"columnNames": [
"deleted_at"
],
"composite": false,
"keyName": "IDX_product_image_deleted_at",
"primary": false,
"unique": false
},
{
"keyName": "image_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"id": {
@@ -277,6 +360,15 @@
"name": "product_tag",
"schema": "public",
"indexes": [
{
"columnNames": [
"deleted_at"
],
"composite": false,
"keyName": "IDX_product_tag_deleted_at",
"primary": false,
"unique": false
},
{
"keyName": "product_tag_pkey",
"columnNames": [
@@ -333,6 +425,15 @@
"name": "product_type",
"schema": "public",
"indexes": [
{
"columnNames": [
"deleted_at"
],
"composite": false,
"keyName": "IDX_product_type_deleted_at",
"primary": false,
"unique": false
},
{
"keyName": "product_type_pkey",
"columnNames": [
@@ -588,6 +689,15 @@
"primary": false,
"unique": false
},
{
"columnNames": [
"deleted_at"
],
"composite": false,
"keyName": "IDX_product_deleted_at",
"primary": false,
"unique": false
},
{
"keyName": "IDX_product_handle_unique",
"columnNames": [
@@ -698,6 +808,15 @@
"primary": false,
"unique": false
},
{
"columnNames": [
"deleted_at"
],
"composite": false,
"keyName": "IDX_product_option_deleted_at",
"primary": false,
"unique": false
},
{
"keyName": "product_option_pkey",
"columnNames": [
@@ -789,6 +908,71 @@
}
}
},
{
"columns": {
"product_id": {
"name": "product_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"product_image_id": {
"name": "product_image_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
}
},
"name": "product_images",
"schema": "public",
"indexes": [
{
"keyName": "product_images_pkey",
"columnNames": [
"product_id",
"product_image_id"
],
"composite": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"product_images_product_id_foreign": {
"constraintName": "product_images_product_id_foreign",
"columnNames": [
"product_id"
],
"localTableName": "public.product_images",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.product",
"deleteRule": "cascade",
"updateRule": "cascade"
},
"product_images_product_image_id_foreign": {
"constraintName": "product_images_product_image_id_foreign",
"columnNames": [
"product_image_id"
],
"localTableName": "public.product_images",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.image",
"deleteRule": "cascade",
"updateRule": "cascade"
}
}
},
{
"columns": {
"product_id": {
@@ -917,6 +1101,7 @@
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "100",
"mappedType": "decimal"
},
"allow_backorder": {
@@ -1027,6 +1212,7 @@
"autoincrement": false,
"primary": false,
"nullable": true,
"default": "0",
"mappedType": "decimal"
},
"created_at": {
@@ -1072,12 +1258,21 @@
"name": "product_variant",
"schema": "public",
"indexes": [
{
"columnNames": [
"deleted_at"
],
"composite": false,
"keyName": "IDX_product_variant_deleted_at",
"primary": false,
"unique": false
},
{
"columnNames": [
"product_id"
],
"composite": false,
"keyName": "IDX_product_variant_product_id_index",
"keyName": "IDX_product_variant_product_id",
"primary": false,
"unique": false
},
@@ -1210,7 +1405,25 @@
"option_id"
],
"composite": false,
"keyName": "IDX_product_option_value_product_option",
"keyName": "IDX_product_option_value_option_id",
"primary": false,
"unique": false
},
{
"columnNames": [
"variant_id"
],
"composite": false,
"keyName": "IDX_product_option_value_variant_id",
"primary": false,
"unique": false
},
{
"columnNames": [
"deleted_at"
],
"composite": false,
"keyName": "IDX_product_option_value_deleted_at",
"primary": false,
"unique": false
},
@@ -1,57 +0,0 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20230609132805 extends Migration {
async up(): Promise<void> {
this.addSql('create table "product_category" ("id" text not null, "name" text not null, "description" text not null default \'\', "handle" text not null, "mpath" text not null, "is_active" boolean not null default false, "is_internal" boolean not null default false, "rank" numeric not null default 0, "parent_category_id" text null, "created_at" timestamptz not null, "updated_at" timestamptz not null, constraint "product_category_pkey" primary key ("id"));');
this.addSql('create index "IDX_product_category_path" on "product_category" ("mpath");');
this.addSql('alter table "product_category" add constraint "IDX_product_category_handle" unique ("handle");');
this.addSql('create table "product_collection" ("id" text not null, "title" text not null, "handle" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "product_collection_pkey" primary key ("id"));');
this.addSql('alter table "product_collection" add constraint "IDX_product_collection_handle_unique" unique ("handle");');
this.addSql('create table "product_tag" ("id" text not null, "value" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "product_tag_pkey" primary key ("id"));');
this.addSql('create table "product_type" ("id" text not null, "value" text not null, "metadata" json null, "deleted_at" timestamptz null, constraint "product_type_pkey" primary key ("id"));');
this.addSql('create table "product" ("id" text not null, "title" text not null, "handle" text not null, "subtitle" text null, "description" text null, "is_giftcard" boolean not null default false, "status" text check ("status" in (\'draft\', \'proposed\', \'published\', \'rejected\')) not null, "thumbnail" text null, "weight" text null, "length" text null, "height" text null, "width" text null, "origin_country" text null, "hs_code" text null, "mid_code" text null, "material" text null, "collection_id" text null, "type_id" text null, "discountable" boolean not null default true, "external_id" text null, "created_at" timestamptz not null, "updated_at" timestamptz not null, "deleted_at" timestamptz null, "metadata" jsonb null, constraint "product_pkey" primary key ("id"));');
this.addSql('create index "IDX_product_type_id" on "product" ("type_id");');
this.addSql('alter table "product" add constraint "IDX_product_handle_unique" unique ("handle");');
this.addSql('create table "product_option" ("id" text not null, "title" text not null, "product_id" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "product_option_pkey" primary key ("id"));');
this.addSql('create index "IDX_product_option_product_id" on "product_option" ("product_id");');
this.addSql('create table "product_tags" ("product_id" text not null, "product_tag_id" text not null, constraint "product_tags_pkey" primary key ("product_id", "product_tag_id"));');
this.addSql('create table "product_category_product" ("product_id" text not null, "product_category_id" text not null, constraint "product_category_product_pkey" primary key ("product_id", "product_category_id"));');
this.addSql('create table "product_variant" ("id" text not null, "title" text not null, "sku" text null, "barcode" text null, "ean" text null, "upc" text null, "inventory_quantity" numeric not null, "allow_backorder" boolean not null default false, "manage_inventory" boolean not null default true, "hs_code" text null, "origin_country" text null, "mid_code" text null, "material" text null, "weight" numeric null, "length" numeric null, "height" numeric null, "width" numeric null, "metadata" jsonb null, "variant_rank" numeric null, "created_at" timestamptz not null, "updated_at" timestamptz not null, "deleted_at" timestamptz null, "product_id" text not null, constraint "product_variant_pkey" primary key ("id"));');
this.addSql('create index "IDX_product_variant_product_id_index" on "product_variant" ("product_id");');
this.addSql('alter table "product_variant" add constraint "IDX_product_variant_sku_unique" unique ("sku");');
this.addSql('alter table "product_variant" add constraint "IDX_product_variant_barcode_unique" unique ("barcode");');
this.addSql('alter table "product_variant" add constraint "IDX_product_variant_ean_unique" unique ("ean");');
this.addSql('alter table "product_variant" add constraint "IDX_product_variant_upc_unique" unique ("upc");');
this.addSql('create table "product_option_value" ("id" text not null, "value" text not null, "option_id" text not null, "variant_id" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "product_option_value_pkey" primary key ("id"));');
this.addSql('create index "IDX_product_option_value_product_option" on "product_option_value" ("option_id");');
this.addSql('alter table "product_category" add constraint "product_category_parent_category_id_foreign" foreign key ("parent_category_id") references "product_category" ("id") on update cascade on delete set null;');
this.addSql('alter table "product" add constraint "product_collection_id_foreign" foreign key ("collection_id") references "product_collection" ("id") on update cascade on delete set null;');
this.addSql('alter table "product" add constraint "product_type_id_foreign" foreign key ("type_id") references "product_type" ("id") on update cascade on delete set null;');
this.addSql('alter table "product_option" add constraint "product_option_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade;');
this.addSql('alter table "product_tags" add constraint "product_tags_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;');
this.addSql('alter table "product_tags" add constraint "product_tags_product_tag_id_foreign" foreign key ("product_tag_id") references "product_tag" ("id") on update cascade on delete cascade;');
this.addSql('alter table "product_category_product" add constraint "product_category_product_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;');
this.addSql('alter table "product_category_product" add constraint "product_category_product_product_category_id_foreign" foreign key ("product_category_id") references "product_category" ("id") on update cascade on delete cascade;');
this.addSql('alter table "product_variant" add constraint "product_variant_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;');
this.addSql('alter table "product_option_value" add constraint "product_option_value_option_id_foreign" foreign key ("option_id") references "product_option" ("id") on update cascade;');
this.addSql('alter table "product_option_value" add constraint "product_option_value_variant_id_foreign" foreign key ("variant_id") references "product_variant" ("id") on update cascade on delete cascade;');
}
}
@@ -0,0 +1,162 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20230710091208 extends Migration {
async up(): Promise<void> {
this.addSql(
'create table "product_category" ("id" text not null, "name" text not null, "description" text not null default \'\', "handle" text not null, "mpath" text not null, "is_active" boolean not null default false, "is_internal" boolean not null default false, "rank" numeric not null default 0, "parent_category_id" text null, "created_at" timestamptz not null, "updated_at" timestamptz not null, constraint "product_category_pkey" primary key ("id"));'
)
this.addSql(
'create index "IDX_product_category_path" on "product_category" ("mpath");'
)
this.addSql(
'alter table "product_category" add constraint "IDX_product_category_handle" unique ("handle");'
)
this.addSql(
'create table "product_collection" ("id" text not null, "title" text not null, "handle" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "product_collection_pkey" primary key ("id"));'
)
this.addSql(
'create index "IDX_product_collection_deleted_at" on "product_collection" ("deleted_at");'
)
this.addSql(
'alter table "product_collection" add constraint "IDX_product_collection_handle_unique" unique ("handle");'
)
this.addSql(
'create table "image" ("id" text not null, "url" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "image_pkey" primary key ("id"));'
)
this.addSql('create index "IDX_product_image_url" on "image" ("url");')
this.addSql(
'create index "IDX_product_image_deleted_at" on "image" ("deleted_at");'
)
this.addSql(
'create table "product_tag" ("id" text not null, "value" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "product_tag_pkey" primary key ("id"));'
)
this.addSql(
'create index "IDX_product_tag_deleted_at" on "product_tag" ("deleted_at");'
)
this.addSql(
'create table "product_type" ("id" text not null, "value" text not null, "metadata" json null, "deleted_at" timestamptz null, constraint "product_type_pkey" primary key ("id"));'
)
this.addSql(
'create index "IDX_product_type_deleted_at" on "product_type" ("deleted_at");'
)
this.addSql(
'create table "product" ("id" text not null, "title" text not null, "handle" text not null, "subtitle" text null, "description" text null, "is_giftcard" boolean not null default false, "status" text check ("status" in (\'draft\', \'proposed\', \'published\', \'rejected\')) not null, "thumbnail" text null, "weight" text null, "length" text null, "height" text null, "width" text null, "origin_country" text null, "hs_code" text null, "mid_code" text null, "material" text null, "collection_id" text null, "type_id" text null, "discountable" boolean not null default true, "external_id" text null, "created_at" timestamptz not null, "updated_at" timestamptz not null, "deleted_at" timestamptz null, "metadata" jsonb null, constraint "product_pkey" primary key ("id"));'
)
this.addSql('create index "IDX_product_type_id" on "product" ("type_id");')
this.addSql(
'create index "IDX_product_deleted_at" on "product" ("deleted_at");'
)
this.addSql(
'alter table "product" add constraint "IDX_product_handle_unique" unique ("handle");'
)
this.addSql(
'create table "product_option" ("id" text not null, "title" text not null, "product_id" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "product_option_pkey" primary key ("id"));'
)
this.addSql(
'create index "IDX_product_option_product_id" on "product_option" ("product_id");'
)
this.addSql(
'create index "IDX_product_option_deleted_at" on "product_option" ("deleted_at");'
)
this.addSql(
'create table "product_tags" ("product_id" text not null, "product_tag_id" text not null, constraint "product_tags_pkey" primary key ("product_id", "product_tag_id"));'
)
this.addSql(
'create table "product_images" ("product_id" text not null, "product_image_id" text not null, constraint "product_images_pkey" primary key ("product_id", "product_image_id"));'
)
this.addSql(
'create table "product_category_product" ("product_id" text not null, "product_category_id" text not null, constraint "product_category_product_pkey" primary key ("product_id", "product_category_id"));'
)
this.addSql(
'create table "product_variant" ("id" text not null, "title" text not null, "sku" text null, "barcode" text null, "ean" text null, "upc" text null, "inventory_quantity" numeric not null default 100, "allow_backorder" boolean not null default false, "manage_inventory" boolean not null default true, "hs_code" text null, "origin_country" text null, "mid_code" text null, "material" text null, "weight" numeric null, "length" numeric null, "height" numeric null, "width" numeric null, "metadata" jsonb null, "variant_rank" numeric null default 0, "created_at" timestamptz not null, "updated_at" timestamptz not null, "deleted_at" timestamptz null, "product_id" text not null, constraint "product_variant_pkey" primary key ("id"));'
)
this.addSql(
'create index "IDX_product_variant_deleted_at" on "product_variant" ("deleted_at");'
)
this.addSql(
'create index "IDX_product_variant_product_id" on "product_variant" ("product_id");'
)
this.addSql(
'alter table "product_variant" add constraint "IDX_product_variant_sku_unique" unique ("sku");'
)
this.addSql(
'alter table "product_variant" add constraint "IDX_product_variant_barcode_unique" unique ("barcode");'
)
this.addSql(
'alter table "product_variant" add constraint "IDX_product_variant_ean_unique" unique ("ean");'
)
this.addSql(
'alter table "product_variant" add constraint "IDX_product_variant_upc_unique" unique ("upc");'
)
this.addSql(
'create table "product_option_value" ("id" text not null, "value" text not null, "option_id" text not null, "variant_id" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "product_option_value_pkey" primary key ("id"));'
)
this.addSql(
'create index "IDX_product_option_value_option_id" on "product_option_value" ("option_id");'
)
this.addSql(
'create index "IDX_product_option_value_variant_id" on "product_option_value" ("variant_id");'
)
this.addSql(
'create index "IDX_product_option_value_deleted_at" on "product_option_value" ("deleted_at");'
)
this.addSql(
'alter table "product_category" add constraint "product_category_parent_category_id_foreign" foreign key ("parent_category_id") references "product_category" ("id") on update cascade on delete set null;'
)
this.addSql(
'alter table "product" add constraint "product_collection_id_foreign" foreign key ("collection_id") references "product_collection" ("id") on update cascade on delete set null;'
)
this.addSql(
'alter table "product" add constraint "product_type_id_foreign" foreign key ("type_id") references "product_type" ("id") on update cascade on delete set null;'
)
this.addSql(
'alter table "product_option" add constraint "product_option_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade;'
)
this.addSql(
'alter table "product_tags" add constraint "product_tags_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table "product_tags" add constraint "product_tags_product_tag_id_foreign" foreign key ("product_tag_id") references "product_tag" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table "product_images" add constraint "product_images_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table "product_images" add constraint "product_images_product_image_id_foreign" foreign key ("product_image_id") references "image" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table "product_category_product" add constraint "product_category_product_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table "product_category_product" add constraint "product_category_product_product_category_id_foreign" foreign key ("product_category_id") references "product_category" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table "product_variant" add constraint "product_variant_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table "product_option_value" add constraint "product_option_value_option_id_foreign" foreign key ("option_id") references "product_option" ("id") on update cascade;'
)
this.addSql(
'alter table "product_option_value" add constraint "product_option_value_variant_id_foreign" foreign key ("variant_id") references "product_variant" ("id") on update cascade on delete cascade;'
)
}
}
+2
View File
@@ -4,3 +4,5 @@ export { default as ProductCollection } from "./product-collection"
export { default as ProductTag } from "./product-tag"
export { default as ProductType } from "./product-type"
export { default as ProductVariant } from "./product-variant"
export { default as ProductOption } from "./product-option"
export { default as Image } from "./product-image"
@@ -21,7 +21,7 @@ class ProductCategory {
id!: string
@Property({ columnType: "text", nullable: false })
name: string
name?: string
@Property({ columnType: "text", default: "", nullable: false })
description?: string
@@ -1,15 +1,26 @@
import {
BeforeCreate,
Collection,
Entity,
Index,
OneToMany,
OptionalProps,
PrimaryKey,
Property,
Unique,
} from "@mikro-orm/core"
import { generateEntityId, kebabCase } from "@medusajs/utils"
import Product from "./product"
import { SoftDeletable } from "../utils"
type OptionalRelations = "products"
@Entity({ tableName: "product_collection" })
@SoftDeletable()
class ProductCollection {
[OptionalProps]?: OptionalRelations
@PrimaryKey({ columnType: "text" })
id!: string
@@ -21,13 +32,17 @@ class ProductCollection {
name: "IDX_product_collection_handle_unique",
properties: ["handle"],
})
handle: string
handle?: string
@OneToMany(() => Product, (product) => product.collection)
products = new Collection<Product>(this)
@Property({ columnType: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null
@Index({ name: "IDX_product_collection_deleted_at" })
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date
deleted_at?: Date
@BeforeCreate()
onCreate() {
@@ -0,0 +1,46 @@
import {
BeforeCreate,
Collection,
Entity,
Index,
ManyToMany,
OptionalProps,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import { generateEntityId } from "@medusajs/utils"
import Product from "./product"
import { SoftDeletable } from "../utils"
type OptionalRelations = "products"
@Entity({ tableName: "image" })
@SoftDeletable()
class ProductImage {
[OptionalProps]?: OptionalRelations
@PrimaryKey({ columnType: "text" })
id!: string
@Index({ name: "IDX_product_image_url" })
@Property({ columnType: "text" })
url: string
@Property({ columnType: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null
@Index({ name: "IDX_product_image_deleted_at" })
@Property({ columnType: "timestamptz", nullable: true })
deleted_at?: Date
@ManyToMany(() => Product, (product) => product.images)
products = new Collection<Product>(this)
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "img")
}
}
export default ProductImage
@@ -1,7 +1,9 @@
import {
BeforeCreate,
Entity,
Index,
ManyToOne,
OptionalProps,
PrimaryKey,
Property,
} from "@mikro-orm/core"
@@ -9,28 +11,53 @@ import { generateEntityId } from "@medusajs/utils"
import ProductOption from "./product-option"
import { ProductVariant } from "./index"
import { SoftDeletable } from "../utils"
type OptionalFields =
| "created_at"
| "updated_at"
| "allow_backorder"
| "manage_inventory"
| "option_id"
| "variant_id"
type OptionalRelations = "product" | "option" | "variant"
@Entity({ tableName: "product_option_value" })
@SoftDeletable()
class ProductOptionValue {
[OptionalProps]?: OptionalFields | OptionalRelations
@PrimaryKey({ columnType: "text" })
id!: string
@Property({ columnType: "text" })
value: string
@Property({ persist: false })
option_id!: string
@ManyToOne(() => ProductOption, {
index: "IDX_product_option_value_product_option",
index: "IDX_product_option_value_option_id",
fieldName: "option_id",
})
option: ProductOption
@ManyToOne(() => ProductVariant, { onDelete: "cascade" })
@Property({ persist: false })
variant_id!: string
@ManyToOne(() => ProductVariant, {
onDelete: "cascade",
index: "IDX_product_option_value_variant_id",
fieldName: "variant_id",
})
variant: ProductVariant
@Property({ columnType: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null
@Index({ name: "IDX_product_option_value_deleted_at" })
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date
deleted_at?: Date
@BeforeCreate()
beforeCreate() {
+14 -3
View File
@@ -3,17 +3,26 @@ import {
Cascade,
Collection,
Entity,
Index,
ManyToOne,
OneToMany,
OptionalProps,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import { generateEntityId } from "@medusajs/utils"
import { Product } from "./index"
import ProductOptionValue from "./product-option-value"
import { SoftDeletable } from "../utils"
type OptionalRelations = "values" | "product"
type OptionalFields = "product_id"
@Entity({ tableName: "product_option" })
@SoftDeletable()
class ProductOption {
[OptionalProps]?: OptionalRelations | OptionalFields
@PrimaryKey({ columnType: "text" })
id!: string
@@ -21,23 +30,25 @@ class ProductOption {
title: string
@Property({ persist: false })
product_id!: number
product_id!: string
@ManyToOne(() => Product, {
index: "IDX_product_option_product_id",
fieldName: "product_id",
})
product: Product
@OneToMany(() => ProductOptionValue, (value) => value.option, {
cascade: [Cascade.REMOVE],
cascade: [Cascade.REMOVE, "soft-remove" as any],
})
values = new Collection<ProductOptionValue>(this)
@Property({ columnType: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null
@Index({ name: "IDX_product_option_deleted_at" })
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date
deleted_at?: Date
@BeforeCreate()
beforeCreate() {
+10 -1
View File
@@ -2,16 +2,24 @@ import {
BeforeCreate,
Collection,
Entity,
Index,
ManyToMany,
OptionalProps,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import { generateEntityId } from "@medusajs/utils"
import Product from "./product"
import { SoftDeletable } from "../utils"
type OptionalRelations = "products"
@Entity({ tableName: "product_tag" })
@SoftDeletable()
class ProductTag {
[OptionalProps]?: OptionalRelations
@PrimaryKey({ columnType: "text" })
id!: string
@@ -21,8 +29,9 @@ class ProductTag {
@Property({ columnType: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null
@Index({ name: "IDX_product_tag_deleted_at" })
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date
deleted_at?: Date
@ManyToMany(() => Product, (product) => product.tags)
products = new Collection<Product>(this)
+11 -2
View File
@@ -1,8 +1,16 @@
import { BeforeCreate, Entity, PrimaryKey, Property } from "@mikro-orm/core"
import {
BeforeCreate,
Entity,
Index,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import { generateEntityId } from "@medusajs/utils"
import { SoftDeletable } from "../utils"
@Entity({ tableName: "product_type" })
@SoftDeletable()
class ProductType {
@PrimaryKey({ columnType: "text" })
id!: string
@@ -13,8 +21,9 @@ class ProductType {
@Property({ columnType: "json", nullable: true })
metadata?: Record<string, unknown> | null
@Index({ name: "IDX_product_type_deleted_at" })
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date
deleted_at?: Date
@BeforeCreate()
onCreate() {
+12 -10
View File
@@ -3,6 +3,7 @@ import {
Cascade,
Collection,
Entity,
Index,
ManyToOne,
OneToMany,
OptionalProps,
@@ -13,18 +14,18 @@ import {
import { generateEntityId } from "@medusajs/utils"
import { Product } from "@models"
import ProductOptionValue from "./product-option-value"
import { SoftDeletable } from "../utils"
type OptionalFields =
| "created_at"
| "updated_at"
| "updated_at"
| "deleted_at"
| "allow_backorder"
| "manage_inventory"
| "product"
| "product_id"
@Entity({ tableName: "product_variant" })
@SoftDeletable()
class ProductVariant {
[OptionalProps]?: OptionalFields
@@ -65,14 +66,14 @@ class ProductVariant {
// Note: Upon serialization, this turns to a string. This is on purpose, because you would loose
// precision if you cast numeric to JS number, as JS number is a float.
// Ref: https://github.com/mikro-orm/mikro-orm/issues/2295
@Property({ columnType: "numeric" })
inventory_quantity: number
@Property({ columnType: "numeric", default: 100 })
inventory_quantity?: number = 100
@Property({ columnType: "boolean", default: false })
allow_backorder: boolean
allow_backorder?: boolean = false
@Property({ columnType: "boolean", default: true })
manage_inventory: boolean
manage_inventory?: boolean = true
@Property({ columnType: "text", nullable: true })
hs_code?: string | null
@@ -101,7 +102,7 @@ class ProductVariant {
@Property({ columnType: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null
@Property({ columnType: "numeric", nullable: true })
@Property({ columnType: "numeric", nullable: true, default: 0 })
variant_rank?: number | null
@Property({ persist: false })
@@ -117,18 +118,19 @@ class ProductVariant {
})
updated_at: Date
@Index({ name: "IDX_product_variant_deleted_at" })
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date
deleted_at?: Date
@ManyToOne(() => Product, {
onDelete: "cascade",
index: "IDX_product_variant_product_id_index",
index: "IDX_product_variant_product_id",
fieldName: "product_id",
})
product!: Product
@OneToMany(() => ProductOptionValue, (optionValue) => optionValue.variant, {
cascade: [Cascade.PERSIST, Cascade.REMOVE],
cascade: [Cascade.PERSIST, Cascade.REMOVE, "soft-remove" as any],
})
options = new Collection<ProductOptionValue>(this)
+35 -8
View File
@@ -3,6 +3,7 @@ import {
Collection,
Entity,
Enum,
Index,
ManyToMany,
ManyToOne,
OneToMany,
@@ -20,16 +21,20 @@ import ProductOption from "./product-option"
import ProductTag from "./product-tag"
import ProductType from "./product-type"
import ProductVariant from "./product-variant"
import ProductImage from "./product-image"
import { SoftDeletable } from "../utils"
type OptionalRelations = "collection" | "type"
type OptionalFields =
| "collection_id"
| "type_id"
| "is_giftcard"
| "discountable"
| "created_at"
| "updated_at"
| "deleted_at"
@Entity({ tableName: "product" })
@SoftDeletable()
class Product {
[OptionalProps]?: OptionalRelations | OptionalFields
@@ -58,16 +63,17 @@ class Product {
@Enum(() => ProductTypes.ProductStatus)
status!: ProductTypes.ProductStatus
// TODO: add images model
// images: Image[]
@Property({ columnType: "text", nullable: true })
thumbnail?: string | null
@OneToMany(() => ProductOption, (o) => o.product)
@OneToMany(() => ProductOption, (o) => o.product, {
cascade: ["soft-remove"] as any,
})
options = new Collection<ProductOption>(this)
@OneToMany(() => ProductVariant, (variant) => variant.product)
@OneToMany(() => ProductVariant, (variant) => variant.product, {
cascade: ["soft-remove"] as any,
})
variants = new Collection<ProductVariant>(this)
@Property({ columnType: "text", nullable: true })
@@ -94,12 +100,22 @@ class Product {
@Property({ columnType: "text", nullable: true })
material?: string | null
@ManyToOne(() => ProductCollection, { nullable: true })
@Property({ persist: false })
collection_id!: string
@ManyToOne(() => ProductCollection, {
nullable: true,
fieldName: "collection_id",
})
collection!: ProductCollection
@Property({ persist: false })
type_id!: string
@ManyToOne(() => ProductType, {
nullable: true,
index: "IDX_product_type_id",
fieldName: "type_id",
})
type!: ProductType
@@ -107,12 +123,22 @@ class Product {
owner: true,
pivotTable: "product_tags",
index: "IDX_product_tag_id",
cascade: ["soft-remove"] as any,
})
tags = new Collection<ProductTag>(this)
@ManyToMany(() => ProductImage, "products", {
owner: true,
pivotTable: "product_images",
index: "IDX_product_image_id",
cascade: ["soft-remove"] as any,
})
images = new Collection<ProductImage>(this)
@ManyToMany(() => ProductCategory, "products", {
owner: true,
pivotTable: "product_category_product",
cascade: ["soft-remove"] as any,
})
categories = new Collection<ProductCategory>(this)
@@ -132,8 +158,9 @@ class Product {
})
updated_at: Date
@Index({ name: "IDX_product_deleted_at" })
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date
deleted_at?: Date
@Property({ columnType: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null
@@ -3,7 +3,6 @@ import { ProductModuleService } from "@services"
import loadContainer from "./loaders/container"
import loadConnection from "./loaders/connection"
import * as ProductModels from "@models"
import { revertMigration, runMigrations } from "./scripts"
const service = ProductModuleService
const loaders = [loadContainer, loadConnection] as any
@@ -13,6 +12,4 @@ export const moduleDefinition: ModuleExports = {
service,
loaders,
models,
runMigrations,
revertMigration,
}
+243
View File
@@ -0,0 +1,243 @@
import { Context, DAL, RepositoryTransformOptions } from "@medusajs/types"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import {
buildQuery,
InjectTransactionManager,
MedusaContext,
} from "@medusajs/utils"
import { serialize } from "@mikro-orm/core"
import { doNotForceTransaction } from "../utils"
// TODO: Should we create a mikro orm specific package for this and the soft deletable decorator util?
async function transactionWrapper(
this: any,
task: (transactionManager: unknown) => Promise<any>,
{
transaction,
isolationLevel,
enableNestedTransactions = false,
}: {
isolationLevel?: string
transaction?: unknown
enableNestedTransactions?: boolean
} = {}
): Promise<any> {
// Reuse the same transaction if it is already provided and nested transactions are disabled
if (!enableNestedTransactions && transaction) {
return await task(transaction)
}
const forkedManager = this.manager_.fork()
const options = {}
if (isolationLevel) {
Object.assign(options, { isolationLevel })
}
if (transaction) {
Object.assign(options, { ctx: transaction })
await forkedManager.begin(options)
} else {
await forkedManager.begin(options)
}
try {
const result = await task(forkedManager)
await forkedManager.commit()
return result
} catch (e) {
await forkedManager.rollback()
throw e
}
}
const updateDeletedAtRecursively = async <T extends object = any>(
manager: SqlEntityManager,
entities: T[],
value: Date | null
) => {
for await (const entity of entities) {
if (!("deleted_at" in entity)) continue
;(entity as any).deleted_at = value
const relations = manager
.getDriver()
.getMetadata()
.get(entities[0].constructor.name).relations
const relationsToCascade = relations.filter((relation) =>
relation.cascade.includes("soft-remove" as any)
)
for (const relation of relationsToCascade) {
const relationEntities = (await entity[relation.name].init()).getItems({
filters: {
[DAL.SoftDeletableFilterKey]: {
withDeleted: true,
},
},
})
await updateDeletedAtRecursively(manager, relationEntities, value)
}
await manager.persist(entities)
}
}
const serializer = <
T extends object | object[],
TResult extends object | object[]
>(
data: T,
options?: any
): Promise<TResult> => {
options ??= {}
const result = serialize(data, options)
return Array.isArray(data) ? result : result[0]
}
export abstract class AbstractBaseRepository<T = any>
implements DAL.RepositoryService<T>
{
protected readonly manager_: SqlEntityManager
protected constructor({ manager }) {
this.manager_ = manager
}
async transaction(
task: (transactionManager: unknown) => Promise<any>,
{
transaction,
isolationLevel,
enableNestedTransactions = false,
}: {
isolationLevel?: string
enableNestedTransactions?: boolean
transaction?: unknown
} = {}
): Promise<any> {
return await transactionWrapper.apply(this, arguments)
}
serialize<
TData extends object | object[] = object[],
TResult extends object | object[] = object[]
>(data: TData, options?: any): Promise<TResult> {
return serializer<TData, TResult>(data, options)
}
abstract find(options?: DAL.FindOptions<T>, context?: Context)
abstract findAndCount(
options?: DAL.FindOptions<T>,
context?: Context
): Promise<[T[], number]>
abstract create(data: unknown[], context?: Context): Promise<T[]>
abstract delete(ids: string[], context?: Context): Promise<void>
@InjectTransactionManager()
async softDelete(
ids: string[],
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<T[]> {
const entities = await this.find({ where: { id: { $in: ids } } as any })
const date = new Date()
await updateDeletedAtRecursively(
manager as SqlEntityManager,
entities,
date
)
return entities
}
@InjectTransactionManager()
async restore(
ids: string[],
@MedusaContext()
{ transactionManager: manager }: Context<SqlEntityManager> = {}
): Promise<T[]> {
const query = buildQuery(
{ id: { $in: ids } },
{
withDeleted: true,
}
)
const entities = await this.find(query)
await updateDeletedAtRecursively(
manager as SqlEntityManager,
entities,
null
)
return entities
}
}
export abstract class AbstractTreeRepositoryBase<T = any>
extends AbstractBaseRepository<T>
implements DAL.TreeRepositoryService<T>
{
protected constructor({ manager }) {
// @ts-ignore
super(...arguments)
}
abstract find(
options?: DAL.FindOptions<T>,
transformOptions?: RepositoryTransformOptions,
context?: Context
)
abstract findAndCount(
options?: DAL.FindOptions<T>,
transformOptions?: RepositoryTransformOptions,
context?: Context
): Promise<[T[], number]>
}
/**
* Only used internally in order to be able to wrap in transaction from a
* non identified repository
*/
export class BaseRepository extends AbstractBaseRepository {
constructor({ manager }) {
// @ts-ignore
super(...arguments)
}
serialize<
TData extends object | object[] = object[],
TResult extends object | object[] = object[]
>(data: TData, options?: any): Promise<TResult> {
return serializer<TData, TResult>(data, options)
}
create(data: unknown[], context?: Context): Promise<any[]> {
throw new Error("Method not implemented.")
}
delete(ids: string[], context?: Context): Promise<void> {
throw new Error("Method not implemented.")
}
find(options?: DAL.FindOptions, context?: Context): Promise<any[]> {
throw new Error("Method not implemented.")
}
findAndCount(
options?: DAL.FindOptions,
context?: Context
): Promise<[any[], number]> {
throw new Error("Method not implemented.")
}
}
@@ -1,5 +1,9 @@
export { BaseRepository } from "./base"
export { ProductRepository } from "./product"
export { ProductTagRepository } from "./product-tag"
export { ProductVariantRepository } from "./product-variant"
export { ProductCollectionRepository } from "./product-collection"
export { ProductCategoryRepository } from "./product-category"
export { ProductImageRepository } from "./product-image"
export { ProductTypeRepository } from "./product-type"
export { ProductOptionRepository } from "./product-option"
@@ -1,35 +1,36 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import {
FilterQuery as MikroFilterQuery,
FindOptions as MikroOptions,
LoadStrategy,
} from "@mikro-orm/core"
import { deduplicateIfNecessary } from "../utils"
import { ProductCategory } from "@models"
import { DAL, ProductCategoryTransformOptions } from "@medusajs/types"
import { Product, ProductCategory } from "@models"
import { Context, DAL, ProductCategoryTransformOptions } from "@medusajs/types"
import groupBy from "lodash/groupBy"
import { AbstractTreeRepositoryBase } from "./base"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { InjectTransactionManager, MedusaContext } from "@medusajs/utils"
export class ProductCategoryRepository
implements DAL.RepositoryService<ProductCategory>
{
export class ProductCategoryRepository extends AbstractTreeRepositoryBase<ProductCategory> {
protected readonly manager_: SqlEntityManager
constructor({ manager }) {
this.manager_ = manager.fork()
constructor({ manager }: { manager: SqlEntityManager }) {
// @ts-ignore
super(...arguments)
this.manager_ = manager
}
async find(
findOptions: DAL.FindOptions<ProductCategory> = { where: {} },
transformOptions: ProductCategoryTransformOptions = {},
context: { transaction?: any } = {}
context: Context = {}
): Promise<ProductCategory[]> {
// Spread is used to copy the options in case of manipulation to prevent side effects
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
const findOptions_ = { ...findOptions }
const { includeDescendantsTree } = transformOptions
findOptions_.options ??= {}
const fields = (findOptions_.options.fields ??= [])
findOptions_.options.limit ??= 15
// Ref: Building descendants
// mpath and parent_category_id needs to be added to the query for the tree building to be done accurately
@@ -39,19 +40,11 @@ export class ProductCategoryRepository
fields.push("parent_category_id")
}
if (findOptions_.options.populate) {
deduplicateIfNecessary(findOptions_.options.populate)
}
if (context.transaction) {
Object.assign(findOptions_.options, { ctx: context.transaction })
}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
const productCategories = await this.manager_.find(
const productCategories = await manager.find(
ProductCategory,
findOptions_.where as MikroFilterQuery<ProductCategory>,
findOptions_.options as MikroOptions<ProductCategory>
@@ -69,8 +62,12 @@ export class ProductCategoryRepository
async buildProductCategoriesWithDescendants(
productCategories: ProductCategory[],
findOptions: DAL.FindOptions<ProductCategory> = { where: {} }
findOptions: DAL.FindOptions<ProductCategory> = { where: {} },
context: Context = {}
): Promise<ProductCategory[]> {
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
for (let productCategory of productCategories) {
const whereOptions = {
...findOptions.where,
@@ -78,9 +75,11 @@ export class ProductCategoryRepository
$like: `${productCategory.mpath}%`,
},
}
delete whereOptions.parent_category_id
const descendantsForCategory = await this.manager_.find(
delete whereOptions.parent_category_id
delete whereOptions.id
const descendantsForCategory = await manager.find(
ProductCategory,
whereOptions as MikroFilterQuery<ProductCategory>,
findOptions.options as MikroOptions<ProductCategory>
@@ -111,30 +110,64 @@ export class ProductCategoryRepository
async findAndCount(
findOptions: DAL.FindOptions<ProductCategory> = { where: {} },
transformOptions: ProductCategoryTransformOptions = {},
context: { transaction?: any } = {}
context: Context = {}
): Promise<[ProductCategory[], number]> {
// Spread is used to copy the options in case of manipulation to prevent side effects
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
const findOptions_ = { ...findOptions }
const { includeDescendantsTree } = transformOptions
findOptions_.options ??= {}
findOptions_.options.limit ??= 15
const fields = (findOptions_.options.fields ??= [])
if (findOptions_.options.populate) {
deduplicateIfNecessary(findOptions_.options.populate)
}
if (context.transaction) {
Object.assign(findOptions_.options, { ctx: context.transaction })
// Ref: Building descendants
// mpath and parent_category_id needs to be added to the query for the tree building to be done accurately
if (includeDescendantsTree) {
fields.indexOf("mpath") === -1 && fields.push("mpath")
fields.indexOf("parent_category_id") === -1 &&
fields.push("parent_category_id")
}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return await this.manager_.findAndCount(
const [productCategories, count] = await manager.findAndCount(
ProductCategory,
findOptions_.where as MikroFilterQuery<ProductCategory>,
findOptions_.options as MikroOptions<ProductCategory>
)
if (!includeDescendantsTree) {
return [productCategories, count]
}
return [
await this.buildProductCategoriesWithDescendants(
productCategories,
findOptions_
),
count,
]
}
@InjectTransactionManager()
async delete(
ids: string[],
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<void> {
await (manager as SqlEntityManager).nativeDelete(
Product,
{ id: { $in: ids } },
{}
)
}
async create(
data: unknown[],
context: Context = {}
): Promise<ProductCategory[]> {
throw new Error("Method not implemented.")
}
}
@@ -1,74 +1,84 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { ProductCollection } from "@models"
import { Product, ProductCollection } from "@models"
import {
FilterQuery as MikroFilterQuery,
FindOptions as MikroOptions,
LoadStrategy,
} from "@mikro-orm/core"
import { deduplicateIfNecessary } from "../utils"
import { DAL } from "@medusajs/types"
import { Context, DAL } from "@medusajs/types"
import { AbstractBaseRepository } from "./base"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { InjectTransactionManager, MedusaContext } from "@medusajs/utils"
export class ProductCollectionRepository implements DAL.RepositoryService {
export class ProductCollectionRepository extends AbstractBaseRepository<ProductCollection> {
protected readonly manager_: SqlEntityManager
constructor({ manager }) {
this.manager_ = manager.fork()
constructor({ manager }: { manager: SqlEntityManager }) {
// @ts-ignore
super(...arguments)
this.manager_ = manager
}
async find<T = ProductCollection>(
findOptions: DAL.FindOptions<T> = { where: {} },
context: { transaction?: any } = {}
): Promise<T[]> {
// Spread is used to copy the options in case of manipulation to prevent side effects
async find(
findOptions: DAL.FindOptions<ProductCollection> = { where: {} },
context: Context = {}
): Promise<ProductCollection[]> {
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
findOptions_.options.limit ??= 15
if (findOptions_.options.populate) {
deduplicateIfNecessary(findOptions_.options.populate)
}
if (context.transaction) {
Object.assign(findOptions_.options, { ctx: context.transaction })
}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return (await this.manager_.find(
return await manager.find(
ProductCollection,
findOptions_.where as MikroFilterQuery<ProductCollection>,
findOptions_.options as MikroOptions<ProductCollection>
)) as unknown as T[]
)
}
async findAndCount<T = ProductCollection>(
findOptions: DAL.FindOptions<T> = { where: {} },
context: { transaction?: any } = {}
): Promise<[T[], number]> {
// Spread is used to copy the options in case of manipulation to prevent side effects
async findAndCount(
findOptions: DAL.FindOptions<ProductCollection> = { where: {} },
context: Context = {}
): Promise<[ProductCollection[], number]> {
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
findOptions_.options.limit ??= 15
if (findOptions_.options.populate) {
deduplicateIfNecessary(findOptions_.options.populate)
}
if (context.transaction) {
Object.assign(findOptions_.options, { ctx: context.transaction })
}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return (await this.manager_.findAndCount(
return await manager.findAndCount(
ProductCollection,
findOptions_.where as MikroFilterQuery<ProductCollection>,
findOptions_.options as MikroOptions<ProductCollection>
)) as unknown as [T[], number]
)
}
@InjectTransactionManager()
async delete(
ids: string[],
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<void> {
await (manager as SqlEntityManager).nativeDelete(
Product,
{ id: { $in: ids } },
{}
)
}
@InjectTransactionManager()
async create(
data: unknown[],
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<ProductCollection[]> {
throw new Error("Method not implemented.")
}
}
@@ -0,0 +1,128 @@
import {
FilterQuery as MikroFilterQuery,
FindOptions as MikroOptions,
LoadStrategy,
} from "@mikro-orm/core"
import { Context, DAL } from "@medusajs/types"
import { Image, Product } from "@models"
import { AbstractBaseRepository } from "./base"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { InjectTransactionManager, MedusaContext } from "@medusajs/utils"
export class ProductImageRepository extends AbstractBaseRepository<Image> {
protected readonly manager_: SqlEntityManager
constructor({ manager }: { manager: SqlEntityManager }) {
// @ts-ignore
super(...arguments)
this.manager_ = manager
}
async find(
findOptions: DAL.FindOptions<Image> = { where: {} },
context: Context = {}
): Promise<Image[]> {
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return await manager.find(
Image,
findOptions_.where as MikroFilterQuery<Image>,
findOptions_.options as MikroOptions<Image>
)
}
async findAndCount(
findOptions: DAL.FindOptions<Image> = { where: {} },
context: Context = {}
): Promise<[Image[], number]> {
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return await manager.findAndCount(
Image,
findOptions_.where as MikroFilterQuery<Image>,
findOptions_.options as MikroOptions<Image>
)
}
@InjectTransactionManager()
async upsert(
urls: string[],
@MedusaContext()
context: Context = {}
): Promise<Image[]> {
const { transactionManager: manager } = context
const existingImages = await this.find(
{
where: {
url: {
$in: urls,
},
},
},
context
)
const existingImagesMap = new Map(
existingImages.map<[string, Image]>((img) => [img.url, img])
)
const upsertedImgs: Image[] = []
const imageToCreate: Image[] = []
urls.forEach((url) => {
const aImg = existingImagesMap.get(url)
if (aImg) {
upsertedImgs.push(aImg)
} else {
const newImg = (manager as SqlEntityManager).create(Image, { url })
imageToCreate.push(newImg)
}
})
if (imageToCreate.length) {
await (manager as SqlEntityManager).persist(imageToCreate)
upsertedImgs.push(...imageToCreate)
}
return upsertedImgs
}
@InjectTransactionManager()
async delete(
ids: string[],
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<void> {
await (manager as SqlEntityManager).nativeDelete(
Product,
{ id: { $in: ids } },
{}
)
}
@InjectTransactionManager()
async create(
data: unknown[],
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<Image[]> {
throw new Error("Method not implemented.")
}
}
@@ -0,0 +1,90 @@
import {
FilterQuery as MikroFilterQuery,
FindOptions as MikroOptions,
LoadStrategy,
} from "@mikro-orm/core"
import { Product, ProductOption } from "@models"
import { Context, DAL, ProductTypes } from "@medusajs/types"
import { AbstractBaseRepository } from "./base"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { InjectTransactionManager, MedusaContext } from "@medusajs/utils"
export class ProductOptionRepository extends AbstractBaseRepository<ProductOption> {
protected readonly manager_: SqlEntityManager
constructor({ manager }: { manager: SqlEntityManager }) {
// @ts-ignore
super(...arguments)
this.manager_ = manager
}
async find(
findOptions: DAL.FindOptions<ProductOption> = { where: {} },
context: Context = {}
): Promise<ProductOption[]> {
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return await manager.find(
ProductOption,
findOptions_.where as MikroFilterQuery<ProductOption>,
findOptions_.options as MikroOptions<ProductOption>
)
}
async findAndCount(
findOptions: DAL.FindOptions<ProductOption> = { where: {} },
context: Context = {}
): Promise<[ProductOption[], number]> {
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return await manager.findAndCount(
ProductOption,
findOptions_.where as MikroFilterQuery<ProductOption>,
findOptions_.options as MikroOptions<ProductOption>
)
}
@InjectTransactionManager()
async delete(
ids: string[],
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<void> {
await (manager as SqlEntityManager).nativeDelete(
Product,
{ id: { $in: ids } },
{}
)
}
@InjectTransactionManager()
async create(
data: (ProductTypes.CreateProductOptionDTO & { product: { id: string } })[],
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<ProductOption[]> {
const options = data.map((option) => {
return (manager as SqlEntityManager).create(ProductOption, option)
})
await (manager as SqlEntityManager).persist(options)
return options
}
}
+102 -41
View File
@@ -1,74 +1,135 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import {
FilterQuery as MikroFilterQuery,
FindOptions as MikroOptions,
LoadStrategy,
RequiredEntityData,
} from "@mikro-orm/core"
import { deduplicateIfNecessary } from "../utils"
import { ProductTag } from "@models"
import { DAL } from "@medusajs/types"
import { Product, ProductTag } from "@models"
import { Context, CreateProductTagDTO, DAL } from "@medusajs/types"
import { AbstractBaseRepository } from "./base"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { InjectTransactionManager, MedusaContext } from "@medusajs/utils"
export class ProductTagRepository implements DAL.RepositoryService {
export class ProductTagRepository extends AbstractBaseRepository<ProductTag> {
protected readonly manager_: SqlEntityManager
constructor({ manager }) {
this.manager_ = manager.fork()
constructor({ manager }: { manager: SqlEntityManager }) {
// @ts-ignore
super(...arguments)
this.manager_ = manager
}
async find<T = ProductTag>(
findOptions: DAL.FindOptions<T> = { where: {} },
context: { transaction?: any } = {}
): Promise<T[]> {
// Spread is used to copy the options in case of manipulation to prevent side effects
async find(
findOptions: DAL.FindOptions<ProductTag> = { where: {} },
context: Context = {}
): Promise<ProductTag[]> {
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
findOptions_.options.limit ??= 15
if (findOptions_.options.populate) {
deduplicateIfNecessary(findOptions_.options.populate)
}
if (context.transaction) {
Object.assign(findOptions_.options, { ctx: context.transaction })
}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return (await this.manager_.find(
return await manager.find(
ProductTag,
findOptions_.where as MikroFilterQuery<ProductTag>,
findOptions_.options as MikroOptions<ProductTag>
)) as unknown as T[]
)
}
async findAndCount<T = ProductTag>(
findOptions: DAL.FindOptions<T> = { where: {} },
context: { transaction?: any } = {}
): Promise<[T[], number]> {
// Spread is used to copy the options in case of manipulation to prevent side effects
async findAndCount(
findOptions: DAL.FindOptions<ProductTag> = { where: {} },
context: Context = {}
): Promise<[ProductTag[], number]> {
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
findOptions_.options.limit ??= 15
if (findOptions_.options.populate) {
deduplicateIfNecessary(findOptions_.options.populate)
}
if (context.transaction) {
Object.assign(findOptions_.options, { ctx: context.transaction })
}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return (await this.manager_.findAndCount(
return await manager.findAndCount(
ProductTag,
findOptions_.where as MikroFilterQuery<ProductTag>,
findOptions_.options as MikroOptions<ProductTag>
)) as unknown as [T[], number]
)
}
@InjectTransactionManager()
async upsert(
tags: CreateProductTagDTO[],
@MedusaContext()
context: Context = {}
): Promise<ProductTag[]> {
const { transactionManager: manager } = context
const tagsValues = tags.map((tag) => tag.value)
const existingTags = await this.find(
{
where: {
value: {
$in: tagsValues,
},
},
},
context
)
const existingTagsMap = new Map(
existingTags.map<[string, ProductTag]>((tag) => [tag.value, tag])
)
const upsertedTags: ProductTag[] = []
const tagsToCreate: RequiredEntityData<ProductTag>[] = []
tags.forEach((tag) => {
const aTag = existingTagsMap.get(tag.value)
if (aTag) {
upsertedTags.push(aTag)
} else {
const newTag = (manager as SqlEntityManager).create(ProductTag, tag)
tagsToCreate.push(newTag)
}
})
if (tagsToCreate.length) {
const newTags: ProductTag[] = []
tagsToCreate.forEach((tag) => {
newTags.push((manager as SqlEntityManager).create(ProductTag, tag))
})
await (manager as SqlEntityManager).persist(newTags)
upsertedTags.push(...newTags)
}
return upsertedTags
}
@InjectTransactionManager()
async delete(
ids: string[],
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<void> {
await (manager as SqlEntityManager).nativeDelete(
Product,
{ id: { $in: ids } },
{}
)
}
@InjectTransactionManager()
async create(
data: unknown[],
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<ProductTag[]> {
throw new Error("Method not implemented.")
}
}
@@ -0,0 +1,135 @@
import {
FilterQuery as MikroFilterQuery,
FindOptions as MikroOptions,
LoadStrategy,
RequiredEntityData,
} from "@mikro-orm/core"
import { Product, ProductType } from "@models"
import { Context, CreateProductTypeDTO, DAL } from "@medusajs/types"
import { AbstractBaseRepository } from "./base"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { InjectTransactionManager, MedusaContext } from "@medusajs/utils"
export class ProductTypeRepository extends AbstractBaseRepository<ProductType> {
protected readonly manager_: SqlEntityManager
constructor({ manager }: { manager: SqlEntityManager }) {
// @ts-ignore
super(...arguments)
this.manager_ = manager
}
async find(
findOptions: DAL.FindOptions<ProductType> = { where: {} },
context: Context = {}
): Promise<ProductType[]> {
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return await manager.find(
ProductType,
findOptions_.where as MikroFilterQuery<ProductType>,
findOptions_.options as MikroOptions<ProductType>
)
}
async findAndCount(
findOptions: DAL.FindOptions<ProductType> = { where: {} },
context: Context = {}
): Promise<[ProductType[], number]> {
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return await manager.findAndCount(
ProductType,
findOptions_.where as MikroFilterQuery<ProductType>,
findOptions_.options as MikroOptions<ProductType>
)
}
@InjectTransactionManager()
async upsert(
types: CreateProductTypeDTO[],
@MedusaContext()
context: Context = {}
): Promise<ProductType[]> {
const { transactionManager: manager } = context
const typesValues = types.map((type) => type.value)
const existingTypes = await this.find(
{
where: {
value: {
$in: typesValues,
},
},
},
context
)
const existingTypesMap = new Map(
existingTypes.map<[string, ProductType]>((type) => [type.value, type])
)
const upsertedTypes: ProductType[] = []
const typesToCreate: RequiredEntityData<ProductType>[] = []
types.forEach((type) => {
const aType = existingTypesMap.get(type.value)
if (aType) {
upsertedTypes.push(aType)
} else {
const newType = (manager as SqlEntityManager).create(ProductType, type)
typesToCreate.push(newType)
}
})
if (typesToCreate.length) {
const newTypes: ProductType[] = []
typesToCreate.forEach((type) => {
newTypes.push((manager as SqlEntityManager).create(ProductType, type))
})
await (manager as SqlEntityManager).persist(newTypes)
upsertedTypes.push(...newTypes)
}
return upsertedTypes
}
@InjectTransactionManager()
async delete(
ids: string[],
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<void> {
await (manager as SqlEntityManager).nativeDelete(
Product,
{ id: { $in: ids } },
{}
)
}
@InjectTransactionManager()
async create(
data: unknown[],
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<ProductType[]> {
throw new Error("Method not implemented.")
}
}
@@ -1,74 +1,92 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import {
FilterQuery as MikroFilterQuery,
FindOptions as MikroOptions,
LoadStrategy,
RequiredEntityData,
} from "@mikro-orm/core"
import { deduplicateIfNecessary } from "../utils"
import { ProductVariant } from "@models"
import { DAL } from "@medusajs/types"
import { Product, ProductVariant } from "@models"
import { Context, DAL } from "@medusajs/types"
import { AbstractBaseRepository } from "./base"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { InjectTransactionManager, MedusaContext } from "@medusajs/utils"
import { doNotForceTransaction } from "../utils"
export class ProductVariantRepository implements DAL.RepositoryService {
export class ProductVariantRepository extends AbstractBaseRepository<ProductVariant> {
protected readonly manager_: SqlEntityManager
constructor({ manager }) {
this.manager_ = manager.fork()
constructor({ manager }: { manager: SqlEntityManager }) {
// @ts-ignore
super(...arguments)
this.manager_ = manager
}
async find<T = ProductVariant>(
findOptions: DAL.FindOptions<T> = { where: {} },
context: { transaction?: any } = {}
): Promise<T[]> {
// Spread is used to copy the options in case of manipulation to prevent side effects
async find(
findOptions: DAL.FindOptions<ProductVariant> = { where: {} },
context: Context = {}
): Promise<ProductVariant[]> {
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
findOptions_.options.limit ??= 15
if (findOptions_.options.populate) {
deduplicateIfNecessary(findOptions_.options.populate)
}
if (context.transaction) {
Object.assign(findOptions_.options, { ctx: context.transaction })
}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return (await this.manager_.find(
return await manager.find(
ProductVariant,
findOptions_.where as MikroFilterQuery<ProductVariant>,
findOptions_.options as MikroOptions<ProductVariant>
)) as unknown as T[]
)
}
async findAndCount<T = ProductVariant>(
findOptions: DAL.FindOptions<T> = { where: {} },
context: { transaction?: any } = {}
): Promise<[T[], number]> {
// Spread is used to copy the options in case of manipulation to prevent side effects
async findAndCount(
findOptions: DAL.FindOptions<ProductVariant> = { where: {} },
context: Context = {}
): Promise<[ProductVariant[], number]> {
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
findOptions_.options.limit ??= 15
if (findOptions_.options.populate) {
deduplicateIfNecessary(findOptions_.options.populate)
}
if (context.transaction) {
Object.assign(findOptions_.options, { ctx: context.transaction })
}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return (await this.manager_.findAndCount(
return await manager.findAndCount(
ProductVariant,
findOptions_.where as MikroFilterQuery<ProductVariant>,
findOptions_.options as MikroOptions<ProductVariant>
)) as unknown as [T[], number]
)
}
@InjectTransactionManager()
async delete(
ids: string[],
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<void> {
await (manager as SqlEntityManager).nativeDelete(
Product,
{ id: { $in: ids } },
{}
)
}
@InjectTransactionManager()
async create(
data: RequiredEntityData<ProductVariant>[],
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<ProductVariant[]> {
const variants = data.map((variant) => {
return (manager as SqlEntityManager).create(ProductVariant, variant)
})
await (manager as SqlEntityManager).persist(variants)
return variants
}
}
+59 -33
View File
@@ -1,36 +1,37 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { Product } from "@models"
import {
FilterQuery as MikroFilterQuery,
FindOptions as MikroOptions,
LoadStrategy,
} from "@mikro-orm/core"
import { deduplicateIfNecessary } from "../utils"
import { DAL } from "@medusajs/types"
import {
Context,
DAL,
ProductTypes,
WithRequiredProperty,
} from "@medusajs/types"
import { AbstractBaseRepository } from "./base"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { InjectTransactionManager, MedusaContext } from "@medusajs/utils"
export class ProductRepository implements DAL.RepositoryService<Product> {
export class ProductRepository extends AbstractBaseRepository<Product> {
protected readonly manager_: SqlEntityManager
constructor({ manager }) {
this.manager_ = manager.fork()
constructor({ manager }: { manager: SqlEntityManager }) {
// @ts-ignore
super(...arguments)
this.manager_ = manager
}
async find(
findOptions: DAL.FindOptions<Product> = { where: {} },
context: { transaction?: any } = {}
context: Context = {}
): Promise<Product[]> {
// Spread is used to cssopy the options in case of manipulation to prevent side effects
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
findOptions_.options.limit ??= 15
if (findOptions_.options.populate) {
deduplicateIfNecessary(findOptions_.options.populate)
}
if (context.transaction) {
Object.assign(findOptions_.options, { ctx: context.transaction })
}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
@@ -38,7 +39,7 @@ export class ProductRepository implements DAL.RepositoryService<Product> {
await this.mutateNotInCategoriesConstraints(findOptions_)
return await this.manager_.find(
return await manager.find(
Product,
findOptions_.where as MikroFilterQuery<Product>,
findOptions_.options as MikroOptions<Product>
@@ -47,20 +48,13 @@ export class ProductRepository implements DAL.RepositoryService<Product> {
async findAndCount(
findOptions: DAL.FindOptions<Product> = { where: {} },
context: { transaction?: any } = {}
context: Context = {}
): Promise<[Product[], number]> {
// Spread is used to copy the options in case of manipulation to prevent side effects
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
findOptions_.options.limit ??= 15
if (findOptions_.options.populate) {
deduplicateIfNecessary(findOptions_.options.populate)
}
if (context.transaction) {
Object.assign(findOptions_.options, { ctx: context.transaction })
if (context.transactionManager) {
Object.assign(findOptions_.options, { ctx: context.transactionManager })
}
Object.assign(findOptions_.options, {
@@ -75,17 +69,20 @@ export class ProductRepository implements DAL.RepositoryService<Product> {
findOptions_.options as MikroOptions<Product>
)
}
/**
* In order to be able to have a strict not in categories, and prevent a product
* to be return in the case it also belongs to other categories, we need to
* first find all products that are in the categories, and then exclude them
*/
private async mutateNotInCategoriesConstraints(
findOptions: DAL.FindOptions<Product> = { where: {} }
protected async mutateNotInCategoriesConstraints(
findOptions: DAL.FindOptions<Product> = { where: {} },
context: Context = {}
): Promise<void> {
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
if (findOptions.where.categories?.id?.["$nin"]) {
const productsInCategories = await this.manager_.find(
const productsInCategories = await manager.find(
Product,
{
categories: {
@@ -109,4 +106,33 @@ export class ProductRepository implements DAL.RepositoryService<Product> {
}
}
}
@InjectTransactionManager()
async delete(
ids: string[],
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<void> {
await (manager as SqlEntityManager).nativeDelete(
Product,
{ id: { $in: ids } },
{}
)
}
@InjectTransactionManager()
async create(
data: WithRequiredProperty<ProductTypes.CreateProductOnlyDTO, "status">[],
@MedusaContext()
{ transactionManager: manager }: Context = {}
): Promise<Product[]> {
console.log((this as any).prototype)
const products = data.map((product) => {
return (manager as SqlEntityManager).create(Product, product)
})
await (manager as SqlEntityManager).persist(products)
return products
}
}
@@ -1,11 +1,8 @@
import { LoaderOptions, Logger } from "@medusajs/types"
import {
ProductServiceInitializeCustomDataLayerOptions,
ProductServiceInitializeOptions,
} from "../types"
import { createConnection, loadDatabaseConfig } from "../utils"
import { LoaderOptions, Logger, ModulesSdkTypes } from "@medusajs/types"
import { createConnection } from "../utils"
import * as ProductModels from "@models"
import { EntitySchema } from "@mikro-orm/core"
import { ModulesSdkUtils } from "@medusajs/utils"
/**
* This script is only valid for mikro orm managers. If a user provide a custom manager
@@ -19,14 +16,14 @@ export async function revertMigration({
logger,
}: Pick<
LoaderOptions<
| ProductServiceInitializeOptions
| ProductServiceInitializeCustomDataLayerOptions
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
>,
"options" | "logger"
> = {}) {
logger ??= console as unknown as Logger
const dbData = loadDatabaseConfig(options)
const dbData = ModulesSdkUtils.loadDatabaseConfig("product", options)
const entities = Object.values(ProductModels) as unknown as EntitySchema[]
const orm = await createConnection(dbData, entities)
+6 -9
View File
@@ -1,11 +1,8 @@
import { LoaderOptions, Logger } from "@medusajs/types"
import {
ProductServiceInitializeCustomDataLayerOptions,
ProductServiceInitializeOptions,
} from "../types"
import { createConnection, loadDatabaseConfig } from "../utils"
import { LoaderOptions, Logger, ModulesSdkTypes } from "@medusajs/types"
import { createConnection } from "../utils"
import * as ProductModels from "@models"
import { EntitySchema } from "@mikro-orm/core"
import { ModulesSdkUtils } from "@medusajs/utils"
/**
* This script is only valid for mikro orm managers. If a user provide a custom manager
@@ -19,14 +16,14 @@ export async function runMigrations({
logger,
}: Pick<
LoaderOptions<
| ProductServiceInitializeOptions
| ProductServiceInitializeCustomDataLayerOptions
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
>,
"options" | "logger"
> = {}) {
logger ??= console as unknown as Logger
const dbData = loadDatabaseConfig(options)
const dbData = ModulesSdkUtils.loadDatabaseConfig("product", options)
const entities = Object.values(ProductModels) as unknown as EntitySchema[]
const orm = await createConnection(dbData, entities)
+6 -9
View File
@@ -1,15 +1,12 @@
import { createConnection, loadDatabaseConfig } from "../utils"
import { createConnection } from "../utils"
import * as ProductModels from "@models"
import { Product, ProductCategory, ProductVariant } from "@models"
import { EntitySchema } from "@mikro-orm/core"
import { LoaderOptions, Logger } from "@medusajs/types"
import {
ProductServiceInitializeCustomDataLayerOptions,
ProductServiceInitializeOptions,
} from "../types"
import { LoaderOptions, Logger, ModulesSdkTypes } from "@medusajs/types"
import { EOL } from "os"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { resolve } from "path"
import { ModulesSdkUtils } from "@medusajs/utils"
export async function run({
options,
@@ -18,8 +15,8 @@ export async function run({
}: Partial<
Pick<
LoaderOptions<
| ProductServiceInitializeOptions
| ProductServiceInitializeCustomDataLayerOptions
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
>,
"options" | "logger"
>
@@ -38,7 +35,7 @@ export async function run({
logger ??= console as unknown as Logger
const dbData = loadDatabaseConfig(options)
const dbData = ModulesSdkUtils.loadDatabaseConfig("product", options)
const entities = Object.values(ProductModels) as unknown as EntitySchema[]
const orm = await createConnection(dbData, entities)
@@ -0,0 +1,20 @@
import { asClass, asValue, createContainer } from "awilix"
import { ProductService } from "@services"
export const nonExistingProductId = "non-existing-id"
export const mockContainer = createContainer()
mockContainer.register({
transaction: asValue(async (task) => await task()),
productRepository: asValue({
find: jest.fn().mockImplementation(async ({ where: { id } }) => {
if (id === nonExistingProductId) {
return []
}
return [{}]
}),
findAndCount: jest.fn().mockResolvedValue([[], 0]),
}),
productService: asClass(ProductService),
})
@@ -1,29 +1,65 @@
import { asClass, asValue, createContainer } from "awilix"
import { ProductService } from "@services"
const container = createContainer()
container.register({
productRepository: asValue({
find: jest.fn().mockResolvedValue([]),
findAndCount: jest.fn().mockResolvedValue([[], 0]),
}),
productVariantService: asValue({
list: jest.fn().mockResolvedValue([]),
}),
productTagService: asValue({
list: jest.fn().mockResolvedValue([]),
}),
productService: asClass(ProductService),
})
import { mockContainer, nonExistingProductId } from "../__fixtures__/product"
describe("Product service", function () {
beforeEach(function () {
jest.clearAllMocks()
})
it("should retrieve a product", async function () {
const productService = mockContainer.resolve("productService")
const productRepository = mockContainer.resolve("productRepository")
const productId = "existing-product"
await productService.retrieve(productId)
expect(productRepository.find).toHaveBeenCalledWith(
{
where: {
id: productId,
},
options: {
fields: undefined,
limit: 15,
offset: undefined,
populate: [],
withDeleted: undefined,
},
},
undefined
)
})
it("should fail to retrieve a product", async function () {
const productService = mockContainer.resolve("productService")
const productRepository = mockContainer.resolve("productRepository")
const err = await productService
.retrieve(nonExistingProductId)
.catch((e) => e)
expect(productRepository.find).toHaveBeenCalledWith(
{
where: {
id: nonExistingProductId,
},
options: {
fields: undefined,
limit: 15,
offset: undefined,
populate: [],
withDeleted: undefined,
},
},
undefined
)
expect(err.message).toBe(
`Product with id: ${nonExistingProductId} was not found`
)
})
it("should list products", async function () {
const productService = container.resolve("productService")
const productRepository = container.resolve("productRepository")
const productService = mockContainer.resolve("productService")
const productRepository = mockContainer.resolve("productRepository")
const filters = {}
const config = {
@@ -32,27 +68,31 @@ describe("Product service", function () {
await productService.list(filters, config)
expect(productRepository.find).toHaveBeenCalledWith({
where: {},
options: {
fields: undefined,
limit: undefined,
offset: undefined,
populate: [],
expect(productRepository.find).toHaveBeenCalledWith(
{
where: {},
options: {
fields: undefined,
limit: 15,
offset: undefined,
populate: [],
withDeleted: undefined,
},
},
})
undefined
)
})
it("should list products with filters", async function () {
const productService = container.resolve("productService")
const productRepository = container.resolve("productRepository")
const productService = mockContainer.resolve("productService")
const productRepository = mockContainer.resolve("productRepository")
const filters = {
tags: {
value: {
$in: ["test"],
}
}
},
},
}
const config = {
relations: [],
@@ -60,33 +100,37 @@ describe("Product service", function () {
await productService.list(filters, config)
expect(productRepository.find).toHaveBeenCalledWith({
where: {
tags: {
value: {
$in: ["test"]
}
expect(productRepository.find).toHaveBeenCalledWith(
{
where: {
tags: {
value: {
$in: ["test"],
},
},
},
options: {
fields: undefined,
limit: 15,
offset: undefined,
populate: [],
withDeleted: undefined,
},
},
options: {
fields: undefined,
limit: undefined,
offset: undefined,
populate: [],
},
})
undefined
)
})
it("should list products with filters and relations", async function () {
const productService = container.resolve("productService")
const productRepository = container.resolve("productRepository")
const productService = mockContainer.resolve("productService")
const productRepository = mockContainer.resolve("productRepository")
const filters = {
tags: {
value: {
$in: ["test"],
}
}
},
},
}
const config = {
relations: ["tags"],
@@ -94,20 +138,62 @@ describe("Product service", function () {
await productService.list(filters, config)
expect(productRepository.find).toHaveBeenCalledWith({
where: {
tags: {
value: {
$in: ["test"]
}
expect(productRepository.find).toHaveBeenCalledWith(
{
where: {
tags: {
value: {
$in: ["test"],
},
},
},
options: {
fields: undefined,
limit: 15,
offset: undefined,
withDeleted: undefined,
populate: ["tags"],
},
},
options: {
fields: undefined,
limit: undefined,
offset: undefined,
populate: ["tags"],
undefined
)
})
it("should list and count the products with filters and relations", async function () {
const productService = mockContainer.resolve("productService")
const productRepository = mockContainer.resolve("productRepository")
const filters = {
tags: {
value: {
$in: ["test"],
},
},
})
}
const config = {
relations: ["tags"],
}
await productService.listAndCount(filters, config)
expect(productRepository.findAndCount).toHaveBeenCalledWith(
{
where: {
tags: {
value: {
$in: ["test"],
},
},
},
options: {
fields: undefined,
limit: 15,
offset: undefined,
withDeleted: undefined,
populate: ["tags"],
},
},
undefined
)
})
})
+3
View File
@@ -4,3 +4,6 @@ export { default as ProductTagService } from "./product-tag"
export { default as ProductVariantService } from "./product-variant"
export { default as ProductCollectionService } from "./product-collection"
export { default as ProductCategoryService } from "./product-category"
export { default as ProductTypeService } from "./product-type"
export { default as ProductOptionService } from "./product-option"
export { default as ProductImageService } from "./product-image"
@@ -1,34 +1,99 @@
import { ProductCategory } from "@models"
import { DAL, FindConfig, ProductTypes, SharedContext } from "@medusajs/types"
import { buildQuery } from "../utils"
import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types"
import { ModulesSdkUtils, MedusaError, isDefined } from "@medusajs/utils"
type InjectedDependencies = {
productCategoryRepository: DAL.RepositoryService
productCategoryRepository: DAL.TreeRepositoryService
}
export default class ProductCategoryService<TEntity = ProductCategory> {
protected readonly productCategoryRepository_: DAL.RepositoryService
export default class ProductCategoryService<
TEntity extends ProductCategory = ProductCategory
> {
protected readonly productCategoryRepository_: DAL.TreeRepositoryService
constructor({ productCategoryRepository }: InjectedDependencies) {
this.productCategoryRepository_ = productCategoryRepository
}
async retrieve(
productCategoryId: string,
config: FindConfig<ProductTypes.ProductCategoryDTO> = {},
sharedContext?: Context
): Promise<TEntity> {
if (!isDefined(productCategoryId)) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`"productCategoryId" must be defined`
)
}
const queryOptions = ModulesSdkUtils.buildQuery<ProductCategory>({
id: productCategoryId,
}, config)
const transformOptions = {
includeDescendantsTree: true,
}
const productCategories = await this.productCategoryRepository_.find(
queryOptions,
transformOptions,
sharedContext
)
if (!productCategories?.length) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`ProductCategory with id: ${productCategoryId} was not found`
)
}
return productCategories[0] as TEntity
}
async list(
filters: ProductTypes.FilterableProductCategoryProps = {},
config: FindConfig<ProductTypes.ProductCategoryDTO> = {},
sharedContext?: SharedContext
sharedContext?: Context
): Promise<TEntity[]> {
const transformOptions = {
includeDescendantsTree: filters?.include_descendants_tree || false
includeDescendantsTree: filters?.include_descendants_tree || false,
}
delete filters.include_descendants_tree
const queryOptions = buildQuery<TEntity>(filters, config)
const queryOptions = ModulesSdkUtils.buildQuery<ProductCategory>(
filters,
config
)
queryOptions.where ??= {}
return await this.productCategoryRepository_.find(
return (await this.productCategoryRepository_.find(
queryOptions,
transformOptions,
sharedContext
)) as TEntity[]
}
async listAndCount(
filters: ProductTypes.FilterableProductCategoryProps = {},
config: FindConfig<ProductTypes.ProductCategoryDTO> = {},
sharedContext?: Context
): Promise<[TEntity[], number]> {
const transformOptions = {
includeDescendantsTree: filters?.include_descendants_tree || false,
}
delete filters.include_descendants_tree
const queryOptions = ModulesSdkUtils.buildQuery<ProductCategory>(
filters,
config
)
queryOptions.where ??= {}
return (await this.productCategoryRepository_.findAndCount(
queryOptions,
transformOptions,
sharedContext
)) as [TEntity[], number]
}
}
@@ -1,30 +1,74 @@
import { ProductCollection } from "@models"
import { DAL, FindConfig, ProductTypes, SharedContext } from "@medusajs/types"
import { buildQuery } from "../utils"
import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types"
import { ModulesSdkUtils, retrieveEntity } from "@medusajs/utils"
type InjectedDependencies = {
productCollectionRepository: DAL.RepositoryService
}
export default class ProductCollectionService<TEntity = ProductCollection> {
protected readonly productCollectionRepository_: DAL.RepositoryService<TEntity>
export default class ProductCollectionService<
TEntity extends ProductCollection = ProductCollection
> {
protected readonly productCollectionRepository_: DAL.TreeRepositoryService
constructor({ productCollectionRepository }: InjectedDependencies) {
this.productCollectionRepository_ = productCollectionRepository
}
async retrieve(
productCollectionId: string,
config: FindConfig<ProductTypes.ProductCollectionDTO> = {},
sharedContext?: Context
): Promise<TEntity> {
return (await retrieveEntity<
ProductCollection,
ProductTypes.ProductCollectionDTO
>({
id: productCollectionId,
entityName: ProductCollection.name,
repository: this.productCollectionRepository_,
config,
sharedContext,
})) as TEntity
}
async list(
filters: ProductTypes.FilterableProductCollectionProps = {},
config: FindConfig<ProductTypes.ProductCollectionDTO> = {},
sharedContext?: SharedContext
sharedContext?: Context
): Promise<TEntity[]> {
const queryOptions = buildQuery<TEntity>(filters, config)
return (await this.productCollectionRepository_.find(
this.buildListQueryOptions(filters, config),
sharedContext
)) as TEntity[]
}
async listAndCount(
filters: ProductTypes.FilterableProductCollectionProps = {},
config: FindConfig<ProductTypes.ProductCollectionDTO> = {},
sharedContext?: Context
): Promise<[TEntity[], number]> {
return (await this.productCollectionRepository_.findAndCount(
this.buildListQueryOptions(filters, config),
sharedContext
)) as [TEntity[], number]
}
protected buildListQueryOptions(
filters: ProductTypes.FilterableProductCollectionProps = {},
config: FindConfig<ProductTypes.ProductCollectionDTO> = {}
) {
const queryOptions = ModulesSdkUtils.buildQuery<ProductCollection>(
filters,
config
)
queryOptions.where ??= {}
if (filters.title) {
queryOptions.where["title"] = { $like: filters.title }
}
return await this.productCollectionRepository_.find(queryOptions)
return queryOptions
}
}
@@ -0,0 +1,26 @@
import { Image } from "@models"
import { Context, DAL } from "@medusajs/types"
import { InjectTransactionManager, MedusaContext } from "@medusajs/utils"
import { doNotForceTransaction } from "../utils"
import { ProductImageRepository } from "@repositories"
type InjectedDependencies = {
productImageRepository: DAL.RepositoryService
}
export default class ProductImageService<TEntity extends Image = Image> {
protected readonly productImageRepository_: DAL.RepositoryService
constructor({ productImageRepository }: InjectedDependencies) {
this.productImageRepository_ = productImageRepository
}
@InjectTransactionManager(doNotForceTransaction, "productImageRepository_")
async upsert(
urls: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return (await (this.productImageRepository_ as ProductImageRepository)
.upsert!(urls, sharedContext)) as TEntity[]
}
}
@@ -1,66 +1,105 @@
import {
ProductCategoryService,
ProductCollectionService,
ProductOptionService,
ProductService,
ProductTagService,
ProductTypeService,
ProductVariantService,
} from "@services"
import {
Image,
Product,
ProductCategory,
ProductCollection,
ProductOption,
ProductTag,
ProductType,
ProductVariant,
} from "@models"
import { FindConfig, ProductTypes, SharedContext } from "@medusajs/types"
import {
Context,
CreateProductOnlyDTO,
DAL,
FindConfig,
InternalModuleDeclaration,
ProductTypes,
} from "@medusajs/types"
import ProductImageService from "./product-image"
import {
InjectTransactionManager,
isDefined,
isString,
kebabCase,
MedusaContext,
} from "@medusajs/utils"
import { shouldForceTransaction } from "../utils"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
productService: ProductService<any>
productVariantService: ProductVariantService<any>
productVariantService: ProductVariantService<any, any>
productTagService: ProductTagService<any>
productCategoryService: ProductCategoryService<any>
productCollectionService: ProductCollectionService<any>
productImageService: ProductImageService<any>
productTypeService: ProductTypeService<any>
productOptionService: ProductOptionService<any>
}
export default class ProductModuleService<
TProduct = Product,
TProductVariant = ProductVariant,
TProductTag = ProductTag,
TProductCollection = ProductCollection,
TProductCategory = ProductCategory
> implements
ProductTypes.IProductModuleService<
TProduct,
TProductVariant,
TProductTag,
TProductCollection,
TProductCategory
>
TProduct extends Product = Product,
TProductVariant extends ProductVariant = ProductVariant,
TProductTag extends ProductTag = ProductTag,
TProductCollection extends ProductCollection = ProductCollection,
TProductCategory extends ProductCategory = ProductCategory,
TProductImage extends Image = Image,
TProductType extends ProductType = ProductType,
TProductOption extends ProductOption = ProductOption
> implements ProductTypes.IProductModuleService
{
protected baseRepository_: DAL.RepositoryService
protected readonly productService_: ProductService<TProduct>
protected readonly productVariantService: ProductVariantService<TProductVariant>
protected readonly productCategoryService: ProductCategoryService<TProductCategory>
protected readonly productTagService: ProductTagService<TProductTag>
protected readonly productCollectionService: ProductCollectionService<TProductCollection>
protected readonly productVariantService_: ProductVariantService<
TProductVariant,
TProduct
>
protected readonly productCategoryService_: ProductCategoryService<TProductCategory>
protected readonly productTagService_: ProductTagService<TProductTag>
protected readonly productCollectionService_: ProductCollectionService<TProductCollection>
protected readonly productImageService_: ProductImageService<TProductImage>
protected readonly productTypeService_: ProductTypeService<TProductType>
protected readonly productOptionService_: ProductOptionService<TProductOption>
constructor({
productService,
productVariantService,
productTagService,
productCategoryService,
productCollectionService,
}: InjectedDependencies) {
constructor(
{
baseRepository,
productService,
productVariantService,
productTagService,
productCategoryService,
productCollectionService,
productImageService,
productTypeService,
productOptionService,
}: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
) {
this.baseRepository_ = baseRepository
this.productService_ = productService
this.productVariantService = productVariantService
this.productTagService = productTagService
this.productCategoryService = productCategoryService
this.productCollectionService = productCollectionService
this.productVariantService_ = productVariantService
this.productTagService_ = productTagService
this.productCategoryService_ = productCategoryService
this.productCollectionService_ = productCollectionService
this.productImageService_ = productImageService
this.productTypeService_ = productTypeService
this.productOptionService_ = productOptionService
}
async list(
filters: ProductTypes.FilterableProductProps = {},
config: FindConfig<ProductTypes.ProductDTO> = {},
sharedContext?: SharedContext
sharedContext?: Context
): Promise<ProductTypes.ProductDTO[]> {
const products = await this.productService_.list(
filters,
@@ -71,10 +110,22 @@ export default class ProductModuleService<
return JSON.parse(JSON.stringify(products))
}
async retrieve(
productId: string,
sharedContext?: Context
): Promise<ProductTypes.ProductDTO> {
const product = await this.productService_.retrieve(
productId,
sharedContext
)
return JSON.parse(JSON.stringify(product))
}
async listAndCount(
filters: ProductTypes.FilterableProductProps = {},
config: FindConfig<ProductTypes.ProductDTO> = {},
sharedContext?: SharedContext
sharedContext?: Context
): Promise<[ProductTypes.ProductDTO[], number]> {
const [products, count] = await this.productService_.listAndCount(
filters,
@@ -85,12 +136,26 @@ export default class ProductModuleService<
return [JSON.parse(JSON.stringify(products)), count]
}
async retrieveVariant(
productVariantId: string,
config: FindConfig<ProductTypes.ProductVariantDTO> = {},
sharedContext?: Context
): Promise<ProductTypes.ProductVariantDTO> {
const productVariant = await this.productVariantService_.retrieve(
productVariantId,
config,
sharedContext
)
return JSON.parse(JSON.stringify(productVariant))
}
async listVariants(
filters: ProductTypes.FilterableProductVariantProps = {},
config: FindConfig<ProductTypes.ProductVariantDTO> = {},
sharedContext?: SharedContext
sharedContext?: Context
): Promise<ProductTypes.ProductVariantDTO[]> {
const variants = await this.productVariantService.list(
const variants = await this.productVariantService_.list(
filters,
config,
sharedContext
@@ -99,12 +164,26 @@ export default class ProductModuleService<
return JSON.parse(JSON.stringify(variants))
}
async listAndCountVariants(
filters: ProductTypes.FilterableProductVariantProps = {},
config: FindConfig<ProductTypes.ProductVariantDTO> = {},
sharedContext?: Context
): Promise<[ProductTypes.ProductVariantDTO[], number]> {
const [variants, count] = await this.productVariantService_.listAndCount(
filters,
config,
sharedContext
)
return [JSON.parse(JSON.stringify(variants)), count]
}
async listTags(
filters: ProductTypes.FilterableProductTagProps = {},
config: FindConfig<ProductTypes.ProductTagDTO> = {},
sharedContext?: SharedContext
sharedContext?: Context
): Promise<ProductTypes.ProductTagDTO[]> {
const tags = await this.productTagService.list(
const tags = await this.productTagService_.list(
filters,
config,
sharedContext
@@ -113,12 +192,26 @@ export default class ProductModuleService<
return JSON.parse(JSON.stringify(tags))
}
async retrieveCollection(
productCollectionId: string,
config: FindConfig<ProductTypes.ProductCollectionDTO> = {},
sharedContext?: Context
): Promise<ProductTypes.ProductCollectionDTO> {
const productCollection = await this.productCollectionService_.retrieve(
productCollectionId,
config,
sharedContext
)
return JSON.parse(JSON.stringify(productCollection))
}
async listCollections(
filters: ProductTypes.FilterableProductCollectionProps = {},
config: FindConfig<ProductTypes.ProductCollectionDTO> = {},
sharedContext?: SharedContext
sharedContext?: Context
): Promise<ProductTypes.ProductCollectionDTO[]> {
const collections = await this.productCollectionService.list(
const collections = await this.productCollectionService_.list(
filters,
config,
sharedContext
@@ -127,12 +220,40 @@ export default class ProductModuleService<
return JSON.parse(JSON.stringify(collections))
}
async listAndCountCollections(
filters: ProductTypes.FilterableProductCollectionProps = {},
config: FindConfig<ProductTypes.ProductCollectionDTO> = {},
sharedContext?: Context
): Promise<[ProductTypes.ProductCollectionDTO[], number]> {
const collections = await this.productCollectionService_.listAndCount(
filters,
config,
sharedContext
)
return JSON.parse(JSON.stringify(collections))
}
async retrieveCategory(
productCategoryId: string,
config: FindConfig<ProductTypes.ProductCategoryDTO> = {},
sharedContext?: Context
): Promise<ProductTypes.ProductCategoryDTO> {
const productCategory = await this.productCategoryService_.retrieve(
productCategoryId,
config,
sharedContext
)
return JSON.parse(JSON.stringify(productCategory))
}
async listCategories(
filters: ProductTypes.FilterableProductCategoryProps = {},
config: FindConfig<ProductTypes.ProductCategoryDTO> = {},
sharedContext?: SharedContext
sharedContext?: Context
): Promise<ProductTypes.ProductCategoryDTO[]> {
const categories = await this.productCategoryService.list(
const categories = await this.productCategoryService_.list(
filters,
config,
sharedContext
@@ -140,4 +261,199 @@ export default class ProductModuleService<
return JSON.parse(JSON.stringify(categories))
}
async listAndCountCategories(
filters: ProductTypes.FilterableProductCategoryProps = {},
config: FindConfig<ProductTypes.ProductCategoryDTO> = {},
sharedContext?: Context
): Promise<[ProductTypes.ProductCategoryDTO[], number]> {
const categories = await this.productCategoryService_.listAndCount(
filters,
config,
sharedContext
)
return JSON.parse(JSON.stringify(categories))
}
async create(data: ProductTypes.CreateProductDTO[], sharedContext?: Context) {
const products = await this.create_(data, sharedContext)
return this.baseRepository_.serialize<
TProduct[],
ProductTypes.ProductDTO[]
>(products, {
populate: true,
})
}
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
protected async create_(
data: ProductTypes.CreateProductDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TProduct[]> {
const productVariantsMap = new Map<
string,
ProductTypes.CreateProductVariantDTO[]
>()
const productOptionsMap = new Map<
string,
ProductTypes.CreateProductOptionDTO[]
>()
const productsData = await Promise.all(
data.map(async (product) => {
const productData = { ...product }
if (!productData.handle) {
productData.handle = kebabCase(product.title)
}
const variants = productData.variants
const options = productData.options
delete productData.options
delete productData.variants
productVariantsMap.set(productData.handle!, variants ?? [])
productOptionsMap.set(productData.handle!, options ?? [])
if (!productData.thumbnail && productData.images?.length) {
productData.thumbnail = isString(productData.images[0])
? (productData.images[0] as string)
: (productData.images[0] as { url: string }).url
}
if (productData.is_giftcard) {
productData.discountable = false
}
if (productData.images?.length) {
productData.images = await this.productImageService_.upsert(
productData.images.map((image) =>
isString(image) ? image : image.url
),
sharedContext
)
}
if (productData.tags?.length) {
productData.tags = await this.productTagService_.upsert(
productData.tags,
sharedContext
)
}
if (isDefined(productData.type)) {
productData.type_id = (
await this.productTypeService_.upsert(
[productData.type as ProductTypes.CreateProductTypeDTO],
sharedContext
)
)?.[0]!.id
}
return productData as CreateProductOnlyDTO
})
)
const products = await this.productService_.create(
productsData,
sharedContext
)
const productByHandleMap = new Map<string, TProduct>(
products.map((product) => [product.handle!, product])
)
const productOptionsData = [...productOptionsMap]
.map(([handle, options]) => {
return options.map((option) => {
return {
...option,
product: productByHandleMap.get(handle)!,
}
})
})
.flat()
const productOptions = await this.productOptionService_.create(
productOptionsData,
sharedContext
)
for (const variants of productVariantsMap.values()) {
variants.forEach((variant) => {
variant.options = variant.options?.map((option, index) => {
const productOption = productOptions[index]
return {
option: productOption,
value: option.value,
}
})
})
}
await Promise.all(
[...productVariantsMap].map(async ([handle, variants]) => {
return await this.productVariantService_.create(
productByHandleMap.get(handle)!,
variants as unknown as ProductTypes.CreateProductVariantOnlyDTO[],
sharedContext
)
})
)
return products
}
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
async delete(
productIds: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
await this.productService_.delete(productIds, sharedContext)
}
async softDelete(
productIds: string[],
sharedContext: Context = {}
): Promise<ProductTypes.ProductDTO[]> {
const products = await this.softDelete_(productIds, sharedContext)
return this.baseRepository_.serialize<
TProduct[],
ProductTypes.ProductDTO[]
>(products, {
populate: true,
})
}
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
protected async softDelete_(
productIds: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<TProduct[]> {
return await this.productService_.softDelete(productIds, sharedContext)
}
async restore(
productIds: string[],
sharedContext: Context = {}
): Promise<ProductTypes.ProductDTO[]> {
const products = await this.restore_(productIds, sharedContext)
return this.baseRepository_.serialize<
TProduct[],
ProductTypes.ProductDTO[]
>(products, {
populate: true,
})
}
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
async restore_(
productIds: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<TProduct[]> {
return await this.productService_.restore(productIds, sharedContext)
}
}
@@ -0,0 +1,32 @@
import { ProductOption } from "@models"
import { Context, DAL, ProductTypes } from "@medusajs/types"
import { ProductOptionRepository } from "@repositories"
import { InjectTransactionManager, MedusaContext } from "@medusajs/utils"
import { doNotForceTransaction } from "../utils"
type InjectedDependencies = {
productOptionRepository: DAL.RepositoryService
}
export default class ProductOptionService<
TEntity extends ProductOption = ProductOption
> {
protected readonly productOptionRepository_: DAL.RepositoryService
constructor({ productOptionRepository }: InjectedDependencies) {
this.productOptionRepository_ =
productOptionRepository as ProductOptionRepository
}
@InjectTransactionManager(doNotForceTransaction, "productOptionRepository_")
async create(
data: ProductTypes.CreateProductOptionOnlyDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return (await (
this.productOptionRepository_ as ProductOptionRepository
).create(data, {
transactionManager: sharedContext.transactionManager,
})) as TEntity[]
}
}
+35 -7
View File
@@ -1,13 +1,27 @@
import { ProductTag } from "@models"
import { DAL, FindConfig, ProductTypes, SharedContext } from "@medusajs/types"
import { buildQuery } from "../utils"
import {
Context,
CreateProductTagDTO,
DAL,
FindConfig,
ProductTypes,
} from "@medusajs/types"
import {
InjectTransactionManager,
MedusaContext,
ModulesSdkUtils,
} from "@medusajs/utils"
import { doNotForceTransaction } from "../utils"
import { ProductTagRepository } from "@repositories"
type InjectedDependencies = {
productTagRepository: DAL.RepositoryService
}
export default class ProductTagService<TEntity = ProductTag> {
protected readonly productTagRepository_: DAL.RepositoryService<TEntity>
export default class ProductTagService<
TEntity extends ProductTag = ProductTag
> {
protected readonly productTagRepository_: DAL.RepositoryService
constructor({ productTagRepository }: InjectedDependencies) {
this.productTagRepository_ = productTagRepository
@@ -16,14 +30,28 @@ export default class ProductTagService<TEntity = ProductTag> {
async list(
filters: ProductTypes.FilterableProductTagProps = {},
config: FindConfig<ProductTypes.ProductTagDTO> = {},
sharedContext?: SharedContext
sharedContext?: Context
): Promise<TEntity[]> {
const queryOptions = buildQuery<TEntity>(filters, config)
const queryOptions = ModulesSdkUtils.buildQuery<ProductTag>(filters, config)
if (filters.value) {
queryOptions.where["value"] = { $ilike: filters.value }
}
return await this.productTagRepository_.find(queryOptions)
return (await this.productTagRepository_.find(
queryOptions,
sharedContext
)) as TEntity[]
}
@InjectTransactionManager(doNotForceTransaction, "productTagRepository_")
async upsert(
tags: CreateProductTagDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return (await (this.productTagRepository_ as ProductTagRepository).upsert!(
tags,
sharedContext
)) as TEntity[]
}
}
@@ -0,0 +1,28 @@
import { ProductType } from "@models"
import { Context, CreateProductTypeDTO, DAL } from "@medusajs/types"
import { InjectTransactionManager, MedusaContext } from "@medusajs/utils"
import { doNotForceTransaction } from "../utils"
import { ProductTypeRepository } from "@repositories"
type InjectedDependencies = {
productTypeRepository: DAL.RepositoryService
}
export default class ProductTypeService<
TEntity extends ProductType = ProductType
> {
protected readonly productTypeRepository_: DAL.RepositoryService
constructor({ productTypeRepository }: InjectedDependencies) {
this.productTypeRepository_ = productTypeRepository
}
@InjectTransactionManager(doNotForceTransaction, "productTypeRepository_")
async upsert(
types: CreateProductTypeDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return (await (this.productTypeRepository_ as ProductTypeRepository)
.upsert!(types, sharedContext)) as TEntity[]
}
}
@@ -1,24 +1,115 @@
import { ProductVariant } from "@models"
import { DAL, FindConfig, ProductTypes, SharedContext } from "@medusajs/types"
import { buildQuery } from "../utils"
import { Product, ProductVariant } from "@models"
import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types"
import {
InjectTransactionManager,
isString,
MedusaContext,
ModulesSdkUtils,
retrieveEntity,
} from "@medusajs/utils"
import ProductService from "./product"
import { doNotForceTransaction } from "../utils"
import { ProductVariantRepository } from "@repositories"
type InjectedDependencies = {
productVariantRepository: DAL.RepositoryService
productService: ProductService<any>
}
export default class ProductVariantService<TEntity = ProductVariant> {
protected readonly productVariantRepository_: DAL.RepositoryService<TEntity>
export default class ProductVariantService<
TEntity extends ProductVariant = ProductVariant,
TProduct extends Product = Product
> {
protected readonly productVariantRepository_: DAL.RepositoryService
protected readonly productService_: ProductService<TProduct>
constructor({ productVariantRepository }: InjectedDependencies) {
constructor({
productVariantRepository,
productService,
}: InjectedDependencies) {
this.productVariantRepository_ = productVariantRepository
this.productService_ = productService
}
async retrieve(
productVariantId: string,
config: FindConfig<ProductTypes.ProductVariantDTO> = {},
sharedContext?: Context
): Promise<TEntity> {
return (await retrieveEntity<
ProductVariant,
ProductTypes.ProductVariantDTO
>({
id: productVariantId,
entityName: ProductVariant.name,
repository: this.productVariantRepository_,
config,
sharedContext,
})) as TEntity
}
async list(
filters: ProductTypes.FilterableProductVariantProps = {},
config: FindConfig<ProductTypes.ProductVariantDTO> = {},
sharedContext?: SharedContext
sharedContext?: Context
): Promise<TEntity[]> {
const queryOptions = buildQuery<TEntity>(filters, config)
return await this.productVariantRepository_.find(queryOptions)
const queryOptions = ModulesSdkUtils.buildQuery<ProductVariant>(
filters,
config
)
return (await this.productVariantRepository_.find(
queryOptions,
sharedContext
)) as TEntity[]
}
async listAndCount(
filters: ProductTypes.FilterableProductVariantProps = {},
config: FindConfig<ProductTypes.ProductVariantDTO> = {},
sharedContext?: Context
): Promise<[TEntity[], number]> {
const queryOptions = ModulesSdkUtils.buildQuery<ProductVariant>(
filters,
config
)
return (await this.productVariantRepository_.findAndCount(
queryOptions,
sharedContext
)) as [TEntity[], number]
}
@InjectTransactionManager(doNotForceTransaction, "productVariantRepository_")
async create(
productOrId: TProduct | string,
data: ProductTypes.CreateProductVariantOnlyDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
let product = productOrId as unknown as Product
if (isString(productOrId)) {
product = await this.productService_.retrieve(
productOrId as string,
sharedContext
)
}
let computedRank = product.variants.toArray().length
const data_ = [...data]
data_.forEach((variant) => {
Object.assign(variant, {
variant_rank: computedRank++,
product,
})
})
return (await (
this.productVariantRepository_ as ProductVariantRepository
).create(data_, {
transactionManager: sharedContext.transactionManager,
})) as TEntity[]
}
}
+99 -13
View File
@@ -1,25 +1,55 @@
import { ProductTagService, ProductVariantService } from "@services"
import { Product } from "@models"
import { DAL, FindConfig, ProductTypes, SharedContext } from "@medusajs/types"
import { buildQuery } from "../utils"
import {
Context,
DAL,
FindConfig,
ProductStatus,
ProductTypes,
WithRequiredProperty,
} from "@medusajs/types"
import {
InjectTransactionManager,
MedusaContext,
MedusaError,
ModulesSdkUtils,
} from "@medusajs/utils"
import { ProductRepository } from "@repositories"
import { doNotForceTransaction } from "../utils"
type InjectedDependencies = {
productRepository: DAL.RepositoryService
productVariantService: ProductVariantService
productTagService: ProductTagService
}
export default class ProductService<TEntity = Product> {
protected readonly productRepository_: DAL.RepositoryService<TEntity>
export default class ProductService<TEntity extends Product = Product> {
protected readonly productRepository_: DAL.RepositoryService
constructor({ productRepository }: InjectedDependencies) {
this.productRepository_ = productRepository
}
async retrieve(productId: string, sharedContext?: Context): Promise<TEntity> {
const queryOptions = ModulesSdkUtils.buildQuery<Product>({
id: productId,
})
const product = await this.productRepository_.find(
queryOptions,
sharedContext
)
if (!product?.length) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product with id: ${productId} was not found`
)
}
return product[0] as TEntity
}
async list(
filters: ProductTypes.FilterableProductProps = {},
config: FindConfig<ProductTypes.ProductDTO> = {},
sharedContext?: SharedContext
sharedContext?: Context
): Promise<TEntity[]> {
if (filters.category_ids) {
if (Array.isArray(filters.category_ids)) {
@@ -34,14 +64,17 @@ export default class ProductService<TEntity = Product> {
delete filters.category_ids
}
const queryOptions = buildQuery<TEntity>(filters, config)
return await this.productRepository_.find(queryOptions)
const queryOptions = ModulesSdkUtils.buildQuery<Product>(filters, config)
return (await this.productRepository_.find(
queryOptions,
sharedContext
)) as TEntity[]
}
async listAndCount(
filters: ProductTypes.FilterableProductProps = {},
config: FindConfig<ProductTypes.ProductDTO> = {},
sharedContext?: SharedContext
sharedContext?: Context
): Promise<[TEntity[], number]> {
if (filters.category_ids) {
if (Array.isArray(filters.category_ids)) {
@@ -56,7 +89,60 @@ export default class ProductService<TEntity = Product> {
delete filters.category_ids
}
const queryOptions = buildQuery<TEntity>(filters, config)
return await this.productRepository_.findAndCount(queryOptions)
const queryOptions = ModulesSdkUtils.buildQuery<Product>(filters, config)
return (await this.productRepository_.findAndCount(
queryOptions,
sharedContext
)) as [TEntity[], number]
}
@InjectTransactionManager(doNotForceTransaction, "productRepository_")
async create(
data: ProductTypes.CreateProductOnlyDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
data.forEach((product) => {
product.status ??= ProductStatus.DRAFT
})
return (await (this.productRepository_ as ProductRepository).create(
data as WithRequiredProperty<
ProductTypes.CreateProductOnlyDTO,
"status"
>[],
{
transactionManager: sharedContext.transactionManager,
}
)) as TEntity[]
}
@InjectTransactionManager(doNotForceTransaction, "productRepository_")
async delete(
ids: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
await this.productRepository_.delete(ids, {
transactionManager: sharedContext.transactionManager,
})
}
@InjectTransactionManager(doNotForceTransaction, "productRepository_")
async softDelete(
productIds: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return await this.productRepository_.softDelete(productIds, {
transactionManager: sharedContext.transactionManager,
})
}
@InjectTransactionManager(doNotForceTransaction, "productRepository_")
async restore(
productIds: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return await this.productRepository_.restore(productIds, {
transactionManager: sharedContext.transactionManager,
})
}
}
+1 -14
View File
@@ -1,17 +1,4 @@
import { Constructor, DAL, IEventBusService } from "@medusajs/types"
export type ProductServiceInitializeOptions = {
database: {
clientUrl: string
schema?: string
driverOptions?: Record<string, unknown>
}
}
export type ProductServiceInitializeCustomDataLayerOptions = {
manager?: any
repositories?: { [key: string]: Constructor<DAL.RepositoryService> }
}
import { IEventBusService } from "@medusajs/types"
export type InitializeModuleInjectableDependencies = {
eventBusService?: IEventBusService
@@ -1,15 +1,15 @@
import { MikroORM, PostgreSqlDriver } from "@mikro-orm/postgresql"
import { ProductServiceInitializeOptions } from "../types"
import { ModuleServiceInitializeOptions } from "@medusajs/types"
export async function createConnection(
database: ProductServiceInitializeOptions["database"],
database: ModuleServiceInitializeOptions["database"],
entities: any[]
) {
const schema = database.schema || "public"
const orm = await MikroORM.init<PostgreSqlDriver>({
discovery: { disableDynamicFileAccess: true },
entities,
debug: process.env.NODE_ENV === "development",
debug: database.debug ?? process.env.NODE_ENV?.startsWith("dev") ?? false,
baseDir: process.cwd(),
clientUrl: database.clientUrl,
schema,
+11 -2
View File
@@ -1,3 +1,12 @@
export * from "./query"
import { MODULE_RESOURCE_TYPE } from "@medusajs/types"
export * from "./create-connection"
export * from "./load-database-config"
export * from "./soft-deletable"
export function shouldForceTransaction(target: any): boolean {
return target.moduleDeclaration?.resources === MODULE_RESOURCE_TYPE.ISOLATED
}
export function doNotForceTransaction(): boolean {
return false
}
@@ -0,0 +1,23 @@
// TODO: Should we create a mikro orm specific package for this and the base repository?
import { Filter } from "@mikro-orm/core"
import { DAL } from "@medusajs/types"
interface FilterArguments {
withDeleted?: boolean
}
export const SoftDeletable = (): ClassDecorator => {
return Filter({
name: DAL.SoftDeletableFilterKey,
cond: ({ withDeleted }: FilterArguments = {}) => {
if (withDeleted) {
return {}
}
return {
deleted_at: null,
}
},
default: true,
})
}
+1
View File
@@ -3,6 +3,7 @@
"version": "1.8.10",
"description": "Medusa Types definition",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
+2 -1
View File
@@ -41,11 +41,12 @@ export type Writable<T> = {
}
export interface FindConfig<Entity> {
select?: (keyof Entity)[]
select?: (keyof Entity | string)[]
skip?: number
take?: number
relations?: string[]
order?: { [K: string]: "ASC" | "DESC" }
withDeleted?: boolean
}
export type ExtendedFindConfig<TEntity> = (
+2
View File
@@ -21,4 +21,6 @@ export type FindOptions<T = any> = {
options?: OptionsQuery<T, any>
}
export const SoftDeletableFilterKey = "softDeletable"
export * from "./repository-service"
+51 -2
View File
@@ -1,5 +1,6 @@
import { FindOptions } from "./index"
import { RepositoryTransformOptions } from "../common"
import { Context } from "../shared-context"
/**
* Data access layer (DAL) interface to implements for any repository service.
@@ -7,6 +8,54 @@ import { RepositoryTransformOptions } from "../common"
* ORM directly and allows to switch to another ORM without changing the business logic.
*/
export interface RepositoryService<T = any> {
find(options?: FindOptions<T>, transformOptions?: RepositoryTransformOptions): Promise<T[]>
findAndCount(options?: FindOptions<T>, transformOptions?: RepositoryTransformOptions): Promise<[T[], number]>
transaction(
task: (transactionManager: unknown) => Promise<any>,
context?: {
isolationLevel?: string
transaction?: unknown
enableNestedTransactions?: boolean
}
): Promise<any>
serialize<TData extends object, TResult extends object, TOptions = any>(
data: TData,
options?: TOptions
): Promise<TResult>
serialize<TData extends object[], TResult extends object[], TOptions = any>(
data: TData[],
options?: TOptions
): Promise<TResult>
find(options?: FindOptions<T>, context?: Context): Promise<T[]>
findAndCount(
options?: FindOptions<T>,
context?: Context
): Promise<[T[], number]>
// Only required for some repositories
upsert?(data: any, context?: Context): Promise<T[]>
create(data: unknown[], context?: Context): Promise<T[]>
delete(ids: string[], context?: Context): Promise<void>
softDelete(ids: string[], context?: Context): Promise<T[]>
restore(ids: string[], context?: Context): Promise<T[]>
}
export interface TreeRepositoryService<T = any> extends RepositoryService<T> {
find(
options?: FindOptions<T>,
transformOptions?: RepositoryTransformOptions,
context?: Context
): Promise<T[]>
findAndCount(
options?: FindOptions<T>,
transformOptions?: RepositoryTransformOptions,
context?: Context
): Promise<[T[], number]>
}
@@ -56,6 +56,7 @@ export interface IInventoryService {
context?: SharedContext
): Promise<ReservationItemDTO>
// TODO make it bulk
createInventoryItem(
input: CreateInventoryItemInput,
context?: SharedContext
@@ -95,6 +96,7 @@ export interface IInventoryService {
context?: SharedContext
): Promise<void>
// TODO make it bulk
deleteInventoryItem(
inventoryItemId: string,
context?: SharedContext
+15
View File
@@ -1,5 +1,6 @@
import { MedusaContainer } from "../common"
import { Logger } from "../logger"
import { RepositoryService } from "../dal"
export type Constructor<T> = new (...args: any[]) => T
export * from "../common/medusa-container"
@@ -93,3 +94,17 @@ export type ModuleExports = {
moduleDeclaration?: InternalModuleDeclaration
): Promise<void>
}
export interface ModuleServiceInitializeOptions {
database: {
clientUrl: string
schema?: string
driverOptions?: Record<string, unknown>
debug?: boolean
}
}
export type ModuleServiceInitializeCustomDataLayerOptions = {
manager?: any
repositories?: { [key: string]: Constructor<RepositoryService> }
}
+133 -1
View File
@@ -34,6 +34,7 @@ export interface ProductDTO {
tags: ProductTagDTO[]
variants: ProductVariantDTO[]
options: ProductOptionDTO[]
images: ProductImageDTO[]
discountable?: boolean
external_id?: string | null
created_at?: string | Date
@@ -86,7 +87,7 @@ export interface ProductTagDTO {
id: string
value: string
metadata?: Record<string, unknown> | null
products: ProductDTO[]
products?: ProductDTO[]
}
export interface ProductCollectionDTO {
@@ -95,6 +96,7 @@ export interface ProductCollectionDTO {
handle: string
metadata?: Record<string, unknown> | null
deleted_at?: string | Date
products?: ProductDTO[]
}
export interface ProductTypeDTO {
@@ -113,6 +115,13 @@ export interface ProductOptionDTO {
deleted_at?: string | Date
}
export interface ProductImageDTO {
id: string
url: string
metadata?: Record<string, unknown> | null
deleted_at?: string | Date
}
export interface ProductOptionValueDTO {
id: string
value: string
@@ -164,3 +173,126 @@ export interface FilterableProductCategoryProps
is_internal?: boolean
include_descendants_tree?: boolean
}
/**
* Write DTO (module API input)
*/
export interface CreateProductTypeDTO {
id?: string
value: string
}
export interface CreateProductTagDTO {
id?: string
value: string
}
export interface CreateProductOptionDTO {
title: string
}
export interface CreateProductVariantOptionDTO {
value: string
}
export interface CreateProductVariantDTO {
title: string
sku?: string
barcode?: string
ean?: string
upc?: string
allow_backorder?: boolean
inventory_quantity?: number
manage_inventory?: boolean
hs_code?: string
origin_country?: string
mid_code?: string
material?: string
weight?: number
length?: number
height?: number
width?: number
options?: CreateProductVariantOptionDTO[]
metadata?: Record<string, unknown>
}
export interface CreateProductDTO {
title: string
subtitle?: string
description?: string
is_giftcard?: boolean
discountable?: boolean
images?: string[] | { id?: string; url: string }[]
thumbnail?: string
handle?: string
status?: ProductStatus
type?: CreateProductTypeDTO
type_id?: string
collection_id?: string
tags?: CreateProductTagDTO[]
// sales_channel
categories?: { id: string }[]
options?: CreateProductOptionDTO[]
variants?: CreateProductVariantDTO[]
width?: number
height?: number
length?: number
weight?: number
origin_country?: string
hs_code?: string
material?: string
mid_code?: string
metadata?: Record<string, unknown>
}
export interface CreateProductOnlyDTO {
title: string
subtitle?: string
description?: string
is_giftcard?: boolean
discountable?: boolean
images?: { id?: string; url: string }[]
thumbnail?: string
handle?: string
status?: ProductStatus
collection_id?: string
width?: number
height?: number
length?: number
weight?: number
origin_country?: string
hs_code?: string
material?: string
mid_code?: string
metadata?: Record<string, unknown>
tags?: { id: string }[]
categories?: { id: string }[]
type_id?: string
}
export interface CreateProductVariantOnlyDTO {
title: string
sku?: string
barcode?: string
ean?: string
upc?: string
allow_backorder?: boolean
inventory_quantity?: number
manage_inventory?: boolean
hs_code?: string
origin_country?: string
mid_code?: string
material?: string
weight?: number
length?: number
height?: number
width?: number
options?: (CreateProductVariantOptionDTO & { option: any })[]
metadata?: Record<string, unknown>
}
export interface CreateProductOptionOnlyDTO {
product: { id: string }
title: string
}
+61 -14
View File
@@ -1,4 +1,5 @@
import {
CreateProductDTO,
FilterableProductCategoryProps,
FilterableProductCollectionProps,
FilterableProductProps,
@@ -11,48 +12,94 @@ import {
ProductVariantDTO,
} from "./common"
import { FindConfig } from "../common"
import { SharedContext } from "../shared-context"
import { Context } from "../shared-context"
export interface IProductModuleService {
retrieve(productId: string, sharedContext?: Context): Promise<ProductDTO>
export interface IProductModuleService<
TProduct = any,
TProductVariant = any,
TProductTag = any,
TProductCollection = any,
TProductCategory = any
> {
list(
filters?: FilterableProductProps,
config?: FindConfig<ProductDTO>,
sharedContext?: SharedContext
sharedContext?: Context
): Promise<ProductDTO[]>
listAndCount(
filters?: FilterableProductProps,
config?: FindConfig<ProductDTO>,
sharedContext?: SharedContext
sharedContext?: Context
): Promise<[ProductDTO[], number]>
listTags(
filters?: FilterableProductTagProps,
config?: FindConfig<ProductTagDTO>,
sharedContext?: SharedContext
sharedContext?: Context
): Promise<ProductTagDTO[]>
retrieveVariant(
productVariantId: string,
config?: FindConfig<ProductVariantDTO>,
sharedContext?: Context
): Promise<ProductVariantDTO>
listVariants(
filters?: FilterableProductVariantProps,
config?: FindConfig<ProductVariantDTO>,
sharedContext?: SharedContext
sharedContext?: Context
): Promise<ProductVariantDTO[]>
listAndCountVariants(
filters?: FilterableProductVariantProps,
config?: FindConfig<ProductVariantDTO>,
sharedContext?: Context
): Promise<[ProductVariantDTO[], number]>
retrieveCollection(
productCollectionId: string,
config?: FindConfig<ProductCollectionDTO>,
sharedContext?: Context
): Promise<ProductCollectionDTO>
listCollections(
filters?: FilterableProductCollectionProps,
config?: FindConfig<ProductCollectionDTO>,
sharedContext?: SharedContext
sharedContext?: Context
): Promise<ProductCollectionDTO[]>
listAndCountCollections(
filters?: FilterableProductCollectionProps,
config?: FindConfig<ProductCollectionDTO>,
sharedContext?: Context
): Promise<[ProductCollectionDTO[], number]>
retrieveCategory(
productCategoryId: string,
config?: FindConfig<ProductCategoryDTO>,
sharedContext?: Context
): Promise<ProductCategoryDTO>
listCategories(
filters?: FilterableProductCategoryProps,
config?: FindConfig<ProductCategoryDTO>,
sharedContext?: SharedContext
sharedContext?: Context
): Promise<ProductCategoryDTO[]>
listAndCountCategories(
filters?: FilterableProductCategoryProps,
config?: FindConfig<ProductCategoryDTO>,
sharedContext?: Context
): Promise<[ProductCategoryDTO[], number]>
create(
data: CreateProductDTO[],
sharedContext?: Context
): Promise<ProductDTO[]>
delete(productIds: string[], sharedContext?: Context): Promise<void>
softDelete(
productIds: string[],
sharedContext?: Context
): Promise<ProductDTO[]>
restore(productIds: string[], sharedContext?: Context): Promise<ProductDTO[]>
}
+6
View File
@@ -3,3 +3,9 @@ import { EntityManager } from "typeorm"
export type SharedContext = {
transactionManager?: EntityManager
}
export type Context<TManager = unknown> = {
transactionManager?: TManager
isolationLevel?: string
enableNestedTransactions?: boolean
}
+3 -1
View File
@@ -3,6 +3,7 @@
"version": "1.9.2",
"description": "Medusa utilities functions shared by Medusa core and Modules",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
@@ -22,6 +23,7 @@
"cross-env": "^5.2.1",
"express": "^4.18.2",
"jest": "^25.5.4",
"rimraf": "^5.0.1",
"ts-jest": "^25.5.1",
"typescript": "^4.4.4"
},
@@ -32,7 +34,7 @@
},
"scripts": {
"prepare": "cross-env NODE_ENV=production yarn run build",
"build": "tsc --build",
"build": "rimraf dist && tsc --build",
"watch": "tsc --build --watch",
"test": "jest"
}
+4 -4
View File
@@ -1,4 +1,4 @@
export * as DecoratorUtils from "./decorators";
export * as EventBusUtils from "./event-bus";
export * as SearchUtils from "./search";
export * as DecoratorUtils from "./decorators"
export * as EventBusUtils from "./event-bus"
export * as SearchUtils from "./search"
export * as ModulesSdkUtils from "./modules-sdk"
+3
View File
@@ -0,0 +1,3 @@
export function deduplicate<T = any>(collection: T[]): T[] {
return [...new Set(collection)]
}
+1
View File
@@ -1,4 +1,5 @@
export * from "./build-query"
export * from "./deduplicate"
export * from "./errors"
export * from "./generate-entity-id"
export * from "./get-config-file"
@@ -4,16 +4,7 @@ export function MedusaContext() {
propertyKey: string | symbol,
parameterIndex: number
) {
if (!target.MedusaContextIndex_) {
target.MedusaContextIndex_ = {}
}
if (propertyKey in target.MedusaContextIndex_) {
throw new Error(
`Only one MedusaContext is allowed on method "${String(propertyKey)}".`
)
}
target.MedusaContextIndex_ ??= {}
target.MedusaContextIndex_[propertyKey] = parameterIndex
}
}
@@ -1,8 +1,8 @@
import { SharedContext } from "@medusajs/types"
import { Context, SharedContext } from "@medusajs/types"
export function InjectEntityManager(
shouldForceTransaction: (target: any) => boolean = () => false,
managerProperty: string = "manager_"
managerProperty: string | false = "manager_"
): MethodDecorator {
return function (
target: any,
@@ -20,18 +20,27 @@ export function InjectEntityManager(
const argIndex = target.MedusaContextIndex_[propertyKey]
descriptor.value = async function (...args: any[]) {
const shouldForceTransactionRes = shouldForceTransaction(target)
const context: SharedContext = args[argIndex] ?? {}
const context: SharedContext | Context = args[argIndex] ?? {}
if (!shouldForceTransactionRes && context?.transactionManager) {
return await originalMethod.apply(this, args)
}
return await this[managerProperty].transaction(
return await (managerProperty === false
? this
: this[managerProperty]
).transaction(
async (transactionManager) => {
args[argIndex] = args[argIndex] ?? {}
args[argIndex].transactionManager = transactionManager
return await originalMethod.apply(this, args)
},
{
transaction: context?.transactionManager,
isolationLevel: (context as Context)?.isolationLevel,
enableNestedTransactions:
(context as Context).enableNestedTransactions ?? false,
}
)
}
+2 -1
View File
@@ -3,4 +3,5 @@ export * from "./cli"
export * from "./common"
export * from "./decorators"
export * from "./event-bus"
export * from "./search"
export * from "./search"
export * from "./modules-sdk"
@@ -1,4 +1,4 @@
import { loadDatabaseConfig } from "../load-database-config"
import { loadDatabaseConfig } from "../load-module-database-config"
describe("loadDatabaseConfig", function () {
afterEach(() => {
@@ -8,7 +8,7 @@ describe("loadDatabaseConfig", function () {
it("should return the local configuration using the environment variable", function () {
process.env.POSTGRES_URL = "postgres://localhost:5432/medusa"
let config = loadDatabaseConfig()
let config = loadDatabaseConfig("product")
expect(config).toEqual({
clientUrl: process.env.POSTGRES_URL,
@@ -17,12 +17,13 @@ describe("loadDatabaseConfig", function () {
ssl: false,
},
},
debug: false,
schema: "",
})
delete process.env.POSTGRES_URL
process.env.PRODUCT_POSTGRES_URL = "postgres://localhost:5432/medusa"
config = loadDatabaseConfig()
config = loadDatabaseConfig("product")
expect(config).toEqual({
clientUrl: process.env.PRODUCT_POSTGRES_URL,
@@ -31,13 +32,14 @@ describe("loadDatabaseConfig", function () {
ssl: false,
},
},
debug: false,
schema: "",
})
})
it("should return the remote configuration using the environment variable", function () {
process.env.POSTGRES_URL = "postgres://https://test.com:5432/medusa"
let config = loadDatabaseConfig()
let config = loadDatabaseConfig("product")
expect(config).toEqual({
clientUrl: process.env.POSTGRES_URL,
@@ -48,12 +50,13 @@ describe("loadDatabaseConfig", function () {
},
},
},
debug: false,
schema: "",
})
delete process.env.POSTGRES_URL
process.env.PRODUCT_POSTGRES_URL = "postgres://https://test.com:5432/medusa"
config = loadDatabaseConfig()
config = loadDatabaseConfig("product")
expect(config).toEqual({
clientUrl: process.env.PRODUCT_POSTGRES_URL,
@@ -64,6 +67,7 @@ describe("loadDatabaseConfig", function () {
},
},
},
debug: false,
schema: "",
})
})
@@ -76,7 +80,7 @@ describe("loadDatabaseConfig", function () {
},
}
const config = loadDatabaseConfig(options)
let config = loadDatabaseConfig("product", options)
expect(config).toEqual({
clientUrl: options.database.clientUrl,
@@ -85,6 +89,7 @@ describe("loadDatabaseConfig", function () {
ssl: false,
},
},
debug: false,
schema: "",
})
})
@@ -97,7 +102,7 @@ describe("loadDatabaseConfig", function () {
},
}
const config = loadDatabaseConfig(options)
let config = loadDatabaseConfig("product", options)
expect(config).toEqual({
clientUrl: options.database.clientUrl,
@@ -108,6 +113,7 @@ describe("loadDatabaseConfig", function () {
},
},
},
debug: false,
schema: "",
})
})
@@ -115,7 +121,7 @@ describe("loadDatabaseConfig", function () {
it("should throw if no clientUrl is provided", function () {
let error
try {
loadDatabaseConfig()
loadDatabaseConfig("product")
} catch (e) {
error = e
}
@@ -1,12 +1,5 @@
/**
* Move to a new build query utils
*/
import { DAL, FindConfig } from "@medusajs/types"
import { isObject } from "@medusajs/utils"
export function deduplicateIfNecessary<T = any>(collection: T | T[]) {
return Array.isArray(collection) ? [...new Set(collection)] : collection
}
import { DAL, FindConfig, SoftDeletableFilterKey } from "@medusajs/types"
import { deduplicate, isObject } from "../common"
export function buildQuery<T = any, TDto = any>(
filters: Record<string, any> = {},
@@ -16,11 +9,18 @@ export function buildQuery<T = any, TDto = any>(
buildWhere(filters, where)
const findOptions: DAL.OptionsQuery<T, any> = {
populate: config.relations ?? [],
fields: config.select,
limit: config.take,
populate: deduplicate(config.relations ?? []),
fields: config.select as string[],
limit: config.take ?? 15,
offset: config.skip,
} as any
}
if (config.withDeleted) {
findOptions.filters ??= {}
findOptions.filters[SoftDeletableFilterKey] = {
withDeleted: true,
}
}
return { where, options: findOptions }
}
@@ -28,7 +28,7 @@ export function buildQuery<T = any, TDto = any>(
function buildWhere(filters: Record<string, any> = {}, where = {}) {
for (let [prop, value] of Object.entries(filters)) {
if (Array.isArray(value)) {
value = deduplicateIfNecessary(value)
value = deduplicate(value)
where[prop] = ["$in", "$nin"].includes(prop) ? value : { $in: value }
continue
}
@@ -0,0 +1 @@
export * from "./inject-transaction-manager"
@@ -0,0 +1,48 @@
import { Context, SharedContext } from "@medusajs/types"
export function InjectTransactionManager(
shouldForceTransaction: (target: any) => boolean = () => false,
managerProperty?: string
): MethodDecorator {
return function (
target: any,
propertyKey: string | symbol,
descriptor: any
): void {
if (!target.MedusaContextIndex_) {
throw new Error(
`To apply @InjectTransactionManager you have to flag a parameter using @MedusaContext`
)
}
const originalMethod = descriptor.value
const argIndex = target.MedusaContextIndex_[propertyKey]
descriptor.value = async function (...args: any[]) {
const shouldForceTransactionRes = shouldForceTransaction(target)
const context: SharedContext | Context = args[argIndex] ?? {}
if (!shouldForceTransactionRes && context?.transactionManager) {
return await originalMethod.apply(this, args)
}
return await (!managerProperty
? this
: this[managerProperty]
).transaction(
async (transactionManager) => {
args[argIndex] = args[argIndex] ?? {}
args[argIndex].transactionManager = transactionManager
return await originalMethod.apply(this, args)
},
{
transaction: context?.transactionManager,
isolationLevel: (context as Context)?.isolationLevel,
enableNestedTransactions:
(context as Context).enableNestedTransactions ?? false,
}
)
}
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./load-module-database-config"
export * from "./decorators"
export * from "./build-query"
export * from "./retrieve-entity"
@@ -1,23 +1,19 @@
import {
ProductServiceInitializeCustomDataLayerOptions,
ProductServiceInitializeOptions,
} from "../types"
import { MedusaError } from "@medusajs/utils"
import { MedusaError } from "../common"
import { ModulesSdkTypes } from "@medusajs/types"
function getEnv(key: string): string {
const value = process.env[`PRODUCT_${key}`] ?? process.env[`${key}`]
function getEnv(key: string, moduleName: string): string {
const value =
process.env[`${moduleName.toUpperCase()}_${key}`] ?? process.env[`${key}`]
return value ?? ""
}
function isProductServiceInitializeOptions(
function isModuleServiceInitializeOptions(
obj: unknown
): obj is ProductServiceInitializeOptions {
return !!(obj as ProductServiceInitializeOptions)?.database
): obj is ModulesSdkTypes.ModuleServiceInitializeOptions {
return !!(obj as any)?.database
}
function getDefaultDriverOptions(
clientUrl: string
): ProductServiceInitializeOptions["database"]["driverOptions"] {
function getDefaultDriverOptions(clientUrl: string) {
const localOptions = {
connection: {
ssl: false,
@@ -45,31 +41,35 @@ function getDefaultDriverOptions(
/**
* Load the config for the database connection. The options can be retrieved
* through PRODUCT_* (e.g PRODUCT_POSTGRES_URL) or * (e.g POSTGRES_URL) environment variables or the options object.
* e.g through PRODUCT_* (e.g PRODUCT_POSTGRES_URL) or * (e.g POSTGRES_URL) environment variables or the options object.
* @param options
* @param moduleName
*/
export function loadDatabaseConfig(
moduleName: string,
options?:
| ProductServiceInitializeOptions
| ProductServiceInitializeCustomDataLayerOptions
): ProductServiceInitializeOptions["database"] {
const clientUrl = getEnv("POSTGRES_URL")
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
): ModulesSdkTypes.ModuleServiceInitializeOptions["database"] {
const clientUrl = getEnv("POSTGRES_URL", moduleName)
const database: ProductServiceInitializeOptions["database"] = {
clientUrl: getEnv("POSTGRES_URL"),
schema: getEnv("POSTGRES_SCHEMA") ?? "public",
const database = {
clientUrl: getEnv("POSTGRES_URL", moduleName),
schema: getEnv("POSTGRES_SCHEMA", moduleName) ?? "public",
driverOptions: JSON.parse(
getEnv("POSTGRES_DRIVER_OPTIONS") ||
getEnv("POSTGRES_DRIVER_OPTIONS", moduleName) ||
JSON.stringify(getDefaultDriverOptions(clientUrl))
),
debug: process.env.NODE_ENV?.startsWith("dev") ?? false,
}
if (isProductServiceInitializeOptions(options)) {
if (isModuleServiceInitializeOptions(options)) {
database.clientUrl = options.database.clientUrl ?? database.clientUrl
database.schema = options.database.schema ?? database.schema
database.driverOptions =
options.database.driverOptions ??
getDefaultDriverOptions(database.clientUrl)
database.debug = options.database.debug ?? database.debug
}
if (!database.clientUrl) {
@@ -0,0 +1,47 @@
import { FindConfig, DAL, Context } from "@medusajs/types"
import { MedusaError, isDefined, lowerCaseFirst } from "../common"
import { buildQuery } from "./build-query"
type RetrieveEntityParams<TDTO> = {
id: string,
entityName: string,
repository: DAL.TreeRepositoryService
config: FindConfig<TDTO>
sharedContext?: Context
}
export async function retrieveEntity<
TEntity,
TDTO,
>({
id,
entityName,
repository,
config = {},
sharedContext,
}: RetrieveEntityParams<TDTO>): Promise<TEntity> {
if (!isDefined(id)) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`"${lowerCaseFirst(entityName)}Id" must be defined`
)
}
const queryOptions = buildQuery<TEntity>({
id,
}, config)
const entities = await repository.find(
queryOptions,
sharedContext
)
if (!entities?.length) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`${entityName} with id: ${id} was not found`
)
}
return entities[0]
}