@@ -70,17 +70,18 @@ private function upsertPageNode( TransactionInterface $transaction, Page $page )
7070
7171 /**
7272 * Removes the subjects that are attached to the page in the graph but are no longer present on the
73- * page. This reuses deleteSubject so that a removed subject still referenced by other subjects is
73+ * page. This reuses removeSubjects so that a removed subject still referenced by other subjects is
7474 * kept as a stub instead of being deleted, keeping the incoming relations valid.
7575 */
7676 private function removeAbsentSubjects ( TransactionInterface $ transaction , Page $ page ): void {
7777 $ presentSubjectIds = $ page ->getSubjects ()->getAllSubjects ()->getIdsAsTextArray ();
7878
79- foreach ( $ this ->getSubjectIdsByPageId ( $ transaction , $ page ->getId () ) as $ attachedSubjectId ) {
80- if ( !in_array ( $ attachedSubjectId , $ presentSubjectIds , true ) ) {
81- $ this ->deleteSubject ( $ transaction , new SubjectId ( $ attachedSubjectId ) );
82- }
83- }
79+ $ absentSubjectIds = array_values ( array_filter (
80+ $ this ->getSubjectIdsByPageId ( $ transaction , $ page ->getId () ),
81+ fn ( string $ subjectId ) => !in_array ( $ subjectId , $ presentSubjectIds , true )
82+ ) );
83+
84+ $ this ->removeSubjects ( $ transaction , $ absentSubjectIds );
8485 }
8586
8687 private function detachSubjectsFromPage ( TransactionInterface $ transaction , PageId $ pageId ): void {
@@ -153,9 +154,7 @@ private function updateSubjects( TransactionInterface $transaction, Page $page )
153154
154155 public function deletePage ( PageId $ pageId ): void {
155156 $ this ->client ->writeTransaction ( function ( TransactionInterface $ transaction ) use ( $ pageId ): void {
156- foreach ( $ this ->getSubjectIdsByPageId ( $ transaction , $ pageId ) as $ subjectId ) {
157- $ this ->deleteSubject ( $ transaction , new SubjectId ( $ subjectId ) );
158- }
157+ $ this ->removeSubjects ( $ transaction , $ this ->getSubjectIdsByPageId ( $ transaction , $ pageId ) );
159158
160159 $ this ->deletePageNode ( $ transaction , $ pageId );
161160 } );
@@ -178,7 +177,7 @@ private function getSubjectIdsByPageId( TransactionInterface $transaction, PageI
178177 */
179178 $ results = $ transaction ->run (
180179 'MATCH (page:Page {id: $pageId, wiki_id: $wikiId})-[:HasSubject]->(subject:Subject)
181- RETURN subject.id AS id, subject AS properties, labels(subject) AS labels ' ,
180+ RETURN subject.id AS id ' ,
182181 [ 'pageId ' => $ pageId ->id , 'wikiId ' => $ this ->wikiId ]
183182 );
184183
@@ -188,17 +187,68 @@ private function getSubjectIdsByPageId( TransactionInterface $transaction, PageI
188187 );
189188 }
190189
191- private function deleteSubject ( TransactionInterface $ transaction , SubjectId $ subjectId ): void {
192- if ( $ this ->subjectHasIncomingRelations ( $ transaction , $ subjectId ) ) {
193- $ this ->reduceSubjectToStub ( $ transaction , $ subjectId );
190+ /**
191+ * Removes the given subjects from the graph. A subject still referenced by an incoming relation from
192+ * another subject is reduced to a stub so that reference stays valid; the rest are deleted outright.
193+ *
194+ * The referenced/unreferenced split is computed with a single query and the unreferenced subjects are
195+ * deleted with a single query, rather than one round trip per subject.
196+ *
197+ * @param string[] $subjectIds
198+ */
199+ private function removeSubjects ( TransactionInterface $ transaction , array $ subjectIds ): void {
200+ if ( $ subjectIds === [] ) {
201+ return ;
194202 }
195- else {
196- $ transaction ->run (
197- 'MATCH (subject {id: $subjectId})
198- DETACH DELETE subject ' ,
199- [ 'subjectId ' => $ subjectId ->text ]
200- );
203+
204+ $ referencedSubjectIds = $ this ->subjectIdsWithIncomingRelations ( $ transaction , $ subjectIds );
205+
206+ $ this ->deleteSubjects ( $ transaction , array_values ( array_diff ( $ subjectIds , $ referencedSubjectIds ) ) );
207+
208+ foreach ( $ referencedSubjectIds as $ subjectId ) {
209+ $ this ->reduceSubjectToStub ( $ transaction , new SubjectId ( $ subjectId ) );
210+ }
211+ }
212+
213+ /**
214+ * Returns the subset of the given subject ids that still have an incoming relation from a *different*
215+ * subject. HasSubject relations do not count, and neither does a self-loop: a subject whose only
216+ * incoming relation is its own outgoing self-reference has no external referrer, so it is deleted
217+ * rather than kept as an unreachable stub.
218+ *
219+ * @param string[] $subjectIds
220+ * @return string[]
221+ */
222+ private function subjectIdsWithIncomingRelations ( TransactionInterface $ transaction , array $ subjectIds ): array {
223+ /**
224+ * @var SummarizedResult $result
225+ */
226+ $ result = $ transaction ->run (
227+ 'UNWIND $subjectIds AS subjectId
228+ MATCH (subject {id: subjectId})<-[incomingRelation]-(other)
229+ WHERE NOT incomingRelation:HasSubject AND other <> subject
230+ RETURN DISTINCT subject.id AS id ' ,
231+ [ 'subjectIds ' => $ subjectIds ]
232+ );
233+
234+ return array_map (
235+ fn ( $ record ) => $ record ->get ( 'id ' ),
236+ $ result ->toArray ()
237+ );
238+ }
239+
240+ /**
241+ * @param string[] $subjectIds
242+ */
243+ private function deleteSubjects ( TransactionInterface $ transaction , array $ subjectIds ): void {
244+ if ( $ subjectIds === [] ) {
245+ return ;
201246 }
247+
248+ $ transaction ->run (
249+ 'MATCH (subject) WHERE subject.id IN $subjectIds DETACH DELETE subject ' ,
250+ [ 'subjectIds ' => $ subjectIds ]
251+ );
202252 }
203253
204254 /**
@@ -213,6 +263,7 @@ private function reduceSubjectToStub( TransactionInterface $transaction, Subject
213263 OPTIONAL MATCH ()-[hasSubject:HasSubject]->(subject)
214264 OPTIONAL MATCH (subject)-[outgoingRelation]->()
215265 DELETE hasSubject, outgoingRelation
266+ WITH DISTINCT subject
216267 SET subject = {id: $subjectId, wiki_id: $wikiId}
217268 SET subject:Subject ' ,
218269 [ 'subjectId ' => $ subjectId ->text , 'wikiId ' => $ this ->wikiId ]
@@ -229,13 +280,4 @@ private function removeNonStubLabels( TransactionInterface $transaction, Subject
229280 );
230281 }
231282
232- private function subjectHasIncomingRelations ( TransactionInterface $ transaction , SubjectId $ subjectId ): bool {
233- return $ transaction ->run (
234- 'MATCH (subject {id: $subjectId})<-[incomingRelation]-()
235- WHERE NOT incomingRelation:HasSubject
236- RETURN incomingRelation ' ,
237- [ 'subjectId ' => $ subjectId ->text ]
238- )->isEmpty () === false ;
239- }
240-
241283}
0 commit comments