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
+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
}
}