add list mappers

This commit is contained in:
araemer 2026-02-27 20:00:35 +01:00
parent c944b5c6b7
commit 2fbb1523fd
5 changed files with 142 additions and 152 deletions

View file

@ -14,22 +14,13 @@ export const compactRecipeBasicRoute = "/compact-recipe"
const router = Router();
// Inject repo + mapper here
const compactRecipeHandler = new CompactRecipeHandler(new RecipeRepository(), new CompactRecipeDtoEntityMapper());
/**
* Load header data of all recipes
* Responds with a list of CompactRecipeDtos
*/
router.get(
"/",
asyncHandler(async (req, res) => {
// extract search string from query parameters, convert to lower case for case insensitive search
const searchString : string = req.query.search ? req.query.search.toString().toLowerCase() : "";
console.log("Searching for recipes with title containing", searchString)
const response = await compactRecipeHandler.getMatchingRecipes(searchString);
res.status(HttpStatusCode.OK).json(response);
})
);
const recipeRepository = new RecipeRepository();
const compactRecipeDtoEntityMapper = new CompactRecipeDtoEntityMapper();
const compactRecipeHandler = new CompactRecipeHandler(recipeRepository, compactRecipeDtoEntityMapper);
/**
* Load recipe header data of all recipes matching the filter. If no filter is given, all recipes are returned.
*/
router.get(
"/list-by-filter",
asyncHandler(async (req , res) => {

View file

@ -2,8 +2,8 @@ import { CompactRecipeDto } from "../api/dtos/CompactRecipeDto.js";
import { RecipeEntity } from "../entities/RecipeEntity.js";
import { CompactRecipeDtoEntityMapper } from "../mappers/CompactRecipeDtoEntityMapper.js";
import { RecipeRepository } from "../repositories/RecipeRepository.js";
import {CompactRecipeFilterRequest} from "../api/dtos/CompactRecipeFilterRequest.js";
import {CompactRecipeFilterResponse} from "../api/dtos/CompactRecipeFilterResponse.js";
import { CompactRecipeFilterRequest } from "../api/dtos/CompactRecipeFilterRequest.js";
import { CompactRecipeFilterResponse } from "../api/dtos/CompactRecipeFilterResponse.js";
/**
* Responsible for loading recipe header data
@ -16,46 +16,25 @@ export class CompactRecipeHandler {
/**
* Load list of all recipes
* @returns List of all recipes
* @returns List of all recipes as CompactRecipeDtos
*/
async getAllCompactRecipes() {
async getAllCompactRecipes(): Promise<CompactRecipeDto[]> {
const recipeEntities: RecipeEntity[] = await this.repository.findAll();
let recipeDtos: CompactRecipeDto[] = [];
recipeEntities.forEach(recipeEntity => {
recipeDtos.push(this.mapper.toDto(recipeEntity));
});
return recipeDtos;
return this.mapper.mapEntityListToDtoList(recipeEntities);
}
/**
* Get all recipes matching search
*
* Recipe title must contain type string
* @todo Full text search??
* Get all recipes matching the given filter criteria.
* @param request Filter request containing optional search string and tag ID list
* @returns CompactRecipeFilterResponse containing the matching recipes
*/
async getMatchingRecipes(searchString : string){
if(!searchString || searchString.length===0){
// get all
return this.getAllCompactRecipes();
} else {
const tagIdList : string[] = [];
return this.repository.findCompactRecipeByFilter(searchString, tagIdList);
}
}
async getRecipesByFilter(request: CompactRecipeFilterRequest) : Promise<CompactRecipeFilterResponse> {
const searchString = request.searchString;
const tagIdList = request.tagIdList;
var recipeEntities : RecipeEntity[] = await this.repository.findCompactRecipeByFilter(searchString, tagIdList);
async getRecipesByFilter(request: CompactRecipeFilterRequest): Promise<CompactRecipeFilterResponse> {
const recipeEntities: RecipeEntity[] = await this.repository.findCompactRecipeByFilter(
request.searchString,
request.tagIdList
);
const response = new CompactRecipeFilterResponse();
// @todo move list mapping to mapper
let recipeDtos: CompactRecipeDto[] = [];
// Add mapper function to map the result list.
recipeEntities.forEach(recipeEntity => {
recipeDtos.push(this.mapper.toDto(recipeEntity));
});
response.compactRecipeList = recipeDtos;
response.compactRecipeList = this.mapper.mapEntityListToDtoList(recipeEntities);
return response;
}
}

View file

@ -2,80 +2,102 @@ import { AbstractDto } from "../api/dtos/AbstractDto.js";
import { AbstractEntity } from "../entities/AbstractEntity.js";
export abstract class AbstractDtoEntityMapper<
E extends AbstractEntity,
D extends AbstractDto
E extends AbstractEntity,
D extends AbstractDto
> {
/**
* Map base entity fields (id, createdAt, updatedAt) to DTO.
*/
protected mapBaseEntityToDto(entity: E, dto: D): D {
dto.id = entity.id;
dto.createdAt = entity.createDate;
dto.updatedAt = entity.updateDate;
return dto;
}
/**
* Map base DTO fields to entity.
*/
protected mapBaseDtoToEntity(dto: D, entity: E): E {
entity.id = dto.id;
entity.createDate = dto.createdAt;
entity.updateDate = dto.updatedAt;
return entity;
}
/**
* Merge entity list with changes contained in DTO list
* @param dtos List of dtos
* @param entities List of entities
* @returns Merged list
*
* elements no longer contained in the dto list will be removed from the entity list
* new elements will be mapped to entity and added to the entity list
* existing elements will be updated
*/
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));
}
/**
* Map base entity fields (id, createdAt, updatedAt) to DTO.
*/
protected mapBaseEntityToDto(entity: E, dto: D): D {
dto.id = entity.id;
dto.createdAt = entity.createDate;
dto.updatedAt = entity.updateDate;
return dto;
}
return updatedEntities;
}
/**
* Map base DTO fields to entity.
*/
protected mapBaseDtoToEntity(dto: D, entity: E): E {
entity.id = dto.id;
entity.createDate = dto.createdAt;
entity.updateDate = dto.updatedAt;
return entity;
}
// Abstract methods to be implemented by subclasses
/**
* Maps an entity to DTO
* @param entity Entity that is mapped to DTO
*/
abstract toDto(entity: E): D;
/**
* Maps a DTO to entity
* @param dto DTO to map to entity
*/
abstract toEntity(dto: D): E;
/**
* Merge changes in DTO into entity
* @param dto Dto containing changes
* @param entity existing entity
*
* Used for merging user changes (DTO) into the existing entity (database).
*/
abstract mergeDtoIntoEntity(dto: D, entity: E): E;
/**
* Defines how to create a new entity. Required by mergeDtoListIntoEntityList
* to add new elements to the list
*/
abstract createNewEntity() : E;
}
/**
* Maps a list of entities to a list of DTOs.
* @param entities List of entities to map
* @returns List of DTOs
*/
mapEntityListToDtoList(entities: E[]): D[] {
return entities.map(entity => this.toDto(entity));
}
/**
* Maps a list of DTOs to a list of entities.
* @param dtos List of DTOs to map
* @returns List of entities
*/
mapDtoListToEntityList(dtos: D[]): E[] {
return dtos.map(dto => this.toEntity(dto));
}
/**
* Merge entity list with changes contained in DTO list.
* @param dtos List of dtos
* @param entities List of entities
* @returns Merged list
*
* Elements no longer contained in the dto list will be removed from the entity list.
* New elements will be mapped to entity and added to the entity list.
* Existing elements will be updated.
*/
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;
}
// Abstract methods to be implemented by subclasses
/**
* Maps an entity to DTO.
* @param entity Entity that is mapped to DTO
*/
abstract toDto(entity: E): D;
/**
* Maps a DTO to entity.
* @param dto DTO to map to entity
*/
abstract toEntity(dto: D): E;
/**
* Merge changes in DTO into entity.
* @param dto Dto containing changes
* @param entity existing entity
*
* Used for merging user changes (DTO) into the existing entity (database).
*/
abstract mergeDtoIntoEntity(dto: D, entity: E): E;
/**
* Defines how to create a new entity. Required by mergeDtoListIntoEntityList
* to add new elements to the list.
*/
abstract createNewEntity(): E;
}

