diff --git a/.changeset/real-impalas-walk.md b/.changeset/real-impalas-walk.md new file mode 100644 index 0000000000..0152c9ffd5 --- /dev/null +++ b/.changeset/real-impalas-walk.md @@ -0,0 +1,5 @@ +--- +"@comet/cms-api": patch +--- + +API Generator: Fix generated types for position code diff --git a/demo/api/src/products/entities/product-variant.entity.ts b/demo/api/src/products/entities/product-variant.entity.ts index 32a856b4c7..3913850669 100644 --- a/demo/api/src/products/entities/product-variant.entity.ts +++ b/demo/api/src/products/entities/product-variant.entity.ts @@ -1,7 +1,8 @@ import { BlockDataInterface, RootBlock, RootBlockEntity } from "@comet/blocks-api"; import { CrudField, CrudGenerator, DamImageBlock, RootBlockType } from "@comet/cms-api"; import { BaseEntity, Entity, ManyToOne, OptionalProps, PrimaryKey, Property, Ref } from "@mikro-orm/core"; -import { Field, ID, ObjectType } from "@nestjs/graphql"; +import { Field, ID, Int, ObjectType } from "@nestjs/graphql"; +import { Min } from "class-validator"; import { v4 as uuid } from "uuid"; import { Product } from "./product.entity"; @@ -9,7 +10,7 @@ import { Product } from "./product.entity"; @ObjectType() @Entity() @RootBlockEntity() -@CrudGenerator({ targetDirectory: `${__dirname}/../generated/`, requiredPermission: "products" }) +@CrudGenerator({ targetDirectory: `${__dirname}/../generated/`, requiredPermission: "products", position: { groupByFields: ["product"] } }) export class ProductVariant extends BaseEntity { [OptionalProps]?: "createdAt" | "updatedAt"; @@ -25,6 +26,11 @@ export class ProductVariant extends BaseEntity { @RootBlock(DamImageBlock) image: BlockDataInterface; + @Property({ columnType: "integer" }) + @Field(() => Int) + @Min(1) + position: number; + @ManyToOne(() => Product, { ref: true }) @CrudField({ resolveField: true, // default is true diff --git a/demo/api/src/products/generated/dto/product-variant.input.ts b/demo/api/src/products/generated/dto/product-variant.input.ts index f9d5a6dbd2..876e0bef8f 100644 --- a/demo/api/src/products/generated/dto/product-variant.input.ts +++ b/demo/api/src/products/generated/dto/product-variant.input.ts @@ -2,9 +2,9 @@ // You may choose to use this file as scaffold by moving this file out of generated folder and removing this comment. import { BlockInputInterface, isBlockInputInterface } from "@comet/blocks-api"; import { DamImageBlock, PartialType, RootBlockInputScalar } from "@comet/cms-api"; -import { Field, InputType } from "@nestjs/graphql"; +import { Field, InputType, Int } from "@nestjs/graphql"; import { Transform } from "class-transformer"; -import { IsNotEmpty, IsString, ValidateNested } from "class-validator"; +import { IsInt, IsNotEmpty, IsOptional, IsString, Min, ValidateNested } from "class-validator"; @InputType() export class ProductVariantInput { @@ -18,6 +18,12 @@ export class ProductVariantInput { @Transform(({ value }) => (isBlockInputInterface(value) ? value : DamImageBlock.blockInputFactory(value)), { toClassOnly: true }) @ValidateNested() image: BlockInputInterface; + + @IsOptional() + @Min(1) + @IsInt() + @Field(() => Int, { nullable: true }) + position?: number; } @InputType() diff --git a/demo/api/src/products/generated/dto/product-variant.sort.ts b/demo/api/src/products/generated/dto/product-variant.sort.ts index 41b8738148..5bf93b7a3c 100644 --- a/demo/api/src/products/generated/dto/product-variant.sort.ts +++ b/demo/api/src/products/generated/dto/product-variant.sort.ts @@ -6,6 +6,7 @@ import { IsEnum } from "class-validator"; export enum ProductVariantSortField { name = "name", + position = "position", product = "product", createdAt = "createdAt", updatedAt = "updatedAt", diff --git a/demo/api/src/products/generated/product-variant.resolver.ts b/demo/api/src/products/generated/product-variant.resolver.ts index e0b20dfc6b..10f35e4d5e 100644 --- a/demo/api/src/products/generated/product-variant.resolver.ts +++ b/demo/api/src/products/generated/product-variant.resolver.ts @@ -20,12 +20,14 @@ import { ProductVariant } from "../entities/product-variant.entity"; import { PaginatedProductVariants } from "./dto/paginated-product-variants"; import { ProductVariantInput, ProductVariantUpdateInput } from "./dto/product-variant.input"; import { ProductVariantsArgs } from "./dto/product-variants.args"; +import { ProductVariantsService } from "./product-variants.service"; @Resolver(() => ProductVariant) @RequiredPermission("products", { skipScopeCheck: true }) export class ProductVariantResolver { constructor( private readonly entityManager: EntityManager, + private readonly productVariantsService: ProductVariantsService, @InjectRepository(ProductVariant) private readonly repository: EntityRepository, @InjectRepository(Product) private readonly productRepository: EntityRepository, private readonly blocksTransformer: BlocksTransformerService, @@ -75,10 +77,18 @@ export class ProductVariantResolver { @Args("product", { type: () => ID }) product: string, @Args("input", { type: () => ProductVariantInput }) input: ProductVariantInput, ): Promise { + const lastPosition = await this.productVariantsService.getLastPosition({ product }); + let position = input.position; + if (position !== undefined && position < lastPosition + 1) { + await this.productVariantsService.incrementPositions({ product }, position); + } else { + position = lastPosition + 1; + } + const { image: imageInput, ...assignInput } = input; const productVariant = this.repository.create({ ...assignInput, - + position, product: Reference.create(await this.productRepository.findOneOrFail(product)), image: imageInput.transformToBlockData(), @@ -97,6 +107,18 @@ export class ProductVariantResolver { ): Promise { const productVariant = await this.repository.findOneOrFail(id); + if (input.position !== undefined) { + const lastPosition = await this.productVariantsService.getLastPosition({ product: productVariant.product.id }); + if (input.position > lastPosition + 1) { + input.position = lastPosition + 1; + } + if (productVariant.position < input.position) { + await this.productVariantsService.decrementPositions({ product: productVariant.product.id }, productVariant.position, input.position); + } else if (productVariant.position > input.position) { + await this.productVariantsService.incrementPositions({ product: productVariant.product.id }, input.position, productVariant.position); + } + } + const { image: imageInput, ...assignInput } = input; productVariant.assign({ ...assignInput, @@ -116,6 +138,7 @@ export class ProductVariantResolver { async deleteProductVariant(@Args("id", { type: () => ID }) id: string): Promise { const productVariant = await this.repository.findOneOrFail(id); this.entityManager.remove(productVariant); + await this.productVariantsService.decrementPositions({ product: productVariant.product.id }, productVariant.position); await this.entityManager.flush(); return true; } diff --git a/demo/api/src/products/generated/product-variants.service.ts b/demo/api/src/products/generated/product-variants.service.ts new file mode 100644 index 0000000000..254d6ed24a --- /dev/null +++ b/demo/api/src/products/generated/product-variants.service.ts @@ -0,0 +1,52 @@ +// This file has been generated by comet api-generator. +// You may choose to use this file as scaffold by moving this file out of generated folder and removing this comment. +import { FilterQuery } from "@mikro-orm/core"; +import { InjectRepository } from "@mikro-orm/nestjs"; +import { EntityManager, EntityRepository } from "@mikro-orm/postgresql"; +import { Injectable } from "@nestjs/common"; + +import { ProductVariant } from "../entities/product-variant.entity"; + +@Injectable() +export class ProductVariantsService { + constructor( + private readonly entityManager: EntityManager, + @InjectRepository(ProductVariant) private readonly repository: EntityRepository, + ) {} + + async incrementPositions(group: { product: string }, lowestPosition: number, highestPosition?: number) { + // Increment positions between newPosition (inclusive) and oldPosition (exclusive) + await this.repository.nativeUpdate( + { + $and: [ + { position: { $gte: lowestPosition, ...(highestPosition ? { $lt: highestPosition } : {}) } }, + this.getPositionGroupCondition(group), + ], + }, + { position: this.entityManager.raw("position + 1") }, + ); + } + + async decrementPositions(group: { product: string }, lowestPosition: number, highestPosition?: number) { + // Decrement positions between oldPosition (exclusive) and newPosition (inclusive) + await this.repository.nativeUpdate( + { + $and: [ + { position: { $gt: lowestPosition, ...(highestPosition ? { $lte: highestPosition } : {}) } }, + this.getPositionGroupCondition(group), + ], + }, + { position: this.entityManager.raw("position - 1") }, + ); + } + + async getLastPosition(group: { product: string }) { + return this.repository.count(this.getPositionGroupCondition(group)); + } + + getPositionGroupCondition(data: { product: string }): FilterQuery { + return { + product: { $eq: data.product }, + }; + } +} diff --git a/packages/api/cms-api/src/generator/generate-crud.ts b/packages/api/cms-api/src/generator/generate-crud.ts index 2543de185a..919a2069e5 100644 --- a/packages/api/cms-api/src/generator/generate-crud.ts +++ b/packages/api/cms-api/src/generator/generate-crud.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { EntityMetadata } from "@mikro-orm/core"; +import { EntityMetadata, ReferenceType } from "@mikro-orm/core"; import * as path from "path"; import { singular } from "pluralize"; @@ -462,7 +462,19 @@ function generateService({ generatorOptions, metadata }: { generatorOptions: Cru const { classNameSingular, fileNameSingular, classNamePlural } = buildNameVariants(metadata); const { hasPositionProp, positionGroupProps } = buildOptions(metadata, generatorOptions); - const positionGroupType = positionGroupProps.length ? `{ ${positionGroupProps.map((prop) => `${prop.name}: ${prop.type}`).join(",")} }` : false; + const positionGroupType = positionGroupProps.length + ? `{ ${positionGroupProps + .map((prop) => { + const notSupportedReferenceTypes = [ReferenceType.ONE_TO_MANY, ReferenceType.MANY_TO_MANY]; + if (notSupportedReferenceTypes.includes(prop.reference)) { + throw new Error(`Not supported reference-type for position-group. ${prop.name}`); + } + return `${prop.name}${prop.nullable ? `?` : ``}: ${ + [ReferenceType.MANY_TO_ONE, ReferenceType.ONE_TO_ONE].includes(prop.reference) ? "string" : prop.type + }`; + }) + .join(",")} }` + : false; const serviceOut = `import { FilterQuery } from "@mikro-orm/core"; import { InjectRepository } from "@mikro-orm/nestjs"; @@ -538,7 +550,7 @@ function generateService({ generatorOptions, metadata }: { generatorOptions: Cru ${ positionGroupProps.length - ? `getPositionGroupCondition(data: ${positionGroupType}): FilterQuery<${metadata.className}> { + ? `getPositionGroupCondition(group: ${positionGroupType}): FilterQuery<${metadata.className}> { return { ${positionGroupProps.map((field) => `${field.name}: { $eq: data.${field.name} }`).join(",")} }; @@ -1161,7 +1173,15 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr const lastPosition = await this.${instanceNamePlural}Service.getLastPosition(${ positionGroupProps.length ? `{ ${positionGroupProps - .map((prop) => (prop.name === "scope" ? `scope` : `${prop.name}: input.${prop.name}`)) + .map((prop) => + prop.name === "scope" + ? `scope` + : dedicatedResolverArgProps.find( + (dedicatedResolverArgProp) => dedicatedResolverArgProp.name === prop.name, + ) !== undefined + ? prop.name + : `${prop.name}: input.${prop.name}`, + ) .join(",")} }` : `` }); @@ -1170,7 +1190,15 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr await this.${instanceNamePlural}Service.incrementPositions(${ positionGroupProps.length ? `{ ${positionGroupProps - .map((prop) => (prop.name === "scope" ? `scope` : `${prop.name}: input.${prop.name}`)) + .map((prop) => + prop.name === "scope" + ? `scope` + : dedicatedResolverArgProps.find( + (dedicatedResolverArgProp) => dedicatedResolverArgProp.name === prop.name, + ) !== undefined + ? prop.name + : `${prop.name}: input.${prop.name}`, + ) .join(",")} }, ` : `` }position); @@ -1207,7 +1235,16 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr if (input.position !== undefined) { const lastPosition = await this.${instanceNamePlural}Service.getLastPosition(${ positionGroupProps.length - ? `{ ${positionGroupProps.map((prop) => `${prop.name}: ${instanceNameSingular}.${prop.name}`).join(",")} }` + ? `{ ${positionGroupProps + .map( + (prop) => + `${prop.name}: ${instanceNameSingular}.${prop.name}${ + [ReferenceType.MANY_TO_ONE, ReferenceType.ONE_TO_ONE].includes(prop.reference) + ? `${prop.nullable ? `?` : ``}.id` + : `` + }`, + ) + .join(",")} }` : `` }); if (input.position > lastPosition + 1) { @@ -1216,13 +1253,31 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr if (${instanceNameSingular}.position < input.position) { await this.${instanceNamePlural}Service.decrementPositions(${ positionGroupProps.length - ? `{ ${positionGroupProps.map((prop) => `${prop.name}: ${instanceNameSingular}.${prop.name}`).join(",")} },` + ? `{ ${positionGroupProps + .map( + (prop) => + `${prop.name}: ${instanceNameSingular}.${prop.name}${ + [ReferenceType.MANY_TO_ONE, ReferenceType.ONE_TO_ONE].includes(prop.reference) + ? `${prop.nullable ? `?` : ``}.id` + : `` + }`, + ) + .join(",")} },` : `` }${instanceNameSingular}.position, input.position); } else if (${instanceNameSingular}.position > input.position) { await this.${instanceNamePlural}Service.incrementPositions(${ positionGroupProps.length - ? `{ ${positionGroupProps.map((prop) => `${prop.name}: ${instanceNameSingular}.${prop.name}`).join(",")} },` + ? `{ ${positionGroupProps + .map( + (prop) => + `${prop.name}: ${instanceNameSingular}.${prop.name}${ + [ReferenceType.MANY_TO_ONE, ReferenceType.ONE_TO_ONE].includes(prop.reference) + ? `${prop.nullable ? `?` : ``}.id` + : `` + }`, + ) + .join(",")} },` : `` }input.position, ${instanceNameSingular}.position); } @@ -1251,7 +1306,16 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr hasPositionProp ? `await this.${instanceNamePlural}Service.decrementPositions(${ positionGroupProps.length - ? `{ ${positionGroupProps.map((prop) => `${prop.name}: ${instanceNameSingular}.${prop.name}`).join(",")} },` + ? `{ ${positionGroupProps + .map( + (prop) => + `${prop.name}: ${instanceNameSingular}.${prop.name}${ + [ReferenceType.MANY_TO_ONE, ReferenceType.ONE_TO_ONE].includes(prop.reference) + ? `${prop.nullable ? `?` : ``}.id` + : `` + }`, + ) + .join(",")} },` : `` }${instanceNameSingular}.position);` : ""