-
Notifications
You must be signed in to change notification settings - Fork 8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
카테고리 순서 변경 #988
base: dev/be
Are you sure you want to change the base?
카테고리 순서 변경 #988
Conversation
15f20ef
to
1242840
Compare
316e4ef
to
5a2e765
Compare
c0558b1
to
d795cbe
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
코드 넘 깔끔해졌네요 ⚡⚡⚡
버그 발견해서 제안과 함께 RC 남겨요!!
private void validateOrdinal(Member member, CreateCategoryRequest request) { | ||
long ordinal = request.ordinal(); | ||
long count = categoryRepository.countByMember(member); | ||
if (ordinal != count) { | ||
throw new CodeZapException(ErrorCode.INVALID_REQUEST, "카테고리 순서가 잘못되었습니다."); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
코드가 너무 길어서 검증 로직을 CategoryValidationService로 분리하면 어떨까 제안해요.
@Service
@RequiredArgsConstructor
public class CategoryService {
private final CategoryRepository categoryRepository;
private final CategoryValidationService validationService;
@Transactional
public CreateCategoryResponse create(Member member, CreateCategoryRequest request) {
validationService.validateDuplicatedCategory(request.name(), member);
validationService.validateOrdinal(member, request);
Category category = categoryRepository.save(createCategory(member, request));
return CreateCategoryResponse.from(category);
}
@Transactional(readOnly = true)
public FindAllCategoriesResponse findAllByMemberId(Long memberId) {
return FindAllCategoriesResponse.from(categoryRepository.findAllByMemberIdOrderById(memberId));
}
@Transactional
public void updateCategories(Member member, UpdateAllCategoriesRequest request) {
validationService.validateDuplicateNameRequest(request);
validationService.validateOrdinals(request);
validationService.validateIds(request);
createCategories(member, request);
updateCategories(request.updateCategories(), member);
deleteCategories(request.deleteCategoryIds(), member);
validationService.validateCategoriesCount(member, request);
}
private void createCategories(Member member, UpdateAllCategoriesRequest request) {
categoryRepository.saveAll(request.createCategories().stream()
.map(createRequest -> new Category(createRequest.name(), member, createRequest.ordinal()))
.toList());
}
private void updateCategories(List<UpdateCategoryRequest> updates, Member member) {
updates.forEach(update -> {
Category category = categoryRepository.fetchById(update.id());
category.validateAuthorization(member);
validationService.validateDefaultCategory(category);
category.update(update.name(), update.ordinal());
});
}
private void deleteCategories(List<Long> ids, Member member) {
ids.forEach(id -> {
Category category = categoryRepository.fetchById(id);
category.validateAuthorization(member);
validationService.validateDefaultCategory(category);
validationService.validateHasTemplate(id);
categoryRepository.deleteById(id);
});
}
}
package codezap.category.service;
import java.util.HashSet;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import codezap.category.domain.Category;
import codezap.category.dto.request.CreateCategoryRequest;
import codezap.category.dto.request.UpdateAllCategoriesRequest;
import codezap.category.dto.request.UpdateCategoryRequest;
import codezap.category.repository.CategoryRepository;
import codezap.global.exception.CodeZapException;
import codezap.global.exception.ErrorCode;
import codezap.member.domain.Member;
import codezap.template.repository.TemplateRepository;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class CategoryValidationService {
private final CategoryRepository categoryRepository;
private final TemplateRepository templateRepository;
public void validateDuplicatedCategory(String categoryName, Member member) {
if (categoryRepository.existsByNameAndMember(categoryName, member)) {
throw new CodeZapException(ErrorCode.DUPLICATE_CATEGORY, "이름이 " + categoryName + "인 카테고리가 이미 존재합니다.");
}
}
public void validateDefaultCategory(Category category) {
if (category.isDefault()) {
throw new CodeZapException(ErrorCode.DEFAULT_CATEGORY, "기본 카테고리는 수정 및 삭제할 수 없습니다.");
}
}
public void validateHasTemplate(Long id) {
if (templateRepository.existsByCategoryId(id)) {
throw new CodeZapException(ErrorCode.CATEGORY_HAS_TEMPLATES, "템플릿이 존재하는 카테고리는 삭제할 수 없습니다.");
}
}
public void validateDuplicateNameRequest(UpdateAllCategoriesRequest request) {
List<String> allNames = Stream.concat(
request.createCategories().stream().map(CreateCategoryRequest::name),
request.updateCategories().stream().map(UpdateCategoryRequest::name)
).toList();
if (hasDuplicates(allNames)) {
throw new CodeZapException(ErrorCode.INVALID_REQUEST, "요청에 중복된 카테고리 이름이 존재합니다.");
}
}
public void validateOrdinal(Member member, CreateCategoryRequest request) {
long ordinal = request.ordinal();
long count = categoryRepository.countByMember(member);
if (ordinal != count) {
throw new CodeZapException(ErrorCode.INVALID_REQUEST, "카테고리 순서가 잘못되었습니다.");
}
}
public void validateOrdinals(UpdateAllCategoriesRequest request) {
List<Long> allOrdinals = Stream.concat(
request.createCategories().stream().map(CreateCategoryRequest::ordinal),
request.updateCategories().stream().map(UpdateCategoryRequest::ordinal)
).sorted().toList();
if (!isSequential(allOrdinals)) {
throw new CodeZapException(ErrorCode.INVALID_REQUEST, "카테고리 순서가 연속적이지 않습니다.");
}
}
public void validateIds(UpdateAllCategoriesRequest request) {
List<Long> allIds = Stream.concat(
request.updateCategories().stream().map(UpdateCategoryRequest::id),
request.deleteCategoryIds().stream()
).toList();
if (hasDuplicates(allIds)) {
throw new CodeZapException(ErrorCode.INVALID_REQUEST, "요청에 중복된 id가 존재합니다.");
}
}
public void validateCategoriesCount(Member member, UpdateAllCategoriesRequest request) {
if (request.updateCategories().size() + request.createCategories().size()
!= categoryRepository.countByMember(member) - 1) {
throw new CodeZapException(ErrorCode.INVALID_REQUEST, "카테고리의 개수가 일치하지 않습니다.");
}
}
private boolean hasDuplicates(List<?> items) {
return items.size() != new HashSet<>(items).size();
}
private boolean isSequential(List<Long> ordinals) {
return ordinals.stream()
.distinct()
.sorted()
.collect(Collectors.toList())
.equals(ordinals);
}
}
다른 분들 의견도 궁금하네용 동의한다면 따봉을, 다른 의견이 있다면 코멘트 부탁해요~!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
네이밍을 단순하게 CategoryValidator
로 지어도 좋을 것 같긴 한데, XXXRepository
들의 의존성을 가지고 있기도 하네요.
CategoryValidationService
도 좋을 것 같기도 하구요~~
@@ -0,0 +1 @@ | |||
ALTER TABLE category ADD COLUMN ordinal BIGINT NOT NULL; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
⚡
(member_id, ordinal) 합성 필드에 unique 조건 걸어야 하지 않을까요?!
ALTER TABLE category ADD COLUMN ordinal BIGINT NOT NULL; | |
ALTER TABLE category ADD COLUMN ordinal BIGINT NOT NULL; | |
ALTER TABLE category ADD CONSTRAINT member_ordinal_unique UNIQUE (member_id, ordinal); |
public Category(String name, Member member, long ordinal) { | ||
this.name = name; | ||
this.member = member; | ||
this.isDefault = false; | ||
this.ordinal = ordinal; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@AllArgsConstructor
생성자 체이닝 제안해요~
public Category(String name, Member member, long ordinal) { | |
this.name = name; | |
this.member = member; | |
this.isDefault = false; | |
this.ordinal = ordinal; | |
} | |
public Category(String name, Member member, long ordinal) { | |
this(null, member, name, false, ordinal); | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
초롱 수고했어요~ 최대한 꼼꼼하게 리뷰했습니다~ 확인 부탁드려요~!
@NotNull(message = "카테고리 순서가 null 입니다.", groups = NotNullGroup.class) | ||
Long ordinal |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
null 뿐만 아니라 최소 값 check도 하면 좋을 것 같아요
@Min(value = 1, message = "1 이상이어야 합니다.")
|
||
@Schema(description = "카테고리 순서", example = "1") | ||
@NotNull(message = "카테고리 순서가 null 입니다.", groups = NotNullGroup.class) | ||
Long ordinal |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
여기도 null 뿐만 아니라 최소 값 check도 하면 좋을 것 같아요
@Min(value = 1, message = "1 이상이어야 합니다.")
@@ -49,34 +49,19 @@ ResponseEntity<CreateCategoryResponse> createCategory( | |||
@SecurityRequirement(name = "쿠키 인증 토큰") | |||
@Operation(summary = "카테고리 수정", description = "해당하는 식별자의 카테고리를 수정합니다.") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
설명을 조금 수정하면 좋을 것 같아요 !
설명만 보면 업데이트 기능만 존재하는 것 같으나, 실제로는 생성, 삭제도 진행되고 있으니까요 !
카테고리를 생성, 수정, 삭제할 수 있습니다.
혹은 사용자의 카테고리들을 생성, 수정, 삭제할 수 있습니다.
어떤가요?
@ErrorCase(description = "카테고리 이름이 15자를 초과한 경우", exampleMessage = "카테고리 이름은 최대 15자까지 입력 가능합니다."), | ||
@ErrorCase(description = "카테고리의 순서가 잘못된 경우", exampleMessage = "템플릿 순서가 중복됩니다.") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ErrorCase(description = "모든 필드 중 null인 값이 있는 경우", exampleMessage = "카테고리 이름이 null 입니다."),
@ErrorCase(description = "카테고리 이름이 15자를 초과한 경우", exampleMessage = "카테고리 이름은 최대 15자까지 입력 가능합니다."),
@ErrorCase(description = "삭제하려는 카테고리에 템플릿이 존재하는 경우", exampleMessage = "템플릿이 존재하는 카테고리는 삭제할 수 없습니다."),
해당 에러 케이스도 추가해주세요~!
그리고 마지막 에러 케이스에도 ,
를 두면 추후 또 다른 에러 케이스가 추가됬을 때 변경 사항이 안잡혀서 좋을 것 같아요 ~!
import codezap.global.validation.ValidationGroups.NotNullGroup; | ||
import io.swagger.v3.oas.annotations.media.Schema; | ||
|
||
public record UpdateAllCategoriesRequest( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(⚡어필의 차원에서 zap을 사용합니다 ㅎㅎ )
해당 dto에서 fail fast를 위해 소스 코드 request처럼 SourceCodesOrdinalValidator를 이용해서 dto에서도 순서 검증하는 건 어떨까요?
현재 해당 로직이 템플릿 수정 로직과 매우 유사하다고 생각합니다. 그래서 현재 SourceCodesOrdinalValidator의 이름을 OrdinalValidator로 변경하여 소스코드에서만 사용하지 않고 이렇게 순서 검증이 필요한 곳에서 재사용하면 좋을 것 같아요 ~!
이렇게 한다면 ValidatedSourceCodesOrdinalRequest의
@SourceCodesOrdinal(message = "소스 코드 순서가 잘못되었습니다.", groups = SourceCodeOrdinalGroup.class)
이 메시지를 순서가 잘못되었습니다.
로 어디서든 사용될 수 있게 변경한다면 더 재사용하는데 좋을 것 같아요~
이에 대해 어떻게 생각하시나요?
만약 변경에 동의하시다면 지금 당장 클래스 이름은 변경 안하면 좋을 것 같아요 ,,! 해당 클래스를 #991 에서 사용하고 있어서 추후 둘 다 머지되고 이름 변경하면 좋을 것 같아요~
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이미 동일한 기능을 하는 Validator
가 존재하니 좋은 의견이네요~~
소스코드와 카테고리의 동작이 언제까지 동일할지 모르니 기존의 클래스를 완전 재사용하는 것이 조금은 조심스럽기도 하지만, 나중에 동작이 달라져야 할 때 상속을 통한 분리를 고려해 봐도 좋을 것 같고요~~
).toList(); | ||
|
||
if (allNames.size() != new HashSet<>(allNames).size()) { | ||
throw new CodeZapException(ErrorCode.INVALID_REQUEST, "요청에 중복된 카테고리 이름이 존재합니다."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
swagger에는 "이름이 %s 인 카테고리가 이미 존재합니다."
로 에러메시지로 되어있네요~ 둘 중 하나를 변경해 통일성 있게 해주세요~
@ErrorCase(description = "카테고리 이름이 15자를 초과한 경우", exampleMessage = "카테고리 이름은 최대 15자까지 입력 가능합니다."), | ||
@ErrorCase(description = "카테고리의 순서가 잘못된 경우", exampleMessage = "템플릿 순서가 중복됩니다.") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
애플리케이션 예외 메시지와 동일하게
@ErrorCase(description = "카테고리의 순서가 잘못된 경우", exampleMessage = "템플릿 순서가 중복됩니다.") | |
@ErrorCase(description = "카테고리의 순서가 잘못된 경우", exampleMessage = "카테고리 순서가 잘못되었습니다.") |
로 변경해주세요~!
.allMatch(i -> allOrdinals.get(i) == i + 1); | ||
|
||
if (!isSequential) { | ||
throw new CodeZapException(ErrorCode.INVALID_REQUEST, "카테고리 순서가 연속적이지 않습니다."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
해당 에러 메시지를 카테고리 순서가 잘못되었습니다.
로 해서 순서에 대한 예외처리 메시지를 동일하게 해주면 좋을 것 같아요.
그렇지 않다면 해당 예외 메시지도 swagger에 추가해주세요
).toList(); | ||
|
||
if (allIds.size() != new HashSet<>(allIds).size()) { | ||
throw new CodeZapException(ErrorCode.INVALID_REQUEST, "요청에 중복된 id가 존재합니다."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
해당 예외도 swagger에 추가해주세요~
private void validateCategoriesCount(Member member, UpdateAllCategoriesRequest request) { | ||
if (request.updateCategories().size() + request.createCategories().size() | ||
!= categoryRepository.countByMember(member) - 1) { | ||
throw new CodeZapException(ErrorCode.INVALID_REQUEST, "카테고리의 개수가 일치하지 않습니다."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
해당 예외도 swagger에 추가해주세요~
⚡️ 관련 이슈
close #966
📍주요 변경 사항
검증 로직
카테고리 생성시 (템플릿 생성 화면에서 생성)
카테고리 편집시 (모달창에서 생성, 수정, 삭제를 한 번에 하는 것)
혹시 더 추가로 검증할만한 내용이 있는지 확인해주시면 감사드리겠습니다.
🎸기타
🍗 PR 첫 리뷰 마감 기한
12/26 23:59