@@ -32,52 +32,66 @@ public function initialize(): void {
3232
3333 public function savePage ( Page $ page ): void {
3434 $ this ->client ->writeTransaction ( function ( TransactionInterface $ transaction ) use ( $ page ): void {
35- $ properties = $ page ->getProperties ()->asArray ();
36- [ $ typedSetClauses , $ typedParams , $ properties ] = $ this ->extractTypedValues ( $ properties );
37-
38- $ cypher = '
39- // Create or update the page. Page identity is scoped per wiki so that
40- // pages from different wikis sharing the same id do not collide in a shared graph.
41- // The wiki_id is persisted by the MERGE pattern itself.
42- MERGE (page:Page {id: $pageId, wiki_id: $wikiId})
43- SET page += $properties
44- SET page.id = $pageId ' ;
45-
46- if ( $ typedSetClauses !== '' ) {
47- $ cypher .= ', ' . $ typedSetClauses ;
48- }
49-
50- $ cypher .= '
51-
52- // Delete subjects that are no longer present on the page
53- WITH page
54- MATCH (page)-[r:HasSubject]->(subject)
55- WHERE NOT subject.id IN $subjectIds
56- DETACH DELETE subject
57-
58- // Remove all existing HasSubject relations
59- WITH page
60- MATCH (page)-[r:HasSubject]->()
61- DELETE r
62- ' ;
63-
64- $ transaction ->run (
65- $ cypher ,
66- array_merge (
67- [
68- 'pageId ' => $ page ->getId ()->id ,
69- 'wikiId ' => $ this ->wikiId ,
70- 'subjectIds ' => $ page ->getSubjects ()->getAllSubjects ()->getIdsAsTextArray (),
71- 'properties ' => new CypherMap ( $ properties ),
72- ],
73- $ typedParams ,
74- )
75- );
76-
35+ $ this ->upsertPageNode ( $ transaction , $ page );
36+ $ this ->removeAbsentSubjects ( $ transaction , $ page );
37+ $ this ->detachSubjectsFromPage ( $ transaction , $ page ->getId () );
7738 $ this ->updateSubjects ( $ transaction , $ page );
7839 } );
7940 }
8041
42+ private function upsertPageNode ( TransactionInterface $ transaction , Page $ page ): void {
43+ $ properties = $ page ->getProperties ()->asArray ();
44+ [ $ typedSetClauses , $ typedParams , $ properties ] = $ this ->extractTypedValues ( $ properties );
45+
46+ // Create or update the page. Page identity is scoped per wiki so that pages from different
47+ // wikis sharing the same id do not collide in a shared graph. The wiki_id is persisted by
48+ // the MERGE pattern itself.
49+ $ cypher = '
50+ MERGE (page:Page {id: $pageId, wiki_id: $wikiId})
51+ SET page += $properties
52+ SET page.id = $pageId ' ;
53+
54+ if ( $ typedSetClauses !== '' ) {
55+ $ cypher .= ', ' . $ typedSetClauses ;
56+ }
57+
58+ $ transaction ->run (
59+ $ cypher ,
60+ array_merge (
61+ [
62+ 'pageId ' => $ page ->getId ()->id ,
63+ 'wikiId ' => $ this ->wikiId ,
64+ 'properties ' => new CypherMap ( $ properties ),
65+ ],
66+ $ typedParams ,
67+ )
68+ );
69+ }
70+
71+ /**
72+ * Removes the subjects that are attached to the page in the graph but are no longer present on the
73+ * page. This reuses removeSubjects so that a removed subject still referenced by other subjects is
74+ * kept as a stub instead of being deleted, keeping the incoming relations valid.
75+ */
76+ private function removeAbsentSubjects ( TransactionInterface $ transaction , Page $ page ): void {
77+ $ presentSubjectIds = $ page ->getSubjects ()->getAllSubjects ()->getIdsAsTextArray ();
78+
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 );
85+ }
86+
87+ private function detachSubjectsFromPage ( TransactionInterface $ transaction , PageId $ pageId ): void {
88+ $ transaction ->run (
89+ 'MATCH (page:Page {id: $pageId, wiki_id: $wikiId})-[hasSubject:HasSubject]->()
90+ DELETE hasSubject ' ,
91+ [ 'pageId ' => $ pageId ->id , 'wikiId ' => $ this ->wikiId ]
92+ );
93+ }
94+
8195 /**
8296 * Extracts PageValue instances from the property map and converts them
8397 * to Cypher SET clauses with parameterized values.
@@ -140,9 +154,7 @@ private function updateSubjects( TransactionInterface $transaction, Page $page )
140154
141155 public function deletePage ( PageId $ pageId ): void {
142156 $ this ->client ->writeTransaction ( function ( TransactionInterface $ transaction ) use ( $ pageId ): void {
143- foreach ( $ this ->getSubjectIdsByPageId ( $ transaction , $ pageId ) as $ subjectId ) {
144- $ this ->deleteSubject ( $ transaction , new SubjectId ( $ subjectId ) );
145- }
157+ $ this ->removeSubjects ( $ transaction , $ this ->getSubjectIdsByPageId ( $ transaction , $ pageId ) );
146158
147159 $ this ->deletePageNode ( $ transaction , $ pageId );
148160 } );
@@ -157,7 +169,6 @@ private function deletePageNode( TransactionInterface $transaction, PageId $page
157169 }
158170
159171 /**
160- * FIXME: tests still pass if this function returns an empty array
161172 * @return string[]
162173 */
163174 private function getSubjectIdsByPageId ( TransactionInterface $ transaction , PageId $ pageId ): array {
@@ -166,7 +177,7 @@ private function getSubjectIdsByPageId( TransactionInterface $transaction, PageI
166177 */
167178 $ results = $ transaction ->run (
168179 'MATCH (page:Page {id: $pageId, wiki_id: $wikiId})-[:HasSubject]->(subject:Subject)
169- RETURN subject.id AS id, subject AS properties, labels(subject) AS labels ' ,
180+ RETURN subject.id AS id ' ,
170181 [ 'pageId ' => $ pageId ->id , 'wikiId ' => $ this ->wikiId ]
171182 );
172183
@@ -176,37 +187,97 @@ private function getSubjectIdsByPageId( TransactionInterface $transaction, PageI
176187 );
177188 }
178189
179- private function deleteSubject ( TransactionInterface $ transaction , SubjectId $ subjectId ): void {
180- if ( $ this ->subjectHasIncomingRelations ( $ transaction , $ subjectId ) ) {
181- // Only remove HasSubject relations and outgoing relations.
182- // Keep the subject node itself because other subjects still reference it.
183- $ transaction ->run (
184- '
185- MATCH ()-[hsRelation:HasSubject]->(subject {id: $subjectId})
186- OPTIONAL MATCH (subject)-[outgoingSubjectRelation]->(o)
187- DELETE hsRelation, outgoingSubjectRelation
188- ' ,
189- [ 'subjectId ' => $ subjectId ->text ]
190- );
191- // TODO: clear properties?
192- // TODO: clear labels?
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 ;
193202 }
194- else {
195- $ transaction ->run (
196- 'MATCH (subject {id: $subjectId})
197- DETACH DELETE subject ' ,
198- [ 'subjectId ' => $ subjectId ->text ]
199- );
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 ) );
200210 }
201211 }
202212
203- private function subjectHasIncomingRelations ( TransactionInterface $ transaction , SubjectId $ subjectId ): bool {
204- return $ transaction ->run (
205- 'MATCH (subject {id: $subjectId})<-[incomingRelation]-()
206- WHERE NOT incomingRelation:HasSubject
207- RETURN incomingRelation ' ,
208- [ 'subjectId ' => $ subjectId ->text ]
209- )->isEmpty () === false ;
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 ;
246+ }
247+
248+ $ transaction ->run (
249+ 'MATCH (subject) WHERE subject.id IN $subjectIds DETACH DELETE subject ' ,
250+ [ 'subjectIds ' => $ subjectIds ]
251+ );
252+ }
253+
254+ /**
255+ * Reduces a subject node to a stub: it is detached from its page and its outgoing relations, and
256+ * stripped down to only the id and wiki_id properties and the Subject label. The incoming relations
257+ * from other subjects are kept, so the stub keeps those references valid. The stub is upgraded back
258+ * to a full subject in place if the subject is saved again.
259+ */
260+ private function reduceSubjectToStub ( TransactionInterface $ transaction , SubjectId $ subjectId ): void {
261+ $ transaction ->run (
262+ 'MATCH (subject {id: $subjectId})
263+ OPTIONAL MATCH ()-[hasSubject:HasSubject]->(subject)
264+ OPTIONAL MATCH (subject)-[outgoingRelation]->()
265+ DELETE hasSubject, outgoingRelation
266+ WITH DISTINCT subject
267+ SET subject = {id: $subjectId, wiki_id: $wikiId}
268+ SET subject:Subject ' ,
269+ [ 'subjectId ' => $ subjectId ->text , 'wikiId ' => $ this ->wikiId ]
270+ );
271+
272+ $ this ->removeNonStubLabels ( $ transaction , $ subjectId );
273+ }
274+
275+ private function removeNonStubLabels ( TransactionInterface $ transaction , SubjectId $ subjectId ): void {
276+ Neo4jNodeLabels::remove (
277+ $ transaction ,
278+ $ subjectId ->text ,
279+ array_diff ( Neo4jNodeLabels::read ( $ transaction , $ subjectId ->text ), [ 'Subject ' ] )
280+ );
210281 }
211282
212283}
0 commit comments