forked from gothinkster/spring-boot-realworld-example-app
-
Notifications
You must be signed in to change notification settings - Fork 0
MTM-6: bookmark persistence foundation (article_bookmarks) #181
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
Merged
devin-ai-integration
merged 2 commits into
feat/reading-list
from
devin/1782442112-mtm-6-bookmark-foundation
Jun 26, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
18 changes: 18 additions & 0 deletions
18
src/main/java/io/spring/core/bookmark/ArticleBookmark.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package io.spring.core.bookmark; | ||
|
|
||
| import lombok.EqualsAndHashCode; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @NoArgsConstructor | ||
| @Getter | ||
| @EqualsAndHashCode | ||
| public class ArticleBookmark { | ||
| private String articleId; | ||
| private String userId; | ||
|
|
||
| public ArticleBookmark(String articleId, String userId) { | ||
| this.articleId = articleId; | ||
| this.userId = userId; | ||
| } | ||
| } |
11 changes: 11 additions & 0 deletions
11
src/main/java/io/spring/core/bookmark/ArticleBookmarkRepository.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package io.spring.core.bookmark; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| public interface ArticleBookmarkRepository { | ||
| void save(ArticleBookmark bookmark); | ||
|
|
||
| Optional<ArticleBookmark> find(String articleId, String userId); | ||
|
|
||
| void remove(ArticleBookmark bookmark); | ||
| } |
14 changes: 14 additions & 0 deletions
14
src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleBookmarkMapper.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package io.spring.infrastructure.mybatis.mapper; | ||
|
|
||
| import io.spring.core.bookmark.ArticleBookmark; | ||
| import org.apache.ibatis.annotations.Mapper; | ||
| import org.apache.ibatis.annotations.Param; | ||
|
|
||
| @Mapper | ||
| public interface ArticleBookmarkMapper { | ||
| ArticleBookmark find(@Param("articleId") String articleId, @Param("userId") String userId); | ||
|
|
||
| void insert(@Param("bookmark") ArticleBookmark bookmark); | ||
|
|
||
| void delete(@Param("bookmark") ArticleBookmark bookmark); | ||
| } |
18 changes: 18 additions & 0 deletions
18
src/main/java/io/spring/infrastructure/mybatis/readservice/ArticleBookmarksReadService.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package io.spring.infrastructure.mybatis.readservice; | ||
|
|
||
| import io.spring.application.CursorPageParameter; | ||
| import io.spring.core.user.User; | ||
| import java.util.List; | ||
| import java.util.Set; | ||
| import org.apache.ibatis.annotations.Mapper; | ||
| import org.apache.ibatis.annotations.Param; | ||
|
|
||
| @Mapper | ||
| public interface ArticleBookmarksReadService { | ||
| boolean isUserBookmark(@Param("userId") String userId, @Param("articleId") String articleId); | ||
|
|
||
| Set<String> userBookmarks(@Param("ids") List<String> ids, @Param("currentUser") User currentUser); | ||
|
|
||
| List<String> findUserBookmarkedArticleIdsWithCursor( | ||
| @Param("userId") String userId, @Param("page") CursorPageParameter page); | ||
| } |
35 changes: 35 additions & 0 deletions
35
src/main/java/io/spring/infrastructure/repository/MyBatisArticleBookmarkRepository.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package io.spring.infrastructure.repository; | ||
|
|
||
| import io.spring.core.bookmark.ArticleBookmark; | ||
| import io.spring.core.bookmark.ArticleBookmarkRepository; | ||
| import io.spring.infrastructure.mybatis.mapper.ArticleBookmarkMapper; | ||
| import java.util.Optional; | ||
| import org.springframework.beans.factory.annotation.Autowired; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| @Repository | ||
| public class MyBatisArticleBookmarkRepository implements ArticleBookmarkRepository { | ||
| private ArticleBookmarkMapper mapper; | ||
|
|
||
| @Autowired | ||
| public MyBatisArticleBookmarkRepository(ArticleBookmarkMapper mapper) { | ||
| this.mapper = mapper; | ||
| } | ||
|
|
||
| @Override | ||
| public void save(ArticleBookmark bookmark) { | ||
| if (mapper.find(bookmark.getArticleId(), bookmark.getUserId()) == null) { | ||
| mapper.insert(bookmark); | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public Optional<ArticleBookmark> find(String articleId, String userId) { | ||
| return Optional.ofNullable(mapper.find(articleId, userId)); | ||
| } | ||
|
|
||
| @Override | ||
| public void remove(ArticleBookmark bookmark) { | ||
| mapper.delete(bookmark); | ||
| } | ||
| } |
8 changes: 8 additions & 0 deletions
8
src/main/resources/db/migration/V3__create_article_bookmarks.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| create table article_bookmarks ( | ||
| article_id varchar(255) not null, | ||
| user_id varchar(255) not null, | ||
| created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| primary key (article_id, user_id) | ||
| ); | ||
| create index idx_article_bookmarks_user_created | ||
| on article_bookmarks (user_id, created_at desc); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| <?xml version="1.0" encoding="UTF-8" ?> | ||
| <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" > | ||
| <mapper namespace="io.spring.infrastructure.mybatis.mapper.ArticleBookmarkMapper"> | ||
| <insert id="insert"> | ||
| insert into article_bookmarks (article_id, user_id) values (#{bookmark.articleId}, #{bookmark.userId}) | ||
| </insert> | ||
| <delete id="delete"> | ||
| delete from article_bookmarks where article_id = #{bookmark.articleId} and user_id = #{bookmark.userId} | ||
| </delete> | ||
| <select id="find" resultMap="articleBookmark"> | ||
| select | ||
| AB.article_id articleBookmarkArticleId, | ||
| AB.user_id articleBookmarkUserId | ||
| from article_bookmarks AB | ||
| where AB.article_id = #{articleId} and AB.user_id = #{userId} | ||
| </select> | ||
| <resultMap id="articleBookmark" type="io.spring.core.bookmark.ArticleBookmark"> | ||
| <result column="articleBookmarkArticleId" property="articleId"/> | ||
| <result column="articleBookmarkUserId" property="userId"/> | ||
| </resultMap> | ||
| </mapper> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| <?xml version="1.0" encoding="UTF-8" ?> | ||
| <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" > | ||
| <mapper namespace="io.spring.infrastructure.mybatis.readservice.ArticleBookmarksReadService"> | ||
| <select id="isUserBookmark" resultType="java.lang.Boolean"> | ||
| select count(1) from article_bookmarks where user_id = #{userId} and article_id = #{articleId} | ||
| </select> | ||
| <select id="userBookmarks" resultType="java.lang.String"> | ||
| select | ||
| A.id | ||
| from articles A | ||
| left join article_bookmarks AB on A.id = AB.article_id | ||
| where id in | ||
| <foreach collection="ids" item="item" separator="," open="(" close=")"> | ||
| #{item} | ||
| </foreach> | ||
| and AB.user_id = #{currentUser.id} | ||
| </select> | ||
| <select id="findUserBookmarkedArticleIdsWithCursor" resultType="java.lang.String"> | ||
| select AB.article_id | ||
| from article_bookmarks AB | ||
| <where> | ||
| AB.user_id = #{userId} | ||
| <if test='page.cursor != null and page.direction.name() == "NEXT"'> | ||
| AND AB.created_at < #{page.cursor} | ||
| </if> | ||
| <if test='page.cursor != null and page.direction.name() == "PREV"'> | ||
| AND AB.created_at > #{page.cursor} | ||
| </if> | ||
| </where> | ||
| <if test='page.direction.name() == "NEXT"'> | ||
| order by AB.created_at desc | ||
| </if> | ||
| <if test='page.direction.name() == "PREV"'> | ||
| order by AB.created_at asc | ||
| </if> | ||
| limit #{page.queryLimit} | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
| </select> | ||
| </mapper> | ||
141 changes: 141 additions & 0 deletions
141
src/test/java/io/spring/infrastructure/bookmark/ArticleBookmarksReadServiceTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| package io.spring.infrastructure.bookmark; | ||
|
|
||
| import io.spring.application.CursorPageParameter; | ||
| import io.spring.application.CursorPager.Direction; | ||
| import io.spring.core.user.User; | ||
| import io.spring.infrastructure.DbTestBase; | ||
| import io.spring.infrastructure.mybatis.readservice.ArticleBookmarksReadService; | ||
| import java.util.ArrayList; | ||
| import java.util.Arrays; | ||
| import java.util.Collections; | ||
| import java.util.List; | ||
| import java.util.Set; | ||
| import javax.sql.DataSource; | ||
| import org.junit.jupiter.api.Assertions; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.springframework.beans.factory.annotation.Autowired; | ||
| import org.springframework.jdbc.core.JdbcTemplate; | ||
|
|
||
| public class ArticleBookmarksReadServiceTest extends DbTestBase { | ||
| @Autowired private ArticleBookmarksReadService readService; | ||
|
|
||
| @Autowired private DataSource dataSource; | ||
|
|
||
| private JdbcTemplate jdbcTemplate; | ||
|
|
||
| @BeforeEach | ||
| public void setUp() { | ||
| jdbcTemplate = new JdbcTemplate(dataSource); | ||
| jdbcTemplate.execute( | ||
| "create table if not exists article_bookmarks (" | ||
| + "article_id varchar(255) not null," | ||
| + "user_id varchar(255) not null," | ||
| + "created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," | ||
| + "primary key (article_id, user_id))"); | ||
| } | ||
|
|
||
| private void insertBookmark(String articleId, String userId, String createdAt) { | ||
| jdbcTemplate.update( | ||
| "insert into article_bookmarks (article_id, user_id, created_at) values (?, ?, ?)", | ||
| articleId, | ||
| userId, | ||
| createdAt); | ||
| } | ||
|
|
||
| private void insertArticle(String id, String slug) { | ||
| jdbcTemplate.update( | ||
| "insert into articles (id, user_id, slug, title, description, body, created_at, updated_at)" | ||
| + " values (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))", | ||
| id, | ||
| "author", | ||
| slug, | ||
| "title", | ||
| "desc", | ||
| "body"); | ||
| } | ||
|
|
||
| @Test | ||
| public void should_report_whether_user_bookmarked_article() { | ||
| insertBookmark("a1", "u1", "2020-01-01 00:00:01"); | ||
|
|
||
| Assertions.assertTrue(readService.isUserBookmark("u1", "a1")); | ||
| Assertions.assertFalse(readService.isUserBookmark("u1", "a2")); | ||
| Assertions.assertFalse(readService.isUserBookmark("u2", "a1")); | ||
| } | ||
|
|
||
| @Test | ||
| public void should_return_only_current_users_bookmarks() { | ||
| insertArticle("a1", "slug-1"); | ||
| insertArticle("a2", "slug-2"); | ||
|
|
||
| User currentUser = new User("cur@example.com", "cur", "123", "", ""); | ||
| insertBookmark("a1", currentUser.getId(), "2020-01-01 00:00:01"); | ||
| insertBookmark("a2", "other-user", "2020-01-01 00:00:02"); | ||
|
|
||
| Set<String> bookmarks = readService.userBookmarks(Arrays.asList("a1", "a2"), currentUser); | ||
|
|
||
| Assertions.assertEquals(1, bookmarks.size()); | ||
| Assertions.assertTrue(bookmarks.contains("a1")); | ||
| Assertions.assertFalse(bookmarks.contains("a2")); | ||
| } | ||
|
|
||
| @Test | ||
| public void should_return_bookmarked_ids_ordered_by_created_at_desc_with_cursor() { | ||
| String userId = "cursor-user"; | ||
| insertBookmark("a1", userId, "2020-01-01 00:00:01"); | ||
| insertBookmark("a2", userId, "2020-01-01 00:00:02"); | ||
| insertBookmark("a3", userId, "2020-01-01 00:00:03"); | ||
| insertBookmark("a4", userId, "2020-01-01 00:00:04"); | ||
|
|
||
| List<String> firstPage = | ||
| readService.findUserBookmarkedArticleIdsWithCursor( | ||
| userId, new CursorPageParameter<>(null, 2, Direction.NEXT)); | ||
| Assertions.assertEquals(Arrays.asList("a4", "a3", "a2"), firstPage); | ||
|
|
||
| List<String> nextPage = | ||
| readService.findUserBookmarkedArticleIdsWithCursor( | ||
| userId, new CursorPageParameter<>("2020-01-01 00:00:03", 2, Direction.NEXT)); | ||
| Assertions.assertEquals(Arrays.asList("a2", "a1"), nextPage); | ||
|
|
||
| List<String> prevPage = | ||
| readService.findUserBookmarkedArticleIdsWithCursor( | ||
| userId, new CursorPageParameter<>("2020-01-01 00:00:02", 2, Direction.PREV)); | ||
| Assertions.assertEquals(Arrays.asList("a3", "a4"), prevPage); | ||
| } | ||
|
|
||
| @Test | ||
| public void should_return_nearest_page_on_prev_when_results_exceed_limit() { | ||
| String userId = "cursor-user"; | ||
| insertBookmark("a1", userId, "2020-01-01 00:00:01"); | ||
| insertBookmark("a2", userId, "2020-01-01 00:00:02"); | ||
| insertBookmark("a3", userId, "2020-01-01 00:00:03"); | ||
| insertBookmark("a4", userId, "2020-01-01 00:00:04"); | ||
| insertBookmark("a5", userId, "2020-01-01 00:00:05"); | ||
|
|
||
| // PREV from the oldest cursor: 4 rows (a2..a5) are above it but the page size is 2. | ||
| // The primitive must return the rows NEAREST the cursor ascending (a2, a3, [+1 extra a4]), | ||
| // not the farthest (a5, a4, ...). queryLimit = limit + 1 = 3. | ||
| List<String> prevPage = | ||
| readService.findUserBookmarkedArticleIdsWithCursor( | ||
| userId, new CursorPageParameter<>("2020-01-01 00:00:01", 2, Direction.PREV)); | ||
|
|
||
| Assertions.assertEquals(Arrays.asList("a2", "a3", "a4"), prevPage); | ||
| Assertions.assertFalse(prevPage.contains("a5")); | ||
|
|
||
| // Mirror the query-service trimming + reverse the cursor wiring (MTM-3) applies for PREV: | ||
| // drop the extra row, reverse -> stable newest-bookmarked-first page that does not overlap | ||
| // the next page. | ||
| List<String> trimmed = new ArrayList<>(prevPage.subList(0, 2)); | ||
| Collections.reverse(trimmed); | ||
| Assertions.assertEquals(Arrays.asList("a3", "a2"), trimmed); | ||
| } | ||
|
|
||
| @Test | ||
| public void should_return_empty_for_user_without_bookmarks() { | ||
| List<String> result = | ||
| readService.findUserBookmarkedArticleIdsWithCursor( | ||
| "nobody", new CursorPageParameter<>(null, 20, Direction.NEXT)); | ||
| Assertions.assertTrue(result.isEmpty()); | ||
| } | ||
| } |
70 changes: 70 additions & 0 deletions
70
src/test/java/io/spring/infrastructure/bookmark/MyBatisArticleBookmarkRepositoryTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| package io.spring.infrastructure.bookmark; | ||
|
|
||
| import io.spring.core.bookmark.ArticleBookmark; | ||
| import io.spring.core.bookmark.ArticleBookmarkRepository; | ||
| import io.spring.infrastructure.DbTestBase; | ||
| import io.spring.infrastructure.repository.MyBatisArticleBookmarkRepository; | ||
| import javax.sql.DataSource; | ||
| import org.junit.jupiter.api.Assertions; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.springframework.beans.factory.annotation.Autowired; | ||
| import org.springframework.context.annotation.Import; | ||
| import org.springframework.jdbc.core.JdbcTemplate; | ||
|
|
||
| @Import({MyBatisArticleBookmarkRepository.class}) | ||
| public class MyBatisArticleBookmarkRepositoryTest extends DbTestBase { | ||
| @Autowired private ArticleBookmarkRepository articleBookmarkRepository; | ||
|
|
||
| @Autowired private DataSource dataSource; | ||
|
|
||
| private JdbcTemplate jdbcTemplate; | ||
|
|
||
| @BeforeEach | ||
| public void setUp() { | ||
| jdbcTemplate = new JdbcTemplate(dataSource); | ||
| jdbcTemplate.execute( | ||
| "create table if not exists article_bookmarks (" | ||
| + "article_id varchar(255) not null," | ||
| + "user_id varchar(255) not null," | ||
| + "created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," | ||
| + "primary key (article_id, user_id))"); | ||
| } | ||
|
|
||
| @Test | ||
| public void should_save_and_fetch_articleBookmark_success() { | ||
| ArticleBookmark bookmark = new ArticleBookmark("123", "456"); | ||
| articleBookmarkRepository.save(bookmark); | ||
| Assertions.assertTrue(articleBookmarkRepository.find("123", "456").isPresent()); | ||
| } | ||
|
|
||
| @Test | ||
| public void should_be_idempotent_when_saving_same_bookmark_twice() { | ||
| ArticleBookmark bookmark = new ArticleBookmark("123", "456"); | ||
| articleBookmarkRepository.save(bookmark); | ||
| articleBookmarkRepository.save(bookmark); | ||
|
|
||
| Integer rows = | ||
| jdbcTemplate.queryForObject( | ||
| "select count(1) from article_bookmarks where article_id = ? and user_id = ?", | ||
| Integer.class, | ||
| "123", | ||
| "456"); | ||
| Assertions.assertEquals(1, rows); | ||
| } | ||
|
|
||
| @Test | ||
| public void should_remove_bookmark_success() { | ||
| ArticleBookmark bookmark = new ArticleBookmark("123", "456"); | ||
| articleBookmarkRepository.save(bookmark); | ||
| articleBookmarkRepository.remove(bookmark); | ||
| Assertions.assertFalse(articleBookmarkRepository.find("123", "456").isPresent()); | ||
| } | ||
|
|
||
| @Test | ||
| public void should_be_noop_when_removing_non_existent_bookmark() { | ||
| ArticleBookmark bookmark = new ArticleBookmark("does-not", "exist"); | ||
| Assertions.assertDoesNotThrow(() -> articleBookmarkRepository.remove(bookmark)); | ||
| Assertions.assertFalse(articleBookmarkRepository.find("does-not", "exist").isPresent()); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
🚩 No API controller or application service wires up the new bookmark feature
This PR adds domain model, repository, read service, migration, and tests for bookmarks, but no REST controller, GraphQL mutation, or application-layer service consumes these components. The bookmark feature is not yet exposed to any client. This is presumably intentional (incremental delivery of the persistence layer first), but worth confirming that a follow-up PR is planned to wire up API endpoints.
Was this helpful? React with 👍 or 👎 to provide feedback.
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.
Intentional. MTM-6 is the persistence-only foundation for the Reading List epic (MTM-1); REST controller and GraphQL mutation/query are explicitly out of scope here and are delivered in later stories (the query-service cursor wiring is MTM-3). No client-facing wiring in this PR by design.