feat(medusa): Add ProductCategory model (#2945)
This commit is contained in:
5
.changeset/honest-guests-wash.md
Normal file
5
.changeset/honest-guests-wash.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@medusajs/medusa": patch
|
||||
---
|
||||
|
||||
feat(nested-categories): Introduces a model and migration to create category table that can be nested
|
||||
@@ -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",
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm"
|
||||
|
||||
export class productCategory1672906846559 implements MigrationInterface {
|
||||
name = "productCategory1672906846559"
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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"`)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
112
packages/medusa/src/models/product-category.ts
Normal file
112
packages/medusa/src/models/product-category.ts
Normal file
@@ -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
|
||||
*/
|
||||
Reference in New Issue
Block a user