View file

@ -2,7 +2,10 @@ import { CompactRecipeDto } from "../api/dtos/CompactRecipeDto.js";
import { RecipeEntity } from "../entities/RecipeEntity.js";
import { AbstractDtoEntityMapper } from "./AbstractDtoEntityMapper.js";
export class CompactRecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity,CompactRecipeDto>{
export class CompactRecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity, CompactRecipeDto> {
constructor() {
super();
}
toDto(entity: RecipeEntity): CompactRecipeDto {
const dto = new CompactRecipeDto();
@ -13,20 +16,23 @@ export class CompactRecipeDtoEntityMapper extends AbstractDtoEntityMapper<Recipe
return dto;
}
toEntity(dto: CompactRecipeDto): RecipeEntity {
throw new Error("Mapping CompactRecipeDto to RecipeEntity is not allowed!");
}
toEntity(dto: CompactRecipeDto): RecipeEntity {
throw new Error("Mapping CompactRecipeDto to RecipeEntity is not allowed!");
}
createNewEntity() : RecipeEntity {
throw new Error("Mapping CompactRecipeDto to RecipeEntity is not allowed!");
}
createNewEntity(): RecipeEntity {
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!");
}
mergeDtoIntoEntity(dto: CompactRecipeDto, entity: RecipeEntity): 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!");
}
mergeDtoListIntoEntityList(dtos: CompactRecipeDto[], entities: RecipeEntity[]): RecipeEntity[] {
throw new Error("Mapping CompactRecipeDto to RecipeEntity is not allowed!");
}
mapDtoListToEntityList(dtos: CompactRecipeDto[]): RecipeEntity[] {
throw new Error("Mapping CompactRecipeDto to RecipeEntity is not allowed!");
}
}

