Skip to content

Commit 172037f

Browse files
alistair3149claude
andcommitted
Batch absent/deleted subject removal in the Neo4j projection
Follow-up to #1080. - removeAbsentSubjects and deletePage now route through a single removeSubjects() that classifies referenced-vs-unreferenced subjects in one query and deletes the unreferenced ones in one query, instead of one round trip per subject. Both call sites stay unified on the same stub/delete logic. - subjectIdsWithIncomingRelations excludes self-loops, so a subject whose only incoming relation is its own self-reference is deleted rather than left as an unreachable orphan stub. - reduceSubjectToStub collapses the OPTIONAL MATCH cartesian product with WITH DISTINCT so the property reset runs once, not once per outgoing relation. - getSubjectIdsByPageId no longer fetches the node properties and labels that both callers discard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 67427dd commit 172037f

1 file changed

Lines changed: 70 additions & 28 deletions

File tree

src/GraphDatabasePlugins/Neo4j/Persistence/Neo4jProjectionStore.php

Lines changed: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)