From f2da11db144c420b0b69d8a1e96c09d71ee59be2 Mon Sep 17 00:00:00 2001 From: Ben-Ho Date: Wed, 2 Oct 2024 10:44:27 +0200 Subject: [PATCH] API Generator: Support position field (#2255) Generate code to correctly update position fields in create, update and delete mutations. - position missing for create: add at the end - position set for create: update all later entities - position set for update: move others - entity deleted: remove gap - position in input is always optional, min(1) and int - position in input to high: set current max value - support for position-grouping, always adding scope if existing --------- Co-authored-by: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com> --- .changeset/dull-windows-glow.md | 7 + demo/api/schema.gql | 4 + .../db/migrations/Migration20240723114541.ts | 9 + .../entities/product-category.entity.ts | 8 +- .../generated/dto/product-category.input.ts | 10 +- .../generated/dto/product-category.sort.ts | 1 + .../generated/product-categories.service.ts | 35 +++ .../generated/product-category.resolver.ts | 24 ++ demo/api/src/products/products.module.ts | 2 + .../src/generator/crud-generator.decorator.ts | 4 +- .../src/generator/generate-crud-input.ts | 29 ++- .../generator/generate-crud-position.spec.ts | 188 ++++++++++++++++ .../cms-api/src/generator/generate-crud.ts | 210 +++++++++++++++++- 13 files changed, 508 insertions(+), 23 deletions(-) create mode 100644 .changeset/dull-windows-glow.md create mode 100644 demo/api/src/db/migrations/Migration20240723114541.ts create mode 100644 demo/api/src/products/generated/product-categories.service.ts create mode 100644 packages/api/cms-api/src/generator/generate-crud-position.spec.ts diff --git a/.changeset/dull-windows-glow.md b/.changeset/dull-windows-glow.md new file mode 100644 index 0000000000..9555229c54 --- /dev/null +++ b/.changeset/dull-windows-glow.md @@ -0,0 +1,7 @@ +--- +"@comet/cms-api": minor +--- + +API Generator: Add support for position field + +Add a field named `position` to enable this feature. This field will hold and update the position. This should be an integer number field >= 1. It's also possible to define fields (in CrudGenerator-Decorator) to group position by. diff --git a/demo/api/schema.gql b/demo/api/schema.gql index 70f1e9b745..780ceb3b10 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -470,6 +470,7 @@ type ProductCategory { id: ID! title: String! slug: String! + position: Int! createdAt: DateTime! updatedAt: DateTime! products: [Product!]! @@ -1066,6 +1067,7 @@ input ProductCategorySort { enum ProductCategorySortField { title slug + position createdAt updatedAt } @@ -1455,11 +1457,13 @@ input ProductUpdateInput { input ProductCategoryInput { title: String! slug: String! + position: Int } input ProductCategoryUpdateInput { title: String slug: String + position: Int } input ProductTagInput { diff --git a/demo/api/src/db/migrations/Migration20240723114541.ts b/demo/api/src/db/migrations/Migration20240723114541.ts new file mode 100644 index 0000000000..a51af706e1 --- /dev/null +++ b/demo/api/src/db/migrations/Migration20240723114541.ts @@ -0,0 +1,9 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240723114541 extends Migration { + + async up(): Promise { + this.addSql('delete from "ProductCategory";'); + this.addSql('alter table "ProductCategory" add column "position" integer not null;'); + } +} diff --git a/demo/api/src/products/entities/product-category.entity.ts b/demo/api/src/products/entities/product-category.entity.ts index dcbdbd9997..c497284d47 100644 --- a/demo/api/src/products/entities/product-category.entity.ts +++ b/demo/api/src/products/entities/product-category.entity.ts @@ -1,6 +1,7 @@ import { CrudField, CrudGenerator } from "@comet/cms-api"; import { BaseEntity, Collection, Entity, OneToMany, OptionalProps, PrimaryKey, Property } 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"; @@ -23,6 +24,11 @@ export class ProductCategory extends BaseEntity { @Field() slug: string; + @Property({ columnType: "integer" }) + @Field(() => Int) + @Min(1) + position: number; + @CrudField({ resolveField: true, //default is true //search: true, //not implemented diff --git a/demo/api/src/products/generated/dto/product-category.input.ts b/demo/api/src/products/generated/dto/product-category.input.ts index 966b1d3d67..2a029ef587 100644 --- a/demo/api/src/products/generated/dto/product-category.input.ts +++ b/demo/api/src/products/generated/dto/product-category.input.ts @@ -1,8 +1,8 @@ // 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 { IsSlug, PartialType } from "@comet/cms-api"; -import { Field, InputType } from "@nestjs/graphql"; -import { IsNotEmpty, IsString } from "class-validator"; +import { Field, InputType, Int } from "@nestjs/graphql"; +import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from "class-validator"; @InputType() export class ProductCategoryInput { @@ -16,6 +16,12 @@ export class ProductCategoryInput { @IsSlug() @Field() slug: string; + + @IsOptional() + @Min(1) + @IsInt() + @Field(() => Int, { nullable: true }) + position?: number; } @InputType() diff --git a/demo/api/src/products/generated/dto/product-category.sort.ts b/demo/api/src/products/generated/dto/product-category.sort.ts index ff672376b1..7e08c5a552 100644 --- a/demo/api/src/products/generated/dto/product-category.sort.ts +++ b/demo/api/src/products/generated/dto/product-category.sort.ts @@ -7,6 +7,7 @@ import { IsEnum } from "class-validator"; export enum ProductCategorySortField { title = "title", slug = "slug", + position = "position", createdAt = "createdAt", updatedAt = "updatedAt", } diff --git a/demo/api/src/products/generated/product-categories.service.ts b/demo/api/src/products/generated/product-categories.service.ts new file mode 100644 index 0000000000..622fa76a66 --- /dev/null +++ b/demo/api/src/products/generated/product-categories.service.ts @@ -0,0 +1,35 @@ +// 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 { InjectRepository } from "@mikro-orm/nestjs"; +import { EntityManager, EntityRepository } from "@mikro-orm/postgresql"; +import { Injectable } from "@nestjs/common"; + +import { ProductCategory } from "../entities/product-category.entity"; + +@Injectable() +export class ProductCategoriesService { + constructor( + private readonly entityManager: EntityManager, + @InjectRepository(ProductCategory) private readonly repository: EntityRepository, + ) {} + + async incrementPositions(lowestPosition: number, highestPosition?: number) { + // Increment positions between newPosition (inclusive) and oldPosition (exclusive) + await this.repository.nativeUpdate( + { position: { $gte: lowestPosition, ...(highestPosition ? { $lt: highestPosition } : {}) } }, + { position: this.entityManager.raw("position + 1") }, + ); + } + + async decrementPositions(lowestPosition: number, highestPosition?: number) { + // Decrement positions between oldPosition (exclusive) and newPosition (inclusive) + await this.repository.nativeUpdate( + { position: { $gt: lowestPosition, ...(highestPosition ? { $lte: highestPosition } : {}) } }, + { position: this.entityManager.raw("position - 1") }, + ); + } + + async getLastPosition() { + return this.repository.count({}); + } +} diff --git a/demo/api/src/products/generated/product-category.resolver.ts b/demo/api/src/products/generated/product-category.resolver.ts index 57cd6bba04..4cc0f5647d 100644 --- a/demo/api/src/products/generated/product-category.resolver.ts +++ b/demo/api/src/products/generated/product-category.resolver.ts @@ -12,12 +12,14 @@ import { ProductCategory } from "../entities/product-category.entity"; import { PaginatedProductCategories } from "./dto/paginated-product-categories"; import { ProductCategoriesArgs } from "./dto/product-categories.args"; import { ProductCategoryInput, ProductCategoryUpdateInput } from "./dto/product-category.input"; +import { ProductCategoriesService } from "./product-categories.service"; @Resolver(() => ProductCategory) @RequiredPermission(["products"], { skipScopeCheck: true }) export class ProductCategoryResolver { constructor( private readonly entityManager: EntityManager, + private readonly productCategoriesService: ProductCategoriesService, @InjectRepository(ProductCategory) private readonly repository: EntityRepository, ) {} @@ -65,8 +67,17 @@ export class ProductCategoryResolver { @Mutation(() => ProductCategory) async createProductCategory(@Args("input", { type: () => ProductCategoryInput }) input: ProductCategoryInput): Promise { + const lastPosition = await this.productCategoriesService.getLastPosition(); + let position = input.position; + if (position !== undefined && position < lastPosition + 1) { + await this.productCategoriesService.incrementPositions(position); + } else { + position = lastPosition + 1; + } + const productCategory = this.repository.create({ ...input, + position, }); await this.entityManager.flush(); @@ -82,6 +93,18 @@ export class ProductCategoryResolver { ): Promise { const productCategory = await this.repository.findOneOrFail(id); + if (input.position !== undefined) { + const lastPosition = await this.productCategoriesService.getLastPosition(); + if (input.position > lastPosition + 1) { + input.position = lastPosition + 1; + } + if (productCategory.position < input.position) { + await this.productCategoriesService.decrementPositions(productCategory.position, input.position); + } else if (productCategory.position > input.position) { + await this.productCategoriesService.incrementPositions(input.position, productCategory.position); + } + } + productCategory.assign({ ...input, }); @@ -96,6 +119,7 @@ export class ProductCategoryResolver { async deleteProductCategory(@Args("id", { type: () => ID }) id: string): Promise { const productCategory = await this.repository.findOneOrFail(id); this.entityManager.remove(productCategory); + await this.productCategoriesService.decrementPositions(productCategory.position); await this.entityManager.flush(); return true; } diff --git a/demo/api/src/products/products.module.ts b/demo/api/src/products/products.module.ts index 663bfe0976..0da52b00fb 100644 --- a/demo/api/src/products/products.module.ts +++ b/demo/api/src/products/products.module.ts @@ -14,6 +14,7 @@ import { ProductVariant } from "./entities/product-variant.entity"; import { ManufacturerResolver } from "./generated/manufacturer.resolver"; import { ManufacturerCountryResolver } from "./generated/manufacturer-country.resolver"; import { ProductResolver } from "./generated/product.resolver"; +import { ProductCategoriesService } from "./generated/product-categories.service"; import { ProductCategoryResolver } from "./generated/product-category.resolver"; import { ProductTagResolver } from "./generated/product-tag.resolver"; import { ProductToTagResolver } from "./generated/product-to-tag.resolver"; @@ -37,6 +38,7 @@ import { ProductVariantResolver } from "./generated/product-variant.resolver"; providers: [ ProductResolver, ProductCategoryResolver, + ProductCategoriesService, ProductTagResolver, ProductVariantResolver, ManufacturerResolver, diff --git a/packages/api/cms-api/src/generator/crud-generator.decorator.ts b/packages/api/cms-api/src/generator/crud-generator.decorator.ts index 9bbfcf41d1..b8fd949fa3 100644 --- a/packages/api/cms-api/src/generator/crud-generator.decorator.ts +++ b/packages/api/cms-api/src/generator/crud-generator.decorator.ts @@ -5,6 +5,7 @@ export interface CrudGeneratorOptions { update?: boolean; delete?: boolean; list?: boolean; + position?: { groupByFields: string[] }; } export function CrudGenerator({ @@ -14,12 +15,13 @@ export function CrudGenerator({ update = true, delete: deleteMutation = true, list = true, + position, }: CrudGeneratorOptions): ClassDecorator { // eslint-disable-next-line @typescript-eslint/ban-types return function (target: Function) { Reflect.defineMetadata( `data:crudGeneratorOptions`, - { targetDirectory, requiredPermission, create, update, delete: deleteMutation, list }, + { targetDirectory, requiredPermission, create, update, delete: deleteMutation, list, position }, target, ); }; diff --git a/packages/api/cms-api/src/generator/generate-crud-input.ts b/packages/api/cms-api/src/generator/generate-crud-input.ts index 3169dd85b2..942a004ac9 100644 --- a/packages/api/cms-api/src/generator/generate-crud-input.ts +++ b/packages/api/cms-api/src/generator/generate-crud-input.ts @@ -48,7 +48,7 @@ export async function generateCrudInput( ): Promise { const generatedFiles: GeneratedFile[] = []; - const { dedicatedResolverArgProps } = buildOptions(metadata); + const { dedicatedResolverArgProps } = buildOptions(metadata, generatorOptions); const props = metadata.props .filter((prop) => { @@ -70,14 +70,29 @@ export async function generateCrudInput( const fieldName = prop.name; const definedDecorators = morphTsProperty(prop.name, metadata).getDecorators(); const decorators = [] as Array; - if (!prop.nullable) { - decorators.push("@IsNotEmpty()"); - } else { - decorators.push("@IsNullable()"); + let isOptional = prop.nullable; + + if (prop.name != "position") { + if (!prop.nullable) { + decorators.push("@IsNotEmpty()"); + } else { + decorators.push("@IsNullable()"); + } } if (["id", "createdAt", "updatedAt", "scope"].includes(prop.name)) { //skip those (TODO find a non-magic solution?) continue; + } else if (prop.name == "position") { + const initializer = morphTsProperty(prop.name, metadata).getInitializer()?.getText(); + const defaultValue = initializer == "undefined" || initializer == "null" ? "null" : initializer; + const fieldOptions = tsCodeRecordToString({ nullable: "true", defaultValue }); + isOptional = true; + decorators.push(`@IsOptional()`); + decorators.push(`@Min(1)`); + decorators.push("@IsInt()"); + decorators.push(`@Field(() => Int, ${fieldOptions})`); + + type = "number"; } else if (prop.enum) { const initializer = morphTsProperty(prop.name, metadata).getInitializer()?.getText(); const defaultValue = @@ -390,14 +405,14 @@ export async function generateCrudInput( } fieldsOut += `${decorators.join("\n")} - ${fieldName}${prop.nullable ? "?" : ""}: ${type}; + ${fieldName}${isOptional ? "?" : ""}: ${type}; `; } const className = options.className ?? `${metadata.className}Input`; const inputOut = `import { Field, InputType, ID, Int } from "@nestjs/graphql"; import { Transform, Type } from "class-transformer"; -import { IsString, IsNotEmpty, ValidateNested, IsNumber, IsBoolean, IsDate, IsOptional, IsEnum, IsUUID, IsArray, IsInt } from "class-validator"; +import { IsString, IsNotEmpty, ValidateNested, IsNumber, IsBoolean, IsDate, IsOptional, IsEnum, IsUUID, IsArray, IsInt, Min } from "class-validator"; import { IsSlug, RootBlockInputScalar, IsNullable, PartialType} from "@comet/cms-api"; import { GraphQLJSONObject } from "graphql-scalars"; import { BlockInputInterface, isBlockInputInterface } from "@comet/blocks-api"; diff --git a/packages/api/cms-api/src/generator/generate-crud-position.spec.ts b/packages/api/cms-api/src/generator/generate-crud-position.spec.ts new file mode 100644 index 0000000000..aa81265623 --- /dev/null +++ b/packages/api/cms-api/src/generator/generate-crud-position.spec.ts @@ -0,0 +1,188 @@ +import { BaseEntity, Embeddable, Embedded, Entity, MikroORM, PrimaryKey, Property } from "@mikro-orm/core"; +import { Field, Int } from "@nestjs/graphql"; +import { LazyMetadataStorage } from "@nestjs/graphql/dist/schema-builder/storages/lazy-metadata.storage"; +import { Min } from "class-validator"; +import { v4 as uuid } from "uuid"; + +import { CrudGenerator } from "./crud-generator.decorator"; +import { generateCrud } from "./generate-crud"; +import { generateCrudInput } from "./generate-crud-input"; +import { lintSource, parseSource } from "./utils/test-helper"; + +@Entity() +class TestEntityWithPositionField extends BaseEntity { + @PrimaryKey({ type: "uuid" }) + id: string = uuid(); + + @Property({ columnType: "integer" }) + @Field(() => Int) + @Min(1) + position: number; +} + +@Embeddable() +export class TestEntityScope { + @Property({ columnType: "text" }) + language: string; +} +@Entity() +class TestEntityWithPositionFieldAndScope extends BaseEntity { + @PrimaryKey({ type: "uuid" }) + id: string = uuid(); + + @Property({ columnType: "integer" }) + @Field(() => Int) + @Min(1) + position: number; + + @Embedded(() => TestEntityScope) + scope: TestEntityScope; +} + +@Entity() +@CrudGenerator({ targetDirectory: __dirname, position: { groupByFields: ["country"] } }) +class TestEntityWithPositionGroup extends BaseEntity { + @PrimaryKey({ type: "uuid" }) + id: string = uuid(); + + @Property({ columnType: "integer" }) + @Field(() => Int) + @Min(1) + position: number; + + @Property() + country: string; +} + +describe("GenerateCrudPosition", () => { + it("input should contain optional position with Int and Min(1)", async () => { + LazyMetadataStorage.load(); + const orm = await MikroORM.init({ + type: "postgresql", + dbName: "test-db", + entities: [TestEntityWithPositionField], + }); + + const out = await generateCrudInput({ targetDirectory: __dirname }, orm.em.getMetadata().get("TestEntityWithPositionField")); + const lintedOutput = await lintSource(out[0].content); + const source = parseSource(lintedOutput); + + const classes = source.getClasses(); + expect(classes.length).toBe(2); + + { + const cls = classes[0]; + const structure = cls.getStructure(); + + expect(structure.properties?.length).toBe(1); + expect(structure.properties?.[0].name).toBe("position"); + expect(structure.properties?.[0].type).toBe("number"); + expect(structure.properties?.[0].decorators?.map((decorator) => decorator.name)).toContain("IsInt"); + expect(structure.properties?.[0].decorators?.map((decorator) => decorator.name)).toContain("IsOptional"); + + const fieldDecorator = structure.properties?.[0].decorators?.find((i) => i.name === "Field"); + expect(fieldDecorator).not.toBeUndefined(); + expect(fieldDecorator?.arguments).toContain("() => Int"); + expect(fieldDecorator?.arguments).toContain("{ nullable: true }"); + + const minDecorator = structure.properties?.[0].decorators?.find((i) => i.name === "Min"); + expect(minDecorator).not.toBeUndefined(); + expect(minDecorator?.arguments).toContain("1"); + } + + orm.close(); + }); + it("service should implement position-functions", async () => { + LazyMetadataStorage.load(); + const orm = await MikroORM.init({ + type: "postgresql", + dbName: "test-db", + entities: [TestEntityWithPositionField], + }); + + const out = await generateCrud({ targetDirectory: __dirname }, orm.em.getMetadata().get("TestEntityWithPositionField")); + const file = out.find((file) => file.name == "test-entity-with-position-fields.service.ts"); + if (!file) throw new Error("File not found"); + + const lintedOutput = await lintSource(file.content); + const source = parseSource(lintedOutput); + + const classes = source.getClasses(); + expect(classes.length).toBe(1); + + { + const cls = classes[0]; + const structure = cls.getStructure(); + + const incrementPositionsFunction = structure.methods?.find((method) => method.name === "incrementPositions"); + expect(incrementPositionsFunction).not.toBeUndefined(); + + const decrementPositionsFunction = structure.methods?.find((method) => method.name === "decrementPositions"); + expect(decrementPositionsFunction).not.toBeUndefined(); + + const getLastPositionFunction = structure.methods?.find((method) => method.name === "getLastPosition"); + expect(getLastPositionFunction).not.toBeUndefined(); + } + + orm.close(); + }); + it("service should implement getPositionGroupCondition-function if scope existent", async () => { + LazyMetadataStorage.load(); + const orm = await MikroORM.init({ + type: "postgresql", + dbName: "test-db", + entities: [TestEntityWithPositionFieldAndScope, TestEntityWithPositionGroup], + }); + + const out = await generateCrud({ targetDirectory: __dirname }, orm.em.getMetadata().get("TestEntityWithPositionFieldAndScope")); + const file = out.find((file) => file.name == "test-entity-with-position-field-and-scopes.service.ts"); + if (!file) throw new Error("File not found"); + + const lintedOutput = await lintSource(file.content); + const source = parseSource(lintedOutput); + + const classes = source.getClasses(); + expect(classes.length).toBe(1); + + { + const cls = classes[0]; + const structure = cls.getStructure(); + + const getLastPositionFunction = structure.methods?.find((method) => method.name === "getPositionGroupCondition"); + expect(getLastPositionFunction).not.toBeUndefined(); + } + + orm.close(); + }); + it("service should implement getPositionGroupCondition-function if configured", async () => { + LazyMetadataStorage.load(); + const orm = await MikroORM.init({ + type: "postgresql", + dbName: "test-db", + entities: [TestEntityWithPositionGroup], + }); + + const out = await generateCrud( + { targetDirectory: __dirname, position: { groupByFields: ["country"] } }, + orm.em.getMetadata().get("TestEntityWithPositionGroup"), + ); + const file = out.find((file) => file.name == "test-entity-with-position-groups.service.ts"); + if (!file) throw new Error("File not found"); + + const lintedOutput = await lintSource(file.content); + const source = parseSource(lintedOutput); + + const classes = source.getClasses(); + expect(classes.length).toBe(1); + + { + const cls = classes[0]; + const structure = cls.getStructure(); + + const getLastPositionFunction = structure.methods?.find((method) => method.name === "getPositionGroupCondition"); + expect(getLastPositionFunction).not.toBeUndefined(); + } + + orm.close(); + }); +}); diff --git a/packages/api/cms-api/src/generator/generate-crud.ts b/packages/api/cms-api/src/generator/generate-crud.ts index bbbc87dd97..2543de185a 100644 --- a/packages/api/cms-api/src/generator/generate-crud.ts +++ b/packages/api/cms-api/src/generator/generate-crud.ts @@ -14,7 +14,7 @@ import { GeneratedFile } from "./utils/write-generated-files"; // TODO move into own file // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function buildOptions(metadata: EntityMetadata) { +export function buildOptions(metadata: EntityMetadata, generatorOptions: CrudGeneratorOptions) { const { classNameSingular, classNamePlural, fileNameSingular, fileNamePlural } = buildNameVariants(metadata); const dedicatedResolverArgProps = metadata.props.filter((prop) => { @@ -62,6 +62,7 @@ export function buildOptions(metadata: EntityMetadata) { (prop) => hasFieldFeature(metadata.class, prop.name, "filter") && !prop.name.startsWith("scope_") && + prop.name != "position" && (prop.enum || prop.type === "string" || prop.type === "text" || @@ -102,6 +103,15 @@ export function buildOptions(metadata: EntityMetadata) { const scopeProp = metadata.props.find((prop) => prop.name == "scope"); if (scopeProp && !scopeProp.targetMeta) throw new Error("Scope prop has no targetMeta"); + const hasPositionProp = metadata.props.some((prop) => prop.name == "position"); + + const positionGroupPropNames: string[] = hasPositionProp + ? generatorOptions.position?.groupByFields ?? [ + ...(scopeProp ? [scopeProp.name] : []), // if there is a scope prop it's effecting position-group, if not groupByFields should be used + ] + : []; + const positionGroupProps = hasPositionProp ? metadata.props.filter((prop) => positionGroupPropNames.includes(prop.name)) : []; + const scopedEntity = Reflect.getMetadata("scopedEntity", metadata.class); const skipScopeCheck = !scopeProp && !scopedEntity; @@ -120,6 +130,8 @@ export function buildOptions(metadata: EntityMetadata) { crudSortProps, hasSortArg, hasSlugProp, + hasPositionProp, + positionGroupProps, statusProp, statusActiveItems, hasStatusFilter, @@ -134,7 +146,7 @@ export function buildOptions(metadata: EntityMetadata) { function generateFilterDto({ generatorOptions, metadata }: { generatorOptions: CrudGeneratorOptions; metadata: EntityMetadata }): string { const { classNameSingular } = buildNameVariants(metadata); - const { crudFilterProps } = buildOptions(metadata); + const { crudFilterProps } = buildOptions(metadata, generatorOptions); let importsOut = ""; let enumFiltersOut = ""; @@ -277,7 +289,7 @@ function generateFilterDto({ generatorOptions, metadata }: { generatorOptions: C function generateSortDto({ generatorOptions, metadata }: { generatorOptions: CrudGeneratorOptions; metadata: EntityMetadata }): string { const { classNameSingular } = buildNameVariants(metadata); - const { crudSortProps } = buildOptions(metadata); + const { crudSortProps } = buildOptions(metadata, generatorOptions); const sortOut = `import { SortDirection } from "@comet/cms-api"; import { Field, InputType, registerEnumType } from "@nestjs/graphql"; @@ -336,7 +348,7 @@ function generateArgsDto({ generatorOptions, metadata }: { generatorOptions: Cru statusActiveItems, hasStatusFilter, dedicatedResolverArgProps, - } = buildOptions(metadata); + } = buildOptions(metadata, generatorOptions); const imports: Imports = []; if (scopeProp && scopeProp.targetMeta) { imports.push(generateEntityImport(scopeProp.targetMeta, `${generatorOptions.targetDirectory}/dto`)); @@ -446,6 +458,101 @@ function generateArgsDto({ generatorOptions, metadata }: { generatorOptions: Cru return argsOut; } +function generateService({ generatorOptions, metadata }: { generatorOptions: CrudGeneratorOptions; metadata: EntityMetadata }): string { + 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 serviceOut = `import { FilterQuery } from "@mikro-orm/core"; + import { InjectRepository } from "@mikro-orm/nestjs"; + import { EntityRepository, EntityManager } from "@mikro-orm/postgresql"; + import { Injectable } from "@nestjs/common"; + + ${generateImportsCode([generateEntityImport(metadata, generatorOptions.targetDirectory)])} + ${generateImportsCode( + positionGroupProps.reduce((acc, prop) => { + if (prop.targetMeta) { + acc.push(generateEntityImport(prop.targetMeta, generatorOptions.targetDirectory)); + } + return acc; + }, []), + )} + import { ${classNameSingular}Filter } from "./dto/${fileNameSingular}.filter"; + + @Injectable() + export class ${classNamePlural}Service { + ${ + hasPositionProp + ? `constructor( + private readonly entityManager: EntityManager, + @InjectRepository(${metadata.className}) private readonly repository: EntityRepository<${metadata.className}>, + ) {}` + : "" + } + + ${ + hasPositionProp + ? ` + async incrementPositions(${ + positionGroupProps.length ? `group: ${positionGroupType},` : `` + }lowestPosition: number, highestPosition?: number) { + // Increment positions between newPosition (inclusive) and oldPosition (exclusive) + await this.repository.nativeUpdate( + ${ + positionGroupProps.length + ? `{ + $and: [ + { position: { $gte: lowestPosition, ...(highestPosition ? { $lt: highestPosition } : {}) } }, + this.getPositionGroupCondition(group), + ], + },` + : `{ position: { $gte: lowestPosition, ...(highestPosition ? { $lt: highestPosition } : {}) } },` + } + { position: this.entityManager.raw("position + 1") }, + ); + } + + async decrementPositions(${ + positionGroupProps.length ? `group: ${positionGroupType},` : `` + }lowestPosition: number, highestPosition?: number) { + // Decrement positions between oldPosition (exclusive) and newPosition (inclusive) + await this.repository.nativeUpdate( + ${ + positionGroupProps.length + ? `{ + $and: [ + { position: { $gt: lowestPosition, ...(highestPosition ? { $lte: highestPosition } : {}) } }, + this.getPositionGroupCondition(group), + ], + },` + : `{ position: { $gt: lowestPosition, ...(highestPosition ? { $lte: highestPosition } : {}) } },` + } + { position: this.entityManager.raw("position - 1") }, + ); + } + + async getLastPosition(${positionGroupProps.length ? `group: ${positionGroupType}` : ``}) { + return this.repository.count(${positionGroupProps.length ? `this.getPositionGroupCondition(group)` : `{}`}); + } + + ${ + positionGroupProps.length + ? `getPositionGroupCondition(data: ${positionGroupType}): FilterQuery<${metadata.className}> { + return { + ${positionGroupProps.map((field) => `${field.name}: { $eq: data.${field.name} }`).join(",")} + }; + }` + : `` + } + ` + : "" + } + } + `; + return serviceOut; +} + function generateEntityImport(targetMetadata: EntityMetadata, relativeTo: string): Imports[0] { return { name: targetMetadata.className, @@ -456,9 +563,10 @@ function generateEntityImport(targetMetadata: EntityMetadata, relativeTo: s function generateInputHandling( options: { mode: "create" | "update" | "updateNested"; inputName: string; assignEntityCode: string; excludeFields?: string[] }, metadata: EntityMetadata, + generatorOptions: CrudGeneratorOptions, ): { code: string; injectRepositories: EntityMetadata[] } { const { instanceNameSingular } = buildNameVariants(metadata); - const { blockProps, scopeProp, dedicatedResolverArgProps } = buildOptions(metadata); + const { blockProps, scopeProp, hasPositionProp, dedicatedResolverArgProps } = buildOptions(metadata, generatorOptions); const injectRepositories: EntityMetadata[] = []; @@ -535,7 +643,7 @@ function generateInputHandling( } ${options.assignEntityCode} ...${noAssignProps.length ? `assignInput` : options.inputName}, - ${options.mode == "create" && scopeProp ? `scope,` : ""} + ${options.mode == "create" && scopeProp ? `scope,` : ""}${options.mode == "create" && hasPositionProp ? `position,` : ""} ${ options.mode == "create" ? dedicatedResolverArgProps @@ -581,6 +689,7 @@ ${inputRelationToManyProps .map((prop) => prop.name), }, prop.targetMeta, + generatorOptions, ); const isAsync = code.includes("await "); return `if (${prop.name}Input) { @@ -624,6 +733,7 @@ ${inputRelationOneToOneProps .map((prop) => prop.name), }, prop.targetMeta, + generatorOptions, )} ${options.mode != "create" || prop.nullable ? `}` : "}"}`, ) @@ -661,7 +771,7 @@ ${ function generateNestedEntityResolver({ generatorOptions, metadata }: { generatorOptions: CrudGeneratorOptions; metadata: EntityMetadata }) { const { classNameSingular } = buildNameVariants(metadata); - const { skipScopeCheck } = buildOptions(metadata); + const { skipScopeCheck } = buildOptions(metadata, generatorOptions); const imports: Imports = []; @@ -816,10 +926,12 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr hasSearchArg, hasSortArg, hasFilterArg, + hasPositionProp, + positionGroupProps, statusProp, hasStatusFilter, dedicatedResolverArgProps, - } = buildOptions(metadata); + } = buildOptions(metadata, generatorOptions); const relationManyToOneProps = metadata.props.filter((prop) => prop.reference === "m:1"); const relationOneToManyProps = metadata.props.filter((prop) => prop.reference === "1:m"); @@ -836,12 +948,14 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr const { code: createInputHandlingCode, injectRepositories: createInputHandlingInjectRepositories } = generateInputHandling( { mode: "create", inputName: "input", assignEntityCode: `const ${instanceNameSingular} = this.repository.create({` }, metadata, + generatorOptions, ); injectRepositories.push(...createInputHandlingInjectRepositories); const { code: updateInputHandlingCode, injectRepositories: updateInputHandlingInjectRepositories } = generateInputHandling( { mode: "update", inputName: "input", assignEntityCode: `${instanceNameSingular}.assign({` }, metadata, + generatorOptions, ); injectRepositories.push(...updateInputHandlingInjectRepositories); @@ -901,6 +1015,7 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr import { Args, ID, Info, Mutation, Query, Resolver, ResolveField, Parent } from "@nestjs/graphql"; import { GraphQLResolveInfo } from "graphql"; + ${hasPositionProp ? `import { ${classNamePlural}Service } from "./${fileNamePlural}.service";` : ``} import { ${classNameSingular}Input, ${classNameSingular}UpdateInput } from "./dto/${fileNameSingular}.input"; import { Paginated${classNamePlural} } from "./dto/paginated-${fileNamePlural}"; import { ${argsClassName} } from "./dto/${argsFileName}"; @@ -910,7 +1025,9 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr @RequiredPermission(${JSON.stringify(generatorOptions.requiredPermission)}${skipScopeCheck ? `, { skipScopeCheck: true }` : ""}) export class ${classNameSingular}Resolver { constructor( - private readonly entityManager: EntityManager, + private readonly entityManager: EntityManager,${ + hasPositionProp ? `private readonly ${instanceNamePlural}Service: ${classNamePlural}Service,` : `` + } @InjectRepository(${metadata.className}) private readonly repository: EntityRepository<${metadata.className}>, ${[...new Set(injectRepositories.map((meta) => meta.className))] .map((type) => `@InjectRepository(${type}) private readonly ${classNameToInstanceName(type)}Repository: EntityRepository<${type}>,`) @@ -1037,6 +1154,31 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr }) .join("")}@Args("input", { type: () => ${classNameSingular}Input }) input: ${classNameSingular}Input ): Promise<${metadata.className}> { + ${ + // use local position-var because typescript does not narrow down input.position, keeping "| undefined" typing resulting in typescript error in create-function + hasPositionProp + ? ` + const lastPosition = await this.${instanceNamePlural}Service.getLastPosition(${ + positionGroupProps.length + ? `{ ${positionGroupProps + .map((prop) => (prop.name === "scope" ? `scope` : `${prop.name}: input.${prop.name}`)) + .join(",")} }` + : `` + }); + let position = input.position; + if (position !== undefined && position < lastPosition + 1) { + await this.${instanceNamePlural}Service.incrementPositions(${ + positionGroupProps.length + ? `{ ${positionGroupProps + .map((prop) => (prop.name === "scope" ? `scope` : `${prop.name}: input.${prop.name}`)) + .join(",")} }, ` + : `` + }position); + } else { + position = lastPosition + 1; + }` + : "" + } ${createInputHandlingCode} @@ -1058,6 +1200,36 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr @Args("input", { type: () => ${classNameSingular}UpdateInput }) input: ${classNameSingular}UpdateInput ): Promise<${metadata.className}> { const ${instanceNameSingular} = await this.repository.findOneOrFail(id); + + ${ + hasPositionProp + ? ` + if (input.position !== undefined) { + const lastPosition = await this.${instanceNamePlural}Service.getLastPosition(${ + positionGroupProps.length + ? `{ ${positionGroupProps.map((prop) => `${prop.name}: ${instanceNameSingular}.${prop.name}`).join(",")} }` + : `` + }); + if (input.position > lastPosition + 1) { + input.position = lastPosition + 1; + } + if (${instanceNameSingular}.position < input.position) { + await this.${instanceNamePlural}Service.decrementPositions(${ + positionGroupProps.length + ? `{ ${positionGroupProps.map((prop) => `${prop.name}: ${instanceNameSingular}.${prop.name}`).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(",")} },` + : `` + }input.position, ${instanceNameSingular}.position); + } + }` + : "" + } + ${updateInputHandlingCode} await this.entityManager.flush(); @@ -1075,7 +1247,15 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr @AffectedEntity(${metadata.className}) async delete${metadata.className}(${generateIdArg("id", metadata)}): Promise { const ${instanceNameSingular} = await this.repository.findOneOrFail(id); - this.entityManager.remove(${instanceNameSingular}); + this.entityManager.remove(${instanceNameSingular});${ + hasPositionProp + ? `await this.${instanceNamePlural}Service.decrementPositions(${ + positionGroupProps.length + ? `{ ${positionGroupProps.map((prop) => `${prop.name}: ${instanceNameSingular}.${prop.name}`).join(",")} },` + : `` + }${instanceNameSingular}.position);` + : "" + } await this.entityManager.flush(); return true; } @@ -1084,7 +1264,6 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr } ${relationsFieldResolverCode} - } `; return resolverOut; @@ -1103,7 +1282,7 @@ export async function generateCrud(generatorOptionsParam: CrudGeneratorOptions, const generatedFiles: GeneratedFile[] = []; const { fileNameSingular, fileNamePlural, instanceNamePlural } = buildNameVariants(metadata); - const { hasFilterArg, hasSortArg, argsFileName } = buildOptions(metadata); + const { hasFilterArg, hasSortArg, argsFileName, hasPositionProp } = buildOptions(metadata, generatorOptions); if (!generatorOptions.requiredPermission) generatorOptions.requiredPermission = [instanceNamePlural]; async function generateCrudResolver(): Promise { @@ -1131,6 +1310,13 @@ export async function generateCrud(generatorOptionsParam: CrudGeneratorOptions, content: generateArgsDto({ generatorOptions, metadata }), type: "args", }); + if (hasPositionProp) { + generatedFiles.push({ + name: `${fileNamePlural}.service.ts`, + content: generateService({ generatorOptions, metadata }), + type: "service", + }); + } generatedFiles.push({ name: `${fileNameSingular}.resolver.ts`, content: generateResolver({ generatorOptions, metadata }),