Skip to content

Commit b4e31da

Browse files
authored
Merge pull request #241 from DocGrid/fix/240
[Fix] 컬렉션 목록/자식 조회 — 권한 필터링을 앱단이 아니라 SQL에서 처리
2 parents 6598c55 + a68ace3 commit b4e31da

11 files changed

Lines changed: 774 additions & 123 deletions

File tree

backend/src/main/java/com/opensource/docgrid/domain/collection/converter/CollectionConverter.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
import com.opensource.docgrid.domain.collection.dto.response.CollectionResponse;
88
import com.opensource.docgrid.domain.collection.entity.CollectionDocument;
99
import com.opensource.docgrid.domain.collection.entity.DocumentCollection;
10+
import com.opensource.docgrid.domain.collection.enums.CollectionStatus;
11+
import com.opensource.docgrid.domain.collection.repository.CollectionRow;
1012
import com.opensource.docgrid.domain.document.converter.DocumentSummaryConverter;
13+
import com.opensource.docgrid.domain.document.enums.VisibilityType;
1114

1215
import lombok.RequiredArgsConstructor;
1316

@@ -38,6 +41,20 @@ public CollectionResponse toResponse(DocumentCollection collection) {
3841
);
3942
}
4043

44+
// findReadableCollections 네이티브 쿼리 프로젝션 결과를 그대로 변환 (owner 엔티티를 거치지 않음)
45+
public CollectionResponse toResponse(CollectionRow row) {
46+
return new CollectionResponse(
47+
row.getCollectionId(),
48+
row.getName(),
49+
row.getDescription(),
50+
row.getOwnerUserId(),
51+
row.getParentCollectionId(),
52+
VisibilityType.valueOf(row.getVisibility()),
53+
CollectionStatus.valueOf(row.getStatus()),
54+
row.getCreatedAt()
55+
);
56+
}
57+
4158
public CollectionDocumentResponse toDocumentResponse(CollectionDocument cd) {
4259
Long addedById = cd.getAddedBy() != null ? cd.getAddedBy().getId() : null;
4360

backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java

Lines changed: 163 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,22 @@
22

33
import java.util.List;
44

5-
import org.springframework.data.domain.Page;
6-
import org.springframework.data.domain.Pageable;
75
import org.springframework.data.jpa.repository.JpaRepository;
86
import org.springframework.data.jpa.repository.Query;
97
import org.springframework.data.repository.query.Param;
108

119
import com.opensource.docgrid.domain.collection.entity.DocumentCollection;
12-
import com.opensource.docgrid.domain.collection.enums.CollectionStatus;
1310

1411
public interface CollectionRepository extends JpaRepository<DocumentCollection, Long> {
1512

1613
/**
17-
* 사용자가 읽을 수 있는 컬렉션 ID 전체 (GET /collections pre-filter).
14+
* 사용자가 읽을 수 있는 컬렉션을 페이지 단위로 조회 (GET /collections).
1815
* 4가지 접근 경로: OWNER / PUBLIC / USER 직접 권한 / ROLE·DEPARTMENT live(부모 컬렉션 체인 상속 포함).
1916
* ACTIVE 상태만 대상으로 하며, keyword가 있으면 이름·설명 부분일치로도 필터링한다(keyword는 null 가능).
17+
*
18+
* <p>"읽을 수 있는 것 전체를 먼저 찾고 그중 일부를 다시 조회"하는 2단계 구조를 쓰지 않고,
19+
* COUNT(*) OVER() 윈도우 함수로 페이지 내용과 전체 개수를 한 쿼리에서 함께 계산한다 —
20+
* 콘텐츠 쿼리와 count 쿼리를 따로 두면 재귀 CTE가 두 번 계산되므로 일부러 합쳤다.
2021
*/
2122
@Query(value = """
2223
WITH RECURSIVE collection_ancestors AS (
@@ -26,48 +27,168 @@ WITH RECURSIVE collection_ancestors AS (
2627
FROM collection_ancestors ca
2728
JOIN collections c ON c.id = ca.ancestor_id
2829
WHERE c.parent_collection_id IS NOT NULL
30+
),
31+
readable AS (
32+
SELECT c.id FROM collections c
33+
WHERE c.owner_user_id = :userId AND c.status = 'ACTIVE'
34+
AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
35+
UNION
36+
SELECT c.id FROM collections c
37+
WHERE c.visibility = 'PUBLIC' AND c.status = 'ACTIVE'
38+
AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
39+
UNION
40+
SELECT c.id FROM collections c
41+
JOIN collection_permissions cp ON cp.collection_id = c.id
42+
WHERE cp.target_type = 'USER' AND cp.user_id = :userId AND cp.can_read = true
43+
AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
44+
AND c.status = 'ACTIVE'
45+
AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
46+
UNION
47+
SELECT c.id FROM collections c
48+
JOIN collection_ancestors ca ON ca.collection_id = c.id
49+
JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id
50+
JOIN user_roles ur ON ur.role_id = cp.role_id
51+
WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true
52+
AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
53+
AND c.status = 'ACTIVE'
54+
AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
55+
UNION
56+
SELECT c.id FROM collections c
57+
JOIN collection_ancestors ca ON ca.collection_id = c.id
58+
JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id
59+
JOIN users u ON u.department_id = cp.department_id
60+
WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true
61+
AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
62+
AND c.status = 'ACTIVE'
63+
AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
2964
)
30-
SELECT c.id FROM collections c
31-
WHERE c.owner_user_id = :userId AND c.status = 'ACTIVE'
32-
AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
33-
UNION
34-
SELECT c.id FROM collections c
35-
WHERE c.visibility = 'PUBLIC' AND c.status = 'ACTIVE'
36-
AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
37-
UNION
38-
SELECT c.id FROM collections c
39-
JOIN collection_permissions cp ON cp.collection_id = c.id
40-
WHERE cp.target_type = 'USER' AND cp.user_id = :userId AND cp.can_read = true
41-
AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
42-
AND c.status = 'ACTIVE'
43-
AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
44-
UNION
45-
SELECT c.id FROM collections c
46-
JOIN collection_ancestors ca ON ca.collection_id = c.id
47-
JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id
48-
JOIN user_roles ur ON ur.role_id = cp.role_id
49-
WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true
50-
AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
51-
AND c.status = 'ACTIVE'
52-
AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
53-
UNION
54-
SELECT c.id FROM collections c
55-
JOIN collection_ancestors ca ON ca.collection_id = c.id
56-
JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id
57-
JOIN users u ON u.department_id = cp.department_id
58-
WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true
59-
AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
60-
AND c.status = 'ACTIVE'
61-
AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
65+
SELECT
66+
c.id AS collection_id,
67+
c.name AS name,
68+
c.description AS description,
69+
c.owner_user_id AS owner_user_id,
70+
c.parent_collection_id AS parent_collection_id,
71+
c.visibility AS visibility,
72+
c.status AS status,
73+
c.created_at AS created_at,
74+
COUNT(*) OVER() AS total_count
75+
FROM collections c
76+
JOIN readable r ON r.id = c.id
77+
ORDER BY c.created_at DESC, c.id DESC
78+
LIMIT :limit OFFSET :offset
6279
""", nativeQuery = true)
63-
List<Long> findReadableCollectionIds(@Param("userId") Long userId, @Param("keyword") String keyword);
80+
List<CollectionRow> findReadableCollections(
81+
@Param("userId") Long userId,
82+
@Param("keyword") String keyword,
83+
@Param("limit") int limit,
84+
@Param("offset") long offset);
6485

65-
// pre-filter로 걸러진 ID를 받아 정렬·페이징만 담당 (GET /collections)
66-
@Query("SELECT c FROM DocumentCollection c JOIN FETCH c.owner WHERE c.id IN :ids")
67-
Page<DocumentCollection> findAllByIdIn(@Param("ids") List<Long> ids, Pageable pageable);
86+
/**
87+
* findReadableCollections()가 요청한 offset이 실제 결과 범위를 넘어가 0건을 반환했을 때만
88+
* 호출한다 — COUNT(*) OVER()는 반환된 행에만 얹혀 계산되므로, 행이 0개면 전체 개수 자체를
89+
* 알 수 없다(빈 페이지인지, 정말 0건인지 구분이 안 됨). readable CTE는 findReadableCollections
90+
* 와 동일한 조건을 그대로 유지해야 두 쿼리의 판정 결과가 어긋나지 않는다.
91+
*/
92+
@Query(value = """
93+
WITH RECURSIVE collection_ancestors AS (
94+
SELECT id AS collection_id, id AS ancestor_id FROM collections
95+
UNION ALL
96+
SELECT ca.collection_id, c.parent_collection_id AS ancestor_id
97+
FROM collection_ancestors ca
98+
JOIN collections c ON c.id = ca.ancestor_id
99+
WHERE c.parent_collection_id IS NOT NULL
100+
),
101+
readable AS (
102+
SELECT c.id FROM collections c
103+
WHERE c.owner_user_id = :userId AND c.status = 'ACTIVE'
104+
AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
105+
UNION
106+
SELECT c.id FROM collections c
107+
WHERE c.visibility = 'PUBLIC' AND c.status = 'ACTIVE'
108+
AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
109+
UNION
110+
SELECT c.id FROM collections c
111+
JOIN collection_permissions cp ON cp.collection_id = c.id
112+
WHERE cp.target_type = 'USER' AND cp.user_id = :userId AND cp.can_read = true
113+
AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
114+
AND c.status = 'ACTIVE'
115+
AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
116+
UNION
117+
SELECT c.id FROM collections c
118+
JOIN collection_ancestors ca ON ca.collection_id = c.id
119+
JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id
120+
JOIN user_roles ur ON ur.role_id = cp.role_id
121+
WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true
122+
AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
123+
AND c.status = 'ACTIVE'
124+
AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
125+
UNION
126+
SELECT c.id FROM collections c
127+
JOIN collection_ancestors ca ON ca.collection_id = c.id
128+
JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id
129+
JOIN users u ON u.department_id = cp.department_id
130+
WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true
131+
AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
132+
AND c.status = 'ACTIVE'
133+
AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%'))
134+
)
135+
SELECT COUNT(*) FROM readable
136+
""", nativeQuery = true)
137+
long countReadableCollections(@Param("userId") Long userId, @Param("keyword") String keyword);
68138

69-
// 직계 자식 컬렉션 목록 조회 (GET /collections/{id}/children)
70-
List<DocumentCollection> findAllByParentCollectionIdAndStatus(Long parentCollectionId, CollectionStatus status);
139+
/**
140+
* 직계 자식 중 사용자가 읽을 수 있는 것만 조회 (GET /collections/{id}/children).
141+
* 부모(및 그 위 조상들)로부터 상속받는 ROLE/DEPARTMENT 권한은 모든 자식이 공유하는 값이라
142+
* parent_ancestors 서브쿼리로 한 번만 계산한다 — 자식마다 다시 계산하지 않는다.
143+
*/
144+
@Query(value = """
145+
WITH RECURSIVE parent_ancestors AS (
146+
SELECT id, parent_collection_id FROM collections WHERE id = :parentId
147+
UNION ALL
148+
SELECT c.id, c.parent_collection_id
149+
FROM collections c
150+
JOIN parent_ancestors a ON c.id = a.parent_collection_id
151+
)
152+
SELECT c.* FROM collections c
153+
WHERE c.parent_collection_id = :parentId AND c.status = 'ACTIVE'
154+
AND (
155+
c.owner_user_id = :userId
156+
OR c.visibility = 'PUBLIC'
157+
OR EXISTS (
158+
SELECT 1 FROM collection_permissions cp
159+
WHERE cp.collection_id = c.id AND cp.target_type = 'USER' AND cp.user_id = :userId
160+
AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
161+
)
162+
OR EXISTS (
163+
SELECT 1 FROM collection_permissions cp
164+
JOIN user_roles ur ON ur.role_id = cp.role_id
165+
WHERE cp.collection_id = c.id AND cp.target_type = 'ROLE' AND ur.user_id = :userId
166+
AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
167+
)
168+
OR EXISTS (
169+
SELECT 1 FROM collection_permissions cp
170+
JOIN users u ON u.department_id = cp.department_id
171+
WHERE cp.collection_id = c.id AND cp.target_type = 'DEPARTMENT' AND u.id = :userId
172+
AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
173+
)
174+
OR EXISTS (
175+
SELECT 1 FROM collection_permissions cp
176+
JOIN parent_ancestors pa ON pa.id = cp.collection_id
177+
JOIN user_roles ur ON ur.role_id = cp.role_id
178+
WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId
179+
AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
180+
)
181+
OR EXISTS (
182+
SELECT 1 FROM collection_permissions cp
183+
JOIN parent_ancestors pa ON pa.id = cp.collection_id
184+
JOIN users u ON u.department_id = cp.department_id
185+
WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId
186+
AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW())
187+
)
188+
)
189+
ORDER BY c.created_at DESC, c.id DESC
190+
""", nativeQuery = true)
191+
List<DocumentCollection> findReadableChildren(@Param("parentId") Long parentId, @Param("userId") Long userId);
71192

72193
/**
73194
* 자기 자신 + 모든 조상 컬렉션 ID (권한 상속 판단용).
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.opensource.docgrid.domain.collection.repository;
2+
3+
import java.time.LocalDateTime;
4+
5+
/**
6+
* 읽기 가능한 컬렉션 목록 네이티브 쿼리 프로젝션 (GET /collections).
7+
*
8+
* <p>컬럼 alias가 snake_case일 때 Spring Data JPA가 camelCase getter로 자동 매핑한다.
9+
* totalCount는 COUNT(*) OVER()로 모든 행에 동일하게 실려오는 전체 개수다.
10+
*/
11+
public interface CollectionRow {
12+
Long getCollectionId();
13+
String getName();
14+
String getDescription();
15+
Long getOwnerUserId();
16+
Long getParentCollectionId();
17+
String getVisibility();
18+
String getStatus();
19+
LocalDateTime getCreatedAt();
20+
Long getTotalCount();
21+
}

0 commit comments

Comments
 (0)