Skip to content

Commit

Permalink
API Generator: Fix generated API for many-to-many-relations with cust…
Browse files Browse the repository at this point in the history
…om relation entity (#1967)

This fixes APIs generated by API Generator for many-to-many-relations
that are not made using `@ManyToMany` but rather have a custom relation
entity (to allow storage of additional data in the relation).

See individual commits:

- Correctly call `loadItems()` onto collection before set so existing
entries will be overwritten
- Fix input handling for n:m relations that can be async
- Fix missing injected repository for n:m relation input. Move the
building of the repositories to inject into `generateInputHandling` that
will recurse
- Create unique nested input DTO for n:m relation table. Needed if both
sides have the API generated in the same target folder, as each side has
different relation arguments
- Demo API: Extend products by a product-to-tag-relation (example for
this kind of relation)

---------

Co-authored-by: Tobias Kuhn <tobias.kuhn@vivid-planet.com>
Co-authored-by: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com>
  • Loading branch information
3 people authored May 2, 2024
1 parent 74d1c9d commit 94ac6b7
Show file tree
Hide file tree
Showing 20 changed files with 448 additions and 75 deletions.
5 changes: 5 additions & 0 deletions .changeset/cuddly-months-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@comet/cms-api": minor
---

API Generator: Fix generated API for many-to-many-relations with custom relation entity
35 changes: 29 additions & 6 deletions demo/api/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -423,11 +423,19 @@ type ProductStatistics {
updatedAt: DateTime!
}

type ProductToTag {
id: String!
exampleStatus: Boolean!
product: Product!
tag: ProductTag!
}

type ProductTag {
id: ID!
title: String!
createdAt: DateTime!
updatedAt: DateTime!
productsWithStatus: [ProductToTag!]!
products: [Product!]!
}

Expand Down Expand Up @@ -469,6 +477,7 @@ type Product {
updatedAt: DateTime!
category: ProductCategory
variants: [ProductVariant!]!
tagsWithStatus: [ProductToTag!]!
tags: [ProductTag!]!
}

Expand Down Expand Up @@ -1133,21 +1142,27 @@ input ProductInput {
discounts: [ProductDiscountsInput!]! = []
articleNumbers: [String!]! = []
dimensions: ProductDimensionsInput
statistics: ProductStatisticsInput
variants: [ProductVariantInput!]! = []
statistics: ProductNestedProductStatisticsInput
variants: [ProductNestedProductVariantInput!]! = []
category: ID = null
tags: [ID!]! = []
tagsWithStatus: [ProductNestedProductToTagInput!]! = []
}

input ProductStatisticsInput {
input ProductNestedProductStatisticsInput {
views: Float!
}

input ProductVariantInput {
input ProductNestedProductVariantInput {
name: String!
image: DamImageBlockInput!
}

input ProductNestedProductToTagInput {
tag: ID!
exampleStatus: Boolean! = true
}

input ProductUpdateInput {
title: String
slug: String
Expand All @@ -1159,10 +1174,11 @@ input ProductUpdateInput {
discounts: [ProductDiscountsInput!]
articleNumbers: [String!]
dimensions: ProductDimensionsInput
statistics: ProductStatisticsInput
variants: [ProductVariantInput!]
statistics: ProductNestedProductStatisticsInput
variants: [ProductNestedProductVariantInput!]
category: ID
tags: [ID!]
tagsWithStatus: [ProductNestedProductToTagInput!]
}

input ProductCategoryInput {
Expand All @@ -1180,9 +1196,16 @@ input ProductCategoryUpdateInput {
input ProductTagInput {
title: String!
products: [ID!]! = []
productsWithStatus: [ProductTagNestedProductToTagInput!]! = []
}

input ProductTagNestedProductToTagInput {
product: ID!
exampleStatus: Boolean! = true
}

input ProductTagUpdateInput {
title: String
products: [ID!]
productsWithStatus: [ProductTagNestedProductToTagInput!]
}
16 changes: 16 additions & 0 deletions demo/api/src/db/migrations/Migration20240419135211.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Migration } from '@mikro-orm/migrations';

export class Migration20240419135211 extends Migration {

async up(): Promise<void> {
this.addSql('create table "ProductToTag" ("id" uuid not null, "product" uuid not null, "tag" uuid not null, "exampleStatus" boolean not null default true, constraint "ProductToTags_pkey" primary key ("id"));');

this.addSql('alter table "ProductToTags" add constraint "ProductToTag_product_foreign" foreign key ("product") references "Product" ("id") on update cascade on delete cascade;');
this.addSql('alter table "ProductToTags" add constraint "ProductToTag_tag_foreign" foreign key ("tag") references "ProductTag" ("id") on update cascade on delete cascade;');
}

async down(): Promise<void> {
this.addSql('drop table if exists "ProductToTag" cascade;');
}

}
6 changes: 5 additions & 1 deletion demo/api/src/products/entities/product-tag.entity.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { CrudField, CrudGenerator } from "@comet/cms-api";
import { BaseEntity, Collection, Entity, ManyToMany, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
import { BaseEntity, Collection, Entity, ManyToMany, OneToMany, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
import { Field, ID, ObjectType } from "@nestjs/graphql";
import { v4 as uuid } from "uuid";

import { Product } from "./product.entity";
import { ProductToTag } from "./product-to-tag.entity";

@ObjectType()
@Entity()
Expand All @@ -29,6 +30,9 @@ export class ProductTag extends BaseEntity<ProductTag, "id"> {
@ManyToMany(() => Product, (products) => products.tags)
products = new Collection<Product>(this);

@OneToMany(() => ProductToTag, (productToTag) => productToTag.tag, { orphanRemoval: true })
productsWithStatus = new Collection<ProductToTag>(this);

@Property()
@Field()
createdAt: Date = new Date();
Expand Down
24 changes: 24 additions & 0 deletions demo/api/src/products/entities/product-to-tag.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { BaseEntity, Entity, ManyToOne, PrimaryKey, Property, Ref, types } from "@mikro-orm/core";
import { Field, ObjectType } from "@nestjs/graphql";
import { v4 as uuid } from "uuid";

import { Product } from "./product.entity";
import { ProductTag } from "./product-tag.entity";

@Entity()
@ObjectType()
export class ProductToTag extends BaseEntity<ProductToTag, "id"> {
@Field()
@PrimaryKey({ type: "uuid" })
id: string = uuid();

@ManyToOne(() => Product, { onDelete: "cascade", ref: true })
product: Ref<Product>;

@ManyToOne(() => ProductTag, { onDelete: "cascade", ref: true })
tag: Ref<ProductTag>;

@Field()
@Property({ type: types.boolean })
exampleStatus: boolean = true;
}
4 changes: 4 additions & 0 deletions demo/api/src/products/entities/product.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { v4 as uuid } from "uuid";
import { ProductCategory } from "./product-category.entity";
import { ProductStatistics } from "./product-statistics.entity";
import { ProductTag } from "./product-tag.entity";
import { ProductToTag } from "./product-to-tag.entity";
import { ProductType } from "./product-type.enum";
import { ProductVariant } from "./product-variant.entity";

Expand Down Expand Up @@ -157,6 +158,9 @@ export class Product extends BaseEntity<Product, "id"> {
})
tags = new Collection<ProductTag>(this);

@OneToMany(() => ProductToTag, (productToTag) => productToTag.product, { orphanRemoval: true })
tagsWithStatus = new Collection<ProductToTag>(this);

@Property()
@Field()
createdAt: Date = new Date();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Field, InputType } from "@nestjs/graphql";
import { IsInt, IsNotEmpty } from "class-validator";

@InputType()
export class ProductStatisticsInput {
export class ProductNestedProductStatisticsInput {
@IsNotEmpty()
@IsInt()
@Field()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// 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 { Field, ID, InputType } from "@nestjs/graphql";
import { IsBoolean, IsNotEmpty, IsUUID } from "class-validator";

@InputType()
export class ProductNestedProductToTagInput {
@IsNotEmpty()
@Field(() => ID)
@IsUUID()
tag: string;

@IsNotEmpty()
@IsBoolean()
@Field({ defaultValue: true })
exampleStatus: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Transform } from "class-transformer";
import { IsNotEmpty, IsString, ValidateNested } from "class-validator";

@InputType()
export class ProductVariantInput {
export class ProductNestedProductVariantInput {
@IsNotEmpty()
@IsString()
@Field()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// 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 { Field, ID, InputType } from "@nestjs/graphql";
import { IsBoolean, IsNotEmpty, IsUUID } from "class-validator";

@InputType()
export class ProductTagNestedProductToTagInput {
@IsNotEmpty()
@Field(() => ID)
@IsUUID()
product: string;

@IsNotEmpty()
@IsBoolean()
@Field({ defaultValue: true })
exampleStatus: boolean;
}
8 changes: 8 additions & 0 deletions demo/api/src/products/generated/dto/product-tag.input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
// You may choose to use this file as scaffold by moving this file out of generated folder and removing this comment.
import { PartialType } from "@comet/cms-api";
import { Field, ID, InputType } from "@nestjs/graphql";
import { Type } from "class-transformer";
import { IsArray, IsNotEmpty, IsString, IsUUID } from "class-validator";

import { ProductTagNestedProductToTagInput } from "./product-tag-nested-product-to-tag.input";

@InputType()
export class ProductTagInput {
@IsNotEmpty()
Expand All @@ -15,6 +18,11 @@ export class ProductTagInput {
@IsArray()
@IsUUID(undefined, { each: true })
products: string[];

@Field(() => [ProductTagNestedProductToTagInput], { defaultValue: [] })
@IsArray()
@Type(() => ProductTagNestedProductToTagInput)
productsWithStatus: ProductTagNestedProductToTagInput[];
}

@InputType()
Expand Down
22 changes: 14 additions & 8 deletions demo/api/src/products/generated/dto/product.input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsString, IsUUID, Val

import { ProductDimensions, ProductDiscounts } from "../../entities/product.entity";
import { ProductType } from "../../entities/product-type.enum";
import { ProductStatisticsInput } from "./product-statistics.nested.input";
import { ProductVariantInput } from "./product-variant.nested.input";
import { ProductNestedProductStatisticsInput } from "./product-nested-product-statistics.input";
import { ProductNestedProductToTagInput } from "./product-nested-product-to-tag.input";
import { ProductNestedProductVariantInput } from "./product-nested-product-variant.input";

@InputType()
export class ProductInput {
Expand Down Expand Up @@ -70,15 +71,15 @@ export class ProductInput {
dimensions?: ProductDimensions;

@IsNullable()
@Field(() => ProductStatisticsInput, { nullable: true })
@Type(() => ProductStatisticsInput)
@Field(() => ProductNestedProductStatisticsInput, { nullable: true })
@Type(() => ProductNestedProductStatisticsInput)
@ValidateNested()
statistics?: ProductStatisticsInput;
statistics?: ProductNestedProductStatisticsInput;

@Field(() => [ProductVariantInput], { defaultValue: [] })
@Field(() => [ProductNestedProductVariantInput], { defaultValue: [] })
@IsArray()
@Type(() => ProductVariantInput)
variants: ProductVariantInput[];
@Type(() => ProductNestedProductVariantInput)
variants: ProductNestedProductVariantInput[];

@IsNullable()
@Field(() => ID, { nullable: true, defaultValue: null })
Expand All @@ -89,6 +90,11 @@ export class ProductInput {
@IsArray()
@IsUUID(undefined, { each: true })
tags: string[];

@Field(() => [ProductNestedProductToTagInput], { defaultValue: [] })
@IsArray()
@Type(() => ProductNestedProductToTagInput)
tagsWithStatus: ProductNestedProductToTagInput[];
}

@InputType()
Expand Down
46 changes: 42 additions & 4 deletions demo/api/src/products/generated/product-tag.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { GraphQLResolveInfo } from "graphql";

import { Product } from "../entities/product.entity";
import { ProductTag } from "../entities/product-tag.entity";
import { ProductToTag } from "../entities/product-to-tag.entity";
import { PaginatedProductTags } from "./dto/paginated-product-tags";
import { ProductTagInput, ProductTagUpdateInput } from "./dto/product-tag.input";
import { ProductTagsArgs } from "./dto/product-tags.args";
Expand All @@ -21,6 +22,7 @@ export class ProductTagResolver {
private readonly entityManager: EntityManager,
private readonly productTagsService: ProductTagsService,
@InjectRepository(ProductTag) private readonly repository: EntityRepository<ProductTag>,
@InjectRepository(ProductToTag) private readonly productToTagRepository: EntityRepository<ProductToTag>,
@InjectRepository(Product) private readonly productRepository: EntityRepository<Product>,
) {}

Expand All @@ -40,6 +42,9 @@ export class ProductTagResolver {

const fields = extractGraphqlFields(info, { root: "nodes" });
const populate: string[] = [];
if (fields.includes("productsWithStatus")) {
populate.push("productsWithStatus");
}
if (fields.includes("products")) {
populate.push("products");
}
Expand All @@ -61,11 +66,25 @@ export class ProductTagResolver {

@Mutation(() => ProductTag)
async createProductTag(@Args("input", { type: () => ProductTagInput }) input: ProductTagInput): Promise<ProductTag> {
const { products: productsInput, ...assignInput } = input;
const { productsWithStatus: productsWithStatusInput, products: productsInput, ...assignInput } = input;
const productTag = this.repository.create({
...assignInput,
});

if (productsWithStatusInput) {
await productTag.productsWithStatus.loadItems();
productTag.productsWithStatus.set(
await Promise.all(
productsWithStatusInput.map(async (productsWithStatusInput) => {
const { product: productInput, ...assignInput } = productsWithStatusInput;
return this.productToTagRepository.assign(new ProductToTag(), {
...assignInput,

product: Reference.create(await this.productRepository.findOneOrFail(productInput)),
});
}),
),
);
}
if (productsInput) {
const products = await this.productRepository.find({ id: productsInput });
if (products.length != productsInput.length) throw new Error("Couldn't find all products that were passed as input");
Expand All @@ -90,11 +109,25 @@ export class ProductTagResolver {
validateNotModified(productTag, lastUpdatedAt);
}

const { products: productsInput, ...assignInput } = input;
const { productsWithStatus: productsWithStatusInput, products: productsInput, ...assignInput } = input;
productTag.assign({
...assignInput,
});

if (productsWithStatusInput) {
await productTag.productsWithStatus.loadItems();
productTag.productsWithStatus.set(
await Promise.all(
productsWithStatusInput.map(async (productsWithStatusInput) => {
const { product: productInput, ...assignInput } = productsWithStatusInput;
return this.productToTagRepository.assign(new ProductToTag(), {
...assignInput,

product: Reference.create(await this.productRepository.findOneOrFail(productInput)),
});
}),
),
);
}
if (productsInput) {
const products = await this.productRepository.find({ id: productsInput });
if (products.length != productsInput.length) throw new Error("Couldn't find all products that were passed as input");
Expand All @@ -116,6 +149,11 @@ export class ProductTagResolver {
return true;
}

@ResolveField(() => [ProductToTag])
async productsWithStatus(@Parent() productTag: ProductTag): Promise<ProductToTag[]> {
return productTag.productsWithStatus.loadItems();
}

@ResolveField(() => [Product])
async products(@Parent() productTag: ProductTag): Promise<Product[]> {
return productTag.products.loadItems();
Expand Down
Loading

0 comments on commit 94ac6b7

Please sign in to comment.