Skip to content
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
18 changes: 18 additions & 0 deletions src/main/java/io/spring/core/bookmark/ArticleBookmark.java
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;
}
}

Copy link
Copy Markdown
Author

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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

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.

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);
}
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);
}
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);
}
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);
}
}
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);
21 changes: 21 additions & 0 deletions src/main/resources/mapper/ArticleBookmarkMapper.xml
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>
38 changes: 38 additions & 0 deletions src/main/resources/mapper/ArticleBookmarksReadService.xml
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 &lt; #{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}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
</select>
</mapper>
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());
}
}
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());
}
}
Loading