22
33import java .util .List ;
44
5- import org .springframework .data .domain .Page ;
6- import org .springframework .data .domain .Pageable ;
75import org .springframework .data .jpa .repository .JpaRepository ;
86import org .springframework .data .jpa .repository .Query ;
97import org .springframework .data .repository .query .Param ;
108
119import com .opensource .docgrid .domain .collection .entity .DocumentCollection ;
12- import com .opensource .docgrid .domain .collection .enums .CollectionStatus ;
1310
1411public 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 (권한 상속 판단용).
0 commit comments