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:
co-authored by
fPolic
Riqwan Thamir
Carlos R. L. Rodrigues
parent
ffeeb84d97
commit
14c0f62f84
@@ -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"
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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;');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 })
|
||||
})()
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user