correct cascade delete options
This commit is contained in:
parent
e33dfdb845
commit
b1b714f44e
19 changed files with 207 additions and 52 deletions
|
|
@ -11,7 +11,7 @@ get {
|
|||
}
|
||||
|
||||
auth:bearer {
|
||||
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1ODk4Njk4MSwiZXhwIjoxNzU5MDczMzgxfQ.rYvECzhI3Tptse3yVjZvR9RXgs1gkwAt2_5-hpAXvB0
|
||||
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1OTA3NjA2NCwiZXhwIjoxNzU5MTYyNDY0fQ.exf_3fCrarW0LhUqPuadvp89BOUazEXtdSTkGDIAU_Q
|
||||
}
|
||||
|
||||
settings {
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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<RecipeIngredientGroupEntity>[];
|
||||
}
|
||||
|
|
@ -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<RecipeIngredientGroupEntity>;
|
||||
}
|
||||
|
|
@ -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<RecipeEntity>;
|
||||
|
||||
@OneToMany(() => RecipeIngredientEntity, (ingredient) => ingredient.ingredientGroup, {
|
||||
cascade: true,
|
||||
cascade: true
|
||||
})
|
||||
ingredients!: Relation<RecipeIngredientEntity>[];
|
||||
|
||||
|
|
|
|||
|
|
@ -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<RecipeEntity>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,4 +17,16 @@ export class CompactRecipeDtoEntityMapper extends AbstractDtoEntityMapper<Recipe
|
|||
throw new Error("Mapping CompactRecipeDto to RecipeEntity is not allowed!");
|
||||
}
|
||||
|
||||
mergeDtoIntoEntity(dto: CompactRecipeDto, entity: RecipeEntity): RecipeEntity {
|
||||
throw new Error("Mapping CompactRecipeDto to RecipeEntity is not allowed!");
|
||||
}
|
||||
|
||||
createNewEntity() : RecipeEntity {
|
||||
throw new Error("Mapping CompactRecipeDto to RecipeEntity is not allowed!");
|
||||
}
|
||||
|
||||
mergeDtoListIntoEntityList(dtos: CompactRecipeDto[], entities: RecipeEntity[]) : RecipeEntity[]{
|
||||
throw new Error("Mapping CompactRecipeDto to RecipeEntity is not allowed!");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -21,14 +21,12 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity,
|
|||
dto.amountDescription = entity.amountDescription;
|
||||
|
||||
// map instructions
|
||||
const instructionStepEntities = entity.instructionSteps;
|
||||
const instructionStepDtos = instructionStepEntities.map((stepEntity) => 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<RecipeEntity,
|
|||
entity.amountDescription = dto.amountDescription;
|
||||
|
||||
// map instructions
|
||||
const instructionStepDtos = dto.instructions;
|
||||
const instructionStepEntities = instructionStepDtos.map((stepDto) => 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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -29,4 +29,17 @@ export class RecipeIngredientDtoEntityMapper extends AbstractDtoEntityMapper<Rec
|
|||
|
||||
return entity;
|
||||
}
|
||||
|
||||
createNewEntity(): RecipeIngredientEntity {
|
||||
return new RecipeIngredientEntity();
|
||||
}
|
||||
|
||||
mergeDtoIntoEntity(dto: RecipeIngredientDto, entity: RecipeIngredientEntity): RecipeIngredientEntity {
|
||||
entity.name = dto.name;
|
||||
entity.amount = dto.amount;
|
||||
entity.unit = dto.unit;
|
||||
entity.subtext = dto.subtext;
|
||||
entity.sortOrder = dto.sortOrder;
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
|
@ -18,9 +18,7 @@ export class RecipeIngredientGroupDtoEntityMapper extends AbstractDtoEntityMappe
|
|||
dto.sortOrder = entity.sortOrder
|
||||
|
||||
// map ingredients
|
||||
const ingredientEntities = entity.ingredients;
|
||||
const ingredientDtos = ingredientEntities?.map((ingredientEntity) => this.ingredientMapper.toDto(ingredientEntity));
|
||||
dto.ingredients = ingredientDtos;
|
||||
dto.ingredients = entity.ingredients?.map((ingredientEntity) => this.ingredientMapper.toDto(ingredientEntity));
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
|
@ -33,9 +31,32 @@ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -20,6 +20,16 @@ export class UserDtoEntityMapper extends AbstractDtoEntityMapper<UserEntity, Use
|
|||
const entity = new UserEntity();
|
||||
this.mapBaseDtoToEntity(dto, entity);
|
||||
|
||||
this.mergeDtoIntoEntity(dto, entity)
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
createNewEntity(): UserEntity {
|
||||
return new UserEntity;
|
||||
}
|
||||
|
||||
mergeDtoIntoEntity(dto: UserDto, entity: UserEntity): UserEntity {
|
||||
entity.userName = dto.userName;
|
||||
entity.email = dto.email;
|
||||
entity.firstName = dto.firstName;
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export abstract class AbstractRepository<T extends AbstractEntity> {
|
|||
await this.repo.delete(id as any);
|
||||
}
|
||||
|
||||
async save(entity: T): Promise<T> {
|
||||
async update(entity: T): Promise<T> {
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<RecipeEntity> {
|
||||
constructor() {
|
||||
|
|
@ -20,6 +21,20 @@ export class RecipeRepository extends AbstractRepository<RecipeEntity> {
|
|||
'instructionSteps'
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
async updateRecipe(entity: RecipeEntity): Promise<RecipeEntity> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue