Skip to content
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

[고도화] 해시태그 검색 기능 고도화 #65

Merged
merged 4 commits into from
Jan 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public String articles(
map.addAttribute("articles", articles);
map.addAttribute("paginationBarNumbers", barNumbers);
map.addAttribute("searchTypes", SearchType.values());
map.addAttribute("searchTypeHashtag", SearchType.HASHTAG);

return "articles/index";
}
Expand All @@ -53,6 +54,7 @@ public String article(@PathVariable Long articleId, ModelMap map) {
map.addAttribute("article", article);
map.addAttribute("articleComments", article.articleCommentsResponse());
map.addAttribute("totalCount", articleService.getArticleCount());
map.addAttribute("searchTypeHashtag", SearchType.HASHTAG);

return "articles/detail";
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.fastcampus.projectboard.service;

import com.fastcampus.projectboard.domain.Article;
import com.fastcampus.projectboard.domain.Hashtag;
import com.fastcampus.projectboard.domain.UserAccount;
import com.fastcampus.projectboard.domain.constant.SearchType;
import com.fastcampus.projectboard.dto.ArticleDto;
import com.fastcampus.projectboard.dto.ArticleWithCommentsDto;
import com.fastcampus.projectboard.repository.ArticleRepository;
import com.fastcampus.projectboard.repository.HashtagRepository;
import com.fastcampus.projectboard.repository.UserAccountRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -17,15 +19,19 @@
import javax.persistence.EntityNotFoundException;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class ArticleService {

private final HashtagService hashtagService;
private final ArticleRepository articleRepository;
private final UserAccountRepository userAccountRepository;
private final HashtagRepository hashtagRepository;

@Transactional(readOnly = true)
public Page<ArticleDto> searchArticles(SearchType searchType, String searchKeyword, Pageable pageable) {
Expand Down Expand Up @@ -62,7 +68,12 @@ public ArticleDto getArticle(Long articleId) {

public void saveArticle(ArticleDto dto) {
UserAccount userAccount = userAccountRepository.getReferenceById(dto.userAccountDto().userId());
articleRepository.save(dto.toEntity(userAccount));
Set<Hashtag> hashtags = renewHashtagsFromContent(dto.content());

Article article = dto.toEntity(userAccount);
article.addHashtags(hashtags);

articleRepository.save(article);
}

public void updateArticle(Long articleId, ArticleDto dto) {
Expand All @@ -71,37 +82,68 @@ public void updateArticle(Long articleId, ArticleDto dto) {
UserAccount userAccount = userAccountRepository.getReferenceById(dto.userAccountDto().userId());

if (article.getUserAccount().equals(userAccount)) {
if (dto.title() != null) {
article.setTitle(dto.title());
}
if (dto.content() != null) {
article.setContent(dto.content());
}
if (dto.title() != null) { article.setTitle(dto.title()); }
if (dto.content() != null) { article.setContent(dto.content()); }

Set<Long> hashtagIds = article.getHashtags().stream()
.map(Hashtag::getId)
.collect(Collectors.toUnmodifiableSet());
article.clearHashtags();
articleRepository.flush();

hashtagIds.forEach(hashtagService::deleteHashtagWithoutArticles);

Set<Hashtag> hashtags = renewHashtagsFromContent(dto.content());
article.addHashtags(hashtags);
}
} catch (EntityNotFoundException e) {
log.warn("게시글 업데이트 실패. 게시글을 수정하는데 필요한 정보를 찾을 수 없습니다 - {}", e.getLocalizedMessage());
}
}

public void deleteArticle(long articleId, String userId) {
Article article = articleRepository.getReferenceById(articleId);
Set<Long> hashtagIds = article.getHashtags().stream()
.map(Hashtag::getId)
.collect(Collectors.toUnmodifiableSet());

articleRepository.deleteByIdAndUserAccount_UserId(articleId, userId);
articleRepository.flush();

hashtagIds.forEach(hashtagService::deleteHashtagWithoutArticles);
}

public long getArticleCount() {
return articleRepository.count();
}

@Transactional(readOnly = true)
public Page<ArticleDto> searchArticlesViaHashtag(String hashtag, Pageable pageable) {
if (hashtag == null || hashtag.isBlank()) {
public Page<ArticleDto> searchArticlesViaHashtag(String hashtagName, Pageable pageable) {
if (hashtagName == null || hashtagName.isBlank()) {
return Page.empty(pageable);
}

return articleRepository.findByHashtagNames(null, pageable).map(ArticleDto::from);
return articleRepository.findByHashtagNames(List.of(hashtagName), pageable)
.map(ArticleDto::from);
}

public List<String> getHashtags() {
return articleRepository.findAllDistinctHashtags();
return hashtagRepository.findAllHashtagNames(); //TODO: HashtagService로 이동을 고려해보자.
}

}
private Set<Hashtag> renewHashtagsFromContent(String content) {
Set<String> hashtagNamesInContent = hashtagService.parseHashtagNames(content);
Set<Hashtag> hashtags = hashtagService.findHashtagsByNames(hashtagNamesInContent);
Set<String> existingHashtagNames = hashtags.stream()
.map(Hashtag::getHashtagName)
.collect(Collectors.toUnmodifiableSet());

hashtagNamesInContent.forEach(newHashtagName -> {
if(!existingHashtagNames.contains(newHashtagName)) {
hashtags.add(Hashtag.of(newHashtagName));
}
});

return hashtags;
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,44 @@
package com.fastcampus.projectboard.service;

import com.fastcampus.projectboard.domain.Hashtag;
import com.fastcampus.projectboard.repository.HashtagRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@RequiredArgsConstructor
@Service
public class HashtagService {
public Object parseHashtagNames(String content) {
return null;

private final HashtagRepository hashtagRepository;
public Set<String> parseHashtagNames(String content) {
if(content == null) {
return Set.of();
}

Pattern pattern = Pattern.compile("#[\\w가-힣]+");
Matcher matcher = pattern.matcher(content.strip());
Set<String> result = new HashSet<>();

while(matcher.find()) {
result.add(matcher.group().replace("#", ""));
}

return Set.copyOf(result);
}

public Object findHashtagsByNames(Set<String> expectedHashtagNames) {
return null;
public Set<Hashtag> findHashtagsByNames(Set<String> hashtagNames) {
return new HashSet<>(hashtagRepository.findByHashtagNameIn(hashtagNames));
}

public void deleteHashtagWithoutArticles(Object any) {
public void deleteHashtagWithoutArticles(Long hashtagId) {
Hashtag hashtag = hashtagRepository.getReferenceById(hashtagId);
if(hashtag.getArticles().isEmpty()) {
hashtagRepository.delete(hashtag);
}
}
}
2 changes: 1 addition & 1 deletion src/main/resources/templates/articles/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ <h1>첫번째 글</h1>
<p><span id="nickname">Uno</span></p>
<p><a id="email" href="mailto:[email protected]">[email protected]</a></p>
<p><time id="created-at" datetime="2022-01-01T00:00:00">2022-01-01</time></p>
<p><span id="hashtag">#java</span></p>
<p><span id="hashtag" class="badge text-bg-secondary mx-1"><a class="text-reset">#java</a></span></p>
</aside>
</section>

Expand Down
8 changes: 7 additions & 1 deletion src/main/resources/templates/articles/detail.th.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
<attr sel="#nickname" th:text="*{nickname}" />
<attr sel="#email" th:text="*{email}" />
<attr sel="#created-at" th:datetime="*{createdAt}" th:text="*{#temporals.format(createdAt, 'yyyy-MM-dd HH:mm:ss')}" />
<attr sel="#hashtag" th:text="*{hashtag}" />
<attr sel="#hashtag" th:each="hashtag : ${article.hashtags}">
<attr sel="a"
th:text="'#' + ${hashtag}"
th:href="@{/articles(searchType=${searchTypeHashtag},searchValue=${hashtag})}"
/>
</attr>

<attr sel="#article-content/pre" th:text="*{content}" />

<attr sel="#article-buttons" th:if="${#authorization.expression('isAuthenticated()')} and *{userId} == ${#authentication.name}">
Expand Down
6 changes: 0 additions & 6 deletions src/main/resources/templates/articles/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,6 @@ <h1>게시글 작성</h1>
<textarea class="form-control" id="content" name="content" rows="5" required></textarea>
</div>
</div>
<div class="row mb-4 justify-content-md-center">
<label for="hashtag" class="col-sm-2 col-lg-1 col-form-label text-sm-end">해시태그</label>
<div class="col-sm-8 col-lg-9">
<input type="text" class="form-control" id="hashtag" name="hashtag">
</div>
</div>
<div class="row mb-5 justify-content-md-center">
<div class="col-sm-10 d-grid gap-2 d-sm-flex justify-content-sm-end">
<button type="submit" class="btn btn-primary" id="submit-button">저장</button>
Expand Down
1 change: 0 additions & 1 deletion src/main/resources/templates/articles/form.th.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
<attr sel="#article-form" th:action="${formStatus?.update} ? '/articles/' + ${article.id} + '/form' : '/articles/form'" th:method="post">
<attr sel="#title" th:value="${article?.title} ?: _" />
<attr sel="#content" th:text="${article?.content} ?: _" />
<attr sel="#hashtag" th:value="${article?.hashtag} ?: _" />
<attr sel="#submit-button" th:text="${formStatus?.description} ?: _" />
<attr sel="#cancel-button" th:onclick="'history.back()'" />
</attr>
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/templates/articles/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
<tbody>
<tr>
<td class="title"><a>첫글</a></td>
<td class="hashtag">#java</td>
<td class="hashtag"><span class="badge text-bg-secondary mx-1"><a class="text-reset">#java</a></span></td>
<td class="user-id">Uno</td>
<td class="created-at"><time>2022-01-01</time></td>
</tr>
Expand Down
9 changes: 7 additions & 2 deletions src/main/resources/templates/articles/index.th.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
)}"/>
<attr sel="th.hashtag/a" th:text="'해시태그'" th:href="@{/articles(
page=${articles.number},
sort='hashtag' + (*{sort.getOrderFor('hashtag')} != null ? (*{sort.getOrderFor('hashtag').direction.name} != 'DESC' ? ',desc' : '') : ''),
sort='hashtags' + (*{sort.getOrderFor('hashtags')} != null ? (*{sort.getOrderFor('hashtags').direction.name} != 'DESC' ? ',desc' : '') : ''),
searchType=${param.searchType},
searchValue=${param.searchValue}
)}"/>
Expand All @@ -46,7 +46,12 @@
<attr sel="tbody" th:remove="all-but-first">
<attr sel="tr[0]" th:each="article : ${articles}">
<attr sel="td.title/a" th:text="${article.title}" th:href="@{'/articles/' + ${article.id}}" />
<attr sel="td.hashtag" th:text="${article.hashtag}" />
<attr sel="td.hashtag/span" th:each="hashtag : ${article.hashtags}">
<attr sel="a"
th:text="'#' + ${hashtag}"
th:href="@{/articles(searchType=${searchTypeHashtag},searchValue=${hashtag})}"
/>
</attr>
<attr sel="td.user-id" th:text="${article.nickname}" />
<attr sel="td.created-at/time" th:datetime="${article.createdAt}" th:text="${#temporals.format(article.createdAt, 'yyyy-MM-dd')}" />
</attr>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,6 @@ void givenArticleInfo_whenSavingArticle_thenExtractsHashtagsFromContentAndSavesA

given(userAccountRepository.getReferenceById(dto.userAccountDto().userId())).willReturn(createUserAccount());
given(hashtagService.parseHashtagNames(dto.content())).willReturn(expectedHashtagNames);
given(hashtagService.findHashtagsByNames(expectedHashtagNames)).willReturn(expectedHashtags);
given(articleRepository.save(any(Article.class))).willReturn(createArticle());

// When
Expand All @@ -242,7 +241,6 @@ void givenModifiedArticleInfo_whenUpdatingArticle_thenUpdatesArticle() {
willDoNothing().given(articleRepository).flush();
willDoNothing().given(hashtagService).deleteHashtagWithoutArticles(any());
given(hashtagService.parseHashtagNames(dto.content())).willReturn(expectedHashtagNames);
given(hashtagService.findHashtagsByNames(expectedHashtagNames)).willReturn(expectedHashtags);

// When
sut.updateArticle(dto.id(), dto);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.fastcampus.projectboard.service;

import com.fastcampus.projectboard.domain.Hashtag;
import com.fastcampus.projectboard.repository.HashtagRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;
import java.util.Set;
import java.util.stream.Stream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.params.provider.Arguments.arguments;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;


@DisplayName("비즈니스 로직 - 해시태그")
@ExtendWith(MockitoExtension.class)
class HashtagServiceTest {

@InjectMocks private HashtagService sut; //System under Test

@Mock private HashtagRepository hashtagRepository;

@DisplayName("본문을 파싱하면, 해시태그 이름들을 중복없이 반환한다.")
@MethodSource
@ParameterizedTest(name = "[{index}] \"{0}\" => {1}")
void givenContent_whenParsing_thenReturnsUniqueHashtagNames(String input, Set<String> expected) {
// Given

// When
Set<String> actual = sut.parseHashtagNames(input);

// Then
assertThat(actual).containsExactlyInAnyOrderElementsOf(expected); // 순서는 상관없고, 요소들은 정확하게 다 들어가 있어야 함.
then(hashtagRepository).shouldHaveNoInteractions();
}

static Stream<Arguments> givenContent_whenParsing_thenReturnsUniqueHashtagNames() {
return Stream.of(
arguments(null, Set.of()),
arguments("", Set.of()),
arguments(" ", Set.of()),
arguments("#", Set.of()),
arguments(" #", Set.of()),
arguments("# ", Set.of()),
arguments("java", Set.of()),
arguments("java#", Set.of()),
arguments("ja#va", Set.of("va")),
arguments("#java", Set.of("java")),
arguments("#java_spring", Set.of("java_spring")),
arguments("#java-spring", Set.of("java")),
arguments("#_java_spring", Set.of("_java_spring")),
arguments("#-java-spring", Set.of()),
arguments("#_java_spring__", Set.of("_java_spring__")),
arguments("#java#spring", Set.of("java", "spring")),
arguments("#java #spring", Set.of("java", "spring")),
arguments("#java #spring", Set.of("java", "spring")),
arguments("#java #spring", Set.of("java", "spring")),
arguments("#java #spring", Set.of("java", "spring")),
arguments(" #java #spring ", Set.of("java", "spring")),
arguments(" #java #spring ", Set.of("java", "spring")),
arguments("#java#spring#부트", Set.of("java", "spring", "부트")),
arguments("#java #spring#부트", Set.of("java", "spring", "부트")),
arguments("#java#spring #부트", Set.of("java", "spring", "부트")),
arguments("#java,#spring,#부트", Set.of("java", "spring", "부트")),
arguments("#java.#spring;#부트", Set.of("java", "spring", "부트")),
arguments("#java|#spring:#부트", Set.of("java", "spring", "부트")),
arguments("#java #spring #부트", Set.of("java", "spring", "부트")),
arguments(" #java,? #spring ... #부트 ", Set.of("java", "spring", "부트")),
arguments("#java#java#spring#부트", Set.of("java", "spring", "부트")),
arguments("#java#java#java#spring#부트", Set.of("java", "spring", "부트")),
arguments("#java#spring#java#부트#java", Set.of("java", "spring", "부트")),
arguments("#java#스프링 아주 긴 글~~~~~~~~~~~~~~~~~~~~~", Set.of("java", "스프링")),
arguments("아주 긴 글~~~~~~~~~~~~~~~~~~~~~#java#스프링", Set.of("java", "스프링")),
arguments("아주 긴 글~~~~~~#java#스프링~~~~~~~~~~~~~~~", Set.of("java", "스프링")),
arguments("아주 긴 글~~~~~~#java~~~~~~~#스프링~~~~~~~~", Set.of("java", "스프링"))
);
}

@DisplayName("해시태그 이름들을 입력하면, 저장된 해시태그 중 이름에 매칭되는 것들을 중복없이 반환한다.")
@Test
void givenHashtagNames_whenFindingHashtags_thenReturnsHashtagSet() {
// Given
Set<String> hashtagNames = Set.of("java", "spring", "boots");
given(hashtagRepository.findByHashtagNameIn(hashtagNames)).willReturn(List.of(
Hashtag.of("java"),
Hashtag.of("spring")
));

// When
Set<Hashtag> hashtags = sut.findHashtagsByNames(hashtagNames);

// Then
assertThat(hashtags).hasSize(2);
then(hashtagRepository).should().findByHashtagNameIn(hashtagNames);

}
}