View file

@ -6,7 +6,6 @@ import { AbstractDtoEntityMapper } from "./AbstractDtoEntityMapper.js";
import { RecipeIngredientGroupDtoEntityMapper } from "./RecipeIngredientGroupDtoEntityMapper.js";
import { RecipeInstructionStepDtoEntityMapper } from "./RecipeInstructionStepDtoEntityMapper.js";
import { TagDtoEntityMapper } from "./TagDtoEntityMapper.js";
import {TagEntity} from "../entities/TagEntity.js";
export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity, RecipeDto> {
constructor(
@ -40,9 +39,7 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity,
});
// map tags
dto.tagList = (entity.tagList ?? []).map((tagEntity) =>
this.tagMapper.toDto(tagEntity)
);
dto.tagList = this.tagMapper.mapEntityListToDtoList(entity.tagList ?? []);
return dto;
}
@ -90,9 +87,7 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity,
});
// map tags
entity.tagList = (dto.tagList ?? []).map((tagDto) =>
this.tagMapper.toEntity(tagDto)
);
entity.tagList = this.tagMapper.mapDtoListToEntityList(dto.tagList ?? []);
return entity;
}
@ -115,15 +110,12 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity,
);
// --- Tags ---
/* Only build a stub here instead of using the mapper to avoid causing a cascade on the tag table while
* updating the join table correctly. For this purpose, the ORM only needs to know the tag ID in order to
* signal that we are dealing with an existing tag.
*/
entity.tagList = (dto.tagList ?? []).map((tagDto) => {
const stub = new TagEntity();
stub.id = tagDto.id;
return stub;
});
// Tags are looked up by ID and replaced wholesale: the recipe holds
// references to existing tag entities, so we map each DTO to its entity
// form and let TypeORM sync the join table on save.
entity.tagList = (dto.tagList ?? []).map((tagDto) =>
this.tagMapper.toEntity(tagDto)
);
return entity;
}