feat: Product Module (#4161)

* chore: boilerplate setup

* wip: add Product, ProductTag, ProductType, ProductCollection models

* wip: `IProductService` definition

* wip: test function in index, build passing

* fix: where condition

* chore: get boilerplate working with modules sdk, create a boilerplate test, create product variant model, register services properly

* chore: added variant to model

* chore: changed definition details + add migrator

* cleanup and update product entity

* Update product unique index to include soft deleted

* Migrations tests

* generated migration

* update dev orm config

* add path aliases

* WIP

* chore: added boilerplate integration test + database helper + product variant migraiton + model

* chore: remove old test utils

* update ts and jest config to include path aliases

* tweak config

* WIP migrations variant

* Migrations round

* integration tests migrations polishing

* chore: fixed issues with test db

* fix path aliases when published

* use ts-alias

* fix connection loader

* fixes

* wip: product list

* (WIP): Data access layer

* (WIP): DAL cleanup services

* wip: `ProductTag` DAL

* wip: `ProductTag` expose list in product service

* (WIP): Continue DAL and test list product filtering/populate

* WIP: unit tests

* chore: added tests for service - productvariant

* chore: WIP finding issues with orm manager fork

* WIP fix fields selection

* chore: make text fixes work

* (WIP) product integration

* (WIP) product integration

* chore: create a product in variant test

* list product with relations

* wip: `ProductTag` service + integrations

* chore: added with and without serialization example

* chore: remove only in spec

* wip: `ProductCollection` service + integrations

* uncomment product.variants

* Update type IProductService

* (WIP) type work

* (WIP) Product variants relation

* WIP: replacable data layer

* (WIP): Use bundle types

* WIP: update type

* WIP upadte tests

* WIP

* wip: options/option values entites

* (WIP): cleanup

* wip: add option value to variant, fix collection

* Integration tests for custom data access layer

* update tests

* chore: merge with latest branch

* chore: scope tests to relations and add category to models/index

* chore: ignore dist folders for jest

* chore: modularize spec data file

* improve DX

* module fixture naming

* chore: added category tests + fix model

* chore: use kebab case

* chore: allow scoping products by category id

* chore: replace `kebabCase` import

* feat: add `deleted_at` to options

* improve typings

* chore: wip

* fix query util

* revert webpack

* fix: update option models, create option DTOs, tests wip, fix `deduplicateIfNecessary` returning `undefined`

* fix: merge conflict

* WIP connection

* rm unsues deps

* fix migrations

* fix query util

* chore: adds mpath on creation

* WIP update types

* improve typings

* WIP typeings improvement

* WIP

* deps

* chore: package medusa/product ot medusa-commerce/product

* chore: added product categories service + descendants filter

* add missing index

* Add support for strict categories not in

* Add support for strict categories not in

* lint

* rename module

* rename module

* Create small-ducks-doubt.md

* yarn lock

* update initialise

* chore: fix/finalise DTOs

* fix: wrong types in `IProductService`

* fix type

* Load database config from env if present (#4175)

* Load database config from env if present

* Load database config from env if present

* options optionnal

* update util

* add defaults

* improve filterable interfaces

* fix import

* fix types

* remove medusa-telemetry from modules-sdk

* WIP fixing webpack issues when bootstraping module

* cleanup

* improve loading driver options

* cleanup

* yarn lock

* fix import

* improve sdk types and naming

* align orther modules initialise method

* fix module tests with singleton module

* fix module tests with singleton module

* add up/down migration scripts

* update types

* scripts

* cleanup migrations and scripts

* hash module singleton

* cleanup migration

* cleanup

* fix stringifyCircular usage

* improvements

* fix deps

* fix deps

* improve load config utils

* improve load config utils

* fix deps

* add declaration to the build

* update yarn

* Do not resolve a module path if the exports are explicitly given

* fix module registration resolution path when exports are provided. Explicitly check for false and assign an empty string in this scenario for segregation purpose

* add comment

* fix migration options to prevent set replica errors

* chore: update types to a proper depedency

* chore: update type package

* add seed scripts

* Add descriptive error during database config loading

* use MedusaError

* chore: added lodash to package

* add more test to the database config loader util

* create bin scripts

* add bin

* update argv retrieval

* update package.json

* chore: add product category to injected deps

* chore: replace with product category service

* move dotenv usage to the functions

* do not load db if there is custom manager

* chore: fix some tests on products repo

* chore: fixed product spec

* chore: skip products module on modules register

* stringifyCircular update

* chore: fix incorrect module resolution

* fix: circular stringify and non required module loading

* yarn lock

* target es5

* chore: mikro-orm back to 5.7.4

* revert module registry

* skip external modules

* es2020

* update indexes, migration and integration tests

* rm only

* unit test script should only run unit tests

* Exclude product integration from the unit tests and make use of the global integration script to run all packages integration tests

* fix integration tests

* improve setup

* cleanup

* log error on setup fail

* Create enum like for package names

* chore: remove EOL

* chore: review part 2

* renamve gateway to productModuleService

* chore: added filters and collections to productmoduleservice

* chore: add collection to the singleton instance

* chore: remove skipped test + add todo

* fix indexes on fields and relations + update migration

* update yarn lock

* update idx

* add foreign key

* rename interface and add listCategories

* rename product module definition

---------

Co-authored-by: fPolic <frane@medusajs.com>
Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
Co-authored-by: Carlos R. L. Rodrigues <rodrigolr@gmail.com>
This commit is contained in:
Adrien de Peretti
2023-06-09 20:47:24 +02:00
committed by GitHub
co-authored by fPolic Riqwan Thamir Carlos R. L. Rodrigues
parent ffeeb84d97
commit 14c0f62f84
104 changed files with 6472 additions and 176 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@medusajs/types": patch
"@medusajs/modules-sdk": patch
"@medusajs/utils": patch
---
feat(product): Experimental product module
+1 -1
View File
@@ -71,7 +71,7 @@
"prettier": "prettier",
"jest": "jest",
"test": "turbo run test --no-daemon",
"test:integration": "turbo run test:integration --no-daemon",
"test:integration": "turbo run test:integration --no-daemon --filter='./packages/*'",
"test:integration:api": "turbo run test:integration --no-daemon --filter=integration-tests-api",
"test:integration:plugins": "turbo run test:integration --no-daemon --filter=integration-tests-plugins",
"test:integration:repositories": "turbo run test:integration --no-daemon --filter=integration-tests-repositories",
+2 -21
View File
@@ -1,27 +1,8 @@
import loadConnection from "./loaders/connection"
import loadContainer from "./loaders/container"
import migrations from "./migrations"
import { revertMigration, runMigrations } from "./migrations/run-migration"
import * as InventoryModels from "./models"
import InventoryService from "./services/inventory"
import { ModuleExports } from "@medusajs/modules-sdk"
const service = InventoryService
const loaders = [loadContainer, loadConnection]
const models = Object.values(InventoryModels)
const moduleDefinition: ModuleExports = {
service,
migrations,
loaders,
models,
runMigrations,
revertMigration,
}
import { moduleDefinition } from "./module-definition"
export default moduleDefinition
export * from "./initialize"
export { revertMigration, runMigrations } from "./migrations/run-migration"
export * from "./types"
@@ -5,6 +5,7 @@ import {
Modules,
} from "@medusajs/modules-sdk"
import { IEventBusService, IInventoryService } from "@medusajs/types"
import { moduleDefinition } from "../module-definition"
import { InventoryServiceInitializeOptions } from "../types"
export const initialize = async (
@@ -18,6 +19,7 @@ export const initialize = async (
serviceKey,
"@medusajs/inventory",
options as InternalModuleDeclaration | ExternalModuleDeclaration,
moduleDefinition,
injectedDependencies
)
@@ -0,0 +1,20 @@
import InventoryService from "./services/inventory"
import loadContainer from "./loaders/container"
import loadConnection from "./loaders/connection"
import * as InventoryModels from "./models"
import { ModuleExports } from "@medusajs/types"
import migrations from "./migrations"
import { revertMigration, runMigrations } from "./migrations/run-migration"
const service = InventoryService
const loaders = [loadContainer, loadConnection]
const models = Object.values(InventoryModels)
export const moduleDefinition: ModuleExports = {
service,
migrations,
loaders,
models,
runMigrations,
revertMigration,
}
+3 -3
View File
@@ -13,15 +13,15 @@ import {
SalesChannel,
} from "../models"
import { dataSource } from "../loaders/database"
import { cloneDeep, groupBy, map, merge } from "lodash"
import { ExtendedFindConfig } from "../types/common"
import { objectToStringPath } from "@medusajs/utils"
import { ExtendedFindConfig } from "@medusajs/types"
import {
applyOrdering,
getGroupedRelations,
queryEntityWithIds,
queryEntityWithoutRelations,
} from "../utils/repository"
import { objectToStringPath } from "@medusajs/utils"
import { cloneDeep, groupBy, map, merge } from "lodash"
export type DefaultWithoutRelations = Omit<
ExtendedFindConfig<Product>,
+2 -3
View File
@@ -26,13 +26,12 @@
"@medusajs/types": "1.8.7",
"@medusajs/utils": "1.9.0",
"awilix": "^8.0.0",
"glob": "7.1.6",
"medusa-telemetry": "^0.0.16",
"resolve-cwd": "^3.0.0"
},
"scripts": {
"prepare": "cross-env NODE_ENV=production yarn run build",
"build": "tsc --build",
"test": "jest"
"test": "jest",
"watch": "tsc --build --watch"
}
}
+18 -1
View File
@@ -1,7 +1,7 @@
import {
ModuleDefinition,
MODULE_RESOURCE_TYPE,
MODULE_SCOPE,
ModuleDefinition,
} from "@medusajs/types"
export enum Modules {
@@ -9,6 +9,7 @@ export enum Modules {
STOCK_LOCATION = "stockLocationService",
INVENTORY = "inventoryService",
CACHE = "cacheService",
PRODUCT = "productModuleService",
}
export const ModulesDefinition: { [key: string]: ModuleDefinition } = {
@@ -63,9 +64,25 @@ export const ModulesDefinition: { [key: string]: ModuleDefinition } = {
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
[Modules.PRODUCT]: {
key: Modules.PRODUCT,
registrationName: Modules.PRODUCT,
defaultPackage: false,
label: "ProductModuleService",
isRequired: false,
canOverride: true,
dependencies: [],
defaultModuleDeclaration: {
scope: MODULE_SCOPE.EXTERNAL,
},
},
}
export const MODULE_DEFINITIONS: ModuleDefinition[] =
Object.values(ModulesDefinition)
export const MODULE_PACKAGE_NAMES = {
[Modules.PRODUCT]: "@medusajs/product",
}
export default MODULE_DEFINITIONS
@@ -24,38 +24,37 @@ describe("Medusa Module", () => {
})
it("MedusaModule bootstrap - Singleton instances", async () => {
await MedusaModule.bootstrap(
"moduleKey",
"@path",
{
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
options: {
abc: 123,
},
} as InternalModuleDeclaration,
{}
)
await MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
options: {
abc: 123,
},
} as InternalModuleDeclaration)
expect(mockRegisterMedusaModule).toBeCalledTimes(1)
expect(mockModuleLoader).toBeCalledTimes(1)
await MedusaModule.bootstrap(
"moduleKey",
"@path",
{
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
options: {
abc: 123,
},
} as InternalModuleDeclaration,
{}
)
await MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
options: {
abc: 123,
},
} as InternalModuleDeclaration)
expect(mockRegisterMedusaModule).toBeCalledTimes(1)
expect(mockModuleLoader).toBeCalledTimes(1)
await MedusaModule.bootstrap("moduleKey", "@path", {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.ISOLATED,
resolve: "@path",
options: {
different_options: "abc",
},
} as InternalModuleDeclaration)
expect(mockRegisterMedusaModule).toBeCalledTimes(2)
expect(mockModuleLoader).toBeCalledTimes(2)
})
})
@@ -5,7 +5,6 @@ import {
} from "@medusajs/types"
import { createMedusaContainer } from "medusa-core-utils"
import { EOL } from "os"
import { trackInstallation } from "../__mocks__/medusa-telemetry"
import { moduleLoader } from "../module-loader"
const logger = {
@@ -81,6 +80,7 @@ describe("modules loader", () => {
{}
)
/*
expect(trackInstallation).toHaveBeenCalledWith(
{
module: moduleResolutions.testService.definition.key,
@@ -88,6 +88,7 @@ describe("modules loader", () => {
},
"module"
)
*/
expect(testService).toBeTruthy()
expect(typeof testService).toEqual("object")
})
@@ -1,8 +1,8 @@
import {
InternalModuleDeclaration,
ModuleDefinition,
MODULE_RESOURCE_TYPE,
MODULE_SCOPE,
ModuleDefinition,
} from "@medusajs/types"
import MODULE_DEFINITIONS from "../../definitions"
import { registerModules } from "../register-modules"
@@ -77,29 +77,30 @@ describe("module definitions loader", () => {
)
}
})
})
it("Resolves module with no resolution path when not given custom resolution path as false as default package", () => {
const definition = {
...defaultDefinition,
defaultPackage: false as false,
}
it("Module with no resolution path when not given custom resolution path, false as default package and required", () => {
const definition = {
...defaultDefinition,
defaultPackage: false as false,
isRequired: true,
}
MODULE_DEFINITIONS.push(definition)
MODULE_DEFINITIONS.push(definition)
const res = registerModules({})
const res = registerModules({})
expect(res[defaultDefinition.key]).toEqual(
expect.objectContaining({
resolutionPath: false,
definition: definition,
options: {},
moduleDeclaration: {
scope: "internal",
resources: "shared",
},
})
)
})
expect(res[defaultDefinition.key]).toEqual(
expect.objectContaining({
resolutionPath: false,
definition: definition,
options: {},
moduleDeclaration: {
scope: "internal",
resources: "shared",
},
})
)
})
describe("string config", () => {
@@ -1,8 +1,8 @@
import {
Logger,
MedusaContainer,
ModuleResolution,
MODULE_SCOPE,
ModuleResolution,
} from "@medusajs/types"
import { asValue } from "awilix"
import { EOL } from "os"
@@ -16,11 +16,17 @@ async function loadModule(
resolution: ModuleResolution,
logger: Logger
): Promise<{ error?: Error } | void> {
const registrationName = resolution.definition.registrationName
const modDefinition = resolution.definition
const registrationName = modDefinition.registrationName
const { scope, resources } = resolution.moduleDeclaration ?? ({} as any)
if (scope === MODULE_SCOPE.EXTERNAL) {
const canSkip =
!resolution.resolutionPath &&
!modDefinition.isRequired &&
!modDefinition.defaultPackage
if (scope === MODULE_SCOPE.EXTERNAL && !canSkip) {
// TODO: implement external Resolvers
// return loadExternalModule(...)
throw new Error("External Modules are not supported yet.")
@@ -41,7 +47,7 @@ async function loadModule(
}
}
if (!resolution.resolutionPath) {
if (resolution.resolutionPath === false) {
container.register({
[registrationName]: asValue(undefined),
})
@@ -3,8 +3,10 @@ import {
InternalModuleDeclaration,
MODULE_SCOPE,
ModuleDefinition,
ModuleExports,
ModuleResolution,
} from "@medusajs/types"
import { isObject } from "@medusajs/utils"
import resolveCwd from "resolve-cwd"
import MODULE_DEFINITIONS from "../definitions"
@@ -21,11 +23,16 @@ export const registerModules = (
for (const definition of MODULE_DEFINITIONS) {
const customConfig = projectModules[definition.key]
const isObj = typeof customConfig === "object"
const canSkip =
!customConfig && !definition.isRequired && !definition.defaultPackage
const isObj = isObject(customConfig)
if (isObj && customConfig.scope === MODULE_SCOPE.EXTERNAL) {
// TODO: getExternalModuleResolution(...)
throw new Error("External Modules are not supported yet.")
if (!canSkip) {
throw new Error("External Modules are not supported yet.")
}
}
moduleResolutions[definition.key] = getInternalModuleResolution(
@@ -39,7 +46,8 @@ export const registerModules = (
export const registerMedusaModule = (
moduleKey: string,
moduleDeclaration: InternalModuleDeclaration | ExternalModuleDeclaration
moduleDeclaration: InternalModuleDeclaration | ExternalModuleDeclaration,
moduleExports?: ModuleExports
): Record<string, ModuleResolution> => {
const moduleResolutions = {} as Record<string, ModuleResolution>
@@ -55,7 +63,8 @@ export const registerMedusaModule = (
moduleResolutions[definition.key] = getInternalModuleResolution(
definition,
moduleDeclaration as InternalModuleDeclaration
moduleDeclaration as InternalModuleDeclaration,
moduleExports
)
}
@@ -64,12 +73,14 @@ export const registerMedusaModule = (
function getInternalModuleResolution(
definition: ModuleDefinition,
moduleConfig: InternalModuleDeclaration | false | string
moduleConfig: InternalModuleDeclaration | false | string,
moduleExports?: ModuleExports
): ModuleResolution {
if (typeof moduleConfig === "boolean") {
if (!moduleConfig && definition.isRequired) {
throw new Error(`Module: ${definition.label} is required`)
}
if (!moduleConfig) {
return {
resolutionPath: false,
@@ -86,9 +97,11 @@ function getInternalModuleResolution(
// If user added a module and it's overridable, we resolve that instead
const isString = typeof moduleConfig === "string"
if (definition.canOverride && (isString || (isObj && moduleConfig.resolve))) {
resolutionPath = resolveCwd(
isString ? moduleConfig : (moduleConfig.resolve as string)
)
resolutionPath = !moduleExports
? resolveCwd(isString ? moduleConfig : (moduleConfig.resolve as string))
: // Explicitly assign an empty string, later, we will check if the value is exactly false.
// This allows to continue the module loading while using the module exports instead of re importing the module itself during the process.
""
}
const moduleDeclaration = isObj ? moduleConfig : {}
@@ -106,6 +119,7 @@ function getInternalModuleResolution(
...definition.defaultModuleDeclaration,
...moduleDeclaration,
},
moduleExports,
options: isObj ? moduleConfig.options ?? {} : {},
}
}
@@ -3,14 +3,13 @@ import {
InternalModuleDeclaration,
Logger,
MedusaContainer,
ModuleExports,
ModuleResolution,
MODULE_RESOURCE_TYPE,
MODULE_SCOPE,
ModuleExports,
ModuleResolution,
} from "@medusajs/types"
import { createMedusaContainer } from "@medusajs/utils"
import { asFunction, asValue } from "awilix"
import { trackInstallation } from "medusa-telemetry"
export async function loadInternalModule(
container: MedusaContainer,
@@ -24,7 +23,13 @@ export async function loadInternalModule(
let loadedModule: ModuleExports
try {
loadedModule = (await import(resolution.resolutionPath as string)).default
// When loading manually, we pass the exports to be loaded, meaning that we do not need to import the package to find
// the exports. This is useful when a package export an initialize function which will bootstrap itself and therefore
// does not need to import the package that is currently being loaded as it would create a
// circular reference.
loadedModule =
resolution.moduleExports ??
(await import(resolution.resolutionPath as string)).default
} catch (error) {
if (
resolution.definition.isRequired &&
@@ -118,14 +123,6 @@ export async function loadInternalModule(
)
}).singleton(),
})
trackInstallation(
{
module: resolution.definition.key,
resolution: resolution.resolutionPath,
},
"module"
)
}
export async function loadModuleMigrations(
+27 -6
View File
@@ -3,8 +3,13 @@ import {
InternalModuleDeclaration,
MODULE_RESOURCE_TYPE,
MODULE_SCOPE,
ModuleExports,
} from "@medusajs/types"
import { createMedusaContainer } from "@medusajs/utils"
import {
createMedusaContainer,
simpleHash,
stringifyCircular,
} from "@medusajs/utils"
import { asValue } from "awilix"
import { moduleLoader, registerMedusaModule } from "./loaders"
import { loadModuleMigrations } from "./loaders/utils"
@@ -18,16 +23,24 @@ const logger: any = {
export class MedusaModule {
private static instances_: Map<string, any> = new Map()
public static clearInstances(): void {
MedusaModule.instances_.clear()
}
public static async bootstrap(
moduleKey: string,
defaultPath: string,
declaration?: InternalModuleDeclaration | ExternalModuleDeclaration,
moduleExports?: ModuleExports,
injectedDependencies?: Record<string, any>
): Promise<{
[key: string]: any
}> {
if (MedusaModule.instances_.has(moduleKey)) {
return MedusaModule.instances_.get(moduleKey)
const hashKey = simpleHash(
stringifyCircular({ moduleKey, defaultPath, declaration })
)
if (MedusaModule.instances_.has(hashKey)) {
return MedusaModule.instances_.get(hashKey)
}
let modDeclaration = declaration
@@ -48,9 +61,17 @@ export class MedusaModule {
}
}
const moduleResolutions = registerMedusaModule(moduleKey, modDeclaration!)
const moduleResolutions = registerMedusaModule(
moduleKey,
modDeclaration!,
moduleExports
)
await moduleLoader({ container, moduleResolutions, logger })
await moduleLoader({
container,
moduleResolutions,
logger,
})
const services = {}
@@ -61,7 +82,7 @@ export class MedusaModule {
services[keyName] = container.resolve(registrationName)
}
MedusaModule.instances_.set(moduleKey, services)
MedusaModule.instances_.set(hashKey, services)
return services
}
+4 -3
View File
@@ -15,11 +15,12 @@
"strictFunctionTypes": true,
"noImplicitThis": true,
"allowJs": true,
"skipLibCheck": true
"skipLibCheck": true,
"downlevelIteration": true // to use ES5 specific tooling
},
"include": ["src"],
"include": ["./src/**/*"],
"exclude": [
"dist",
"./dist/**/*",
"./src/**/__tests__",
"./src/**/__mocks__",
"node_modules"
+6
View File
@@ -0,0 +1,6 @@
/dist
node_modules
.DS_store
.env*
.env
*.sql
@@ -0,0 +1,24 @@
import { FindOptions, RepositoryService } from "@medusajs/types"
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.")
}
}
CustomRepository.prototype.find = jest.fn().mockImplementation(async () => [])
CustomRepository.prototype.findAndCount = jest
.fn()
.mockImplementation(async () => [])
export class ProductRepository extends CustomRepository {}
export class ProductTagRepository extends CustomRepository {}
export class ProductCollectionRepository extends CustomRepository {}
export class ProductVariantRepository extends CustomRepository {}
export class ProductCategoryRepository extends CustomRepository {}
@@ -0,0 +1,28 @@
export const productCategoriesData = [
{
id: "category-0",
name: "category 0",
parent_category_id: null
},
{
id: "category-1",
name: "category 1",
parent_category_id: "category-0"
},
{
id: "category-1-a",
name: "category 1 a",
parent_category_id: "category-1",
},
{
id: "category-1-b",
name: "category 1 b",
parent_category_id: "category-1",
is_internal: true,
},
{
id: "category-1-b-1",
name: "category 1 b 1",
parent_category_id: "category-1-b"
},
]
@@ -0,0 +1,31 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { ProductCategory } from "@models"
export async function createProductCategories(
manager: SqlEntityManager,
categoriesData: any[]
): Promise<ProductCategory[]> {
const categories: ProductCategory[] = []
for (let categoryData of categoriesData) {
let categoryDataClone = { ...categoryData }
let parentCategory: ProductCategory | null = null
const parentCategoryId = categoryDataClone.parent_category_id as string
delete categoryDataClone.parent_category_id
if (parentCategoryId) {
parentCategory = await manager.findOne(ProductCategory, parentCategoryId)
}
const category = await manager.create(ProductCategory, {
...categoryDataClone,
parent_category: parentCategory,
})
categories.push(category)
}
await manager.persistAndFlush(categories)
return categories
}
@@ -0,0 +1,17 @@
export const categoriesData = [
{
id: "category-0",
name: "category 0",
parent_category_id: null
},
{
id: "category-1",
name: "category 1",
parent_category_id: "category-0"
},
{
id: "category-1-a",
name: "category 1 a",
parent_category_id: "category-1"
},
]
@@ -0,0 +1,2 @@
export * from "./categories"
export * from "./products"
@@ -0,0 +1,43 @@
import { ProductTypes } from "@medusajs/types"
export const productsData = [
{
id: "test-1",
title: "product 1",
status: ProductTypes.ProductStatus.PUBLISHED,
tags: [
{
id: "tag-1",
value: "France",
},
],
},
{
id: "test-2",
title: "product",
status: ProductTypes.ProductStatus.PUBLISHED,
tags: [
{
id: "tag-2",
value: "Germany",
},
],
},
]
export const variantsData = [
{
id: "test-1",
title: "variant title",
sku: "sku 1",
product: { id: productsData[0].id },
inventory_quantity: 10,
},
{
id: "test-2",
title: "variant title",
sku: "sku 2",
product: { id: productsData[1].id },
inventory_quantity: 10,
},
]
@@ -0,0 +1,72 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import {
Product,
ProductCategory,
ProductCollection,
ProductVariant,
} from "@models"
import ProductOption from "../../../src/models/product-option"
export async function createProductAndTags(
manager: SqlEntityManager,
data: any[]
) {
const products: any[] = data.map((productData) => {
return manager.create(Product, productData)
})
await manager.persistAndFlush(products)
return products
}
export async function createProductVariants(
manager: SqlEntityManager,
data: any[]
) {
const variants: any[] = data.map((variantsData) => {
return manager.create(ProductVariant, variantsData)
})
await manager.persistAndFlush(variants)
return variants
}
export async function createCollections(
manager: SqlEntityManager,
collectionData: any[]
) {
const collections: any[] = collectionData.map((collectionData) => {
return manager.create(ProductCollection, collectionData)
})
await manager.persistAndFlush(collections)
return collections
}
export async function createOptions(
manager: SqlEntityManager,
optionsData: any[]
) {
const options: any[] = optionsData.map((o) => {
return manager.create(ProductOption, o)
})
await manager.persistAndFlush(options)
return options
}
export async function assignCategoriesToProduct(
manager: SqlEntityManager,
product: Product,
categories: ProductCategory[]
) {
product.categories.add(categories)
await manager.persistAndFlush(product)
return product
}
@@ -0,0 +1,109 @@
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"
const beforeEach_ = async () => {
await TestDatabase.setupDatabase()
return await TestDatabase.forkManager()
}
const afterEach_ = async () => {
await TestDatabase.clearDatabase()
}
describe("Product module", function () {
describe("Using built-in data access layer", function () {
let module
let products: Product[]
beforeEach(async () => {
const testManager = await beforeEach_()
products = await createProductAndTags(testManager, productsData)
module = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
},
})
})
afterEach(afterEach_)
it("should initialize", async () => {
expect(module).toBeDefined()
})
it("should return a list of product", async () => {
const products = await module.list()
expect(products).toHaveLength(2)
})
})
describe("Using custom data access layer", function () {
let module
let products: Product[]
beforeEach(async () => {
const testManager = await beforeEach_()
products = await createProductAndTags(testManager, productsData)
module = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
},
repositories: CustomRepositories,
})
})
afterEach(afterEach_)
it("should initialize", async () => {
expect(module).toBeDefined()
})
it("should return a list of product", async () => {
const products = await module.list()
expect(ProductRepository.prototype.find).toHaveBeenCalled()
expect(products).toHaveLength(0)
})
})
describe("Using custom data access layer and connection", function () {
let module
let products: Product[]
beforeEach(async () => {
const testManager = await beforeEach_()
products = await createProductAndTags(testManager, productsData)
MedusaModule.clearInstances()
module = await initialize({
manager: testManager,
repositories: CustomRepositories,
})
})
afterEach(afterEach_)
it("should initialize and return a list of product", async () => {
expect(module).toBeDefined()
})
it("should return a list of product", async () => {
const products = await module.list()
expect(ProductRepository.prototype.find).toHaveBeenCalled()
expect(products).toHaveLength(0)
})
})
})
@@ -0,0 +1,233 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { ProductCategoryService } from "@services"
import { ProductCategoryRepository } from "@repositories"
import { ProductCategory } from "@models"
import { TestDatabase } from "../../../utils"
import { createProductCategories } from "../../../__fixtures__/product-category"
import { productCategoriesData } from "../../../__fixtures__/product-category/data"
jest.setTimeout(30000)
describe("ProductCategory Service", () => {
let service: ProductCategoryService
let testManager: SqlEntityManager
let repositoryManager: SqlEntityManager
let productCategories!: ProductCategory[]
beforeEach(async () => {
await TestDatabase.setupDatabase()
repositoryManager = await TestDatabase.forkManager()
const productCategoryRepository = new ProductCategoryRepository({
manager: repositoryManager,
})
service = new ProductCategoryService({
productCategoryRepository,
})
})
afterEach(async () => {
await TestDatabase.clearDatabase()
})
describe("list", () => {
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
productCategories = await createProductCategories(
testManager,
productCategoriesData
)
})
it("lists all product categories", async () => {
const productCategoryResults = await service.list(
{},
{
select: ["id", "parent_category_id"] as any,
}
)
expect(productCategoryResults).toEqual([
expect.objectContaining({
id: "category-0",
parent_category_id: 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("scopes product categories by parent_category_id", async () => {
let productCategoryResults = await service.list(
{ parent_category_id: null },
{
select: ["id"],
}
)
expect(productCategoryResults).toEqual([
expect.objectContaining({
id: "category-0",
}),
])
productCategoryResults = await service.list({
parent_category_id: "category-0",
})
expect(productCategoryResults).toEqual([
expect.objectContaining({
id: "category-1",
}),
])
productCategoryResults = await service.list({
parent_category_id: ["category-1-b", "category-0"],
})
expect(productCategoryResults).toEqual([
expect.objectContaining({
id: "category-1",
}),
expect.objectContaining({
id: "category-1-b-1",
}),
])
})
it("includes the entire list of descendants when include_descendants_tree is true", async () => {
const productCategoryResults = await service.list(
{
parent_category_id: null,
include_descendants_tree: true,
},
{
select: ["id", "handle"] as any,
}
)
const serializedObject = JSON.parse(
JSON.stringify(productCategoryResults)
)
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("scopes children when include_descendants_tree is true", async () => {
const productCategoryResults = await service.list(
{
parent_category_id: null,
include_descendants_tree: true,
is_internal: false,
},
{
select: ["id", "handle"] as any,
}
)
const serializedObject = JSON.parse(
JSON.stringify(productCategoryResults)
)
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: [],
}),
],
}),
],
}),
])
})
})
})
@@ -0,0 +1,106 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { ProductCollection } from "@models"
import { ProductCollectionService } from "@services"
import { ProductCollectionRepository } from "@repositories"
import { TestDatabase } from "../../../utils"
import { createCollections } from "../../../__fixtures__/product"
jest.setTimeout(30000)
describe("Product Service", () => {
let service: ProductCollectionService
let testManager: SqlEntityManager
let repositoryManager: SqlEntityManager
let collectionsData: ProductCollection[] = []
beforeEach(async () => {
await TestDatabase.setupDatabase()
repositoryManager = await TestDatabase.forkManager()
const productCollectionRepository = new ProductCollectionRepository({
manager: repositoryManager,
})
service = new ProductCollectionService({
productCollectionRepository,
})
})
afterEach(async () => {
await TestDatabase.clearDatabase()
})
describe("list", () => {
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("list product collections", async () => {
const tagsResults = await service.list()
expect(tagsResults).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("list product collections by id", async () => {
const tagsResults = await service.list({ id: data![0].id })
expect(tagsResults).toEqual([
expect.objectContaining({
id: "test-1",
title: "col 1",
}),
])
})
it("list product collections by title matching string", async () => {
const tagsResults = await service.list({ title: "col 3 extra" })
expect(tagsResults).toEqual([
expect.objectContaining({
id: "test-3",
title: "col 3 extra",
}),
])
})
})
})
@@ -0,0 +1,121 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { ProductTagService } from "@services"
import { ProductTagRepository } from "@repositories"
import { Product } from "@models"
import { TestDatabase } from "../../../utils"
import { createProductAndTags } from "../../../__fixtures__/product"
import { ProductTypes } from "@medusajs/types"
jest.setTimeout(30000)
describe("Product Service", () => {
let service: ProductTagService
let testManager: SqlEntityManager
let repositoryManager: SqlEntityManager
let data!: Product[]
beforeEach(async () => {
await TestDatabase.setupDatabase()
repositoryManager = await TestDatabase.forkManager()
const productTagRepository = new ProductTagRepository({
manager: repositoryManager,
})
service = new ProductTagService({
productTagRepository,
})
})
afterEach(async () => {
await TestDatabase.clearDatabase()
})
describe("list", () => {
const productsData = [
{
id: "test-1",
title: "product 1",
status: ProductTypes.ProductStatus.PUBLISHED,
tags: [
{
id: "tag-1",
value: "France",
},
],
},
{
id: "test-2",
title: "product",
status: ProductTypes.ProductStatus.PUBLISHED,
tags: [
{
id: "tag-2",
value: "Germany",
},
{
id: "tag-3",
value: "United States",
},
{
id: "tag-4",
value: "United Kingdom",
},
],
},
]
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
data = await createProductAndTags(testManager, productsData)
})
it("list product tags", async () => {
const tagsResults = await service.list()
expect(tagsResults).toEqual([
expect.objectContaining({
id: "tag-1",
value: "France",
}),
expect.objectContaining({
id: "tag-2",
value: "Germany",
}),
expect.objectContaining({
id: "tag-3",
value: "United States",
}),
expect.objectContaining({
id: "tag-4",
value: "United Kingdom",
}),
])
})
it("list product tags by id", async () => {
const tagsResults = await service.list({ id: data[0].tags![0].id })
expect(tagsResults).toEqual([
expect.objectContaining({
id: "tag-1",
value: "France",
}),
])
})
it("list product tags by value matching string", async () => {
const tagsResults = await service.list({ value: "united kingdom" })
expect(tagsResults).toEqual([
expect.objectContaining({
id: "tag-4",
value: "United Kingdom",
}),
])
})
})
})
@@ -0,0 +1,185 @@
import { TestDatabase } from "../../../utils"
import { ProductVariantService } from "@services"
import { ProductVariantRepository } from "@repositories"
import { Product, ProductTag, ProductVariant } from "@models"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { Collection } from "@mikro-orm/core"
import { ProductTypes } from "@medusajs/types"
import { ProductOption } from "@medusajs/client-types"
import {
createOptions,
createProductAndTags,
createProductVariants,
} from "../../../__fixtures__/product"
import { productsData, variantsData } from "../../../__fixtures__/product/data"
describe("ProductVariant Service", () => {
let service: ProductVariantService
let testManager: SqlEntityManager
let repositoryManager: SqlEntityManager
let variantOne: ProductVariant
let variantTwo: ProductVariant
let productOne: Product
beforeEach(async () => {
await TestDatabase.setupDatabase()
repositoryManager = await TestDatabase.forkManager()
const productVariantRepository = new ProductVariantRepository({
manager: repositoryManager,
})
service = new ProductVariantService({ productVariantRepository })
})
afterEach(async () => {
await TestDatabase.clearDatabase()
})
describe("list", () => {
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: "test-1",
title: "variant 1",
inventory_quantity: 10,
product: productOne,
})
variantTwo = testManager.create(ProductVariant, {
id: "test-2",
title: "variant",
inventory_quantity: 10,
product: productOne,
})
await testManager.persistAndFlush([variantOne, variantTwo])
})
it("selecting by properties, scopes out the results", async () => {
const results = await service.list({
id: variantOne.id,
})
expect(results).toEqual([
expect.objectContaining({
id: variantOne.id,
title: "variant 1",
inventory_quantity: "10",
}),
])
})
it("passing a limit, scopes the result to the limit", async () => {
const results = await service.list(
{},
{
take: 1,
}
)
expect(results).toEqual([
expect.objectContaining({
id: variantOne.id,
}),
])
})
it("passing populate, scopes the results of the response", async () => {
const results = await service.list(
{
id: "test-1",
},
{
select: ["id", "title", "product.title"] as any,
relations: ["product"],
}
)
expect(results).toEqual([
expect.objectContaining({
id: "test-1",
title: "variant 1",
product: expect.objectContaining({
id: "product-1",
title: "product 1",
tags: expect.any(Collection<ProductTag>),
variants: expect.any(Collection<ProductVariant>),
}),
}),
])
expect(JSON.parse(JSON.stringify(results))).toEqual([
{
id: "test-1",
title: "variant 1",
product_id: "product-1",
product: {
id: "product-1",
title: "product 1",
},
},
])
})
})
describe("relation: options", () => {
let products: Product[]
let variants: ProductVariant[]
let options: ProductOption[]
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
products = (await createProductAndTags(
testManager,
productsData
)) as Product[]
variants = (await createProductVariants(
testManager,
variantsData
)) as ProductVariant[]
options = await createOptions(testManager, [
{
id: "test-option-1",
title: "size",
product: products[0],
values: [
{ id: "value-1", value: "XS", variant: products[0].variants[0] },
{ id: "value-1", value: "XL", variant: products[0].variants[0] },
],
},
{
id: "test-option-2",
title: "color",
product: products[0],
value: "blue",
variant: products[0].variants[0],
},
])
})
it("filter by options relation", async () => {
const variants = await service.list(
{ options: { id: ["value-1"] } },
{ relations: ["options"] }
)
expect(JSON.parse(JSON.stringify(variants))).toEqual([
expect.objectContaining({
id: "test-1",
title: "variant title",
sku: "sku 1",
}),
])
})
})
})
@@ -0,0 +1,257 @@
import { TestDatabase } from "../../../utils"
import {
ProductService,
ProductTagService,
ProductVariantService,
} from "@services"
import { ProductRepository } from "@repositories"
import { Product, ProductCategory, ProductVariant } from "@models"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { ProductDTO } from "@medusajs/types"
import { createProductCategories } from "../../../__fixtures__/product-category"
import {
assignCategoriesToProduct,
createProductAndTags,
createProductVariants,
} from "../../../__fixtures__/product"
import {
categoriesData,
productsData,
variantsData,
} from "../../../__fixtures__/product/data"
const productVariantService = {
list: jest.fn(),
} as unknown as ProductVariantService
const productTagService = {
list: jest.fn(),
} as unknown as ProductTagService
jest.setTimeout(30000)
describe("Product Service", () => {
let service: ProductService
let testManager: SqlEntityManager
let repositoryManager: SqlEntityManager
let products!: Product[]
let variants!: ProductVariant[]
let categories!: ProductCategory[]
beforeEach(async () => {
await TestDatabase.setupDatabase()
repositoryManager = await TestDatabase.forkManager()
const productRepository = new ProductRepository({
manager: repositoryManager,
})
service = new ProductService({
productRepository,
productVariantService,
productTagService,
})
})
afterEach(async () => {
await TestDatabase.clearDatabase()
})
describe("list", () => {
describe("relation: tags", () => {
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
products = await createProductAndTags(testManager, productsData)
})
it("filter by id and including relations", async () => {
const productsResult = await service.list(
{
id: products[0].id,
},
{
relations: ["tags"],
}
)
productsResult.forEach((product, index) => {
const tags = product.tags.toArray()
expect(product).toEqual(
expect.objectContaining({
id: productsData[index].id,
title: productsData[index].title,
})
)
tags.forEach((tag, tagIndex) => {
expect(tag).toEqual(
expect.objectContaining({
...productsData[index].tags[tagIndex],
})
)
})
})
})
it("filter by id and without relations", async () => {
const productsResult = await service.list({
id: products[0].id,
})
productsResult.forEach((product, index) => {
const tags = product.tags.getItems(false)
expect(product).toEqual(
expect.objectContaining({
id: productsData[index].id,
title: productsData[index].title,
})
)
expect(tags.length).toBe(0)
})
})
})
describe("relation: categories", () => {
let workingProduct: Product
let workingCategory: ProductCategory
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
products = await createProductAndTags(testManager, productsData)
workingProduct = products.find((p) => p.id === "test-1") as Product
categories = await createProductCategories(testManager, categoriesData)
workingCategory = (await testManager.findOne(
ProductCategory,
"category-1"
)) as ProductCategory
workingProduct = await assignCategoriesToProduct(
testManager,
workingProduct,
categories
)
})
it("filter by categories relation and scope fields", async () => {
const products = await service.list(
{
id: workingProduct.id,
categories: { id: [workingCategory.id] },
},
{
select: [
"title",
"categories.name",
"categories.handle",
"categories.mpath",
] as (keyof ProductDTO)[],
relations: ["categories"],
}
)
const product = products.find(
(p) => p.id === workingProduct.id
) as unknown as Product
expect(product).toEqual(
expect.objectContaining({
id: workingProduct.id,
title: workingProduct.title,
})
)
expect(product.categories.toArray()).toEqual([
{
id: "category-0",
name: "category 0",
handle: "category-0",
mpath: "category-0.",
},
{
id: "category-1",
name: "category 1",
handle: "category-1",
mpath: "category-0.category-1.",
},
{
id: "category-1-a",
name: "category 1 a",
handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.",
},
])
})
it("returns empty array when querying for a category that doesnt exist", async () => {
const products = await service.list(
{
id: workingProduct.id,
categories: { id: ["category-doesnt-exist-id"] },
},
{
select: [
"title",
"categories.name",
"categories.handle",
] as (keyof ProductDTO)[],
relations: ["categories"],
}
)
expect(products).toEqual([])
})
})
describe("relation: variants", () => {
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
products = await createProductAndTags(testManager, productsData)
variants = await createProductVariants(testManager, variantsData)
})
it("filter by id and including relations", async () => {
const productsResult = await service.list(
{
id: products[0].id,
},
{
relations: ["variants"],
}
)
productsResult.forEach((product, index) => {
const variants = product.variants.toArray()
expect(product).toEqual(
expect.objectContaining({
id: productsData[index].id,
title: productsData[index].title,
})
)
variants.forEach((variant, variantIndex) => {
const expectedVariant = variantsData.filter(
(d) => d.product.id === product.id
)[variantIndex]
const variantProduct = variant.product
expect(variant).toEqual(
expect.objectContaining({
id: expectedVariant.id,
sku: expectedVariant.sku,
title: expectedVariant.title,
})
)
})
})
})
})
})
})
@@ -0,0 +1,6 @@
if (typeof process.env.DB_TEMP_NAME === "undefined") {
const tempName = parseInt(process.env.JEST_WORKER_ID || "1")
process.env.DB_TEMP_NAME = `medusa-integration-${tempName}`
}
process.env.MEDUSA_PRODUCT_DB_SCHEMA = "medusa-product"
@@ -0,0 +1,22 @@
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_NAME = process.env.DB_TEMP_NAME
const pgGodCredentials = {
user: DB_USERNAME,
password: DB_PASSWORD,
host: DB_HOST,
}
afterAll(async () => {
try {
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}`
)
}
})
@@ -0,0 +1,95 @@
import { TSMigrationGenerator } from "@mikro-orm/migrations"
import { MikroORM, Options, SqlEntityManager } from "@mikro-orm/postgresql"
import * as ProductModels from "@models"
import * as process from "process"
const DB_HOST = process.env.DB_HOST ?? "localhost"
const DB_USERNAME = process.env.DB_USERNAME ?? ""
const DB_PASSWORD = process.env.DB_PASSWORD
const DB_NAME = process.env.DB_TEMP_NAME
export const DB_URL = `postgres://${DB_USERNAME}${
DB_PASSWORD ? `:${DB_PASSWORD}` : ""
}@${DB_HOST}/${DB_NAME}`
const ORMConfig: Options = {
type: "postgresql",
clientUrl: DB_URL,
entities: Object.values(ProductModels),
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
debug: false,
migrations: {
path: "../../src/migrations",
pathTs: "../../src/migrations",
glob: "!(*.d).{js,ts}",
silent: true,
dropTables: true,
transactional: true,
allOrNothing: true,
safe: false,
generator: TSMigrationGenerator,
},
}
interface TestDatabase {
orm: MikroORM | null
manager: SqlEntityManager | null
setupDatabase(): Promise<void>
clearDatabase(): Promise<void>
getManager(): SqlEntityManager
forkManager(): Promise<SqlEntityManager>
getORM(): MikroORM
}
export const TestDatabase: TestDatabase = {
orm: null,
manager: null,
getManager() {
if (this.manager === null) {
throw new Error("manager entity not available")
}
return this.manager
},
async forkManager() {
if (this.manager === null) {
throw new Error("manager entity not available")
}
return await this.manager.fork()
},
getORM() {
if (this.orm === null) {
throw new Error("orm entity not available")
}
return this.orm
},
async setupDatabase() {
// Initializing the ORM
this.orm = await MikroORM.init(ORMConfig)
if (this.orm === null) {
throw new Error("ORM not configured")
}
this.manager = await this.orm.em
await this.orm.schema.refreshDatabase() // ensure db exists and is fresh
},
async clearDatabase() {
if (this.orm === null) {
throw new Error("ORM not configured")
}
await this.orm.close()
this.orm = null
this.manager = null
},
}
@@ -0,0 +1 @@
export * from "./database"
+21
View File
@@ -0,0 +1,21 @@
module.exports = {
moduleNameMapper: {
"^@models": "<rootDir>/src/models",
"^@services": "<rootDir>/src/services",
"^@repositories": "<rootDir>/src/repositories",
},
globals: {
"ts-jest": {
tsConfig: "tsconfig.spec.json",
isolatedModules: false,
},
},
transform: {
"^.+\\.[jt]s?$": "ts-jest",
},
testEnvironment: `node`,
moduleFileExtensions: [`js`, `ts`],
modulePathIgnorePatterns: ["dist/"],
setupFiles: ["<rootDir>/integration-tests/setup-env.js"],
setupFilesAfterEnv: ["<rootDir>/integration-tests/setup.js"],
}
+8
View File
@@ -0,0 +1,8 @@
import * as entities from "./src/models"
module.exports = {
entities: Object.values(entities),
schema: "public",
clientUrl: "postgres://postgres@localhost/medusa-products",
type: "postgresql",
}
+62
View File
@@ -0,0 +1,62 @@
{
"name": "@medusajs/product",
"version": "0.0.1",
"description": "Medusa Product module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"bin": {
"medusa-product-migrations-down": "dist/scripts/bin/run-migration-down.js",
"medusa-product-migrations-up": "dist/scripts/bin/run-migration-up.js",
"medusa-product-seed": "dist/scripts/bin/run-seed.js"
},
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/product"
},
"publishConfig": {
"access": "public"
},
"author": "Medusa",
"license": "MIT",
"scripts": {
"watch": "tsc --build --watch",
"watch:test": "tsc --build tsconfig.spec.json --watch",
"prepare": "cross-env NODE_ENV=production yarn run build",
"build": "rimraf dist && tsc --build && tsc-alias -p tsconfig.json",
"test": "jest --runInBand --forceExit -- src/**/__tests__/**/*.ts",
"test:integration": "jest --runInBand --forceExit -- integration-tests/**/__tests__/**/*.ts",
"migration:generate": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:generate",
"migration:initial": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create --initial",
"migration:create": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create",
"migration:up": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:up",
"orm:cache:clear": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm cache:clear"
},
"devDependencies": {
"@mikro-orm/cli": "5.7.4",
"@mikro-orm/migrations": "5.7.4",
"cross-env": "^5.2.1",
"jest": "^25.5.4",
"medusa-test-utils": "^1.1.40",
"pg-god": "^1.0.12",
"rimraf": "^5.0.0",
"ts-jest": "^25.5.1",
"ts-node": "^10.9.1",
"tsc-alias": "^1.8.6",
"typescript": "^4.4.4"
},
"dependencies": {
"@medusajs/modules-sdk": "^1.8.3",
"@medusajs/types": "*",
"@medusajs/utils": "^1.8.2",
"@mikro-orm/core": "5.7.4",
"@mikro-orm/migrations": "5.7.4",
"@mikro-orm/postgresql": "5.7.4",
"awilix": "^8.0.0",
"dotenv": "^16.1.4",
"lodash": "^4.17.21"
}
}
+10
View File
@@ -0,0 +1,10 @@
import { moduleDefinition } from "./module-definition"
export default moduleDefinition
export * from "./scripts"
export * from "./initialize"
export * from "./types"
export * from "./loaders"
export * from "./models"
export * from "./services"
+34
View File
@@ -0,0 +1,34 @@
import {
ExternalModuleDeclaration,
InternalModuleDeclaration,
MedusaModule,
MODULE_PACKAGE_NAMES,
Modules,
} from "@medusajs/modules-sdk"
import { IProductModuleService } from "@medusajs/types"
import { moduleDefinition } from "../module-definition"
import {
InitializeModuleInjectableDependencies,
ProductServiceInitializeCustomDataLayerOptions,
ProductServiceInitializeOptions,
} from "../types"
export const initialize = async (
options?:
| ProductServiceInitializeOptions
| ProductServiceInitializeCustomDataLayerOptions
| ExternalModuleDeclaration,
injectedDependencies?: InitializeModuleInjectableDependencies
): Promise<IProductModuleService> => {
const serviceKey = Modules.PRODUCT
const loaded = await MedusaModule.bootstrap(
serviceKey,
MODULE_PACKAGE_NAMES[Modules.PRODUCT],
options as InternalModuleDeclaration | ExternalModuleDeclaration,
moduleDefinition,
injectedDependencies
)
return loaded[serviceKey] as IProductModuleService
}
@@ -0,0 +1,66 @@
import { asValue } from "awilix"
import {
InternalModuleDeclaration,
LoaderOptions,
MODULE_RESOURCE_TYPE,
MODULE_SCOPE,
} from "@medusajs/modules-sdk"
import { MedusaError } from "@medusajs/utils"
import { EntitySchema } from "@mikro-orm/core"
import * as ProductModels from "@models"
import {
ProductServiceInitializeCustomDataLayerOptions,
ProductServiceInitializeOptions,
} from "../types"
import { createConnection, loadDatabaseConfig } from "../utils"
export default async (
{
options,
container,
}: LoaderOptions<
| ProductServiceInitializeOptions
| ProductServiceInitializeCustomDataLayerOptions
>,
moduleDeclaration?: InternalModuleDeclaration
): Promise<void> => {
if (
moduleDeclaration?.scope === MODULE_SCOPE.INTERNAL &&
moduleDeclaration.resources === MODULE_RESOURCE_TYPE.SHARED
) {
return
}
const customManager = (
options as ProductServiceInitializeCustomDataLayerOptions
)?.manager
if (!customManager) {
const dbData = loadDatabaseConfig(options)
await loadDefault({ database: dbData, container })
} else {
container.register({
manager: asValue(customManager),
})
}
}
async function loadDefault({ database, container }) {
if (!database) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
`Database config is not present at module config "options.database"`
)
}
const entities = Object.values(ProductModels) as unknown as EntitySchema[]
const orm = await createConnection(database, entities)
container.register({
manager: asValue(orm.em.fork()),
})
}
+92
View File
@@ -0,0 +1,92 @@
import { LoaderOptions } from "@medusajs/modules-sdk"
import { asClass } from "awilix"
import {
ProductCategoryService,
ProductModuleService,
ProductService,
ProductTagService,
ProductVariantService,
ProductCollectionService,
} from "@services"
import * as DefaultRepositories from "@repositories"
import {
ProductCategoryRepository,
ProductCollectionRepository,
ProductRepository,
ProductTagRepository,
ProductVariantRepository,
} from "@repositories"
import {
ProductServiceInitializeCustomDataLayerOptions,
ProductServiceInitializeOptions,
} from "../types"
import { Constructor, DAL } from "@medusajs/types"
import { lowerCaseFirst } from "@medusajs/utils"
export default async ({
container,
options,
}: LoaderOptions<
| ProductServiceInitializeOptions
| ProductServiceInitializeCustomDataLayerOptions
>): Promise<void> => {
const customRepositories = (
options as ProductServiceInitializeCustomDataLayerOptions
)?.repositories
container.register({
productModuleService: asClass(ProductModuleService).singleton(),
productService: asClass(ProductService).singleton(),
productCategoryService: asClass(ProductCategoryService).singleton(),
productVariantService: asClass(ProductVariantService).singleton(),
productTagService: asClass(ProductTagService).singleton(),
productCollectionService: asClass(ProductCollectionService).singleton(),
})
if (customRepositories) {
await loadCustomRepositories({ customRepositories, container })
} else {
await loadDefaultRepositories({ container })
}
}
function loadDefaultRepositories({ container }) {
container.register({
productRepository: asClass(ProductRepository).singleton(),
productVariantRepository: asClass(ProductVariantRepository).singleton(),
productTagRepository: asClass(ProductTagRepository).singleton(),
productCategoryRepository: asClass(ProductCategoryRepository).singleton(),
productCollectionRepository: asClass(
ProductCollectionRepository
).singleton(),
})
}
/**
* Load the repositories from the custom repositories object. If a repository is not
* present in the custom repositories object, the default repository will be used.
*
* @param customRepositories
* @param container
*/
function loadCustomRepositories({ customRepositories, container }) {
const customRepositoriesMap = new Map(Object.entries(customRepositories))
Object.entries(DefaultRepositories).forEach(([key, DefaultRepository]) => {
let finalRepository = customRepositoriesMap.get(key)
if (
!finalRepository ||
!(finalRepository as Constructor<DAL.RepositoryService>).prototype.find
) {
finalRepository = DefaultRepository
}
container.register({
[lowerCaseFirst(key)]: asClass(
finalRepository as Constructor<DAL.RepositoryService>
).singleton(),
})
})
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./connection"
export * from "./container"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
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;');
}
}
+6
View File
@@ -0,0 +1,6 @@
export { default as Product } from "./product"
export { default as ProductCategory } from "./product-category"
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"
@@ -0,0 +1,101 @@
import { generateEntityId, kebabCase } from "@medusajs/utils"
import {
BeforeCreate,
Collection,
Entity,
EventArgs,
Index,
ManyToMany,
ManyToOne,
OneToMany,
PrimaryKey,
Property,
Unique,
} from "@mikro-orm/core"
import Product from "./product"
@Entity({ tableName: "product_category" })
class ProductCategory {
@PrimaryKey({ columnType: "text" })
id!: string
@Property({ columnType: "text", nullable: false })
name: string
@Property({ columnType: "text", default: "", nullable: false })
description?: string
@Unique({
name: "IDX_product_category_handle",
properties: ["handle"],
})
@Property({ columnType: "text", nullable: false })
handle?: string
@Index({
name: "IDX_product_category_path",
properties: ["mpath"],
})
@Property({ columnType: "text", nullable: false })
mpath?: string
@Property({ columnType: "boolean", default: false })
is_active?: boolean
@Property({ columnType: "boolean", default: false })
is_internal?: boolean
@Property({ columnType: "numeric", nullable: false, default: 0 })
rank?: number
@Property({ columnType: "text", nullable: true })
parent_category_id?: string
@ManyToOne(() => ProductCategory, { nullable: true })
parent_category: ProductCategory
@OneToMany({
entity: () => ProductCategory,
mappedBy: (productCategory) => productCategory.parent_category,
})
category_children = new Collection<ProductCategory>(this)
@Property({ onCreate: () => new Date(), columnType: "timestamptz" })
created_at: Date
@Property({
onCreate: () => new Date(),
onUpdate: () => new Date(),
columnType: "timestamptz",
})
updated_at: Date
@ManyToMany(() => Product, (product) => product.categories)
products = new Collection<Product>(this)
@BeforeCreate()
async onCreate(args: EventArgs<ProductCategory>) {
this.id = generateEntityId(this.id, "pcat")
if (!this.handle) {
this.handle = kebabCase(this.name)
}
const { em } = args
const parentCategoryId = args.changeSet?.entity?.parent_category?.id
let parentCategory: ProductCategory | null = null
if (parentCategoryId) {
parentCategory = await em.findOne(ProductCategory, parentCategoryId)
}
if (parentCategory) {
this.mpath = `${parentCategory?.mpath}${this.id}.`
} else {
this.mpath = `${this.id}.`
}
}
}
export default ProductCategory
@@ -0,0 +1,42 @@
import {
BeforeCreate,
Entity,
PrimaryKey,
Property,
Unique,
} from "@mikro-orm/core"
import { generateEntityId, kebabCase } from "@medusajs/utils"
@Entity({ tableName: "product_collection" })
class ProductCollection {
@PrimaryKey({ columnType: "text" })
id!: string
@Property({ columnType: "text" })
title: string
@Property({ columnType: "text" })
@Unique({
name: "IDX_product_collection_handle_unique",
properties: ["handle"],
})
handle: string
@Property({ columnType: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "pcol")
if (!this.handle) {
this.handle = kebabCase(this.title)
}
}
}
export default ProductCollection
@@ -0,0 +1,41 @@
import {
BeforeCreate,
Entity,
ManyToOne,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import { generateEntityId } from "@medusajs/utils"
import ProductOption from "./product-option"
import { ProductVariant } from "./index"
@Entity({ tableName: "product_option_value" })
class ProductOptionValue {
@PrimaryKey({ columnType: "text" })
id!: string
@Property({ columnType: "text" })
value: string
@ManyToOne(() => ProductOption, {
index: "IDX_product_option_value_product_option",
})
option: ProductOption
@ManyToOne(() => ProductVariant, { onDelete: "cascade" })
variant: ProductVariant
@Property({ columnType: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date
@BeforeCreate()
beforeCreate() {
this.id = generateEntityId(this.id, "optval")
}
}
export default ProductOptionValue
@@ -0,0 +1,48 @@
import {
BeforeCreate,
Cascade,
Collection,
Entity,
ManyToOne,
OneToMany,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import { generateEntityId } from "@medusajs/utils"
import { Product } from "./index"
import ProductOptionValue from "./product-option-value"
@Entity({ tableName: "product_option" })
class ProductOption {
@PrimaryKey({ columnType: "text" })
id!: string
@Property({ columnType: "text" })
title: string
@Property({ persist: false })
product_id!: number
@ManyToOne(() => Product, {
index: "IDX_product_option_product_id",
})
product: Product
@OneToMany(() => ProductOptionValue, (value) => value.option, {
cascade: [Cascade.REMOVE],
})
values = new Collection<ProductOptionValue>(this)
@Property({ columnType: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date
@BeforeCreate()
beforeCreate() {
this.id = generateEntityId(this.id, "opt")
}
}
export default ProductOption
@@ -0,0 +1,36 @@
import {
BeforeCreate,
Collection,
Entity,
ManyToMany,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import { generateEntityId } from "@medusajs/utils"
import Product from "./product"
@Entity({ tableName: "product_tag" })
class ProductTag {
@PrimaryKey({ columnType: "text" })
id!: string
@Property({ columnType: "text" })
value: string
@Property({ columnType: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date
@ManyToMany(() => Product, (product) => product.tags)
products = new Collection<Product>(this)
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "ptag")
}
}
export default ProductTag
@@ -0,0 +1,25 @@
import { BeforeCreate, Entity, PrimaryKey, Property } from "@mikro-orm/core"
import { generateEntityId } from "@medusajs/utils"
@Entity({ tableName: "product_type" })
class ProductType {
@PrimaryKey({ columnType: "text" })
id!: string
@Property({ columnType: "text" })
value: string
@Property({ columnType: "json", nullable: true })
metadata?: Record<string, unknown> | null
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "ptyp")
}
}
export default ProductType
@@ -0,0 +1,141 @@
import {
BeforeCreate,
Cascade,
Collection,
Entity,
ManyToOne,
OneToMany,
OptionalProps,
PrimaryKey,
Property,
Unique,
} from "@mikro-orm/core"
import { generateEntityId } from "@medusajs/utils"
import { Product } from "@models"
import ProductOptionValue from "./product-option-value"
type OptionalFields =
| "created_at"
| "updated_at"
| "updated_at"
| "deleted_at"
| "allow_backorder"
| "manage_inventory"
| "product"
| "product_id"
@Entity({ tableName: "product_variant" })
class ProductVariant {
[OptionalProps]?: OptionalFields
@PrimaryKey({ columnType: "text" })
id!: string
@Property({ columnType: "text" })
title: string
@Property({ columnType: "text", nullable: true })
@Unique({
name: "IDX_product_variant_sku_unique",
properties: ["sku"],
})
sku?: string | null
@Property({ columnType: "text", nullable: true })
@Unique({
name: "IDX_product_variant_barcode_unique",
properties: ["barcode"],
})
barcode?: string | null
@Property({ columnType: "text", nullable: true })
@Unique({
name: "IDX_product_variant_ean_unique",
properties: ["ean"],
})
ean?: string | null
@Property({ columnType: "text", nullable: true })
@Unique({
name: "IDX_product_variant_upc_unique",
properties: ["upc"],
})
upc?: string | null
// 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: "boolean", default: false })
allow_backorder: boolean
@Property({ columnType: "boolean", default: true })
manage_inventory: boolean
@Property({ columnType: "text", nullable: true })
hs_code?: string | null
@Property({ columnType: "text", nullable: true })
origin_country?: string | null
@Property({ columnType: "text", nullable: true })
mid_code?: string | null
@Property({ columnType: "text", nullable: true })
material?: string | null
@Property({ columnType: "numeric", nullable: true })
weight?: number | null
@Property({ columnType: "numeric", nullable: true })
length?: number | null
@Property({ columnType: "numeric", nullable: true })
height?: number | null
@Property({ columnType: "numeric", nullable: true })
width?: number | null
@Property({ columnType: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null
@Property({ columnType: "numeric", nullable: true })
variant_rank?: number | null
@Property({ persist: false })
product_id!: string
@Property({ onCreate: () => new Date(), columnType: "timestamptz" })
created_at: Date
@Property({
onCreate: () => new Date(),
onUpdate: () => new Date(),
columnType: "timestamptz",
})
updated_at: Date
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date
@ManyToOne(() => Product, {
onDelete: "cascade",
index: "IDX_product_variant_product_id_index",
fieldName: "product_id",
})
product!: Product
@OneToMany(() => ProductOptionValue, (optionValue) => optionValue.variant, {
cascade: [Cascade.PERSIST, Cascade.REMOVE],
})
options = new Collection<ProductOptionValue>(this)
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "variant")
}
}
export default ProductVariant
+150
View File
@@ -0,0 +1,150 @@
import {
BeforeCreate,
Collection,
Entity,
Enum,
ManyToMany,
ManyToOne,
OneToMany,
OptionalProps,
PrimaryKey,
Property,
Unique,
} from "@mikro-orm/core"
import { ProductTypes } from "@medusajs/types"
import { generateEntityId, kebabCase } from "@medusajs/utils"
import ProductCategory from "./product-category"
import ProductCollection from "./product-collection"
import ProductOption from "./product-option"
import ProductTag from "./product-tag"
import ProductType from "./product-type"
import ProductVariant from "./product-variant"
type OptionalRelations = "collection" | "type"
type OptionalFields =
| "is_giftcard"
| "discountable"
| "created_at"
| "updated_at"
| "deleted_at"
@Entity({ tableName: "product" })
class Product {
[OptionalProps]?: OptionalRelations | OptionalFields
@PrimaryKey({ columnType: "text" })
id!: string
@Property({ columnType: "text" })
title: string
@Property({ columnType: "text" })
@Unique({
name: "IDX_product_handle_unique",
properties: ["handle"],
})
handle?: string | null
@Property({ columnType: "text", nullable: true })
subtitle?: string | null
@Property({ columnType: "text", nullable: true })
description?: string | null
@Property({ columnType: "boolean", default: false })
is_giftcard!: boolean
@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)
options = new Collection<ProductOption>(this)
@OneToMany(() => ProductVariant, (variant) => variant.product)
variants = new Collection<ProductVariant>(this)
@Property({ columnType: "text", nullable: true })
weight?: number | null
@Property({ columnType: "text", nullable: true })
length?: number | null
@Property({ columnType: "text", nullable: true })
height?: number | null
@Property({ columnType: "text", nullable: true })
width?: number | null
@Property({ columnType: "text", nullable: true })
origin_country?: string | null
@Property({ columnType: "text", nullable: true })
hs_code?: string | null
@Property({ columnType: "text", nullable: true })
mid_code?: string | null
@Property({ columnType: "text", nullable: true })
material?: string | null
@ManyToOne(() => ProductCollection, { nullable: true })
collection!: ProductCollection
@ManyToOne(() => ProductType, {
nullable: true,
index: "IDX_product_type_id",
})
type!: ProductType
@ManyToMany(() => ProductTag, "products", {
owner: true,
pivotTable: "product_tags",
index: "IDX_product_tag_id",
})
tags = new Collection<ProductTag>(this)
@ManyToMany(() => ProductCategory, "products", {
owner: true,
pivotTable: "product_category_product",
})
categories = new Collection<ProductCategory>(this)
@Property({ columnType: "boolean", default: true })
discountable: boolean
@Property({ columnType: "text", nullable: true })
external_id?: string | null
@Property({ onCreate: () => new Date(), columnType: "timestamptz" })
created_at: Date
@Property({
onCreate: () => new Date(),
onUpdate: () => new Date(),
columnType: "timestamptz",
})
updated_at: Date
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date
@Property({ columnType: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null
@BeforeCreate()
beforeCreate() {
this.id = generateEntityId(this.id, "prod")
if (!this.handle) {
this.handle = kebabCase(this.title)
}
}
}
export default Product
+18
View File
@@ -0,0 +1,18 @@
import { ModuleExports } from "@medusajs/types"
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
const models = Object.values(ProductModels)
export const moduleDefinition: ModuleExports = {
service,
loaders,
models,
runMigrations,
revertMigration,
}
@@ -0,0 +1,5 @@
export { ProductRepository } from "./product"
export { ProductTagRepository } from "./product-tag"
export { ProductVariantRepository } from "./product-variant"
export { ProductCollectionRepository } from "./product-collection"
export { ProductCategoryRepository } from "./product-category"
@@ -0,0 +1,140 @@
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 groupBy from "lodash/groupBy"
export class ProductCategoryRepository
implements DAL.RepositoryService<ProductCategory>
{
protected readonly manager_: SqlEntityManager
constructor({ manager }) {
this.manager_ = manager.fork()
}
async find(
findOptions: DAL.FindOptions<ProductCategory> = { where: {} },
transformOptions: ProductCategoryTransformOptions = {},
context: { transaction?: any } = {}
): Promise<ProductCategory[]> {
// Spread is used to copy the options in case of manipulation to prevent side effects
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
if (includeDescendantsTree) {
fields.indexOf("mpath") === -1 && fields.push("mpath")
fields.indexOf("parent_category_id") === -1 &&
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(
ProductCategory,
findOptions_.where as MikroFilterQuery<ProductCategory>,
findOptions_.options as MikroOptions<ProductCategory>
)
if (!includeDescendantsTree) {
return productCategories
}
return this.buildProductCategoriesWithDescendants(
productCategories,
findOptions_
)
}
async buildProductCategoriesWithDescendants(
productCategories: ProductCategory[],
findOptions: DAL.FindOptions<ProductCategory> = { where: {} }
): Promise<ProductCategory[]> {
for (let productCategory of productCategories) {
const whereOptions = {
...findOptions.where,
mpath: {
$like: `${productCategory.mpath}%`,
},
}
delete whereOptions.parent_category_id
const descendantsForCategory = await this.manager_.find(
ProductCategory,
whereOptions as MikroFilterQuery<ProductCategory>,
findOptions.options as MikroOptions<ProductCategory>
)
const descendantsByParentId = groupBy(
descendantsForCategory,
(pc) => pc.parent_category_id
)
const addChildrenToCategory = (category, children) => {
category.category_children = (children || []).map((categoryChild) => {
const moreChildren = descendantsByParentId[categoryChild.id] || []
return addChildrenToCategory(categoryChild, moreChildren)
})
return category
}
let children = descendantsByParentId[productCategory.id] || []
productCategory = addChildrenToCategory(productCategory, children)
}
return productCategories
}
async findAndCount(
findOptions: DAL.FindOptions<ProductCategory> = { where: {} },
transformOptions: ProductCategoryTransformOptions = {},
context: { transaction?: any } = {}
): Promise<[ProductCategory[], 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 })
}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return await this.manager_.findAndCount(
ProductCategory,
findOptions_.where as MikroFilterQuery<ProductCategory>,
findOptions_.options as MikroOptions<ProductCategory>
)
}
}
@@ -0,0 +1,74 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { ProductCollection } from "@models"
import {
FilterQuery as MikroFilterQuery,
FindOptions as MikroOptions,
LoadStrategy,
} from "@mikro-orm/core"
import { deduplicateIfNecessary } from "../utils"
import { DAL } from "@medusajs/types"
export class ProductCollectionRepository implements DAL.RepositoryService {
protected readonly manager_: SqlEntityManager
constructor({ manager }) {
this.manager_ = manager.fork()
}
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
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(
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
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(
ProductCollection,
findOptions_.where as MikroFilterQuery<ProductCollection>,
findOptions_.options as MikroOptions<ProductCollection>
)) as unknown as [T[], number]
}
}
@@ -0,0 +1,74 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import {
FilterQuery as MikroFilterQuery,
FindOptions as MikroOptions,
LoadStrategy,
} from "@mikro-orm/core"
import { deduplicateIfNecessary } from "../utils"
import { ProductTag } from "@models"
import { DAL } from "@medusajs/types"
export class ProductTagRepository implements DAL.RepositoryService {
protected readonly manager_: SqlEntityManager
constructor({ manager }) {
this.manager_ = manager.fork()
}
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
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(
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
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(
ProductTag,
findOptions_.where as MikroFilterQuery<ProductTag>,
findOptions_.options as MikroOptions<ProductTag>
)) as unknown as [T[], number]
}
}
@@ -0,0 +1,74 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import {
FilterQuery as MikroFilterQuery,
FindOptions as MikroOptions,
LoadStrategy,
} from "@mikro-orm/core"
import { deduplicateIfNecessary } from "../utils"
import { ProductVariant } from "@models"
import { DAL } from "@medusajs/types"
export class ProductVariantRepository implements DAL.RepositoryService {
protected readonly manager_: SqlEntityManager
constructor({ manager }) {
this.manager_ = manager.fork()
}
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
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(
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
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(
ProductVariant,
findOptions_.where as MikroFilterQuery<ProductVariant>,
findOptions_.options as MikroOptions<ProductVariant>
)) as unknown as [T[], number]
}
}
@@ -0,0 +1,112 @@
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"
export class ProductRepository implements DAL.RepositoryService<Product> {
protected readonly manager_: SqlEntityManager
constructor({ manager }) {
this.manager_ = manager.fork()
}
async find(
findOptions: DAL.FindOptions<Product> = { where: {} },
context: { transaction?: any } = {}
): Promise<Product[]> {
// Spread is used to cssopy 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 })
}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
await this.mutateNotInCategoriesConstraints(findOptions_)
return await this.manager_.find(
Product,
findOptions_.where as MikroFilterQuery<Product>,
findOptions_.options as MikroOptions<Product>
)
}
async findAndCount(
findOptions: DAL.FindOptions<Product> = { where: {} },
context: { transaction?: any } = {}
): 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 })
}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
await this.mutateNotInCategoriesConstraints(findOptions_)
return await this.manager_.findAndCount(
Product,
findOptions_.where as MikroFilterQuery<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: {} }
): Promise<void> {
if (findOptions.where.categories?.id?.["$nin"]) {
const productsInCategories = await this.manager_.find(
Product,
{
categories: {
id: { $in: findOptions.where.categories.id["$nin"] },
},
},
{
fields: ["id"],
}
)
const productIds = productsInCategories.map((product) => product.id)
if (productIds.length) {
findOptions.where.id = { $nin: productIds }
delete findOptions.where.categories?.id
if (Object.keys(findOptions.where.categories).length === 0) {
delete findOptions.where.categories
}
}
}
}
}
@@ -0,0 +1,7 @@
import { revertMigration } from "../migration-down"
export default (async () => {
const { config } = await import("dotenv")
config()
await revertMigration()
})()
@@ -0,0 +1,7 @@
import { runMigrations } from "../migration-up"
export default (async () => {
const { config } = await import("dotenv")
config()
await runMigrations()
})()
@@ -0,0 +1,17 @@
import { run } from "../seed"
import { EOL } from "os"
const args = process.argv
const path = args.pop() as string
export default (async () => {
const { config } = await import("dotenv")
config()
if (!path) {
throw new Error(
`filePath is required.${EOL}Example: node_modules/@medusajs/product/dist/scripts/bin/run-seed.js <filePath>`
)
}
await run({ path })
})()
+2
View File
@@ -0,0 +1,2 @@
export * from "./migration-up"
export * from "./migration-down"
@@ -0,0 +1,44 @@
import { LoaderOptions, Logger } from "@medusajs/types"
import {
ProductServiceInitializeCustomDataLayerOptions,
ProductServiceInitializeOptions,
} from "../types"
import { createConnection, loadDatabaseConfig } from "../utils"
import * as ProductModels from "@models"
import { EntitySchema } from "@mikro-orm/core"
/**
* This script is only valid for mikro orm managers. If a user provide a custom manager
* he is in charge of reverting the migrations.
* @param options
* @param logger
* @param moduleDeclaration
*/
export async function revertMigration({
options,
logger,
}: Pick<
LoaderOptions<
| ProductServiceInitializeOptions
| ProductServiceInitializeCustomDataLayerOptions
>,
"options" | "logger"
> = {}) {
logger ??= console as unknown as Logger
const dbData = loadDatabaseConfig(options)
const entities = Object.values(ProductModels) as unknown as EntitySchema[]
const orm = await createConnection(dbData, entities)
try {
const migrator = orm.getMigrator()
await migrator.down()
logger?.info("Product module migration executed")
} catch (error) {
logger?.error(`Product module migration failed to run - Error: ${error}`)
}
await orm.close()
}
@@ -0,0 +1,44 @@
import { LoaderOptions, Logger } from "@medusajs/types"
import {
ProductServiceInitializeCustomDataLayerOptions,
ProductServiceInitializeOptions,
} from "../types"
import { createConnection, loadDatabaseConfig } from "../utils"
import * as ProductModels from "@models"
import { EntitySchema } from "@mikro-orm/core"
/**
* This script is only valid for mikro orm managers. If a user provide a custom manager
* he is in charge of running the migrations.
* @param options
* @param logger
* @param moduleDeclaration
*/
export async function runMigrations({
options,
logger,
}: Pick<
LoaderOptions<
| ProductServiceInitializeOptions
| ProductServiceInitializeCustomDataLayerOptions
>,
"options" | "logger"
> = {}) {
logger ??= console as unknown as Logger
const dbData = loadDatabaseConfig(options)
const entities = Object.values(ProductModels) as unknown as EntitySchema[]
const orm = await createConnection(dbData, entities)
try {
const migrator = orm.getMigrator()
await migrator.up()
logger?.info("Product module migration executed")
} catch (error) {
logger?.error(`Product module migration failed to run - Error: ${error}`)
}
await orm.close()
}
+108
View File
@@ -0,0 +1,108 @@
import { createConnection, loadDatabaseConfig } 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 { EOL } from "os"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { resolve } from "path"
export async function run({
options,
logger,
path,
}: Partial<
Pick<
LoaderOptions<
| ProductServiceInitializeOptions
| ProductServiceInitializeCustomDataLayerOptions
>,
"options" | "logger"
>
> & {
path: string
}) {
logger?.info(`Loading seed data from ${path}...`)
const { productCategoriesData, productsData, variantsData } = await import(
resolve(process.cwd(), path)
).catch((e) => {
logger?.error(
`Failed to load seed data from ${path}. Please, provide a relative path and check that you export the following productCategoriesData, productsData, variantsData.${EOL}${e}`
)
throw e
})
logger ??= console as unknown as Logger
const dbData = loadDatabaseConfig(options)
const entities = Object.values(ProductModels) as unknown as EntitySchema[]
const orm = await createConnection(dbData, entities)
const manager = orm.em.fork()
try {
logger?.info("Inserting product categories, products and variants...")
await createProductCategories(manager, productCategoriesData)
await createProducts(manager, productsData)
await createProductVariants(manager, variantsData)
} catch (e) {
logger?.error(
`Failed to insert the seed data in the PostgreSQL database ${dbData.clientUrl}.${EOL}${e}`
)
}
await orm.close(true)
}
async function createProductCategories(
manager: SqlEntityManager,
categoriesData: any[]
): Promise<ProductCategory[]> {
const categories: ProductCategory[] = []
for (let categoryData of categoriesData) {
let categoryDataClone = { ...categoryData }
let parentCategory: ProductCategory | null = null
const parentCategoryId = categoryDataClone.parent_category_id as string
delete categoryDataClone.parent_category_id
if (parentCategoryId) {
parentCategory = await manager.findOne(ProductCategory, parentCategoryId)
}
const category = await manager.create(ProductCategory, {
...categoryDataClone,
parent_category: parentCategory,
})
categories.push(category)
}
await manager.persistAndFlush(categories)
return categories
}
async function createProducts(manager: SqlEntityManager, data: any[]) {
const products: any[] = data.map((productData) => {
return manager.create(Product, productData)
})
await manager.persistAndFlush(products)
return products
}
async function createProductVariants(manager: SqlEntityManager, data: any[]) {
const variants: any[] = data.map((variantsData) => {
return manager.create(ProductVariant, variantsData)
})
await manager.persistAndFlush(variants)
return variants
}
@@ -0,0 +1,113 @@
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),
})
describe("Product service", function () {
beforeEach(function () {
jest.clearAllMocks()
})
it("should list products", async function () {
const productService = container.resolve("productService")
const productRepository = container.resolve("productRepository")
const filters = {}
const config = {
relations: [],
}
await productService.list(filters, config)
expect(productRepository.find).toHaveBeenCalledWith({
where: {},
options: {
fields: undefined,
limit: undefined,
offset: undefined,
populate: [],
},
})
})
it("should list products with filters", async function () {
const productService = container.resolve("productService")
const productRepository = container.resolve("productRepository")
const filters = {
tags: {
value: {
$in: ["test"],
}
}
}
const config = {
relations: [],
}
await productService.list(filters, config)
expect(productRepository.find).toHaveBeenCalledWith({
where: {
tags: {
value: {
$in: ["test"]
}
},
},
options: {
fields: undefined,
limit: undefined,
offset: undefined,
populate: [],
},
})
})
it("should list products with filters and relations", async function () {
const productService = container.resolve("productService")
const productRepository = container.resolve("productRepository")
const filters = {
tags: {
value: {
$in: ["test"],
}
}
}
const config = {
relations: ["tags"],
}
await productService.list(filters, config)
expect(productRepository.find).toHaveBeenCalledWith({
where: {
tags: {
value: {
$in: ["test"]
}
},
},
options: {
fields: undefined,
limit: undefined,
offset: undefined,
populate: ["tags"],
},
})
})
})
+6
View File
@@ -0,0 +1,6 @@
export { default as ProductModuleService } from "./product-module-service"
export { default as ProductService } from "./product"
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"
@@ -0,0 +1,34 @@
import { ProductCategory } from "@models"
import { DAL, FindConfig, ProductTypes, SharedContext } from "@medusajs/types"
import { buildQuery } from "../utils"
type InjectedDependencies = {
productCategoryRepository: DAL.RepositoryService
}
export default class ProductCategoryService<TEntity = ProductCategory> {
protected readonly productCategoryRepository_: DAL.RepositoryService
constructor({ productCategoryRepository }: InjectedDependencies) {
this.productCategoryRepository_ = productCategoryRepository
}
async list(
filters: ProductTypes.FilterableProductCategoryProps = {},
config: FindConfig<ProductTypes.ProductCategoryDTO> = {},
sharedContext?: SharedContext
): Promise<TEntity[]> {
const transformOptions = {
includeDescendantsTree: filters?.include_descendants_tree || false
}
delete filters.include_descendants_tree
const queryOptions = buildQuery<TEntity>(filters, config)
queryOptions.where ??= {}
return await this.productCategoryRepository_.find(
queryOptions,
transformOptions,
)
}
}
@@ -0,0 +1,30 @@
import { ProductCollection } from "@models"
import { DAL, FindConfig, ProductTypes, SharedContext } from "@medusajs/types"
import { buildQuery } from "../utils"
type InjectedDependencies = {
productCollectionRepository: DAL.RepositoryService
}
export default class ProductCollectionService<TEntity = ProductCollection> {
protected readonly productCollectionRepository_: DAL.RepositoryService<TEntity>
constructor({ productCollectionRepository }: InjectedDependencies) {
this.productCollectionRepository_ = productCollectionRepository
}
async list(
filters: ProductTypes.FilterableProductCollectionProps = {},
config: FindConfig<ProductTypes.ProductCollectionDTO> = {},
sharedContext?: SharedContext
): Promise<TEntity[]> {
const queryOptions = buildQuery<TEntity>(filters, config)
queryOptions.where ??= {}
if (filters.title) {
queryOptions.where["title"] = { $like: filters.title }
}
return await this.productCollectionRepository_.find(queryOptions)
}
}
@@ -0,0 +1,143 @@
import {
ProductCategoryService,
ProductCollectionService,
ProductService,
ProductTagService,
ProductVariantService,
} from "@services"
import {
Product,
ProductCategory,
ProductCollection,
ProductTag,
ProductVariant,
} from "@models"
import { FindConfig, ProductTypes, SharedContext } from "@medusajs/types"
type InjectedDependencies = {
productService: ProductService<any>
productVariantService: ProductVariantService<any>
productTagService: ProductTagService<any>
productCategoryService: ProductCategoryService<any>
productCollectionService: ProductCollectionService<any>
}
export default class ProductModuleService<
TProduct = Product,
TProductVariant = ProductVariant,
TProductTag = ProductTag,
TProductCollection = ProductCollection,
TProductCategory = ProductCategory
> implements
ProductTypes.IProductModuleService<
TProduct,
TProductVariant,
TProductTag,
TProductCollection,
TProductCategory
>
{
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>
constructor({
productService,
productVariantService,
productTagService,
productCategoryService,
productCollectionService,
}: InjectedDependencies) {
this.productService_ = productService
this.productVariantService = productVariantService
this.productTagService = productTagService
this.productCategoryService = productCategoryService
this.productCollectionService = productCollectionService
}
async list(
filters: ProductTypes.FilterableProductProps = {},
config: FindConfig<ProductTypes.ProductDTO> = {},
sharedContext?: SharedContext
): Promise<ProductTypes.ProductDTO[]> {
const products = await this.productService_.list(
filters,
config,
sharedContext
)
return JSON.parse(JSON.stringify(products))
}
async listAndCount(
filters: ProductTypes.FilterableProductProps = {},
config: FindConfig<ProductTypes.ProductDTO> = {},
sharedContext?: SharedContext
): Promise<[ProductTypes.ProductDTO[], number]> {
const [products, count] = await this.productService_.listAndCount(
filters,
config,
sharedContext
)
return [JSON.parse(JSON.stringify(products)), count]
}
async listVariants(
filters: ProductTypes.FilterableProductVariantProps = {},
config: FindConfig<ProductTypes.ProductVariantDTO> = {},
sharedContext?: SharedContext
): Promise<ProductTypes.ProductVariantDTO[]> {
const variants = await this.productVariantService.list(
filters,
config,
sharedContext
)
return JSON.parse(JSON.stringify(variants))
}
async listTags(
filters: ProductTypes.FilterableProductTagProps = {},
config: FindConfig<ProductTypes.ProductTagDTO> = {},
sharedContext?: SharedContext
): Promise<ProductTypes.ProductTagDTO[]> {
const tags = await this.productTagService.list(
filters,
config,
sharedContext
)
return JSON.parse(JSON.stringify(tags))
}
async listCollections(
filters: ProductTypes.FilterableProductCollectionProps = {},
config: FindConfig<ProductTypes.ProductCollectionDTO> = {},
sharedContext?: SharedContext
): Promise<ProductTypes.ProductCollectionDTO[]> {
const collections = await this.productCollectionService.list(
filters,
config,
sharedContext
)
return JSON.parse(JSON.stringify(collections))
}
async listCategories(
filters: ProductTypes.FilterableProductCategoryProps = {},
config: FindConfig<ProductTypes.ProductCategoryDTO> = {},
sharedContext?: SharedContext
): Promise<ProductTypes.ProductCategoryDTO[]> {
const categories = await this.productCategoryService.list(
filters,
config,
sharedContext
)
return JSON.parse(JSON.stringify(categories))
}
}
@@ -0,0 +1,29 @@
import { ProductTag } from "@models"
import { DAL, FindConfig, ProductTypes, SharedContext } from "@medusajs/types"
import { buildQuery } from "../utils"
type InjectedDependencies = {
productTagRepository: DAL.RepositoryService
}
export default class ProductTagService<TEntity = ProductTag> {
protected readonly productTagRepository_: DAL.RepositoryService<TEntity>
constructor({ productTagRepository }: InjectedDependencies) {
this.productTagRepository_ = productTagRepository
}
async list(
filters: ProductTypes.FilterableProductTagProps = {},
config: FindConfig<ProductTypes.ProductTagDTO> = {},
sharedContext?: SharedContext
): Promise<TEntity[]> {
const queryOptions = buildQuery<TEntity>(filters, config)
if (filters.value) {
queryOptions.where["value"] = { $ilike: filters.value }
}
return await this.productTagRepository_.find(queryOptions)
}
}
@@ -0,0 +1,24 @@
import { ProductVariant } from "@models"
import { DAL, FindConfig, ProductTypes, SharedContext } from "@medusajs/types"
import { buildQuery } from "../utils"
type InjectedDependencies = {
productVariantRepository: DAL.RepositoryService
}
export default class ProductVariantService<TEntity = ProductVariant> {
protected readonly productVariantRepository_: DAL.RepositoryService<TEntity>
constructor({ productVariantRepository }: InjectedDependencies) {
this.productVariantRepository_ = productVariantRepository
}
async list(
filters: ProductTypes.FilterableProductVariantProps = {},
config: FindConfig<ProductTypes.ProductVariantDTO> = {},
sharedContext?: SharedContext
): Promise<TEntity[]> {
const queryOptions = buildQuery<TEntity>(filters, config)
return await this.productVariantRepository_.find(queryOptions)
}
}
+62
View File
@@ -0,0 +1,62 @@
import { ProductTagService, ProductVariantService } from "@services"
import { Product } from "@models"
import { DAL, FindConfig, ProductTypes, SharedContext } from "@medusajs/types"
import { buildQuery } from "../utils"
type InjectedDependencies = {
productRepository: DAL.RepositoryService
productVariantService: ProductVariantService
productTagService: ProductTagService
}
export default class ProductService<TEntity = Product> {
protected readonly productRepository_: DAL.RepositoryService<TEntity>
constructor({ productRepository }: InjectedDependencies) {
this.productRepository_ = productRepository
}
async list(
filters: ProductTypes.FilterableProductProps = {},
config: FindConfig<ProductTypes.ProductDTO> = {},
sharedContext?: SharedContext
): Promise<TEntity[]> {
if (filters.category_ids) {
if (Array.isArray(filters.category_ids)) {
filters.categories = {
id: { $in: filters.category_ids },
}
} else {
filters.categories = {
id: filters.category_ids,
}
}
delete filters.category_ids
}
const queryOptions = buildQuery<TEntity>(filters, config)
return await this.productRepository_.find(queryOptions)
}
async listAndCount(
filters: ProductTypes.FilterableProductProps = {},
config: FindConfig<ProductTypes.ProductDTO> = {},
sharedContext?: SharedContext
): Promise<[TEntity[], number]> {
if (filters.category_ids) {
if (Array.isArray(filters.category_ids)) {
filters.categories = {
id: { $in: filters.category_ids },
}
} else {
filters.categories = {
id: filters.category_ids,
}
}
delete filters.category_ids
}
const queryOptions = buildQuery<TEntity>(filters, config)
return await this.productRepository_.findAndCount(queryOptions)
}
}
+18
View File
@@ -0,0 +1,18 @@
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> }
}
export type InitializeModuleInjectableDependencies = {
eventBusService?: IEventBusService
}
@@ -0,0 +1,127 @@
import { loadDatabaseConfig } from "../load-database-config"
describe("loadDatabaseConfig", function () {
afterEach(() => {
delete process.env.POSTGRES_URL
delete process.env.PRODUCT_POSTGRES_URL
})
it("should return the local configuration using the environment variable", function () {
process.env.POSTGRES_URL = "postgres://localhost:5432/medusa"
let config = loadDatabaseConfig()
expect(config).toEqual({
clientUrl: process.env.POSTGRES_URL,
driverOptions: {
connection: {
ssl: false,
},
},
schema: "",
})
delete process.env.POSTGRES_URL
process.env.PRODUCT_POSTGRES_URL = "postgres://localhost:5432/medusa"
config = loadDatabaseConfig()
expect(config).toEqual({
clientUrl: process.env.PRODUCT_POSTGRES_URL,
driverOptions: {
connection: {
ssl: 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()
expect(config).toEqual({
clientUrl: process.env.POSTGRES_URL,
driverOptions: {
connection: {
ssl: {
rejectUnauthorized: false,
},
},
},
schema: "",
})
delete process.env.POSTGRES_URL
process.env.PRODUCT_POSTGRES_URL = "postgres://https://test.com:5432/medusa"
config = loadDatabaseConfig()
expect(config).toEqual({
clientUrl: process.env.PRODUCT_POSTGRES_URL,
driverOptions: {
connection: {
ssl: {
rejectUnauthorized: false,
},
},
},
schema: "",
})
})
it("should return the local configuration using the options", function () {
process.env.POSTGRES_URL = "postgres://localhost:5432/medusa"
const options = {
database: {
clientUrl: "postgres://localhost:5432/medusa-test",
},
}
const config = loadDatabaseConfig(options)
expect(config).toEqual({
clientUrl: options.database.clientUrl,
driverOptions: {
connection: {
ssl: false,
},
},
schema: "",
})
})
it("should return the remote configuration using the options", function () {
process.env.POSTGRES_URL = "postgres://localhost:5432/medusa"
const options = {
database: {
clientUrl: "postgres://https://test.com:5432/medusa-test",
},
}
const config = loadDatabaseConfig(options)
expect(config).toEqual({
clientUrl: options.database.clientUrl,
driverOptions: {
connection: {
ssl: {
rejectUnauthorized: false,
},
},
},
schema: "",
})
})
it("should throw if no clientUrl is provided", function () {
let error
try {
loadDatabaseConfig()
} catch (e) {
error = e
}
expect(error.message).toEqual(
"No database clientUrl provided. Please provide the clientUrl through the PRODUCT_POSTGRES_URL or POSTGRES_URL environment variable or the options object in the initialize function."
)
})
})
@@ -0,0 +1,37 @@
import { resolve } from "path"
import { MikroORM, PostgreSqlDriver } from "@mikro-orm/postgresql"
import { ProductServiceInitializeOptions } from "../types"
import { TSMigrationGenerator } from "@mikro-orm/migrations"
export async function createConnection(
database: ProductServiceInitializeOptions["database"],
entities: any[]
) {
const orm = await MikroORM.init<PostgreSqlDriver>({
discovery: { disableDynamicFileAccess: true },
entities,
debug: process.env.NODE_ENV === "development",
baseDir: process.cwd(),
clientUrl: database.clientUrl,
schema: database.schema ?? "public",
driverOptions: database.driverOptions ?? {
connection: { ssl: true },
},
tsNode: process.env.APP_ENV === "development",
type: "postgresql",
migrations: {
path: resolve(__dirname, "../../dist/migrations"),
pathTs: resolve(__dirname, "../../src/migrations"),
glob: "!(*.d).{js,ts}",
disableForeignKeys: false,
silent: false,
dropTables: false,
transactional: true,
allOrNothing: true,
safe: true,
generator: TSMigrationGenerator,
},
})
return orm
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./query"
export * from "./create-connection"
export * from "./load-database-config"
@@ -0,0 +1,82 @@
import {
ProductServiceInitializeCustomDataLayerOptions,
ProductServiceInitializeOptions,
} from "../types"
import { MedusaError } from "@medusajs/utils"
function getEnv(key: string): string {
const value = process.env[`PRODUCT_${key}`] ?? process.env[`${key}`]
return value ?? ""
}
function isProductServiceInitializeOptions(
obj: unknown
): obj is ProductServiceInitializeOptions {
return !!(obj as ProductServiceInitializeOptions)?.database
}
function getDefaultDriverOptions(
clientUrl: string
): ProductServiceInitializeOptions["database"]["driverOptions"] {
const localOptions = {
connection: {
ssl: false,
},
}
const remoteOptions = {
connection: {
ssl: {
rejectUnauthorized: false,
},
},
}
if (clientUrl) {
return clientUrl.match(/localhost/i) ? localOptions : remoteOptions
}
return process.env.NODE_ENV?.match(/prod/i)
? remoteOptions
: process.env.NODE_ENV?.match(/dev/i)
? localOptions
: {}
}
/**
* 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.
* @param options
*/
export function loadDatabaseConfig(
options?:
| ProductServiceInitializeOptions
| ProductServiceInitializeCustomDataLayerOptions
): ProductServiceInitializeOptions["database"] {
const clientUrl = getEnv("POSTGRES_URL")
const database: ProductServiceInitializeOptions["database"] = {
clientUrl: getEnv("POSTGRES_URL"),
schema: getEnv("POSTGRES_SCHEMA") ?? "public",
driverOptions: JSON.parse(
getEnv("POSTGRES_DRIVER_OPTIONS") ||
JSON.stringify(getDefaultDriverOptions(clientUrl))
),
}
if (isProductServiceInitializeOptions(options)) {
database.clientUrl = options.database.clientUrl ?? database.clientUrl
database.schema = options.database.schema ?? database.schema
database.driverOptions =
options.database.driverOptions ??
getDefaultDriverOptions(database.clientUrl)
}
if (!database.clientUrl) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"No database clientUrl provided. Please provide the clientUrl through the PRODUCT_POSTGRES_URL or POSTGRES_URL environment variable or the options object in the initialize function."
)
}
return database
}
+44
View File
@@ -0,0 +1,44 @@
/**
* 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
}
export function buildQuery<T = any, TDto = any>(
filters: Record<string, any> = {},
config: FindConfig<TDto> = {}
): DAL.FindOptions<T> {
const where: DAL.FilterQuery<T> = {}
buildWhere(filters, where)
const findOptions: DAL.OptionsQuery<T, any> = {
populate: config.relations ?? [],
fields: config.select,
limit: config.take,
offset: config.skip,
} as any
return { where, options: findOptions }
}
function buildWhere(filters: Record<string, any> = {}, where = {}) {
for (let [prop, value] of Object.entries(filters)) {
if (Array.isArray(value)) {
value = deduplicateIfNecessary(value)
where[prop] = ["$in", "$nin"].includes(prop) ? value : { $in: value }
continue
}
if (isObject(value)) {
where[prop] = {}
buildWhere(value, where[prop])
continue
}
where[prop] = value
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"lib": ["es2020"],
"target": "es2020",
"outDir": "./dist",
"esModuleInterop": true,
"declaration": true,
"module": "commonjs",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": false,
"noImplicitReturns": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"allowJs": true,
"skipLibCheck": true,
"downlevelIteration": true, // to use ES5 specific tooling
"baseUrl": ".",
"resolveJsonModule": true,
"paths": {
"@models": ["./src/models"],
"@services": ["./src/services"],
"@repositories": ["./src/repositories"]
}
},
"include": ["src"],
"exclude": [
"dist",
"./src/**/__tests__",
"./src/**/__mocks__",
"./src/**/__fixtures__",
"node_modules"
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src", "integration-tests"],
"exclude": ["node_modules", "dist"]
}
+2 -18
View File
@@ -1,24 +1,8 @@
import { ModuleExports } from "@medusajs/modules-sdk"
import loadConnection from "./loaders/connection"
import migrations from "./migrations"
import { revertMigration, runMigrations } from "./migrations/run-migration"
import * as StockLocationModels from "./models"
import StockLocationService from "./services/stock-location"
const service = StockLocationService
const loaders = [loadConnection]
const models = Object.values(StockLocationModels)
const moduleDefinition: ModuleExports = {
service,
migrations,
loaders,
models,
runMigrations,
revertMigration,
}
import { moduleDefinition } from "./module-definition"
export default moduleDefinition
export * from "./initialize"
export { revertMigration, runMigrations } from "./migrations/run-migration"
export * from "./types"
@@ -6,6 +6,7 @@ import {
} from "@medusajs/modules-sdk"
import { IEventBusService, IStockLocationService } from "@medusajs/types"
import { StockLocationServiceInitializeOptions } from "../types"
import { moduleDefinition } from "../module-definition"
export const initialize = async (
options: StockLocationServiceInitializeOptions | ExternalModuleDeclaration,
@@ -18,6 +19,7 @@ export const initialize = async (
serviceKey,
"@medusajs/stock-location",
options as InternalModuleDeclaration | ExternalModuleDeclaration,
moduleDefinition,
injectedDependencies
)
@@ -0,0 +1,19 @@
import StockLocationService from "./services/stock-location"
import loadConnection from "./loaders/connection"
import * as StockLocationModels from "./models"
import { ModuleExports } from "@medusajs/types"
import migrations from "./migrations"
import { revertMigration, runMigrations } from "./migrations/run-migration"
const service = StockLocationService
const loaders = [loadConnection]
const models = Object.values(StockLocationModels)
export const moduleDefinition: ModuleExports = {
service,
migrations,
loaders,
models,
runMigrations,
revertMigration,
}
+2
View File
@@ -2,7 +2,9 @@ export * as CacheTypes from "./cache"
export * as CommonTypes from "./common"
export * as EventBusTypes from "./event-bus"
export * as InventoryTypes from "./inventory"
export * as ProductTypes from "./product"
export * as ModulesSdkTypes from "./modules-sdk"
export * as SearchTypes from "./search"
export * as StockLocationTypes from "./stock-location"
export * as TransactionBaseTypes from "./transaction-base"
export * as DAL from "./dal"
+2
View File
@@ -126,6 +126,8 @@ export type DeleteResponse = {
}
export interface EmptyQueryParams {}
// TODO: Build a tree repository options from this
export interface RepositoryTransformOptions {}
export interface DateComparisonOperator {
lt?: Date
+24
View File
@@ -0,0 +1,24 @@
import { Dictionary, FilterQuery, Order } from "./utils"
export { FilterQuery } from "./utils"
export interface BaseFilterable<T> {
$and?: T
$or?: T
}
export interface OptionsQuery<T, P extends string = never> {
populate?: string[]
orderBy?: Order<T> | Order<T>[]
limit?: number
offset?: number
fields?: string[]
groupBy?: string | string[]
filters?: Dictionary<boolean | Dictionary> | string[] | boolean
}
export type FindOptions<T = any> = {
where: FilterQuery<T>
options?: OptionsQuery<T, any>
}
export * from "./repository-service"
@@ -0,0 +1,12 @@
import { FindOptions } from "./index"
import { RepositoryTransformOptions } from "../common"
/**
* Data access layer (DAL) interface to implements for any repository service.
* This layer helps to separate the business logic (service layer) from accessing the
* 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]>
}
+127
View File
@@ -0,0 +1,127 @@
type ExpandProperty<T> = T extends (infer U)[] ? NonNullable<U> : NonNullable<T>
export type Dictionary<T = any> = {
[k: string]: T
}
type Query<T> = T extends object
? T extends Scalar
? never
: FilterQuery<T>
: FilterValue<T>
type ExpandScalar<T> =
| null
| (T extends string ? string | RegExp : T extends Date ? Date | string : T)
type Scalar =
| boolean
| number
| string
| bigint
| symbol
| Date
| RegExp
| Buffer
| {
toHexString(): string
}
type ExcludeFunctions<T, K extends keyof T> = T[K] extends Function
? never
: K extends symbol
? never
: K
type ReadonlyPrimary<T> = T extends any[] ? Readonly<T> : T
declare const PrimaryKeyType: unique symbol
type Primary<T> = T extends {
[PrimaryKeyType]?: infer PK
}
? ReadonlyPrimary<PK>
: T extends {
_id?: infer PK
}
? ReadonlyPrimary<PK> | string
: T extends {
uuid?: infer PK
}
? ReadonlyPrimary<PK>
: T extends {
id?: infer PK
}
? ReadonlyPrimary<PK>
: never
export type OperatorMap<T> = {
$and?: Query<T>[]
$or?: Query<T>[]
$eq?: ExpandScalar<T> | ExpandScalar<T>[]
$ne?: ExpandScalar<T>
$in?: ExpandScalar<T>[]
$nin?: ExpandScalar<T>[]
$not?: Query<T>
$gt?: ExpandScalar<T>
$gte?: ExpandScalar<T>
$lt?: ExpandScalar<T>
$lte?: ExpandScalar<T>
$like?: string
$re?: string
$ilike?: string
$fulltext?: string
$overlap?: string[]
$contains?: string[]
$contained?: string[]
$exists?: boolean
}
type FilterValue2<T> = T | ExpandScalar<T> | Primary<T>
type FilterValue<T> =
| OperatorMap<FilterValue2<T>>
| FilterValue2<T>
| FilterValue2<T>[]
| null
type PrevLimit = [never, 1, 2, 3]
export type FilterQuery<T = any, Prev extends number = 3> = Prev extends never
? never
: {
[Key in keyof T]?: T[Key] extends
| boolean
| number
| string
| bigint
| symbol
| Date
? T[Key] | OperatorMap<T[Key]>
: T[Key] extends infer U
? U extends { [x: number]: infer V }
? V extends object
? FilterQuery<Partial<V>, PrevLimit[Prev]>
: never
: never
: never
}
declare enum QueryOrder {
ASC = "ASC",
DESC = "DESC",
asc = "asc",
desc = "desc",
}
type QueryOrderKeysFlat = QueryOrder | 1 | -1 | keyof typeof QueryOrder
type QueryOrderKeys<T> = QueryOrderKeysFlat | QueryOrderMap<T>
type QueryOrderMap<T> = {
[K in keyof T as ExcludeFunctions<T, K>]?: QueryOrderKeys<
ExpandProperty<T[K]>
>
}
export type Order<T> = {
[key in keyof T]?:
| "ASC"
| "DESC"
| Order<T[key] extends Array<any> ? T[key][0] : T[key]>
}
+3
View File
@@ -4,7 +4,10 @@ export * from "./common"
export * from "./event-bus"
export * from "./inventory"
export * from "./modules-sdk"
export * from "./product"
export * from "./product-category"
export * from "./search"
export * from "./shared-context"
export * from "./stock-location"
export * from "./transaction-base"
export * from "./dal"
+3 -1
View File
@@ -1,5 +1,6 @@
import { Logger as _Logger } from "winston"
import { MedusaContainer } from "../common/medusa-container"
export type Constructor<T> = new (...args: any[]) => T
export * from "../common/medusa-container"
@@ -39,7 +40,7 @@ export type InternalModuleDeclaration = {
export type ExternalModuleDeclaration = {
scope: MODULE_SCOPE.EXTERNAL
server: {
server?: {
type: "http"
url: string
keepAlive: boolean
@@ -52,6 +53,7 @@ export type ModuleResolution = {
options?: Record<string, unknown>
dependencies?: string[]
moduleDeclaration?: InternalModuleDeclaration | ExternalModuleDeclaration
moduleExports?: ModuleExports
}
export type ModuleDefinition = {
@@ -0,0 +1 @@
export * from "./repository"
@@ -0,0 +1,5 @@
import { RepositoryTransformOptions } from '../common'
export interface ProductCategoryTransformOptions extends RepositoryTransformOptions {
includeDescendantsTree?: boolean
}
+166
View File
@@ -0,0 +1,166 @@
import { BaseFilterable } from "../dal"
import { OperatorMap } from "../dal/utils"
export enum ProductStatus {
DRAFT = "draft",
PROPOSED = "proposed",
PUBLISHED = "published",
REJECTED = "rejected",
}
/**
* DTO in and out of the module (module API)
*/
export interface ProductDTO {
id: string
title: string
handle?: string | null
subtitle?: string | null
description?: string | null
is_giftcard: boolean
status: ProductStatus
thumbnail?: string | null
weight?: number | null
length?: number | null
height?: number | null
origin_country?: string | null
hs_code?: string | null
mid_code?: string | null
material?: string | null
collection: ProductCollectionDTO
categories?: ProductCategoryDTO[] | null
type: ProductTypeDTO[]
tags: ProductTagDTO[]
variants: ProductVariantDTO[]
options: ProductOptionDTO[]
discountable?: boolean
external_id?: string | null
created_at?: string | Date
updated_at?: string | Date
deleted_at?: string | Date
}
export interface ProductVariantDTO {
id: string
title: string
sku?: string | null
barcode?: string | null
ean?: string | null
upc?: string | null
inventory_quantity: number
allow_backorder?: boolean
manage_inventory?: boolean
hs_code?: string | null
origin_country?: string | null
mid_code?: string | null
material?: string | null
weight?: number | null
length?: number | null
height?: number | null
width?: number | null
options: ProductOptionValueDTO
metadata?: Record<string, unknown> | null
product: ProductDTO
variant_rank?: number | null
created_at: string | Date
updated_at: string | Date
deleted_at: string | Date
}
export interface ProductCategoryDTO {
id: string
name: string
description?: string
handle?: string
is_active?: boolean
is_internal?: boolean
rank?: number
parent_category?: ProductCategoryDTO
category_children: ProductCategoryDTO[]
created_at: string | Date
updated_at: string | Date
}
export interface ProductTagDTO {
id: string
value: string
metadata?: Record<string, unknown> | null
products: ProductDTO[]
}
export interface ProductCollectionDTO {
id: string
title: string
handle: string
metadata?: Record<string, unknown> | null
deleted_at?: string | Date
}
export interface ProductTypeDTO {
id: string
value: string
metadata?: Record<string, unknown> | null
deleted_at?: string | Date
}
export interface ProductOptionDTO {
id: string
title: string
product: ProductDTO
values: ProductOptionValueDTO[]
metadata?: Record<string, unknown> | null
deleted_at?: string | Date
}
export interface ProductOptionValueDTO {
id: string
value: string
option: ProductOptionDTO
variant: ProductVariantDTO
metadata?: Record<string, unknown> | null
deleted_at?: string | Date
}
/**
* Filters/Config (module API input filters and config)
*/
export interface FilterableProductProps
extends BaseFilterable<FilterableProductProps> {
handle?: string | string[]
id?: string | string[]
tags?: { value?: string[] }
categories?: {
id?: string | string[] | OperatorMap<string>
}
category_ids?: string | string[] | OperatorMap<string>
}
export interface FilterableProductTagProps
extends BaseFilterable<FilterableProductTagProps> {
id?: string | string[]
value?: string
}
export interface FilterableProductCollectionProps
extends BaseFilterable<FilterableProductCollectionProps> {
id?: string | string[]
title?: string
}
export interface FilterableProductVariantProps
extends BaseFilterable<FilterableProductVariantProps> {
id?: string | string[]
sku?: string | string[]
options?: { id?: string[] }
}
export interface FilterableProductCategoryProps
extends BaseFilterable<FilterableProductCategoryProps> {
id?: string | string[]
parent_category_id?: string | string[] | null
handle?: string | string[]
is_active?: boolean
is_internal?: boolean
include_descendants_tree?: boolean
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./service"
export * from "./common"
+58
View File
@@ -0,0 +1,58 @@
import {
FilterableProductCategoryProps,
FilterableProductCollectionProps,
FilterableProductProps,
FilterableProductTagProps,
FilterableProductVariantProps,
ProductCategoryDTO,
ProductCollectionDTO,
ProductDTO,
ProductTagDTO,
ProductVariantDTO,
} from "./common"
import { FindConfig } from "../common"
import { SharedContext } from "../shared-context"
export interface IProductModuleService<
TProduct = any,
TProductVariant = any,
TProductTag = any,
TProductCollection = any,
TProductCategory = any
> {
list(
filter: FilterableProductProps,
config?: FindConfig<ProductDTO>,
context?: SharedContext
): Promise<ProductDTO[]>
listAndCount(
filter: FilterableProductProps,
config?: FindConfig<ProductDTO>,
context?: SharedContext
): Promise<[ProductDTO[], number]>
listTags(
filter: FilterableProductTagProps,
config?: FindConfig<ProductTagDTO>,
context?: SharedContext
): Promise<ProductTagDTO[]>
listVariants(
filter: FilterableProductVariantProps,
config?: FindConfig<ProductVariantDTO>,
context?: SharedContext
): Promise<ProductVariantDTO[]>
listCollections(
filter: FilterableProductCollectionProps,
config?: FindConfig<ProductCollectionDTO>,
context?: SharedContext
): Promise<ProductCollectionDTO[]>
listCategories(
filters: FilterableProductCategoryProps,
config?: FindConfig<ProductCategoryDTO>,
sharedContext?: SharedContext
): Promise<ProductCategoryDTO[]>
}
+6 -1
View File
@@ -1,3 +1,4 @@
export * from "./build-query"
export * from "./errors"
export * from "./generate-entity-id"
export * from "./get-config-file"
@@ -6,9 +7,13 @@ export * from "./is-defined"
export * from "./is-email"
export * from "./is-object"
export * from "./is-string"
export * from "./object-to-string-path"
export * from "./lower-case-first"
export * from "./medusa-container"
export * from "./object-to-string-path"
export * from "./set-metadata"
export * from "./simple-hash"
export * from "./wrap-handler"
export * from "./to-kebab-case"
export * from "./stringify-circular"
export * from "./build-query"
export * from "./handle-postgres-database-error"
@@ -0,0 +1,3 @@
export function lowerCaseFirst(str: string): string {
return str.charAt(0).toLowerCase() + str.slice(1)
}

Some files were not shown because too many files have changed in this diff Show More