From b1b714f44e7784873f529210126e920602054228 Mon Sep 17 00:00:00 2001 From: Anika Raemer Date: Sat, 4 Oct 2025 18:01:46 +0200 Subject: [PATCH] correct cascade delete options --- bruno/recipe-backend/getCompactRecipes.bru | 2 +- bruno/recipe-backend/updateRecipe.bru | 7 ++- src/controllers/RecipeController.ts | 17 +++++- src/data-source.ts | 2 +- src/dtos/RecipeIngredientDto.ts | 3 +- src/dtos/RecipeIngredientGroupDto.ts | 3 +- src/entities/RecipeEntity.ts | 4 +- src/entities/RecipeIngredientEntity.ts | 6 +- src/entities/RecipeIngredientGroupEntity.ts | 8 ++- src/entities/RecipeInstructionStepEntity.ts | 6 +- src/mappers/AbstractDtoEntityMapper.ts | 21 +++++++ src/mappers/CompactRecipeDtoEntityMapper.ts | 12 ++++ src/mappers/RecipeDtoEntityMapper.ts | 55 +++++++++++++---- .../RecipeIngredientDtoEntityMapper.ts | 13 ++++ .../RecipeIngredientGroupDtoEntityMapper.ts | 61 +++++++++++++------ .../RecipeInstructionStepDtoEntityMapper.ts | 10 +++ src/mappers/UserDtoEntityMapper.ts | 10 +++ src/repositories/AbstractRepository.ts | 2 +- src/repositories/RecipeRepository.ts | 17 +++++- 19 files changed, 207 insertions(+), 52 deletions(-) diff --git a/bruno/recipe-backend/getCompactRecipes.bru b/bruno/recipe-backend/getCompactRecipes.bru index b96cdfc..d62a677 100644 --- a/bruno/recipe-backend/getCompactRecipes.bru +++ b/bruno/recipe-backend/getCompactRecipes.bru @@ -11,7 +11,7 @@ get { } auth:bearer { - token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1ODk4Njk4MSwiZXhwIjoxNzU5MDczMzgxfQ.rYvECzhI3Tptse3yVjZvR9RXgs1gkwAt2_5-hpAXvB0 + token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1OTA3NjA2NCwiZXhwIjoxNzU5MTYyNDY0fQ.exf_3fCrarW0LhUqPuadvp89BOUazEXtdSTkGDIAU_Q } settings { diff --git a/bruno/recipe-backend/updateRecipe.bru b/bruno/recipe-backend/updateRecipe.bru index 85a4191..a4b695f 100644 --- a/bruno/recipe-backend/updateRecipe.bru +++ b/bruno/recipe-backend/updateRecipe.bru @@ -11,7 +11,7 @@ put { } auth:bearer { - token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1OTA3NjA2NCwiZXhwIjoxNzU5MTYyNDY0fQ.exf_3fCrarW0LhUqPuadvp89BOUazEXtdSTkGDIAU_Q + token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1OTE3MjI3MywiZXhwIjoxNzU5MjU4NjczfQ._X_ZtBGtx0_14Nx90ctSQL-ieVPptaPc7WjG3FnyOOA } body:json { @@ -27,7 +27,7 @@ body:json { "id": "9042d658-0102-4e63-8637-a82c5653aa9d", "createdAt": "2025-09-28T10:24:05.429Z", "updatedAt": "2025-09-28T10:24:05.429Z", - "text": "Mürbteig von 400 g Mehl herstellen", + "text": "Mürbteig von 400 g Mehl herstellen.", "sortOrder": 1 }, { @@ -57,6 +57,9 @@ body:json { "updatedAt": "2025-09-28T10:24:05.429Z", "text": "Backen", "sortOrder": 5 + }, { + "text": "Essen", + "sortOrder": 6 } ], "ingredientGroups": [ diff --git a/src/controllers/RecipeController.ts b/src/controllers/RecipeController.ts index 44b3b37..da6b8c1 100644 --- a/src/controllers/RecipeController.ts +++ b/src/controllers/RecipeController.ts @@ -6,6 +6,7 @@ import { RecipeInstructionStepDto } from "../dtos/RecipeInstructionStepDto.js"; import { RecipeIngredientGroupDto } from "../dtos/RecipeIngredientGroupDto.js"; import { RecipeIngredientDto } from "../dtos/RecipeIngredientDto.js"; import { Entity } from "typeorm"; +import { RecipeEntity } from "../entities/RecipeEntity.js"; /** * Controls all recipe specific actions @@ -38,9 +39,19 @@ export class RecipeController { if (!this.isRecipeDtoValid(dto)) { throw new ValidationError("recipe data is not valid!") } - const recipeEntity = this.mapper.toEntity(dto); - // @todo doesn't create new ingredient groups, ingredients or instruction steps yet - const savedEntity = await this.recipeRepository.save(recipeEntity); + const recipeId = dto.id + if(recipeId === undefined){ + throw new ValidationError("Trying to update recipe without ID!") + } + // Load current version of recipe from database + const recipeEntity = await this.recipeRepository.findById(recipeId); + if(!recipeEntity){ + throw new ValidationError("No recipe with ID " + recipeId + " found in database!") + } + // merge changes into entity + this.mapper.mergeDtoIntoEntity(dto, recipeEntity); + // persist changes + const savedEntity = await this.recipeRepository.update(recipeEntity); return this.mapper.toDto(savedEntity); } /** diff --git a/src/data-source.ts b/src/data-source.ts index b05c91b..89636d4 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -24,7 +24,7 @@ export const AppDataSource = new DataSource({ synchronize: NODE_ENV === "dev" ? false : false, //logging logs sql command on the terminal - logging: NODE_ENV === "dev" ? false : false, + logging: NODE_ENV === "dev" ? ["query", "error"] : false, entities: [join(__dirname, "/entities/*.{js, ts}")], migrations: [join(__dirname, "/migrations/*.js")], subscribers: [], diff --git a/src/dtos/RecipeIngredientDto.ts b/src/dtos/RecipeIngredientDto.ts index 20cfa9f..3d7c91f 100644 --- a/src/dtos/RecipeIngredientDto.ts +++ b/src/dtos/RecipeIngredientDto.ts @@ -1,4 +1,3 @@ -import { UUID } from "crypto"; import { AbstractDto } from "./AbstractDto.js"; export class RecipeIngredientDto extends AbstractDto{ @@ -7,5 +6,5 @@ export class RecipeIngredientDto extends AbstractDto{ amount?: number; unit?: string; sortOrder!: number; - ingredientGroupId?: UUID; + ingredientGroupId?: string; } \ No newline at end of file diff --git a/src/dtos/RecipeIngredientGroupDto.ts b/src/dtos/RecipeIngredientGroupDto.ts index 32d6cc0..324d3c5 100644 --- a/src/dtos/RecipeIngredientGroupDto.ts +++ b/src/dtos/RecipeIngredientGroupDto.ts @@ -1,10 +1,9 @@ -import { UUID } from "crypto"; import { AbstractDto } from "./AbstractDto.js"; import { RecipeIngredientDto } from "./RecipeIngredientDto.js"; export class RecipeIngredientGroupDto extends AbstractDto{ title?: string; sortOrder!: number; - recipeId?: UUID; + recipeId?: string; ingredients!: RecipeIngredientDto[]; } \ No newline at end of file diff --git a/src/entities/RecipeEntity.ts b/src/entities/RecipeEntity.ts index 726a505..2f3b8fe 100644 --- a/src/entities/RecipeEntity.ts +++ b/src/entities/RecipeEntity.ts @@ -19,13 +19,13 @@ export class RecipeEntity extends AbstractEntity { // make sure not to induce a circular dependency! user arrow function without brackets! @OneToMany(() => RecipeInstructionStepEntity, (instructionStep) => instructionStep.recipe, { - cascade: true, + cascade: true }) instructionSteps!: RecipeInstructionStepEntity[]; // make sure not to induce a circular dependency! user arrow function without brackets! @OneToMany(() => RecipeIngredientGroupEntity, (ingredientGroup) => ingredientGroup.recipe, { - cascade: true, + cascade: true }) ingredientGroups!: Relation[]; } \ No newline at end of file diff --git a/src/entities/RecipeIngredientEntity.ts b/src/entities/RecipeIngredientEntity.ts index eb90523..6b5a466 100644 --- a/src/entities/RecipeIngredientEntity.ts +++ b/src/entities/RecipeIngredientEntity.ts @@ -24,7 +24,11 @@ export class RecipeIngredientEntity extends AbstractEntity { @JoinColumn({name: "recipe_ingredient_group_id"}) @ManyToOne(() => RecipeIngredientGroupEntity, (ingredientGroup) => ingredientGroup.ingredients, - {onDelete: "CASCADE", nullable: false} + { + onDelete: "CASCADE", + nullable: false, + orphanedRowAction: "delete" + } ) ingredientGroup!: Relation; } \ No newline at end of file diff --git a/src/entities/RecipeIngredientGroupEntity.ts b/src/entities/RecipeIngredientGroupEntity.ts index 64c64c3..6cd763e 100644 --- a/src/entities/RecipeIngredientGroupEntity.ts +++ b/src/entities/RecipeIngredientGroupEntity.ts @@ -16,12 +16,16 @@ export class RecipeIngredientGroupEntity extends AbstractEntity { @JoinColumn({name: "recipe_id"}) @ManyToOne(() => RecipeEntity, (recipe) => recipe.ingredientGroups, - {onDelete: "CASCADE", nullable: false} + { + onDelete: "CASCADE", + nullable: false, + orphanedRowAction: "delete" // delete removed groups + } ) recipe!: Relation; @OneToMany(() => RecipeIngredientEntity, (ingredient) => ingredient.ingredientGroup, { - cascade: true, + cascade: true }) ingredients!: Relation[]; diff --git a/src/entities/RecipeInstructionStepEntity.ts b/src/entities/RecipeInstructionStepEntity.ts index ed238d1..1a1417f 100644 --- a/src/entities/RecipeInstructionStepEntity.ts +++ b/src/entities/RecipeInstructionStepEntity.ts @@ -15,7 +15,11 @@ export class RecipeInstructionStepEntity extends AbstractEntity { @JoinColumn({name: "recipe_id"}) @ManyToOne(() => RecipeEntity, (recipe) => recipe.instructionSteps, - {onDelete: "CASCADE", nullable: false} + { + onDelete: "CASCADE", + nullable: false, + orphanedRowAction: "delete" // delete removed groups + } ) recipe!: Relation; diff --git a/src/mappers/AbstractDtoEntityMapper.ts b/src/mappers/AbstractDtoEntityMapper.ts index 51a5f27..d2ea746 100644 --- a/src/mappers/AbstractDtoEntityMapper.ts +++ b/src/mappers/AbstractDtoEntityMapper.ts @@ -28,4 +28,25 @@ export abstract class AbstractDtoEntityMapper< // Abstract methods to be implemented by subclasses abstract toDto(entity: E): D; abstract toEntity(dto: D): E; + abstract mergeDtoIntoEntity(dto: D, entity: E): E; + abstract createNewEntity() : E; + + mergeDtoListIntoEntityList(dtos: D[], entities: E[]) : E[]{ + const updatedEntities: E[] = []; + const existingMap = new Map(entities?.map(e => [e.id, e]) ?? []); + + for (const dto of dtos) { + if (dto.id && existingMap.has(dto.id)) { + // update existing + const entity = existingMap.get(dto.id)!; + updatedEntities.push(this.mergeDtoIntoEntity(dto, entity)); + } else { + // create new + const newEntity = this.createNewEntity(); + updatedEntities.push(this.mergeDtoIntoEntity(dto, newEntity)); + } + } + + return updatedEntities; + } } diff --git a/src/mappers/CompactRecipeDtoEntityMapper.ts b/src/mappers/CompactRecipeDtoEntityMapper.ts index 77f2268..8f2c2c4 100644 --- a/src/mappers/CompactRecipeDtoEntityMapper.ts +++ b/src/mappers/CompactRecipeDtoEntityMapper.ts @@ -17,4 +17,16 @@ export class CompactRecipeDtoEntityMapper extends AbstractDtoEntityMapper this.instructionStepMapper.toDto(stepEntity)); - dto.instructions = instructionStepDtos; + dto.instructions = entity.instructionSteps.map((stepEntity) => this.instructionStepMapper.toDto(stepEntity)); + // @todo map ids dto.instructions.forEach(step => step.recipeId = entity.id); // set recipe relation explicitly! + // map ingredient groups - const ingredientGroupEntities = entity.ingredientGroups; - const ingredientGroupDtos = ingredientGroupEntities.map((groupEntity) => this.ingredientGroupMapper.toDto(groupEntity)); - dto.ingredientGroups = ingredientGroupDtos; + dto.ingredientGroups = entity.ingredientGroups.map((groupEntity) => this.ingredientGroupMapper.toDto(groupEntity)); return dto; } @@ -42,16 +40,47 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper this.instructionStepMapper.toEntity(stepDto)); - entity.instructionSteps = instructionStepEntities; + entity.instructionSteps = dto.instructions.map((stepDto) => { + const stepEntity = this.instructionStepMapper.toEntity(stepDto); + + // Always set the relation + stepEntity.recipe = entity; + + // If it's a new step (no id from client), let DB generate a new UUID + if (!stepDto.id) { + delete (stepEntity as any).id; + } + + return stepEntity; + }); // map ingredient groups - const ingredientGroupDtos = dto.ingredientGroups; - const ingredientGroupEntities = ingredientGroupDtos.map((ingredientGroupDto) => this.ingredientGroupMapper.toEntity(ingredientGroupDto)); - entity.ingredientGroups = ingredientGroupEntities; + entity.ingredientGroups = dto.ingredientGroups.map((groupDto) => { + const groupEntity = this.ingredientGroupMapper.toEntity(groupDto); + groupEntity.recipe = entity; + if (!groupDto.id) { + delete (groupEntity as any).id; + } + return groupEntity; + }); - return entity; + return entity; } + mergeDtoIntoEntity(dto: RecipeDto, entity: RecipeEntity): RecipeEntity { + entity.title = dto.title; + entity.amount = dto.amount; + entity.amountDescription = dto.amountDescription; + + // --- Instruction Steps --- + entity.instructionSteps = this.instructionStepMapper.mergeDtoListIntoEntityList(dto.instructions, entity.instructionSteps); + + // --- Ingredient Groups --- + entity.ingredientGroups = this.ingredientGroupMapper.mergeDtoListIntoEntityList(dto.ingredientGroups, entity.ingredientGroups); + return entity + } + + createNewEntity(): RecipeEntity { + return new RecipeEntity(); + } } \ No newline at end of file diff --git a/src/mappers/RecipeIngredientDtoEntityMapper.ts b/src/mappers/RecipeIngredientDtoEntityMapper.ts index 0a8baab..fb1f6cd 100644 --- a/src/mappers/RecipeIngredientDtoEntityMapper.ts +++ b/src/mappers/RecipeIngredientDtoEntityMapper.ts @@ -29,4 +29,17 @@ export class RecipeIngredientDtoEntityMapper extends AbstractDtoEntityMapper{ - constructor( - private ingredientMapper : RecipeIngredientDtoEntityMapper - ){ - super(); - } +export class RecipeIngredientGroupDtoEntityMapper extends AbstractDtoEntityMapper { + constructor( + private ingredientMapper: RecipeIngredientDtoEntityMapper + ) { + super(); + } - toDto(entity: RecipeIngredientGroupEntity): RecipeIngredientGroupDto { - const dto = new RecipeIngredientGroupDto(); - this.mapBaseEntityToDto(entity, dto); + toDto(entity: RecipeIngredientGroupEntity): RecipeIngredientGroupDto { + const dto = new RecipeIngredientGroupDto(); + this.mapBaseEntityToDto(entity, dto); - dto.title = entity.title; - dto.sortOrder = entity.sortOrder + dto.title = entity.title; + dto.sortOrder = entity.sortOrder - // map ingredients - const ingredientEntities = entity.ingredients; - const ingredientDtos = ingredientEntities?.map((ingredientEntity) => this.ingredientMapper.toDto(ingredientEntity)); - dto.ingredients = ingredientDtos; + // map ingredients + dto.ingredients = entity.ingredients?.map((ingredientEntity) => this.ingredientMapper.toDto(ingredientEntity)); - return dto; - } + return dto; + } toEntity(dto: RecipeIngredientGroupDto): RecipeIngredientGroupEntity { const entity = new RecipeIngredientGroupEntity(); @@ -33,11 +31,34 @@ export class RecipeIngredientGroupDtoEntityMapper extends AbstractDtoEntityMappe entity.sortOrder = dto.sortOrder // map ingredients - const ingredientDtos = dto.ingredients; - const ingredientEntities = ingredientDtos?.map((ingredientDto) => this.ingredientMapper.toEntity(ingredientDto)); - entity.ingredients = ingredientEntities; + entity.ingredients = dto.ingredients.map((ingredientDto) => { + const ingredientEntity = this.ingredientMapper.toEntity(ingredientDto); + ingredientEntity.ingredientGroup = entity; + // remove id from new entity completely and allow ORM to generate a new one + if (!ingredientDto.id) { + delete (ingredientEntity as any).id; + } + return ingredientEntity; + }); return entity; } + createNewEntity(): RecipeIngredientGroupEntity { + return new RecipeIngredientGroupEntity(); + } + + mergeDtoIntoEntity(dto: RecipeIngredientGroupDto, entity: RecipeIngredientGroupEntity): RecipeIngredientGroupEntity { + entity.title = dto.title; + entity.sortOrder = dto.sortOrder; + + // sync ingredients inside each group + entity.ingredients = this.ingredientMapper.mergeDtoListIntoEntityList( + dto.ingredients, + entity.ingredients, + ); + + return entity; + } + } \ No newline at end of file diff --git a/src/mappers/RecipeInstructionStepDtoEntityMapper.ts b/src/mappers/RecipeInstructionStepDtoEntityMapper.ts index be7ca3a..14f02ff 100644 --- a/src/mappers/RecipeInstructionStepDtoEntityMapper.ts +++ b/src/mappers/RecipeInstructionStepDtoEntityMapper.ts @@ -24,4 +24,14 @@ export class RecipeInstructionStepDtoEntityMapper extends AbstractDtoEntityMappe return entity; } + createNewEntity(): RecipeInstructionStepEntity { + return new RecipeInstructionStepEntity(); + } + + mergeDtoIntoEntity(dto: RecipeInstructionStepDto, entity: RecipeInstructionStepEntity): RecipeInstructionStepEntity { + entity.text = dto.text; + entity.sortOrder = dto.sortOrder; + return entity; + } + } \ No newline at end of file diff --git a/src/mappers/UserDtoEntityMapper.ts b/src/mappers/UserDtoEntityMapper.ts index 927e4f6..675f2fa 100644 --- a/src/mappers/UserDtoEntityMapper.ts +++ b/src/mappers/UserDtoEntityMapper.ts @@ -20,6 +20,16 @@ export class UserDtoEntityMapper extends AbstractDtoEntityMapper { await this.repo.delete(id as any); } - async save(entity: T): Promise { + async update(entity: T): Promise { return this.repo.save(entity); } diff --git a/src/repositories/RecipeRepository.ts b/src/repositories/RecipeRepository.ts index ef48156..1b7e620 100644 --- a/src/repositories/RecipeRepository.ts +++ b/src/repositories/RecipeRepository.ts @@ -1,5 +1,6 @@ import { AbstractRepository } from "./AbstractRepository.js"; import { RecipeEntity } from "../entities/RecipeEntity.js"; +import { AppDataSource } from "../data-source.js"; export class RecipeRepository extends AbstractRepository { constructor() { @@ -20,6 +21,20 @@ export class RecipeRepository extends AbstractRepository { 'instructionSteps' ] }); - } + + async updateRecipe(entity: RecipeEntity): Promise { + return AppDataSource.transaction(async (em) => { + // load existing data + const existing = await this.repo.findOneOrFail({ + where: { id: entity.id }, + relations: ["instructionSteps", "ingredientGroups", "ingredientGroups.ingredients"], + }); + + // merge new entity and existing entity + this.repo.merge(existing, entity); + return this.repo.save(existing); + }); +} + } \ No newline at end of file