From 3f44abe01a7807adf0e807811d4bc52b713cd6b5 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Thu, 5 Jan 2023 19:10:46 +0100 Subject: [PATCH] feat(medusa): Add ProductCategory model (#2945) --- .changeset/honest-guests-wash.md | 5 + .../product-categories/materialized-path.ts | 76 ++++++++++++ .../1672906846559-product-category.ts | 38 ++++++ packages/medusa/src/models/index.ts | 1 + .../medusa/src/models/product-category.ts | 112 ++++++++++++++++++ 5 files changed, 232 insertions(+) create mode 100644 .changeset/honest-guests-wash.md create mode 100644 integration-tests/api/__tests__/product-categories/materialized-path.ts create mode 100644 packages/medusa/src/migrations/1672906846559-product-category.ts create mode 100644 packages/medusa/src/models/product-category.ts diff --git a/.changeset/honest-guests-wash.md b/.changeset/honest-guests-wash.md new file mode 100644 index 0000000000..e01cbe34f1 --- /dev/null +++ b/.changeset/honest-guests-wash.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(nested-categories): Introduces a model and migration to create category table that can be nested diff --git a/integration-tests/api/__tests__/product-categories/materialized-path.ts b/integration-tests/api/__tests__/product-categories/materialized-path.ts new file mode 100644 index 0000000000..ac41694dd3 --- /dev/null +++ b/integration-tests/api/__tests__/product-categories/materialized-path.ts @@ -0,0 +1,76 @@ +import path from "path" +import { ProductCategory } from "@medusajs/medusa" +import { initDb, useDb } from "../../../helpers/use-db" + +describe("Product Categories", () => { + let dbConnection + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + describe("Tree Queries (Materialized Paths)", () => { + it("can fetch ancestors, descendents and root product categories", async () => { + const productCategoryRepository = dbConnection.getTreeRepository(ProductCategory) + + const a1 = productCategoryRepository.create({ name: 'a1', handle: 'a1' }) + await productCategoryRepository.save(a1) + + const a11 = productCategoryRepository.create({ name: 'a11', handle: 'a11', parent_category: a1 }) + await productCategoryRepository.save(a11) + + const a111 = productCategoryRepository.create({ name: 'a111', handle: 'a111', parent_category: a11 }) + await productCategoryRepository.save(a111) + + const a12 = productCategoryRepository.create({ name: 'a12', handle: 'a12', parent_category: a1 }) + await productCategoryRepository.save(a12) + + const rootCategories = await productCategoryRepository.findRoots() + + expect(rootCategories).toEqual([ + expect.objectContaining({ + name: "a1", + }) + ]) + + const a11Parent = await productCategoryRepository.findAncestors(a11) + + expect(a11Parent).toEqual([ + expect.objectContaining({ + name: "a1", + }), + expect.objectContaining({ + name: "a11", + }), + ]) + + const a1Children = await productCategoryRepository.findDescendants(a1) + + expect(a1Children).toEqual([ + expect.objectContaining({ + name: "a1", + }), + expect.objectContaining({ + name: "a11", + }), + expect.objectContaining({ + name: "a111", + }), + expect.objectContaining({ + name: "a12", + }), + ]) + }) + }) +}) diff --git a/packages/medusa/src/migrations/1672906846559-product-category.ts b/packages/medusa/src/migrations/1672906846559-product-category.ts new file mode 100644 index 0000000000..1e9688921f --- /dev/null +++ b/packages/medusa/src/migrations/1672906846559-product-category.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class productCategory1672906846559 implements MigrationInterface { + name = "productCategory1672906846559" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "product_category" + ( + "id" character varying NOT NULL, + "name" text NOT NULL, + "handle" text NOT NULL, + "parent_category_id" character varying, + "mpath" text, + "is_active" boolean DEFAULT false, + "is_internal" boolean DEFAULT false, + "deleted_at" TIMESTAMP WITH TIME ZONE, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_qgguwbn1cwstxk93efl0px9oqwt" PRIMARY KEY ("id") + ) + `) + + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_product_category_handle" ON "product_category" ("handle") WHERE deleted_at IS NULL` + ) + + await queryRunner.query( + `CREATE INDEX "IDX_product_category_path" ON "product_category" ("mpath")` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_product_category_path"`) + await queryRunner.query(`DROP INDEX "IDX_product_category_handle"`) + await queryRunner.query(`DROP TABLE "product_category"`) + } +} diff --git a/packages/medusa/src/models/index.ts b/packages/medusa/src/models/index.ts index 8e213c83da..59eceac832 100644 --- a/packages/medusa/src/models/index.ts +++ b/packages/medusa/src/models/index.ts @@ -2,6 +2,7 @@ export * from "./address" export * from "./analytics-config" export * from "./batch-job" export * from "./cart" +export * from "./product-category" export * from "./claim-image" export * from "./claim-item" export * from "./claim-order" diff --git a/packages/medusa/src/models/product-category.ts b/packages/medusa/src/models/product-category.ts new file mode 100644 index 0000000000..f0900d4691 --- /dev/null +++ b/packages/medusa/src/models/product-category.ts @@ -0,0 +1,112 @@ +import { generateEntityId } from "../utils/generate-entity-id" +import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity" +import { + BeforeInsert, + Index, + Entity, + Tree, + Column, + PrimaryGeneratedColumn, + TreeChildren, + TreeParent, + TreeLevelColumn, + JoinColumn, +} from "typeorm" + +@Entity() +@Tree("materialized-path") +export class ProductCategory extends SoftDeletableEntity { + @Column() + name: string + + @Index({ unique: true, where: "deleted_at IS NULL" }) + @Column({ nullable: false }) + handle: string + + @Column() + is_active: Boolean + + @Column() + is_internal: Boolean + + // The materialized path column is added dynamically by typeorm. Commenting this here for it + // to not be a mystery + // https://github.com/typeorm/typeorm/blob/62518ae1226f22b2f230afa615532c92f1544f01/src/metadata-builder/EntityMetadataBuilder.ts#L615 + // @Column({ nullable: true, default: '' }) + // mpath: String + + @TreeParent() + @JoinColumn({ name: 'parent_category_id' }) + parent_category: ProductCategory | null + + // Typeorm also keeps track of the category's parent at all times. + // TODO: Uncomment this if there is a usecase for accessing this. + // @Column() + // parent_category_id: ProductCategory + + @TreeChildren({ cascade: true }) + category_children: ProductCategory[] + + @BeforeInsert() + private beforeInsert(): void { + this.id = generateEntityId(this.id, "pcat") + } +} + +/** + * @schema productCategory + * title: "ProductCategory" + * description: "Represents a product category" + * x-resourceId: productCategory + * type: object + * required: + * - name + * - handle + * properties: + * id: + * type: string + * description: The product category's ID + * example: pcat_01G2SG30J8C85S4A5CHM2S1NS2 + * name: + * type: string + * description: The product category's name + * example: Regular Fit + * handle: + * description: "A unique string that identifies the Category - example: slug structures." + * type: string + * example: regular-fit + * path: + * type: string + * description: A string for Materialized Paths - used for finding ancestors and descendents + * example: pcat_id1.pcat_id2.pcat_id3 + * is_internal: + * type: boolean + * description: A flag to make product category an internal category for admins + * default: false + * is_active: + * type: boolean + * description: A flag to make product category visible/hidden in the store front + * default: false + * category_children: + * description: Available if the relation `category_children` are expanded. + * type: array + * items: + * type: object + * description: A product category object. + * parent_category: + * description: Available if the relation `parent_category` is expanded. + * type: object + * description: A product category object. + * created_at: + * type: string + * description: "The date with timezone at which the resource was created." + * format: date-time + * updated_at: + * type: string + * description: "The date with timezone at which the resource was updated." + * format: date-time + * deleted_at: + * type: string + * description: "The date with timezone at which the resource was deleted." + * format: date-time + */