From 7d80070944d048144ee7229b6bef0661377f07ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michel=20L=C3=B6w?= Date: Sat, 21 Jun 2025 15:21:55 +0200 Subject: [PATCH 1/9] WIP: Satisfy test suite --- .../Projection/Feature/HierarchyRelation.php | 5 +- .../Projection/Feature/ReferenceRelation.php | 30 +++++++-- .../Repository/Neo4jContentSubgraph.php | 5 +- .../Neo4jProjectionContentGraph.php | 28 ++++++++ Classes/Domain/Repository/NodeFactory.php | 6 +- Classes/Neo4jContentGraphProjection.php | 64 ++++++++++++------- Classes/Neo4jContentGraphReadModelAdapter.php | 2 + 7 files changed, 107 insertions(+), 33 deletions(-) diff --git a/Classes/Domain/Projection/Feature/HierarchyRelation.php b/Classes/Domain/Projection/Feature/HierarchyRelation.php index e3311a8..1b4c56d 100644 --- a/Classes/Domain/Projection/Feature/HierarchyRelation.php +++ b/Classes/Domain/Projection/Feature/HierarchyRelation.php @@ -168,7 +168,10 @@ private function addRootRelation( 'childNodeAggregateId' => $childNode->getId(), 'contentStreamId' => $contentStreamId->value, 'dimensionSpacePointHash' => $dimensionSpacePoint->hash, - 'position' => Neo4jContentGraphProjection::RELATION_DEFAULT_OFFSET, + 'position' => $this->projectionContentGraph->determineRootNodePosition( + $contentStreamId, + $dimensionSpacePoint, + ), 'lastModified' => $lastModified->format(\DateTimeInterface::ATOM), 'originalLastModified' => $originalLastModified->format(\DateTimeInterface::ATOM), ] diff --git a/Classes/Domain/Projection/Feature/ReferenceRelation.php b/Classes/Domain/Projection/Feature/ReferenceRelation.php index 9fc3311..cae2e0c 100644 --- a/Classes/Domain/Projection/Feature/ReferenceRelation.php +++ b/Classes/Domain/Projection/Feature/ReferenceRelation.php @@ -8,7 +8,9 @@ use Laudis\Neo4j\Types\Node; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferences; + use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; +use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; trait ReferenceRelation @@ -21,12 +23,14 @@ private function clearReferenceRelations( DimensionSpacePoint $dimensionSpacePoint, \DateTimeImmutable $lastModified, \DateTimeImmutable $originalLastModified, + SerializedNodeReferences $newReferences, ): void { $this->client->runStatement( Statement::create( 'MATCH (sourceNode:Node {aggregateId: $aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->() OPTIONAL MATCH (sourceNode)-[rel:REFERENCE]->() + WHERE rel.referenceName IN $referenceNames DELETE rel SET sourceNode.lastModified = $lastModified SET sourceNode.originalLastModified = $originalLastModified', @@ -34,6 +38,7 @@ private function clearReferenceRelations( 'aggregateId' => $sourceNodeAggregateId->value, 'contentStreamId' => $contentStreamId->value, 'dimensionSpacePointHash' => $dimensionSpacePoint->hash, + 'referenceNames' => array_map(fn(ReferenceName $referenceName) => $referenceName->value, $newReferences->getReferenceNames()), 'lastModified' => $lastModified->format(\DateTimeInterface::ATOM), 'originalLastModified' => $originalLastModified->format(\DateTimeInterface::ATOM), ] @@ -52,12 +57,13 @@ private function createReferenceRelations( foreach ($references as $reference) { $position = 0; foreach ($reference->references as $nodeReference) { - $this->client->runStatement(Statement::create( + $result = $this->client->runStatement(Statement::create( 'MATCH (sourceNode:Node {aggregateId: $aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->() MATCH (targetNode:Node {aggregateId: $referencedNodeAggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->() - MERGE (sourceNode)-[:REFERENCE {referenceName: $referenceName, position: $position}]->(targetNode) + MERGE (sourceNode)-[newRef:REFERENCE {referenceName: $referenceName, position: $position}]->(targetNode) SET sourceNode.lastModified = $lastModified - SET sourceNode.originalLastModified = $originalLastModified', + SET sourceNode.originalLastModified = $originalLastModified + RETURN newRef', [ 'aggregateId' => $sourceNodeAggregateId->value, 'contentStreamId' => $contentStreamId->value, @@ -67,8 +73,24 @@ private function createReferenceRelations( 'referencedNodeAggregateId' => $nodeReference->targetNodeAggregateId->value, 'lastModified' => $lastModified->format(\DateTimeInterface::ATOM), 'originalLastModified' => $originalLastModified->format(\DateTimeInterface::ATOM), - ] + ], )); + if (empty($result) || !$result->hasKey(0) || !$result->getAsCypherMap(0)->hasKey('newRef')) { + continue; + } + $referenceResult = $result->getAsCypherMap(0)->getAsRelationship('newRef');; + if ($nodeReference->properties->count() > 0) { + $this->client->runStatement( + Statement::create( + 'MATCH ()-[rel]->() WHERE ID(rel) = $relId + SET rel.properties = $properties', + [ + 'properties' => json_encode($nodeReference->properties), + 'relId' => $referenceResult->getId() + ] + ) + ); + } $position++; } } diff --git a/Classes/Domain/Repository/Neo4jContentSubgraph.php b/Classes/Domain/Repository/Neo4jContentSubgraph.php index cc9c3e5..e452420 100644 --- a/Classes/Domain/Repository/Neo4jContentSubgraph.php +++ b/Classes/Domain/Repository/Neo4jContentSubgraph.php @@ -220,6 +220,7 @@ public function findParentNode(NodeAggregateId $childNodeAggregateId): ?Node $this->dimensionSpacePoint, $childNodeAggregateId, ) + ->where('"Node" IN labels(p)') ->returns('p as parent') ->build() ); @@ -726,6 +727,7 @@ public function findReferences(NodeAggregateId $nodeAggregateId, Filter\FindRefe $query = $this->getReferencesQuery(false, $nodeAggregateId, $filter); $query->returns('target, ref'); $result = $this->client->runStatement($query->build()); + return References::fromArray(array_map(fn(CypherMap $map) => ( $this->nodeFactory->mapResultToReference( $map->getAsNode('target'), @@ -779,6 +781,7 @@ private function getReferencesQuery( } $query->match('(source)-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->(:Node)'); + $query->match('(target)-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->(:Node)'); $query->matchNodeByAggregateId($aggregateId, 'source'); $query->withParameters([ 'contentStreamId' => $this->contentStreamId->value, @@ -831,7 +834,7 @@ private function getReferencesQuery( $query->orderBy('ref.'.$ordering->field->value, $ordering->direction->value); } } elseif ($filter->referenceName === null) { - $query->orderBy('ref.name'); + $query->orderBy('ref.referenceName'); } $query->orderBy('ref.position') ->orderBy('target.aggregateid'); diff --git a/Classes/Domain/Repository/Neo4jProjectionContentGraph.php b/Classes/Domain/Repository/Neo4jProjectionContentGraph.php index 254aeaf..1c7d33a 100644 --- a/Classes/Domain/Repository/Neo4jProjectionContentGraph.php +++ b/Classes/Domain/Repository/Neo4jProjectionContentGraph.php @@ -5,6 +5,7 @@ use JvMTECH\ContentGraph\Neo4jAdapter\Domain\Query\NodeQueryBuilder; use JvMTECH\ContentGraph\Neo4jAdapter\Neo4jContentGraphProjection; use Laudis\Neo4j\Contracts\ClientInterface; +use Laudis\Neo4j\Databags\Statement; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; @@ -61,6 +62,8 @@ public function determineHierarchyRelationPosition( if ($precedingSiblingNode->hasKey(0) && $precedingSiblingNode->getAsCypherMap(0)->hasKey('rel')) { $preceedingSiblingNodePosition = $precedingSiblingNode->getAsCypherMap(0)->getAsRelationship('rel')->getProperty('position'); return ($succeedingSiblingNodePosition + $preceedingSiblingNodePosition) / 2; + } else { + return $succeedingSiblingNodePosition - Neo4jContentGraphProjection::RELATION_DEFAULT_OFFSET; } } else { //\Neos\Flow\var_dump($succeedingSiblingAggregateId, 'Succeeding sibling not found'); @@ -90,4 +93,29 @@ public function determineHierarchyRelationPosition( $position = Neo4jContentGraphProjection::RELATION_DEFAULT_OFFSET; return $position; } + + public function determineRootNodePosition( + ContentStreamId $contentStreamId, + DimensionSpacePoint $dimensionSpacePoint, + ): int + { + $result = $this->client->runStatement( + Statement::create( + 'MATCH (r:Root)<-[rel:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]-() + ORDER BY rel.position + LIMIT 1 + RETURN rel.position as position', + [ + 'contentStreamId' => $contentStreamId->value, + 'dimensionSpacePointHash' => $dimensionSpacePoint->hash, + ] + ) + ); + + if ($result->isEmpty() || !$result->get(0) || !$result->getAsCypherMap(0)->hasKey('position')) { + return Neo4jContentGraphProjection::RELATION_DEFAULT_OFFSET; + } + + return $result->getAsCypherMap(0)->getAsInt('position') + Neo4jContentGraphProjection::RELATION_DEFAULT_OFFSET; + } } diff --git a/Classes/Domain/Repository/NodeFactory.php b/Classes/Domain/Repository/NodeFactory.php index b1ac89e..0b41abf 100644 --- a/Classes/Domain/Repository/NodeFactory.php +++ b/Classes/Domain/Repository/NodeFactory.php @@ -221,7 +221,7 @@ public function mapResultToReference( ): ?Reference { $properties = null; try { - $properties = $relationship->getProperty('properties'); + $properties = $relationship->getProperties()->hasKey('properties') ? $relationship->getProperties()->get('properties') : null; } catch (\Exception) {} return new Reference( $this->mapResultToNode( @@ -231,10 +231,10 @@ public function mapResultToReference( $visibilityConstraints ), ReferenceName::fromString($relationship->getProperty('referenceName')), - new PropertyCollection( + $properties ? new PropertyCollection( SerializedPropertyValues::fromJsonString($properties ?: '{}'), $this->propertyConverter, - ), + ) : null, ); } /** diff --git a/Classes/Neo4jContentGraphProjection.php b/Classes/Neo4jContentGraphProjection.php index 4822564..7d09263 100644 --- a/Classes/Neo4jContentGraphProjection.php +++ b/Classes/Neo4jContentGraphProjection.php @@ -746,17 +746,18 @@ private function whenNodePeerVariantWasCreated(NodePeerVariantWasCreated $event, $event->nodeAggregateId, ) ->call('apoc.refactor.cloneNodes([n], true)') - ->yield('output AS generalizedNode') + ->yield('output AS peerNode') ->setProperty('originDimensionSpacePointHash', $event->peerOrigin->toDimensionSpacePoint()->hash, - 'generalizedNode') - ->setProperty('created', $eventEnvelope->recordedAt->format(DateTimeInterface::ATOM), 'generalizedNode') + 'peerNode') + ->setProperty('created', $eventEnvelope->recordedAt->format(DateTimeInterface::ATOM), 'peerNode') ->setProperty('originalCreated', self::initiatingDateTime($eventEnvelope)->format(DateTimeInterface::ATOM), - 'generalizedNode') - ->with('generalizedNode, n, p, rel') - ->optionalMatch('(generalizedNode)-[generalizedRel:IS_CHILD]-() DELETE generalizedRel') + 'peerNode') + ->with('peerNode, n, p, rel') + ->optionalMatch('(peerNode)-[generalizedRel:IS_CHILD]-() DELETE generalizedRel') ->returns('*') ->build() ); + if (empty($nodeCloneResults->getAsCypherMap(0)) || empty($nodeCloneResults->getAsCypherMap(0)->get('p'))) { throw new \RuntimeException(sprintf('Failed to create node generalization variant for node "%s" in sub graph %s@%s because the source parent node is missing', $event->nodeAggregateId->value, $event->sourceOrigin->toJson(), $event->contentStreamId->value), 1749910013); @@ -767,13 +768,26 @@ private function whenNodePeerVariantWasCreated(NodePeerVariantWasCreated $event, } $sourceRelationship = $nodeCloneResults->getAsCypherMap(0)->getAsRelationship('rel'); - $this->copyReferenceRelations( - $nodeCloneResults->getAsCypherMap(0)->getAsNode('n'), - $nodeCloneResults->getAsCypherMap(0)->getAsNode('generalizedNode') + + $peerNode = $nodeCloneResults->getAsCypherMap(0)->getAsNode('peerNode'); + $this->client->runStatement( + Statement::create( + 'MATCH (peerNode) WHERE ID(peerNode) = $peerNodeId + MATCH (peerNode)-[ref:REFERENCE]->(oldReferenceTarget) + OPTIONAL MATCH (newReferenceTarget {aggregateId: oldReferenceTarget.aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->() + CALL apoc.refactor.to(ref, newReferenceTarget) + YIELD output + FINISH', + [ + 'peerNodeId' => $peerNode->getId(), + 'contentStreamId' => $event->contentStreamId->value, + 'dimensionSpacePointHash' => $event->peerOrigin->hash, + ] + ) ); $sourceParentNode = $nodeCloneResults->getAsCypherMap(0)->getAsNode('p'); - $generalizedNode = $nodeCloneResults->getAsCypherMap(0)->getAsNode('generalizedNode'); + $peerNode = $nodeCloneResults->getAsCypherMap(0)->getAsNode('peerNode'); // Find all ingoing outgoing relationships (node is source of IS_CHILD) and change the source to the generalized node that are in the given dsp set $unassignedIngoingDimensionSpacePoints = []; $variantSucceedingSiblings = $event->peerSucceedingSiblings; @@ -793,7 +807,7 @@ private function whenNodePeerVariantWasCreated(NodePeerVariantWasCreated $event, } foreach ($existingChildRelationships as $existingChildRelationship) { $this->moveChildHierarchyRelation( - $generalizedNode, + $peerNode, $existingChildRelationship->getAsRelationship('rel'), $this->projectionContentGraph->determineHierarchyRelationPosition( parentAggregateId: NodeAggregateId::fromString($nodeCloneResults->getAsCypherMap(0)->getAsNode('p')->getProperty('aggregateId')), @@ -815,7 +829,7 @@ private function whenNodePeerVariantWasCreated(NodePeerVariantWasCreated $event, ); foreach ($existingParentRelationships as $existingParentRelationship) { $this->moveParentHierarchyRelation( - $generalizedNode, + $peerNode, $existingParentRelationship->getAsRelationship('rel'), $this->projectionContentGraph->determineHierarchyRelationPosition( parentAggregateId: NodeAggregateId::fromString($nodeCloneResults->getAsCypherMap(0)->getAsNode('p')->getProperty('aggregateId')), @@ -829,34 +843,34 @@ private function whenNodePeerVariantWasCreated(NodePeerVariantWasCreated $event, if (count($unassignedIngoingDimensionSpacePoints) > 0) { foreach ($unassignedIngoingDimensionSpacePoints as $unassignedIngoingDimensionSpacePoint) { - $generalizationParentNodeResult = $this->client->runStatement( + $peerParentNodeResult = $this->client->runStatement( NodeQueryBuilder::createForNodes() ->matchNodeForSubgraph( $event->contentStreamId, $unassignedIngoingDimensionSpacePoint, $sourceParentNode, - nodeAlias: 'generalizationParentNode' + nodeAlias: 'peerParentNode' ) - ->returns('DISTINCT generalizationParentNode') + ->returns('DISTINCT peerParentNode') ->build() ); try { - $generalizationParentNode = $generalizationParentNodeResult->getAsCypherMap(0)->getAsNode('generalizationParentNode'); + $peerParentNode = $peerParentNodeResult->getAsCypherMap(0)->getAsNode('peerParentNode'); } catch (\OutOfBoundsException) { // TODO: throw correctly! throw new \Exception('wrong', 1750009438); } - $generalizationSucceedingSiblingNodeAggregateId = $variantSucceedingSiblings + $peerSucceedingSiblingNodeAggregateId = $variantSucceedingSiblings ->getSucceedingSiblingIdForDimensionSpacePoint($unassignedIngoingDimensionSpacePoint); $this->copyHierarchyRelation( $sourceRelationship, - $generalizedNode, - $generalizationParentNode, + $peerNode, + $peerParentNode, $unassignedIngoingDimensionSpacePoint, $this->projectionContentGraph->determineHierarchyRelationPosition( - parentAggregateId: NodeAggregateId::fromString($generalizationParentNode->getProperty('aggregateId')), - succeedingSiblingAggregateId: $generalizationSucceedingSiblingNodeAggregateId, + parentAggregateId: NodeAggregateId::fromString($peerParentNode->getProperty('aggregateId')), + succeedingSiblingAggregateId: $peerSucceedingSiblingNodeAggregateId, contentStreamId: $event->contentStreamId, dimensionSpacePoint: $unassignedIngoingDimensionSpacePoint ), @@ -920,11 +934,11 @@ private function cloneNodeIfRequired( NodeQueryBuilder::createForNodes() ->match('(n:Node {aggregateId: $aggregateId})-[rel:IS_CHILD]->()') ->withParameter('aggregateId', $affectedNode->getProperty('aggregateId')) - ->returns('n, COUNT(DISTINCT rel) as count') + ->returns('rel as rels, COUNT(DISTINCT rel) as count') ->build() - )->getAsCypherMap(0)->getAsInt('count'); + ); - if ($nodeContentStreams > 1) { + if ($nodeContentStreams->getAsCypherMap(0)->getAsInt('count') > 1) { $affectedNode = $this->cloneNode($affectedNode, $contentStreamIdWhereWriteOccurs); } @@ -958,12 +972,14 @@ private function whenNodeReferencesWereSet(NodeReferencesWereSet $event, EventEn $this->cloneNodeIfRequired($event->contentStreamId, $affectedNode); // WE NEED COPY ON WRITE HERE + $this->clearReferenceRelations( $event->nodeAggregateId, $event->contentStreamId, $dimensionSpacePoint->toDimensionSpacePoint(), $eventEnvelope->recordedAt, self::initiatingDateTime($eventEnvelope), + $event->references, ); $this->createReferenceRelations( diff --git a/Classes/Neo4jContentGraphReadModelAdapter.php b/Classes/Neo4jContentGraphReadModelAdapter.php index 7541ada..d4c6208 100644 --- a/Classes/Neo4jContentGraphReadModelAdapter.php +++ b/Classes/Neo4jContentGraphReadModelAdapter.php @@ -13,6 +13,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; +use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStream; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; @@ -42,6 +43,7 @@ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInter ) ); if ($result->isEmpty()) { + throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); throw new \RuntimeException(sprintf('No content stream found for workspace "%s".', $workspaceName->value), 1750171756); } $currentContentStreamId = ContentStreamId::fromString($result->getAsCypherMap(0)->getAsString('contentStreamId')); From 68e29ecd970776b5f5de384b4ec8eba8bc9b8b34 Mon Sep 17 00:00:00 2001 From: Michel Loew Date: Mon, 23 Jun 2025 17:06:55 +0200 Subject: [PATCH 2/9] WIP: Satisfy Feature/05-NodeReferencing --- .../Projection/Feature/HierarchyRelation.php | 4 +- Classes/Domain/Projection/Feature/Subtree.php | 8 +- Classes/Domain/Query/NodeQueryBuilder.php | 41 ++++- Classes/Domain/Query/QueryBuilder.php | 28 ++- .../Domain/Repository/Neo4jContentGraph.php | 163 +++++++----------- .../Repository/Neo4jContentSubgraph.php | 124 +++++++++---- Classes/Domain/Repository/NodeFactory.php | 37 +++- 7 files changed, 239 insertions(+), 166 deletions(-) diff --git a/Classes/Domain/Projection/Feature/HierarchyRelation.php b/Classes/Domain/Projection/Feature/HierarchyRelation.php index 1b4c56d..d718330 100644 --- a/Classes/Domain/Projection/Feature/HierarchyRelation.php +++ b/Classes/Domain/Projection/Feature/HierarchyRelation.php @@ -33,7 +33,8 @@ private function addParentHierarchyRelation( CREATE (childNode)-[:IS_CHILD { contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash, - position: $position + position: $position, + subtreeTags: $subtreeTags }]->(parentNode) SET childNode.lastModified = $lastModified SET childNode.originalLastModified = $originalLastModified', @@ -43,6 +44,7 @@ private function addParentHierarchyRelation( 'contentStreamId' => $contentStreamId->value, 'dimensionSpacePointHash' => $dimensionSpacePoint->hash, 'position' => $position, + 'subtreeTags' => '{}', 'lastModified' => $lastModified->format(\DateTimeInterface::ATOM), 'originalLastModified' => $originalLastModified->format(\DateTimeInterface::ATOM), ] diff --git a/Classes/Domain/Projection/Feature/Subtree.php b/Classes/Domain/Projection/Feature/Subtree.php index 4f43127..5ba8565 100644 --- a/Classes/Domain/Projection/Feature/Subtree.php +++ b/Classes/Domain/Projection/Feature/Subtree.php @@ -18,9 +18,7 @@ trait Subtree private function addSubtreeTag(ContentStreamId $contentStreamId, NodeAggregateId $nodeAggregateId, DimensionSpacePointSet $affectedDimensionSpacePoints, SubtreeTag $tag): void { - $affectedDimensionSpacePointHashes = $affectedDimensionSpacePoints->getPointHashes(); - /** @var SummarizedResult $currentRelationships */ $currentRelationships = $this->client->runStatement( Statement::create( 'MATCH (:Node {aggregateId: $aggregateId})-[rel:IS_CHILD|IS_ROOT {contentStreamId: $contentStreamId}]->() @@ -38,18 +36,18 @@ private function addSubtreeTag(ContentStreamId $contentStreamId, NodeAggregateId $relationshipDimensionSpacePointHash = $relationship->getProperty('dimensionSpacePointHash'); $currentSubtreeTags = []; if ($relationship->getProperties()->hasKey('subtreeTags')) { - $currentSubtreeTags = $relationship->getProperty('subtreeTags')->toArray(); + $currentSubtreeTags = json_decode($relationship->getProperty('subtreeTags') ?: '{}', true); } $relationshipId = $relationship->getId(); if (in_array($relationshipDimensionSpacePointHash, $affectedDimensionSpacePointHashes)) { - $currentSubtreeTags[] = $tag->value; + $currentSubtreeTags[$tag->value] = true; $this->client->runStatement( Statement::create('MATCH ()-[rel:IS_CHILD|IS_ROOT]->() WHERE id(rel) = $relationshipId SET rel.subtreeTags = $subtreeTags', [ 'relationshipId' => $relationshipId, - 'subtreeTags' => $currentSubtreeTags, + 'subtreeTags' => json_encode($currentSubtreeTags), ], ) ); diff --git a/Classes/Domain/Query/NodeQueryBuilder.php b/Classes/Domain/Query/NodeQueryBuilder.php index 4b24c67..de87e8b 100644 --- a/Classes/Domain/Query/NodeQueryBuilder.php +++ b/Classes/Domain/Query/NodeQueryBuilder.php @@ -5,6 +5,7 @@ use Laudis\Neo4j\Types\Node; use Neos\ContentRepository\Core\NodeType\NodeTypeNames; +use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\NodeType\NodeTypeName; @@ -51,6 +52,40 @@ public function matchNodeForSubgraph( ->withParameter('dimensionSpacePointHash', $dimensionSpacePoint->hash); } + public function withVisibilityConstraints( + VisibilityConstraints $visibilityConstraints, + string $relationAlias= 'rel', + ): self + { + foreach ($visibilityConstraints as $constraint) { + foreach ($constraint as $subtreeTag) { + $this->where(sprintf('COALESCE(apoc.convert.fromJsonMap(%s.subtreeTags).%s, false) <> true', $relationAlias, $subtreeTag->value)); + } + } + return $this; + } + + public function matchNodeForContentStream( + ContentStreamId $contentStreamId, + NodeAggregateId $nodeAggregateId, + string $nodeAlias = 'n', + string $relationAlias = 'rel' + ): self + { + return $this->match("({$nodeAlias}:Node {aggregateId: \$aggregateId})-[{$relationAlias}:IS_CHILD {contentStreamId: \$contentStreamId}]->()") + ->withParameter('aggregateId', $nodeAggregateId->value) + ->withParameter('contentStreamId', $contentStreamId->value); + } + + public function matchNodesForContentStream( + ContentStreamId $contentStreamId, + string $nodeAlias = 'n', + string $relationAlias = 'rel' + ): self { + return $this->match("({$nodeAlias}:Node)-[{$relationAlias}:IS_CHILD {contentStreamId: \$contentStreamId}]->(:Node|Root)") + ->withParameter('contentStreamId', $contentStreamId->value); + } + public function matchChildrenForSubgraph( ContentStreamId $contentStreamId, DimensionSpacePoint $dimensionSpacePoint, @@ -123,7 +158,7 @@ public function matchNodeWithRootRelation( ->withParameter('contentStreamId', $contentStreamId->value); } - public function matchNodeInDimensionSpace( + public function whereNodeInDimensionSpace( string $dimensionSpacePointHash, string $relationAlias = 'rel' ): self { @@ -146,10 +181,12 @@ public function returnStandardNodeFields(string $nodeAlias = 'n', string $relati "{$nodeAlias}.properties as properties, " . "{$relationAlias}.dimensionSpacePointHash as dimensionSpacePointHash, " . "{$relationAlias}.contentStreamId as contentStreamId, " . - "{$relationAlias}.position as position" + "{$relationAlias}.position as position, " . + "{$relationAlias}.subtreeTags as subtreeTags" ); } + public function whereNodeTypeIn(NodeTypeNames|array $nodeTypeNames, string $nodeAlias = 'n', string $parameterAlias = 'allowedNodeTypes', bool $negate = false): self { return $this diff --git a/Classes/Domain/Query/QueryBuilder.php b/Classes/Domain/Query/QueryBuilder.php index a03d91f..d9509c1 100644 --- a/Classes/Domain/Query/QueryBuilder.php +++ b/Classes/Domain/Query/QueryBuilder.php @@ -165,7 +165,7 @@ public function withParameter(string $key, mixed $value): self private function buildCypher(): string { $cypher = []; - $lastClause = null; + $lastClause = []; foreach ($this->clauses as $clause) { $type = $clause['type']; @@ -174,11 +174,13 @@ private function buildCypher(): string if ($expression === '') { $cypher[] = $type; } else { - if ($type === 'WHERE' && $lastClause['type'] === 'WHERE') { - $type = 'AND'; - } - if ($type === 'ORDER BY' && $lastClause['type'] === 'ORDER BY') { - $type = ','; + if (array_key_exists('type', $lastClause)) { + if ($type === 'WHERE' && $lastClause['type'] === 'WHERE') { + $type = 'AND'; + } + if ($type === 'ORDER BY' && $lastClause['type'] === 'ORDER BY') { + $type = ','; + } } $cypher[] = $type . ' ' . $expression; } @@ -202,4 +204,18 @@ public function getClauses(): array { return $this->clauses; } + + /** + * @param callable(static): static $subClauses + * @param string $groupAlias + * @return self + */ + public function whereAll(callable $subClauses, string $groupAlias = 'rels'): self + { + $statement = $subClauses(new QueryBuilder())->build(); + $this + ->rawClause(sprintf('WHERE all(r IN %s %s)', $groupAlias, $statement->getText())) + ->withParameters($statement->getParameters()); + return $this; + } } diff --git a/Classes/Domain/Repository/Neo4jContentGraph.php b/Classes/Domain/Repository/Neo4jContentGraph.php index e191690..b74dcf7 100644 --- a/Classes/Domain/Repository/Neo4jContentGraph.php +++ b/Classes/Domain/Repository/Neo4jContentGraph.php @@ -6,7 +6,6 @@ use JvMTECH\ContentGraph\Neo4jAdapter\Domain\Query\NodeQueryBuilder; use Laudis\Neo4j\Contracts\ClientInterface; use Laudis\Neo4j\Databags\Statement; -use Laudis\Neo4j\Databags\SummarizedResult; use Laudis\Neo4j\Types\CypherMap; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; @@ -118,17 +117,14 @@ public function findRootNodeAggregates(Filter\FindRootNodeAggregatesFilter $filt public function findNodeAggregatesByType(NodeTypeName $nodeTypeName): NodeAggregates { $result = $this->client->runStatement( - Statement::create( - 'MATCH (n:Node {nodeTypeName: $nodeTypeName})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->() - RETURN n.aggregateId as aggregateId, n.nodeTypeName as nodeTypeName, n.name as name, - n.created as created, n.originalCreated as originalCreated, n.lastModified as lastModified, n.originalLastModified as originalLastModified, - n.classification as classification, n.originDimensionSpacePointHash as originDimensionSpacePointHash, - n.properties as properties, rel.dimensionSpacePointHash as dimensionSpacePointHash', - [ + NodeQueryBuilder::createForNodes() + ->match('(n:Node {nodeTypeName: $nodeTypeName})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->()') + ->withParameters([ 'nodeTypeName' => $nodeTypeName->value, - 'contentStreamId' => $this->getContentStreamId()->value, - ] - ) + 'contentStreamId' => $this->contentStreamId->value, + ]) + ->returnStandardNodeFields() + ->build() ); return $this->nodeFactory->mapResultToNodeAggregates( @@ -143,19 +139,15 @@ public function findNodeAggregatesByType(NodeTypeName $nodeTypeName): NodeAggreg public function findNodeAggregateById(NodeAggregateId $nodeAggregateId): ?NodeAggregate { $result = $this->client->runStatement( - Statement::create( - 'MATCH (n:Node {aggregateId: $aggregateId})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->() - RETURN n.aggregateId as aggregateId, n.nodeTypeName as nodeTypeName, n.name as name, - n.classification as classification, n.originDimensionSpacePointHash as originDimensionSpacePointHash, - n.created as created, n.originalCreated as originalCreated, n.lastModified as lastModified, n.originalLastModified as originalLastModified, - n.properties as properties, rel.dimensionSpacePointHash as dimensionSpacePointHash', - [ + NodeQueryBuilder::createForNodes() + ->match('(n:Node {aggregateId: $aggregateId})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->()') + ->withParameters([ 'aggregateId' => $nodeAggregateId->value, 'contentStreamId' => $this->getContentStreamId()->value, - ] - ) + ]) + ->returnStandardNodeFields() + ->build() ); - return $this->nodeFactory->mapResultToNodeAggregate( $result, $this->workspaceName, @@ -170,17 +162,10 @@ public function findNodeAggregatesByIds(NodeAggregateIds $nodeAggregateIds): Nod $nodeAggregates = []; foreach ($nodeAggregateIds as $nodeAggregateId) { $result = $this->client->runStatement( - Statement::create( - 'MATCH (n:Node {aggregateId: $aggregateId})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->() - RETURN n.aggregateId as aggregateId, n.nodeTypeName as nodeTypeName, n.name as name, - n.classification as classification, n.originDimensionSpacePointHash as originDimensionSpacePointHash, - n.created as created, n.originalCreated as originalCreated, n.lastModified as lastModified, n.originalLastModified as originalLastModified, - n.properties as properties, rel.dimensionSpacePointHash as dimensionSpacePointHash', - [ - 'aggregateId' => $nodeAggregateId->value, - 'contentStreamId' => $this->getContentStreamId()->value, - ] - ) + NodeQueryBuilder::createForNodes() + ->matchNodeForContentStream($this->contentStreamId, $nodeAggregateId) + ->returnStandardNodeFields() + ->build() ); $nodeAggregates[] = $this->nodeFactory->mapResultToNodeAggregate( $result, @@ -213,20 +198,13 @@ public function findParentNodeAggregateByChildOriginDimensionSpacePoint( OriginDimensionSpacePoint $childOriginDimensionSpacePoint ): ?NodeAggregate { $result = $this->client->runStatement( - Statement::create( - 'MATCH (:Node {aggregateId: $aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->(parentNode:Node) - MATCH (n:Node {aggregateId: parentNode.aggregateId})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->() - RETURN n.aggregateId as aggregateId, n.nodeTypeName as nodeTypeName, n.name as name, - n.classification as classification, n.originDimensionSpacePointHash as originDimensionSpacePointHash, - n.created as created, n.originalCreated as originalCreated, n.lastModified as lastModified, n.originalLastModified as originalLastModified, - n.properties as properties, rel.dimensionSpacePointHash as dimensionSpacePointHash', - [ - 'aggregateId' => $childNodeAggregateId->value, - 'contentStreamId' => $this->getContentStreamId()->value, - 'dimensionSpacePointHash' => $childOriginDimensionSpacePoint->toDimensionSpacePoint()->hash, - ] - ) + NodeQueryBuilder::createForNodes() + ->matchNodeForSubgraph($this->contentStreamId, $childOriginDimensionSpacePoint->toDimensionSpacePoint(), $childNodeAggregateId, '', '', 'parentNode') + ->match('(n:Node {aggregateId: parentNode.aggregateId})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->()') + ->returnStandardNodeFields() + ->build() ); + if ($result->isEmpty()) { return null; } @@ -242,18 +220,15 @@ public function findParentNodeAggregateByChildOriginDimensionSpacePoint( public function findParentNodeAggregates(NodeAggregateId $childNodeAggregateId): NodeAggregates { $result = $this->client->runStatement( - Statement::create( - 'MATCH (:Node {aggregateId: $aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId}]->(parentNode:Node) - MATCH (n:Node {aggregateId: parentNode.aggregateId})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->() - RETURN n.aggregateId as aggregateId, n.nodeTypeName as nodeTypeName, n.name as name, - n.classification as classification, n.originDimensionSpacePointHash as originDimensionSpacePointHash, - n.created as created, n.originalCreated as originalCreated, n.lastModified as lastModified, n.originalLastModified as originalLastModified, - n.properties as properties, rel.dimensionSpacePointHash as dimensionSpacePointHash', - [ + NodeQueryBuilder::createForNodes() + ->match('(:Node {aggregateId: $aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId}]->(parentNode:Node)') + ->match('(n:Node {aggregateId: parentNode.aggregateId})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->()') + ->withParameters([ 'aggregateId' => $childNodeAggregateId->value, 'contentStreamId' => $this->getContentStreamId()->value, - ] - ) + ]) + ->returnStandardNodeFields() + ->build() ); if ($result->isEmpty()) { @@ -292,18 +267,11 @@ public function findAncestorNodeAggregateIds(NodeAggregateId $entryNodeAggregate public function findChildNodeAggregates(NodeAggregateId $parentNodeAggregateId): NodeAggregates { $result = $this->client->runStatement( - Statement::create( - 'MATCH (original:Node {aggregateId: $aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId}]->() - MATCH (n:Node)-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->(original) - RETURN n.aggregateId as aggregateId, n.nodeTypeName as nodeTypeName, n.name as name, - n.classification as classification, n.originDimensionSpacePointHash as originDimensionSpacePointHash, - n.created as created, n.originalCreated as originalCreated, n.lastModified as lastModified, n.originalLastModified as originalLastModified, - n.properties as properties, rel.dimensionSpacePointHash as dimensionSpacePointHash', - [ - 'aggregateId' => $parentNodeAggregateId->value, - 'contentStreamId' => $this->contentStreamId->value, - ], - ) + NodeQueryBuilder::createForNodes() + ->matchNodeForContentStream($this->contentStreamId, $parentNodeAggregateId, 'original', '') + ->match('(n:Node)-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->(original)') + ->returnStandardNodeFields() + ->build() ); if ($result->isEmpty()) { @@ -319,19 +287,18 @@ public function findChildNodeAggregates(NodeAggregateId $parentNodeAggregateId): public function findChildNodeAggregateByName(NodeAggregateId $parentNodeAggregateId, NodeName $name): ?NodeAggregate { $result = $this->client->runStatement( - Statement::create( - 'MATCH (original:Node {aggregateId: $aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId}]->() - MATCH (n:Node {name: $name})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->(original) - RETURN n.aggregateId as aggregateId, n.nodeTypeName as nodeTypeName, n.name as name, - n.classification as classification, n.originDimensionSpacePointHash as originDimensionSpacePointHash, - n.created as created, n.originalCreated as originalCreated, n.lastModified as lastModified, n.originalLastModified as originalLastModified, - n.properties as properties, rel.dimensionSpacePointHash as dimensionSpacePointHash', - [ - 'aggregateId' => $parentNodeAggregateId->value, - 'contentStreamId' => $this->contentStreamId->value, - 'name' => $name->value, - ], - ) + NodeQueryBuilder::createForNodes() + ->match('(original:Node {aggregateId: $aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId}]->()') + ->match('(n:Node {name: $name})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->(original)') + ->withParameters( + [ + 'aggregateId' => $parentNodeAggregateId->value, + 'contentStreamId' => $this->contentStreamId->value, + 'name' => $name->value, + ] + ) + ->returnStandardNodeFields() + ->build() ); if ($result->isEmpty()) { @@ -347,19 +314,12 @@ public function findChildNodeAggregateByName(NodeAggregateId $parentNodeAggregat public function findTetheredChildNodeAggregates(NodeAggregateId $parentNodeAggregateId): NodeAggregates { $result = $this->client->runStatement( - Statement::create( - 'MATCH (original:Node {aggregateId: $aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId}]->() - MATCH (n:Node {classification: $classification})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->(original) - RETURN n.aggregateId as aggregateId, n.nodeTypeName as nodeTypeName, n.name as name, - n.classification as classification, n.originDimensionSpacePointHash as originDimensionSpacePointHash, - n.created as created, n.originalCreated as originalCreated, n.lastModified as lastModified, n.originalLastModified as originalLastModified, - n.properties as properties, rel.dimensionSpacePointHash as dimensionSpacePointHash', - [ - 'aggregateId' => $parentNodeAggregateId->value, - 'contentStreamId' => $this->contentStreamId->value, - 'classification' => NodeAggregateClassification::CLASSIFICATION_TETHERED->value, - ], - ) + NodeQueryBuilder::createForNodes() + ->matchNodeForContentStream($this->contentStreamId, $parentNodeAggregateId, 'original', '') + ->match('(n:Node {classification: $classification})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->(original)') + ->withParameter('classification', NodeAggregateClassification::CLASSIFICATION_TETHERED->value) + ->returnStandardNodeFields() + ->build() ); if ($result->isEmpty()) { @@ -411,18 +371,11 @@ public function getDimensionSpacePointsOccupiedByChildNodeName( public function findNodeAggregatesTaggedBy(SubtreeTag $subtreeTag): NodeAggregates { $result = $this->client->runStatement( - Statement::create( - 'MATCH (n:Node)-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->() - WHERE $subtreeTag IN rel.subtreeTags - RETURN n.aggregateId as aggregateId, n.nodeTypeName as nodeTypeName, n.name as name, - n.classification as classification, n.originDimensionSpacePointHash as originDimensionSpacePointHash, - n.created as created, n.originalCreated as originalCreated, n.lastModified as lastModified, n.originalLastModified as originalLastModified, - n.properties as properties, rel.dimensionSpacePointHash as dimensionSpacePointHash', - [ - 'subtreeTag' => $subtreeTag->value, - 'contentStreamId' => $this->getContentStreamId()->value, - ] - ) + NodeQueryBuilder::createForNodes() + ->matchNodesForContentStream($this->contentStreamId) + ->where(sprintf('apoc.convert.fromJsonMap(rel.subtreeTags).%s', $subtreeTag->value)) + ->returnStandardNodeFields() + ->build() ); if ($result->isEmpty()) { diff --git a/Classes/Domain/Repository/Neo4jContentSubgraph.php b/Classes/Domain/Repository/Neo4jContentSubgraph.php index e452420..6848e29 100644 --- a/Classes/Domain/Repository/Neo4jContentSubgraph.php +++ b/Classes/Domain/Repository/Neo4jContentSubgraph.php @@ -4,11 +4,13 @@ namespace JvMTECH\ContentGraph\Neo4jAdapter\Domain\Repository; use JvMTECH\ContentGraph\Neo4jAdapter\Domain\Query\NodeQueryBuilder; +use JvMTECH\ContentGraph\Neo4jAdapter\Domain\Query\QueryBuilder; use Laudis\Neo4j\Contracts\ClientInterface; use Laudis\Neo4j\Databags\Statement; use Laudis\Neo4j\Databags\SummarizedResult; use Laudis\Neo4j\Types\CypherMap; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; +use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\Projection\ContentGraph\AbsoluteNodePath; @@ -73,6 +75,7 @@ public function findNodeById(NodeAggregateId $nodeAggregateId): ?Node $this->dimensionSpacePoint, $nodeAggregateId, ) + ->withVisibilityConstraints($this->visibilityConstraints) ->returns('n') ->build() ); @@ -169,6 +172,13 @@ private function getChildNodesQuery( $parentNodeAggregateId, parentAlias: '', ); + /** @var SubtreeTags $constraint */ + foreach ($this->visibilityConstraints as $constraint) { + foreach ($constraint as $subtreeTag) { + $query->with(sprintf('child, rel, COALESCE(apoc.convert.fromJsonMap(rel.subtreeTags).%s, false) AS %s', $subtreeTag->value, $subtreeTag->value)); + $query->where(sprintf('%s <> true', $subtreeTag->value)); + } + } if (!empty($filter->nodeTypes)) { $expandedNodeTypeCriteria = ExpandedNodeTypeCriteria::create($filter->nodeTypes, $this->nodeTypeManager); @@ -308,6 +318,13 @@ private function getSiblingNodesQuery( $siblingNodeAggregateId, ) ->match('(p)<-[otherSiblingRel:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]-(otherSibling:Node)'); + + foreach ($this->visibilityConstraints as $constraint) { + foreach ($constraint as $subtreeTag) { + $query->where(sprintf('COALESCE(apoc.convert.fromJsonMap(otherSiblingRel.subtreeTags).%s, false) <> true', $subtreeTag->value)); + } + } + if ($filter instanceof Filter\FindSucceedingSiblingNodesFilter) { $query ->where('otherSiblingRel.position > rel.position') @@ -344,20 +361,20 @@ private function getSiblingNodesQuery( ->withParameters($propertyParams); } } - if ($filter->ordering !== null) { - foreach ($filter->ordering as $ordering) { - $query->orderBy('ref.' . $ordering->field->value, $ordering->direction->value); - } + if ($filter->ordering !== null) { + foreach ($filter->ordering as $ordering) { + $query->orderBy('ref.' . $ordering->field->value, $ordering->direction->value); } + } + $query + ->orderBy('otherSiblingRel.position') + ->orderBy('otherSibling.aggregateid'); + if ($filter->pagination !== null) { $query - ->orderBy('otherSiblingRel.position') - ->orderBy('otherSibling.aggregateid'); - if ($filter->pagination !== null) { - $query - ->limit($filter->pagination->limit) - ->skip($filter->pagination->offset); - } - $query->returns('DISTINCT otherSibling'); + ->limit($filter->pagination->limit) + ->skip($filter->pagination->offset); + } + $query->returns('DISTINCT otherSibling'); return $query; } @@ -528,7 +545,6 @@ public function findDescendantNodes(NodeAggregateId $entryNodeAggregateId, Filte public function countDescendantNodes(NodeAggregateId $entryNodeAggregateId, Filter\CountDescendantNodesFilter $filter): int { - $result = $this->client->runStatement( Statement::create( 'MATCH (:Node {aggregateId: $aggregateId})<-[:IS_CHILD*1.. {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]-(descendant:Node) RETURN count(DISTINCT descendant) as count', @@ -555,8 +571,23 @@ public function findSubtree(NodeAggregateId $entryNodeAggregateId, Filter\FindSu $this->dimensionSpacePoint, $entryNodeAggregateId, ) - ->match(sprintf('path = (n:Node)<-[rels:IS_CHILD*0..%d]-(descendant)', $maxLevels)) - ->where('all(r IN rels WHERE r.contentStreamId = $contentStreamId AND r.dimensionSpacePointHash = $dimensionSpacePointHash)'); + ->match(sprintf('path = (n:Node)<-[rels:IS_CHILD*0..%d]-(descendant)', $maxLevels)); + + $query->whereAll( + function(QueryBuilder $qb) { + $qb + ->where('r.contentStreamId = $contentStreamId') + ->withParameter('contentStreamId', $this->contentStreamId->value) + ->where('r.dimensionSpacePointHash = $dimensionSpacePointHash') + ->withParameter('dimensionSpacePointHash', $this->dimensionSpacePoint->hash); + foreach ($this->visibilityConstraints as $constraint) { + foreach ($constraint as $subtreeTag) { + $qb->where(sprintf('COALESCE(apoc.convert.fromJsonMap(r.subtreeTags).%s, false) <> true', $subtreeTag->value)); + } + } + return $qb; + }, + ); if (!empty($filter->nodeTypes)) { $expandedNodeTypeCriteria = ExpandedNodeTypeCriteria::create($filter->nodeTypes, $this->nodeTypeManager); @@ -723,7 +754,6 @@ private function shouldIncludeNodeInSubtree(Node $node, Filter\FindSubtreeFilter public function findReferences(NodeAggregateId $nodeAggregateId, Filter\FindReferencesFilter $filter): References { - $query = $this->getReferencesQuery(false, $nodeAggregateId, $filter); $query->returns('target, ref'); $result = $this->client->runStatement($query->build()); @@ -779,6 +809,15 @@ private function getReferencesQuery( } else { $query->match('(source:Node)-[ref:REFERENCE]->(target:Node)'); } + $query->match('(source)-[sourceChild:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->(:Node)'); + $query->match('(target)-[targetChild:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->(:Node)'); + + foreach ($this->visibilityConstraints as $constraint) { + foreach ($constraint as $subtreeTag) { + $query->where(sprintf('COALESCE(apoc.convert.fromJsonMap(sourceChild.subtreeTags).%s, false) <> true', $subtreeTag->value)); + $query->where(sprintf('COALESCE(apoc.convert.fromJsonMap(targetChild.subtreeTags).%s, false) <> true', $subtreeTag->value)); + } + } $query->match('(source)-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->(:Node)'); $query->match('(target)-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->(:Node)'); @@ -848,8 +887,6 @@ private function getReferencesQuery( public function findNodeByPath(NodeName|NodePath $path, NodeAggregateId $startingNodeAggregateId): ?Node { - - /** @var NodePath $path */ $path = $path instanceof NodeName ? NodePath::fromNodeNames($path) : $path; return $this->findNodeByPathFromStartingNode($path, $startingNodeAggregateId); @@ -857,7 +894,6 @@ public function findNodeByPath(NodeName|NodePath $path, NodeAggregateId $startin public function findNodeByAbsolutePath(AbsoluteNodePath $path): ?Node { - $startingNode = $this->findRootNodeByType($path->rootNodeTypeName); return $startingNode @@ -867,34 +903,48 @@ public function findNodeByAbsolutePath(AbsoluteNodePath $path): ?Node private function findNodeByPathFromStartingNode(NodePath $path, Node|NodeAggregateId $startingNode): ?Node { + $query = NodeQueryBuilder::createForNodes() + ->matchNodeForSubgraph( + $this->contentStreamId, + $this->dimensionSpacePoint, + $startingNode, + nodeAlias: 'startingNode', + ); - $statement = 'MATCH p = (:Node {aggregateId: $startingNodeAggregateId})'; + $query->rawClause('MATCH path = '); + $lastNodeAlias = 'startingNode'; + $highestIndex = -1; foreach ($path->getParts() as $part) { if ($part->value === 'sites') continue; - $statement .= sprintf( - '<-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]-(:Node {name: "%s"})', - $part->value - ); + $highestIndex++; + if ($highestIndex === 0) { + $query->rawClause(sprintf('(%s)<-[childRel%s]-(childNode%s)', $lastNodeAlias, $highestIndex, $highestIndex)); + } else { + $query->match(sprintf('(%s)<-[childRel%s]-(childNode%s)', $lastNodeAlias, $highestIndex, $highestIndex)); + } + $query + ->where(sprintf('childNode%s.name = $name%s', $highestIndex, $highestIndex)) + ->withParameter('name' . $highestIndex, $part->value) + ->where(sprintf('childRel%s.contentStreamId = $contentStreamId', $highestIndex)) + ->where(sprintf('childRel%s.dimensionSpacePointHash = $dimensionSpacePointHash', $highestIndex)); + foreach ($this->visibilityConstraints as $constraint) { + foreach ($constraint as $subtreeTag) { + $query->where(sprintf('COALESCE(apoc.convert.fromJsonMap(childRel%s.subtreeTags).%s, false) <> true', $highestIndex, $subtreeTag->value)); + } + } + + $lastNodeAlias = sprintf('childNode%s', $highestIndex); } - $statement .= ' RETURN p'; + $query->returns(sprintf('childNode%s as node', $highestIndex)); - /** @var SummarizedResult $result */ - $result = $this->client->runStatement( - Statement::create($statement, [ - 'startingNodeAggregateId' => $startingNode instanceof NodeAggregateId ? $startingNode->value : $startingNode->aggregateId->value, - 'contentStreamId' => $this->contentStreamId->value, - 'dimensionSpacePointHash' => $this->dimensionSpacePoint->hash, - ]) - ); + $result = $this->client->runStatement($query->build()); if ($result->isEmpty() || !$result->hasKey(0)) { return null; } - $path = $result->getAsCypherMap(0)->getAsPath('p'); - return $this->nodeFactory->mapResultToNode( - $path->getNodes()->reversed()->first(), + $result->getAsCypherMap(0)->getAsNode('node'), $this->workspaceName, $this->dimensionSpacePoint, $this->visibilityConstraints, @@ -903,14 +953,12 @@ private function findNodeByPathFromStartingNode(NodePath $path, Node|NodeAggrega public function retrieveNodePath(NodeAggregateId $nodeAggregateId): AbsoluteNodePath { - // TODO: Implement retrieveNodePath() method. throw new \RuntimeException('Not implemented yet'); } public function countNodes(): int { - /** @var SummarizedResult $result */ $result = $this->client->runStatement( Statement::create( diff --git a/Classes/Domain/Repository/NodeFactory.php b/Classes/Domain/Repository/NodeFactory.php index 0b41abf..7f2c2e7 100644 --- a/Classes/Domain/Repository/NodeFactory.php +++ b/Classes/Domain/Repository/NodeFactory.php @@ -11,6 +11,8 @@ use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePointSet; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; +use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; +use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags; use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\Projection\ContentGraph\CoverageByOrigin; @@ -30,6 +32,7 @@ use Neos\ContentRepository\Core\SharedModel\Node\NodeName; use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\Neos\Domain\SubtreeTagging\NeosSubtreeTag; /** * Factory for creating NodeAggregate objects from Neo4j query results @@ -67,7 +70,7 @@ public function mapResultToNodeAggregates( } $nodeAggregates = []; - foreach ($recordsByNodeAggregateId as $nodeAggregateId => $records) { + foreach ($recordsByNodeAggregateId as $records) { $nodeAggregate = $this->createNodeAggregateFromRecords($records, $workspaceName); if ($nodeAggregate !== null) { $nodeAggregates[] = $nodeAggregate; @@ -88,13 +91,7 @@ public function mapResultToNodeAggregate( return null; } - // Convert all records to array and create a single NodeAggregate - $records = []; - foreach ($result as $record) { - $records[] = $record; - } - - return $this->createNodeAggregateFromRecords($records, $workspaceName); + return $this->createNodeAggregateFromRecords($result->toArray(), $workspaceName); } /** @@ -146,7 +143,7 @@ private function createNodeAggregateFromRecords(array $records, WorkspaceName $w // Build coverage mappings $coverageByOccupants[$originDimensionSpacePoint->hash][$coveredDimensionSpacePoint->hash] = $coveredDimensionSpacePoint; $occupationByCovering[$coveredDimensionSpacePoint->hash] = $originDimensionSpacePoint; - $nodeTagsByCoveredDimensionSpacePoint[$coveredDimensionSpacePoint->hash] = NodeTags::createEmpty(); + $nodeTagsByCoveredDimensionSpacePoint[$coveredDimensionSpacePoint->hash] = self::extractNodeTagsFromJson($record->get('subtreeTags') ?: '{}'); } return NodeAggregate::create( @@ -302,4 +299,26 @@ private function resolveCoveredDimensionSpacePointFromHash(?string $hash): Dimen { return $this->dimensionSpacePointsRepository->getOriginDimensionSpacePointByHash($hash)->toDimensionSpacePoint(); } + + public static function extractNodeTagsFromJson(string $subtreeTagsJson): NodeTags + { + $explicitTags = []; + $inheritedTags = []; + try { + $subtreeTagsArray = json_decode($subtreeTagsJson, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \RuntimeException(sprintf('Failed to JSON-decode subtree tags from JSON string %s: %s', $subtreeTagsJson, $e->getMessage()), 1716476904, $e); + } + foreach ($subtreeTagsArray as $tagValue => $explicit) { + if ($explicit) { + $explicitTags[] = $tagValue; + } else { + $inheritedTags[] = $tagValue; + } + } + return NodeTags::create( + tags: SubtreeTags::fromStrings(...$explicitTags), + inheritedTags: SubtreeTags::fromStrings(...$inheritedTags) + ); + } } From a9c003e6d325a75dad5535c0ee4f0163482a808d Mon Sep 17 00:00:00 2001 From: Michel Loew Date: Mon, 7 Jul 2025 15:21:04 +0200 Subject: [PATCH 3/9] WIP: Satisfy Feature/06-NodeDisabling --- .../Projection/Feature/HierarchyRelation.php | 29 +++- Classes/Domain/Projection/Feature/Subtree.php | 155 ++++++++++-------- Classes/Domain/Query/NodeQueryBuilder.php | 2 +- Classes/Domain/Query/QueryBuilder.php | 2 +- .../Repository/Neo4jContentSubgraph.php | 1 + Classes/Domain/Repository/NodeFactory.php | 4 +- Classes/Neo4jContentGraphProjection.php | 1 + 7 files changed, 114 insertions(+), 80 deletions(-) diff --git a/Classes/Domain/Projection/Feature/HierarchyRelation.php b/Classes/Domain/Projection/Feature/HierarchyRelation.php index d718330..ca51c58 100644 --- a/Classes/Domain/Projection/Feature/HierarchyRelation.php +++ b/Classes/Domain/Projection/Feature/HierarchyRelation.php @@ -29,12 +29,25 @@ private function addParentHierarchyRelation( $this->client->runStatement( Statement::create( 'MATCH (childNode:Node) WHERE ID(childNode) = $childNodeAggregateId - MATCH (parentNode:Node|Root {aggregateId: $parentNodeAggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->() + MATCH (parentNode:Node|Root {aggregateId: $parentNodeAggregateId})-[parentRel:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->() + + // Get parent subtree tags and convert them to inherited tags for the child + WITH childNode, parentNode, parentRel, + CASE WHEN parentRel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(parentRel.subtreeTags) + ELSE {} END as parentTags + + // Convert parent tags to inherited tags for child (true -> inherit, inherit -> inherit) + WITH childNode, parentNode, parentRel, parentTags, + apoc.map.fromPairs([key in keys(parentTags) WHERE parentTags[key] IN [true, "inherit"] | [key, "inherit"]]) as inheritedTags + CREATE (childNode)-[:IS_CHILD { contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash, position: $position, - subtreeTags: $subtreeTags + subtreeTags: CASE WHEN size(keys(inheritedTags)) > 0 + THEN apoc.convert.toJson(inheritedTags) + ELSE null END }]->(parentNode) SET childNode.lastModified = $lastModified SET childNode.originalLastModified = $originalLastModified', @@ -44,7 +57,6 @@ private function addParentHierarchyRelation( 'contentStreamId' => $contentStreamId->value, 'dimensionSpacePointHash' => $dimensionSpacePoint->hash, 'position' => $position, - 'subtreeTags' => '{}', 'lastModified' => $lastModified->format(\DateTimeInterface::ATOM), 'originalLastModified' => $originalLastModified->format(\DateTimeInterface::ATOM), ] @@ -78,6 +90,7 @@ private function copyHierarchyRelation( Node $newParentNode, DimensionSpacePoint $dimensionSpacePoint, int $position, + bool $copyDisabledState = true, ): void { $this->client->runStatement( @@ -92,7 +105,12 @@ private function copyHierarchyRelation( position: $position }]->(newParentNode) FOREACH (_ IN CASE WHEN rOld.subtreeTags IS NOT NULL AND $copySubtreeTags = TRUE THEN [1] ELSE [] END | - SET rNew.subtreeTags = rOld.subtreeTags + // Copy subtree tags conditionally based on copyDisabledState parameter + SET rNew.subtreeTags = CASE WHEN $copyDisabledState = TRUE + THEN rOld.subtreeTags + ELSE CASE WHEN size(keys(apoc.map.removeKey(apoc.convert.fromJsonMap(rOld.subtreeTags), "disabled"))) > 0 + THEN apoc.convert.toJson(apoc.map.removeKey(apoc.convert.fromJsonMap(rOld.subtreeTags), "disabled")) + ELSE null END END )', [ 'relationshipId' => $relationship->getId(), @@ -100,7 +118,8 @@ private function copyHierarchyRelation( 'newParentNodeId' => $newParentNode->getId(), 'position' => $position, 'dimensionSpacePointHash' => $dimensionSpacePoint->hash, - 'copySubtreeTags' => false, + 'copySubtreeTags' => true, + 'copyDisabledState' => $copyDisabledState, ] ) ); diff --git a/Classes/Domain/Projection/Feature/Subtree.php b/Classes/Domain/Projection/Feature/Subtree.php index 5ba8565..7f33792 100644 --- a/Classes/Domain/Projection/Feature/Subtree.php +++ b/Classes/Domain/Projection/Feature/Subtree.php @@ -3,6 +3,7 @@ namespace JvMTECH\ContentGraph\Neo4jAdapter\Domain\Projection\Feature; +use JvMTECH\ContentGraph\Neo4jAdapter\Domain\Query\NodeQueryBuilder; use Laudis\Neo4j\Contracts\ClientInterface; use Laudis\Neo4j\Databags\Statement; use Laudis\Neo4j\Databags\SummarizedResult; @@ -19,80 +20,94 @@ trait Subtree private function addSubtreeTag(ContentStreamId $contentStreamId, NodeAggregateId $nodeAggregateId, DimensionSpacePointSet $affectedDimensionSpacePoints, SubtreeTag $tag): void { $affectedDimensionSpacePointHashes = $affectedDimensionSpacePoints->getPointHashes(); - $currentRelationships = $this->client->runStatement( - Statement::create( - 'MATCH (:Node {aggregateId: $aggregateId})-[rel:IS_CHILD|IS_ROOT {contentStreamId: $contentStreamId}]->() - RETURN rel - ', - [ - 'aggregateId' => $nodeAggregateId->value, - 'contentStreamId' => $contentStreamId->value, - ] - ) - ); - /** @var CypherMap $map */ - foreach ($currentRelationships as $map) { - $relationship = $map->getAsRelationship('rel'); - $relationshipDimensionSpacePointHash = $relationship->getProperty('dimensionSpacePointHash'); - $currentSubtreeTags = []; - if ($relationship->getProperties()->hasKey('subtreeTags')) { - $currentSubtreeTags = json_decode($relationship->getProperty('subtreeTags') ?: '{}', true); - } - $relationshipId = $relationship->getId(); - if (in_array($relationshipDimensionSpacePointHash, $affectedDimensionSpacePointHashes)) { - $currentSubtreeTags[$tag->value] = true; - $this->client->runStatement( - Statement::create('MATCH ()-[rel:IS_CHILD|IS_ROOT]->() - WHERE id(rel) = $relationshipId - SET rel.subtreeTags = $subtreeTags', - [ - 'relationshipId' => $relationshipId, - 'subtreeTags' => json_encode($currentSubtreeTags), - ], - ) - ); - } - } + $this->client->runStatement( + Statement::create(' + MATCH (currentNode:Node {aggregateId: $aggregateId})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->() + WHERE rel.dimensionSpacePointHash IN $affectedDimensionSpacePointHashes + OPTIONAL MATCH (:Node)-[nestedRels:IS_CHILD*..]->(currentNode)-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->() + WHERE all(nestedRel IN nestedRels WHERE nestedRel.contentStreamId = $contentStreamId + AND nestedRel.dimensionSpacePointHash IN $affectedDimensionSpacePointHashes) + SET rel.subtreeTags = $subtreeTags + WITH nestedRels + UNWIND nestedRels AS nestedRel + SET nestedRel.subtreeTags = $nestedSubtreeTags + RETURN *', + [ + 'aggregateId' => $nodeAggregateId->value, + 'contentStreamId' => $contentStreamId->value, + 'affectedDimensionSpacePointHashes' => $affectedDimensionSpacePointHashes, + 'subtreeTags' => json_encode([$tag->value => true]), + 'nestedSubtreeTags' => json_encode([$tag->value => 'inherit']), + ] + )); } + private function removeSubtreeTag(ContentStreamId $contentStreamId, NodeAggregateId $nodeAggregateId, DimensionSpacePointSet $affectedDimensionSpacePoints, SubtreeTag $tag): void { - $affectedDimensionSpacePointHashes = $affectedDimensionSpacePoints->getPointHashes(); - /** @var SummarizedResult $currentRelationships */ - $currentRelationships = $this->client->runStatement( - Statement::create( - 'MATCH (:Node {aggregateId: $aggregateId})-[rel:IS_CHILD|IS_ROOT {contentStreamId: $contentStreamId}]->() - RETURN rel - ', - [ - 'aggregateId' => $nodeAggregateId->value, - 'contentStreamId' => $contentStreamId->value, - ] - ) - ); - /** @var CypherMap $map */ - foreach ($currentRelationships as $map) { - $relationship = $map->getAsRelationship('rel'); - $relationshipDimensionSpacePointHash = $relationship->getProperty('dimensionSpacePointHash'); - $currentSubtreeTags = []; - if ($relationship->getProperties()->hasKey('subtreeTags')) { - $currentSubtreeTags = $relationship->getProperty('subtreeTags')->toArray(); - } - $relationshipId = $relationship->getId(); - if (in_array($relationshipDimensionSpacePointHash, $affectedDimensionSpacePointHashes)) { - $currentSubtreeTags = array_filter($currentSubtreeTags, fn(string $entry) => $entry !== $tag->value); - $this->client->runStatement( - Statement::create('MATCH ()-[rel:IS_CHILD|IS_ROOT]->() - WHERE id(rel) = $relationshipId - SET rel.subtreeTags = $subtreeTags', - [ - 'relationshipId' => $relationshipId, - 'subtreeTags' => $currentSubtreeTags, - ], - ) - ); - } - } + $this->client->runStatement( + Statement::create(' + MATCH (currentNode:Node {aggregateId: $aggregateId})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->() + WHERE rel.dimensionSpacePointHash IN $affectedDimensionSpacePointHashes + + // Check if current node has inherited tag from parent + OPTIONAL MATCH (currentNode)-[currentRel:IS_CHILD {contentStreamId: $contentStreamId}]->(parentNode)-[parentRel:IS_CHILD {contentStreamId: $contentStreamId}]->() + WHERE currentRel.dimensionSpacePointHash IN $affectedDimensionSpacePointHashes + AND parentRel.dimensionSpacePointHash IN $affectedDimensionSpacePointHashes + + // Get current subtree tags as map + WITH currentNode, rel, parentRel, + CASE WHEN rel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(rel.subtreeTags) + ELSE {} END as currentTags, + CASE WHEN parentRel IS NOT NULL AND parentRel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(parentRel.subtreeTags) + ELSE {} END as parentTags + + // Update current node: remove if parent does not have tag, or set to inherit if parent still has it + WITH currentNode, rel, parentRel, currentTags, parentTags, + CASE WHEN $tagValue IN keys(parentTags) AND parentTags[$tagValue] IN [true, "inherit"] + THEN apoc.map.setKey(apoc.map.removeKey(currentTags, $tagValue), $tagValue, "inherit") + ELSE apoc.map.removeKey(currentTags, $tagValue) END as updatedCurrentTags + + SET rel.subtreeTags = CASE WHEN size(keys(updatedCurrentTags)) > 0 + THEN apoc.convert.toJson(updatedCurrentTags) + ELSE null END + + // Handle child nodes + WITH currentNode, updatedCurrentTags + OPTIONAL MATCH (childNode)-[childRels:IS_CHILD*.. {contentStreamId: $contentStreamId}]->(currentNode) + WHERE all(childRel IN childRels WHERE childRel.contentStreamId = $contentStreamId + AND childRel.dimensionSpacePointHash IN $affectedDimensionSpacePointHashes) + + // Get the direct parent relationship for each child + WITH currentNode, updatedCurrentTags, childNode, childRels, + childRels[size(childRels)-1] as directChildRel + + // Get child tags + WITH currentNode, updatedCurrentTags, childNode, directChildRel, + CASE WHEN directChildRel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(directChildRel.subtreeTags) + ELSE {} END as childTags + + // Only remove tag from children if they have it as "inherit", not as true + WITH currentNode, updatedCurrentTags, childNode, directChildRel, childTags, + CASE WHEN $tagValue IN keys(childTags) AND childTags[$tagValue] = "inherit" + THEN apoc.map.removeKey(childTags, $tagValue) + ELSE childTags END as updatedChildTags + + SET directChildRel.subtreeTags = CASE WHEN size(keys(updatedChildTags)) > 0 + THEN apoc.convert.toJson(updatedChildTags) + ELSE null END + + RETURN count(*)', + [ + 'aggregateId' => $nodeAggregateId->value, + 'contentStreamId' => $contentStreamId->value, + 'affectedDimensionSpacePointHashes' => $affectedDimensionSpacePointHashes, + 'tagValue' => $tag->value, + ] + )); } } diff --git a/Classes/Domain/Query/NodeQueryBuilder.php b/Classes/Domain/Query/NodeQueryBuilder.php index de87e8b..8cf4eb5 100644 --- a/Classes/Domain/Query/NodeQueryBuilder.php +++ b/Classes/Domain/Query/NodeQueryBuilder.php @@ -59,7 +59,7 @@ public function withVisibilityConstraints( { foreach ($visibilityConstraints as $constraint) { foreach ($constraint as $subtreeTag) { - $this->where(sprintf('COALESCE(apoc.convert.fromJsonMap(%s.subtreeTags).%s, false) <> true', $relationAlias, $subtreeTag->value)); + $this->where(sprintf('COALESCE(apoc.convert.fromJsonMap(%s.subtreeTags).%s, false) = false', $relationAlias, $subtreeTag->value)); } } return $this; diff --git a/Classes/Domain/Query/QueryBuilder.php b/Classes/Domain/Query/QueryBuilder.php index d9509c1..c048a9e 100644 --- a/Classes/Domain/Query/QueryBuilder.php +++ b/Classes/Domain/Query/QueryBuilder.php @@ -214,7 +214,7 @@ public function whereAll(callable $subClauses, string $groupAlias = 'rels'): sel { $statement = $subClauses(new QueryBuilder())->build(); $this - ->rawClause(sprintf('WHERE all(r IN %s %s)', $groupAlias, $statement->getText())) + ->where(sprintf('all(r IN %s %s)', $groupAlias, $statement->getText())) ->withParameters($statement->getParameters()); return $this; } diff --git a/Classes/Domain/Repository/Neo4jContentSubgraph.php b/Classes/Domain/Repository/Neo4jContentSubgraph.php index 6848e29..8cad222 100644 --- a/Classes/Domain/Repository/Neo4jContentSubgraph.php +++ b/Classes/Domain/Repository/Neo4jContentSubgraph.php @@ -30,6 +30,7 @@ use Neos\ContentRepository\Core\SharedModel\Node\NodeName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use org\bovigo\vfs\vfsStreamResolveIncludePathTestCase; class Neo4jContentSubgraph implements ContentSubgraphInterface { diff --git a/Classes/Domain/Repository/NodeFactory.php b/Classes/Domain/Repository/NodeFactory.php index 7f2c2e7..440c2fd 100644 --- a/Classes/Domain/Repository/NodeFactory.php +++ b/Classes/Domain/Repository/NodeFactory.php @@ -11,7 +11,6 @@ use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePointSet; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; -use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags; use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\NodeType\NodeTypeName; @@ -32,7 +31,6 @@ use Neos\ContentRepository\Core\SharedModel\Node\NodeName; use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use Neos\Neos\Domain\SubtreeTagging\NeosSubtreeTag; /** * Factory for creating NodeAggregate objects from Neo4j query results @@ -310,7 +308,7 @@ public static function extractNodeTagsFromJson(string $subtreeTagsJson): NodeTag throw new \RuntimeException(sprintf('Failed to JSON-decode subtree tags from JSON string %s: %s', $subtreeTagsJson, $e->getMessage()), 1716476904, $e); } foreach ($subtreeTagsArray as $tagValue => $explicit) { - if ($explicit) { + if ($explicit === true) { $explicitTags[] = $tagValue; } else { $inheritedTags[] = $tagValue; diff --git a/Classes/Neo4jContentGraphProjection.php b/Classes/Neo4jContentGraphProjection.php index 7d09263..661892d 100644 --- a/Classes/Neo4jContentGraphProjection.php +++ b/Classes/Neo4jContentGraphProjection.php @@ -874,6 +874,7 @@ private function whenNodePeerVariantWasCreated(NodePeerVariantWasCreated $event, contentStreamId: $event->contentStreamId, dimensionSpacePoint: $unassignedIngoingDimensionSpacePoint ), + copyDisabledState: false, ); } } From 45adb644da67723b2f4964952083bd91728ec412 Mon Sep 17 00:00:00 2001 From: Michel Loew Date: Mon, 1 Sep 2025 16:43:44 +0200 Subject: [PATCH 4/9] WIP: Satisfy Feature/07-NodeRemoval --- .../Projection/Feature/HierarchyRelation.php | 70 +++++- .../Projection/Feature/ReferenceRelation.php | 6 +- Classes/Domain/Projection/Feature/Subtree.php | 216 +++++++++++++----- Classes/Domain/Query/NodeQueryBuilder.php | 6 +- .../Repository/Neo4jContentSubgraph.php | 36 ++- .../Neo4jProjectionContentGraph.php | 4 +- Classes/Domain/Repository/NodeFactory.php | 43 +++- Classes/Neo4jContentGraphProjection.php | 92 +++++++- 8 files changed, 368 insertions(+), 105 deletions(-) diff --git a/Classes/Domain/Projection/Feature/HierarchyRelation.php b/Classes/Domain/Projection/Feature/HierarchyRelation.php index ca51c58..4e145b6 100644 --- a/Classes/Domain/Projection/Feature/HierarchyRelation.php +++ b/Classes/Domain/Projection/Feature/HierarchyRelation.php @@ -99,19 +99,37 @@ private function copyHierarchyRelation( MATCH (newChildNode:Node) MATCH (newParentNode:Node) WHERE ID(newChildNode) = $newChildNodeId AND ID(newParentNode) = $newParentNodeId + + // Find parent node\'s relationship for tag inheritance using the same contentStreamId and dimensionSpacePointHash + OPTIONAL MATCH (newParentNode)-[parentRel:IS_CHILD {contentStreamId: rOld.contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->() + + // Get parent subtree tags and convert them to inherited tags for the child + WITH rOld, newChildNode, newParentNode, parentRel, + CASE WHEN parentRel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(parentRel.subtreeTags) + ELSE {} END as parentTags, + CASE WHEN rOld.subtreeTags IS NOT NULL AND $copySubtreeTags = TRUE + THEN apoc.convert.fromJsonMap(rOld.subtreeTags) + ELSE {} END as oldTags + + // Convert parent tags to inherited tags for child (true -> inherit, inherit -> inherit) + WITH rOld, newChildNode, newParentNode, parentTags, oldTags, + apoc.map.fromPairs([key in keys(parentTags) WHERE parentTags[key] IN [true, "inherit"] | [key, "inherit"]]) as inheritedTags + + // Merge inherited tags with old tags, with old tags taking precedence + WITH rOld, newChildNode, newParentNode, inheritedTags, oldTags, + apoc.map.merge(inheritedTags, CASE WHEN $copyDisabledState = TRUE + THEN oldTags + ELSE apoc.map.removeKey(oldTags, "disabled") END) as finalTags + CREATE (newChildNode)-[rNew:IS_CHILD { contentStreamId: rOld.contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash, - position: $position - }]->(newParentNode) - FOREACH (_ IN CASE WHEN rOld.subtreeTags IS NOT NULL AND $copySubtreeTags = TRUE THEN [1] ELSE [] END | - // Copy subtree tags conditionally based on copyDisabledState parameter - SET rNew.subtreeTags = CASE WHEN $copyDisabledState = TRUE - THEN rOld.subtreeTags - ELSE CASE WHEN size(keys(apoc.map.removeKey(apoc.convert.fromJsonMap(rOld.subtreeTags), "disabled"))) > 0 - THEN apoc.convert.toJson(apoc.map.removeKey(apoc.convert.fromJsonMap(rOld.subtreeTags), "disabled")) - ELSE null END END - )', + position: $position, + subtreeTags: CASE WHEN size(keys(finalTags)) > 0 + THEN apoc.convert.toJson(finalTags) + ELSE null END + }]->(newParentNode)', [ 'relationshipId' => $relationship->getId(), 'newChildNodeId' => $newChildNode->getId(), @@ -129,19 +147,47 @@ private function moveChildHierarchyRelation( Node $newChildNode, Relationship $relationship, int $position, + bool $copyDisabledState = true, ): void { $this->client->runStatement( Statement::create( 'MATCH (newChildNode:Node) WHERE ID(newChildNode) = $newChildNodeId - MATCH ()-[relationship]->() WHERE ID(relationship) = $relationshipId + MATCH ()-[relationship]->(parentNode) WHERE ID(relationship) = $relationshipId + + // Find parent node\'s relationship for tag inheritance using the same contentStreamId and dimensionSpacePointHash + OPTIONAL MATCH (parentNode)-[parentRel:IS_CHILD {contentStreamId: relationship.contentStreamId, dimensionSpacePointHash: relationship.dimensionSpacePointHash}]->() + + // Get parent subtree tags and existing relationship tags + WITH newChildNode, relationship, parentNode, parentRel, + CASE WHEN parentRel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(parentRel.subtreeTags) + ELSE {} END as parentTags, + CASE WHEN relationship.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(relationship.subtreeTags) + ELSE {} END as existingTags + + // Convert parent tags to inherited tags for child (true -> inherit, inherit -> inherit) + WITH newChildNode, relationship, parentNode, parentTags, existingTags, + apoc.map.fromPairs([key in keys(parentTags) WHERE parentTags[key] IN [true, "inherit"] | [key, "inherit"]]) as inheritedTags + + // Merge inherited tags with existing tags, with existing tags taking precedence + WITH newChildNode, relationship, inheritedTags, existingTags, + apoc.map.merge(inheritedTags, CASE WHEN $copyDisabledState = TRUE + THEN existingTags + ELSE apoc.map.removeKey(existingTags, "disabled") END) as finalTags + CALL apoc.refactor.from(relationship, newChildNode) YIELD output as newRelationship - SET newRelationship.position = $position', + SET newRelationship.position = $position, + newRelationship.subtreeTags = CASE WHEN size(keys(finalTags)) > 0 + THEN apoc.convert.toJson(finalTags) + ELSE null END', [ 'newChildNodeId' => $newChildNode->getId(), 'relationshipId' => $relationship->getId(), 'position' => $position, + 'copyDisabledState' => $copyDisabledState, ] ) ); diff --git a/Classes/Domain/Projection/Feature/ReferenceRelation.php b/Classes/Domain/Projection/Feature/ReferenceRelation.php index cae2e0c..d38b041 100644 --- a/Classes/Domain/Projection/Feature/ReferenceRelation.php +++ b/Classes/Domain/Projection/Feature/ReferenceRelation.php @@ -8,7 +8,6 @@ use Laudis\Neo4j\Types\Node; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferences; - use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; @@ -96,10 +95,7 @@ private function createReferenceRelations( } } - private function copyReferenceRelations( - Node $sourceNode, - Node $targetNode, - ): void + private function copyReferenceRelations(Node $sourceNode, Node $targetNode): void { $this->client->runStatement( Statement::create( diff --git a/Classes/Domain/Projection/Feature/Subtree.php b/Classes/Domain/Projection/Feature/Subtree.php index 7f33792..f0e47b5 100644 --- a/Classes/Domain/Projection/Feature/Subtree.php +++ b/Classes/Domain/Projection/Feature/Subtree.php @@ -3,11 +3,14 @@ namespace JvMTECH\ContentGraph\Neo4jAdapter\Domain\Projection\Feature; +use Doctrine\Migrations\Version\State; use JvMTECH\ContentGraph\Neo4jAdapter\Domain\Query\NodeQueryBuilder; use Laudis\Neo4j\Contracts\ClientInterface; use Laudis\Neo4j\Databags\Statement; use Laudis\Neo4j\Databags\SummarizedResult; use Laudis\Neo4j\Types\CypherMap; +use Laudis\Neo4j\Types\Node; +use Laudis\Neo4j\Types\Relationship; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; @@ -17,97 +20,186 @@ trait Subtree { private readonly ClientInterface $client; - private function addSubtreeTag(ContentStreamId $contentStreamId, NodeAggregateId $nodeAggregateId, DimensionSpacePointSet $affectedDimensionSpacePoints, SubtreeTag $tag): void - { + private function addSubtreeTag( + ContentStreamId $contentStreamId, + NodeAggregateId $nodeAggregateId, + DimensionSpacePointSet $affectedDimensionSpacePoints, + SubtreeTag $tag + ): void { $affectedDimensionSpacePointHashes = $affectedDimensionSpacePoints->getPointHashes(); - $this->client->runStatement( + $result = $this->client->runStatement( Statement::create(' MATCH (currentNode:Node {aggregateId: $aggregateId})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->() WHERE rel.dimensionSpacePointHash IN $affectedDimensionSpacePointHashes - OPTIONAL MATCH (:Node)-[nestedRels:IS_CHILD*..]->(currentNode)-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->() - WHERE all(nestedRel IN nestedRels WHERE nestedRel.contentStreamId = $contentStreamId - AND nestedRel.dimensionSpacePointHash IN $affectedDimensionSpacePointHashes) - SET rel.subtreeTags = $subtreeTags - WITH nestedRels - UNWIND nestedRels AS nestedRel - SET nestedRel.subtreeTags = $nestedSubtreeTags - RETURN *', - [ - 'aggregateId' => $nodeAggregateId->value, - 'contentStreamId' => $contentStreamId->value, - 'affectedDimensionSpacePointHashes' => $affectedDimensionSpacePointHashes, - 'subtreeTags' => json_encode([$tag->value => true]), - 'nestedSubtreeTags' => json_encode([$tag->value => 'inherit']), - ] - )); - } + // Get existing tags for this specific relationship and merge with new tag + WITH currentNode, rel, + CASE WHEN rel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(rel.subtreeTags) + ELSE {} END as existingTags + + // Set the new tag on the current node, preserving existing tags + SET rel.subtreeTags = apoc.convert.toJson(apoc.map.setKey(existingTags, $tagValue, true)) + RETURN currentNode, count(*)', + [ + 'aggregateId' => $nodeAggregateId->value, + 'contentStreamId' => $contentStreamId->value, + 'affectedDimensionSpacePointHashes' => $affectedDimensionSpacePointHashes, + 'tagValue' => $tag->value, + ] + )); + $nodeAggregateIdString = $result->getAsCypherMap(0)->getAsNode('currentNode')->getProperty('aggregateId'); + $nodeAggregateId = NodeAggregateId::fromString($nodeAggregateIdString); + $this->updateInheritedSubtreeTag($nodeAggregateId, $contentStreamId, $affectedDimensionSpacePoints, $tag, false); + } - private function removeSubtreeTag(ContentStreamId $contentStreamId, NodeAggregateId $nodeAggregateId, DimensionSpacePointSet $affectedDimensionSpacePoints, SubtreeTag $tag): void - { + private function removeSubtreeTag( + ContentStreamId $contentStreamId, + NodeAggregateId $nodeAggregateId, + DimensionSpacePointSet $affectedDimensionSpacePoints, + SubtreeTag $tag, + ): void { $affectedDimensionSpacePointHashes = $affectedDimensionSpacePoints->getPointHashes(); - $this->client->runStatement( + $result = $this->client->runStatement( Statement::create(' MATCH (currentNode:Node {aggregateId: $aggregateId})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->() WHERE rel.dimensionSpacePointHash IN $affectedDimensionSpacePointHashes - + // Check if current node has inherited tag from parent OPTIONAL MATCH (currentNode)-[currentRel:IS_CHILD {contentStreamId: $contentStreamId}]->(parentNode)-[parentRel:IS_CHILD {contentStreamId: $contentStreamId}]->() WHERE currentRel.dimensionSpacePointHash IN $affectedDimensionSpacePointHashes AND parentRel.dimensionSpacePointHash IN $affectedDimensionSpacePointHashes - + // Get current subtree tags as map WITH currentNode, rel, parentRel, - CASE WHEN rel.subtreeTags IS NOT NULL - THEN apoc.convert.fromJsonMap(rel.subtreeTags) + CASE WHEN rel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(rel.subtreeTags) ELSE {} END as currentTags, - CASE WHEN parentRel IS NOT NULL AND parentRel.subtreeTags IS NOT NULL - THEN apoc.convert.fromJsonMap(parentRel.subtreeTags) + CASE WHEN parentRel IS NOT NULL AND parentRel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(parentRel.subtreeTags) ELSE {} END as parentTags - + // Update current node: remove if parent does not have tag, or set to inherit if parent still has it WITH currentNode, rel, parentRel, currentTags, parentTags, CASE WHEN $tagValue IN keys(parentTags) AND parentTags[$tagValue] IN [true, "inherit"] THEN apoc.map.setKey(apoc.map.removeKey(currentTags, $tagValue), $tagValue, "inherit") ELSE apoc.map.removeKey(currentTags, $tagValue) END as updatedCurrentTags - - SET rel.subtreeTags = CASE WHEN size(keys(updatedCurrentTags)) > 0 + + SET rel.subtreeTags = CASE WHEN size(keys(updatedCurrentTags)) > 0 THEN apoc.convert.toJson(updatedCurrentTags) ELSE null END - - // Handle child nodes - WITH currentNode, updatedCurrentTags + RETURN currentNode, count(*)', + [ + 'aggregateId' => $nodeAggregateId->value, + 'contentStreamId' => $contentStreamId->value, + 'affectedDimensionSpacePointHashes' => $affectedDimensionSpacePointHashes, + 'tagValue' => $tag->value, + ] + )); + $nodeAggregateIdString = $result->getAsCypherMap(0)->getAsNode('currentNode')->getProperty('aggregateId'); + $nodeAggregateId = NodeAggregateId::fromString($nodeAggregateIdString); + $this->updateInheritedSubtreeTag($nodeAggregateId, $contentStreamId, $affectedDimensionSpacePoints, $tag, true); + } + + + private function updateInheritedSubtreeTag( + NodeAggregateId $pathRootAggregateId, + ContentStreamId $contentStreamId, + DimensionSpacePointSet $affectedDimensionSpacePointSet, + SubtreeTag $tag, + bool $remove + ): void { + foreach ($affectedDimensionSpacePointSet->getPointHashes() as $dimensionSpacePointHash) { + $this->client->runStatement( + Statement::create(' + MATCH p = (n:Node {aggregateId: $pathRootAggregateId})<-[rels:IS_CHILD*]-(c) + WHERE all(rel in rels WHERE rel.dimensionSpacePointHash = $dimensionSpacePointHash AND rel.contentStreamId = $contentStreamId) + UNWIND relationships(p) as relationship + WITH + DISTINCT relationship, + CASE + WHEN relationship.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(relationship.subtreeTags) + ELSE {} + END as currentSubtreeTags + WITH relationship, + CASE + WHEN $tagValue IN keys(currentSubtreeTags) AND currentSubtreeTags[$tagValue] <> true + THEN + CASE WHEN $remove + THEN apoc.map.removeKey(currentSubtreeTags, $tagValue) + ELSE apoc.map.setKey(currentSubtreeTags, $tagValue, "inherit") + END + ELSE + CASE + WHEN $remove OR currentSubtreeTags[$tagValue] = true + THEN currentSubtreeTags + ELSE apoc.map.setKey(currentSubtreeTags, $tagValue, "inherit") + END + END AS updatedSubtreeTags + SET relationship.subtreeTags = CASE + WHEN size(keys(updatedSubtreeTags)) > 0 + THEN apoc.convert.toJson(updatedSubtreeTags) + ELSE null + END + RETURN relationship + ', [ + 'pathRootAggregateId' => $pathRootAggregateId->value, + 'contentStreamId' => $contentStreamId->value, + 'dimensionSpacePointHash' => $dimensionSpacePointHash, + 'remove' => $remove, + 'tagValue' => $tag->value, + ]) + ); + } + } + + /** Update existing subtreeTags by setting inherited tags */ + private function updateInheritedSubtreeTags( + ContentStreamId $contentStreamId, + NodeAggregateId $nodeAggregateId, + DimensionSpacePointSet $affectedDimensionSpacePoints, + ): void { + $affectedDimensionSpacePointHashes = $affectedDimensionSpacePoints->getPointHashes(); + $this->client->runStatement( + Statement::create(' + MATCH (currentNode:Node {aggregateId: $aggregateId})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->() + WHERE rel.dimensionSpacePointHash IN $affectedDimensionSpacePointHashes + + // Get existing tags for this specific relationship and merge with new tag + WITH currentNode, rel, + CASE WHEN rel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(rel.subtreeTags) + ELSE {} END as existingTags + + // Set the new tag on the current node, preserving existing tags + SET rel.subtreeTags = apoc.convert.toJson(existingTags) + + // Handle child nodes - find all descendants + WITH currentNode OPTIONAL MATCH (childNode)-[childRels:IS_CHILD*.. {contentStreamId: $contentStreamId}]->(currentNode) WHERE all(childRel IN childRels WHERE childRel.contentStreamId = $contentStreamId AND childRel.dimensionSpacePointHash IN $affectedDimensionSpacePointHashes) - - // Get the direct parent relationship for each child - WITH currentNode, updatedCurrentTags, childNode, childRels, + + // Get the direct parent relationship for each child (the one closest to the child) + WITH currentNode, childNode, childRels, childRels[size(childRels)-1] as directChildRel - - // Get child tags - WITH currentNode, updatedCurrentTags, childNode, directChildRel, - CASE WHEN directChildRel.subtreeTags IS NOT NULL - THEN apoc.convert.fromJsonMap(directChildRel.subtreeTags) - ELSE {} END as childTags - - // Only remove tag from children if they have it as "inherit", not as true - WITH currentNode, updatedCurrentTags, childNode, directChildRel, childTags, - CASE WHEN $tagValue IN keys(childTags) AND childTags[$tagValue] = "inherit" - THEN apoc.map.removeKey(childTags, $tagValue) - ELSE childTags END as updatedChildTags - - SET directChildRel.subtreeTags = CASE WHEN size(keys(updatedChildTags)) > 0 - THEN apoc.convert.toJson(updatedChildTags) - ELSE null END - + + // Get existing child tags and add inherited tag, preserving existing tags + WITH currentNode, childNode, directChildRel, + CASE WHEN directChildRel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(directChildRel.subtreeTags) + ELSE {} END as existingChildTags + + // Set the inherited tag on child nodes, preserving existing tags + SET directChildRel.subtreeTags = apoc.convert.toJson(existingChildTags) + RETURN count(*)', - [ - 'aggregateId' => $nodeAggregateId->value, - 'contentStreamId' => $contentStreamId->value, - 'affectedDimensionSpacePointHashes' => $affectedDimensionSpacePointHashes, - 'tagValue' => $tag->value, - ] - )); + [ + 'aggregateId' => $nodeAggregateId->value, + 'contentStreamId' => $contentStreamId->value, + 'affectedDimensionSpacePointHashes' => $affectedDimensionSpacePointHashes, + ] + )); } } diff --git a/Classes/Domain/Query/NodeQueryBuilder.php b/Classes/Domain/Query/NodeQueryBuilder.php index 8cf4eb5..daa169a 100644 --- a/Classes/Domain/Query/NodeQueryBuilder.php +++ b/Classes/Domain/Query/NodeQueryBuilder.php @@ -10,6 +10,7 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; +use Ramsey\Uuid\Uuid; /** * Specialized query builder for Node-related queries @@ -42,10 +43,11 @@ public function matchNodeForSubgraph( string $nodeAlias = 'n', string $relationAlias = 'rel', string $parentAlias = 'p', + string $parameterAlias = 'alias' ): self { - return $this->match("({$nodeAlias}:Node {aggregateId: \$aggregateId})-[{$relationAlias}:IS_CHILD {contentStreamId: \$contentStreamId, dimensionSpacePointHash: \$dimensionSpacePointHash}]->({$parentAlias}:Node|Root)") + return $this->match("({$nodeAlias}:Node {aggregateId: \$aggregateId$parameterAlias})-[{$relationAlias}:IS_CHILD {contentStreamId: \$contentStreamId, dimensionSpacePointHash: \$dimensionSpacePointHash}]->({$parentAlias}:Node|Root)") ->withParameter( - 'aggregateId', + 'aggregateId'. $parameterAlias, $nodeAggregateId instanceof NodeAggregateId ? $nodeAggregateId->value : $nodeAggregateId->getProperty('aggregateId') ) ->withParameter('contentStreamId', $contentStreamId->value) diff --git a/Classes/Domain/Repository/Neo4jContentSubgraph.php b/Classes/Domain/Repository/Neo4jContentSubgraph.php index 8cad222..3b4cec4 100644 --- a/Classes/Domain/Repository/Neo4jContentSubgraph.php +++ b/Classes/Domain/Repository/Neo4jContentSubgraph.php @@ -30,7 +30,6 @@ use Neos\ContentRepository\Core\SharedModel\Node\NodeName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use org\bovigo\vfs\vfsStreamResolveIncludePathTestCase; class Neo4jContentSubgraph implements ContentSubgraphInterface { @@ -43,32 +42,38 @@ public function __construct( private readonly ClientInterface $client, private readonly NodeFactory $nodeFactory, private readonly NodeTypeManager $nodeTypeManager, + private readonly bool $debug = false, ) { } public function getContentRepositoryId(): ContentRepositoryId { + if ($this->debug) \Neos\Flow\var_dump('getContentRepositoryId called'); return $this->contentRepositoryId; } public function getWorkspaceName(): WorkspaceName { + if ($this->debug) \Neos\Flow\var_dump('getWorkspaceName called'); return $this->workspaceName; } public function getDimensionSpacePoint(): DimensionSpacePoint { + if ($this->debug) \Neos\Flow\var_dump('getDimensionSpacePoint called'); return $this->dimensionSpacePoint; } public function getVisibilityConstraints(): VisibilityConstraints { + if ($this->debug) \Neos\Flow\var_dump('getVisibilityConstraints called'); return $this->visibilityConstraints; } public function findNodeById(NodeAggregateId $nodeAggregateId): ?Node { + if ($this->debug) \Neos\Flow\var_dump('findNodeById called with' . $nodeAggregateId->value); $result = $this->client->runStatement( NodeQueryBuilder::createForNodes() ->matchNodeForSubgraph( @@ -77,7 +82,7 @@ public function findNodeById(NodeAggregateId $nodeAggregateId): ?Node $nodeAggregateId, ) ->withVisibilityConstraints($this->visibilityConstraints) - ->returns('n') + ->returns('n, rel') ->build() ); @@ -86,7 +91,7 @@ public function findNodeById(NodeAggregateId $nodeAggregateId): ?Node } return $this->nodeFactory->mapResultToNode( - $result->getAsCypherMap(0)->getAsNode('n'), + $result->getAsCypherMap(0), $this->workspaceName, $this->dimensionSpacePoint, $this->visibilityConstraints, @@ -95,6 +100,7 @@ public function findNodeById(NodeAggregateId $nodeAggregateId): ?Node public function findNodesByIds(NodeAggregateIds $nodeAggregateIds): Nodes { + if ($this->debug) \Neos\Flow\var_dump('findNodesByIds called with' . implode(',', $nodeAggregateIds->map(fn(NodeAggregateId $id) => $id->value))); $nodes = []; foreach ($nodeAggregateIds as $nodeAggregateId) { $node = $this->findNodeById($nodeAggregateId); @@ -107,6 +113,7 @@ public function findNodesByIds(NodeAggregateIds $nodeAggregateIds): Nodes public function findRootNodeByType(NodeTypeName $nodeTypeName): ?Node { + if ($this->debug) \Neos\Flow\var_dump('findRootNodeByType called with' . $nodeTypeName->value); $result = $this->client->runStatement( Statement::create( 'MATCH (n:Node {nodeTypeName: $nodeTypeName})-[:IS_CHILD{contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->(:Root) RETURN DISTINCT n', @@ -132,6 +139,7 @@ public function findRootNodeByType(NodeTypeName $nodeTypeName): ?Node public function findChildNodes(NodeAggregateId $parentNodeAggregateId, Filter\FindChildNodesFilter $filter): Nodes { + if ($this->debug) \Neos\Flow\var_dump('findChildNodes called with parentNodeAggregateId: ' . $parentNodeAggregateId->value); $query = $this->getChildNodesQuery( $parentNodeAggregateId, $filter @@ -153,6 +161,7 @@ public function findChildNodes(NodeAggregateId $parentNodeAggregateId, Filter\Fi public function countChildNodes(NodeAggregateId $parentNodeAggregateId, Filter\CountChildNodesFilter $filter): int { + if ($this->debug) \Neos\Flow\var_dump('countChildNodes called with parentNodeAggregateId: ' . $parentNodeAggregateId->value); $query = $this->getChildNodesQuery( $parentNodeAggregateId, $filter @@ -224,6 +233,7 @@ private function getChildNodesQuery( public function findParentNode(NodeAggregateId $childNodeAggregateId): ?Node { + if ($this->debug) \Neos\Flow\var_dump('findParentNode called with childNodeAggregateId: ' . $childNodeAggregateId->value); $result = $this->client->runStatement( NodeQueryBuilder::createForNodes() ->matchNodeForSubgraph( @@ -252,6 +262,7 @@ public function findSucceedingSiblingNodes( NodeAggregateId $siblingNodeAggregateId, Filter\FindSucceedingSiblingNodesFilter $filter ): Nodes { + if ($this->debug) \Neos\Flow\var_dump('findSucceedingSiblingNodes called with siblingNodeAggregateId: ' . $siblingNodeAggregateId->value); $result = $this->client->runStatement( $this->getSiblingNodesQuery( $siblingNodeAggregateId, @@ -282,6 +293,7 @@ public function findPrecedingSiblingNodes( NodeAggregateId $siblingNodeAggregateId, Filter\FindPrecedingSiblingNodesFilter $filter ): Nodes { + if ($this->debug) \Neos\Flow\var_dump('findPrecedingSiblingNodes called with siblingNodeAggregateId: ' . $siblingNodeAggregateId->value); $result = $this->client->runStatement( $this->getSiblingNodesQuery( $siblingNodeAggregateId, @@ -381,6 +393,7 @@ private function getSiblingNodesQuery( public function findAncestorNodes(NodeAggregateId $entryNodeAggregateId, Filter\FindAncestorNodesFilter $filter): Nodes { + if ($this->debug) \Neos\Flow\var_dump('findAncestorNodes called with entryNodeAggregateId: ' . $entryNodeAggregateId->value); $query = NodeQueryBuilder::createForNodes() ->matchRootPath( $entryNodeAggregateId, @@ -420,6 +433,7 @@ public function findAncestorNodes(NodeAggregateId $entryNodeAggregateId, Filter\ public function countAncestorNodes(NodeAggregateId $entryNodeAggregateId, Filter\CountAncestorNodesFilter $filter): int { + if ($this->debug) \Neos\Flow\var_dump('countAncestorNodes called with entryNodeAggregateId: ' . $entryNodeAggregateId->value); $result = $this->client->runStatement( Statement::create( 'MATCH (n:Node {aggregateId: $aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->() @@ -440,6 +454,7 @@ public function countAncestorNodes(NodeAggregateId $entryNodeAggregateId, Filter public function findClosestNode(NodeAggregateId $entryNodeAggregateId, Filter\FindClosestNodeFilter $filter): ?Node { + if ($this->debug) \Neos\Flow\var_dump('findClosestNode called with entryNodeAggregateId: ' . $entryNodeAggregateId->value); $query = NodeQueryBuilder::createForNodes() ->matchNodeForSubgraph( $this->contentStreamId, @@ -486,6 +501,7 @@ public function findClosestNode(NodeAggregateId $entryNodeAggregateId, Filter\Fi public function findDescendantNodes(NodeAggregateId $entryNodeAggregateId, Filter\FindDescendantNodesFilter $filter): Nodes { + if ($this->debug) \Neos\Flow\var_dump('findDescendantNodes called with entryNodeAggregateId: ' . $entryNodeAggregateId->value); $query = NodeQueryBuilder::createForNodes() ->matchNodeForSubgraph( $this->contentStreamId, @@ -546,6 +562,7 @@ public function findDescendantNodes(NodeAggregateId $entryNodeAggregateId, Filte public function countDescendantNodes(NodeAggregateId $entryNodeAggregateId, Filter\CountDescendantNodesFilter $filter): int { + if ($this->debug) \Neos\Flow\var_dump('countDescendantNodes called with entryNodeAggregateId: ' . $entryNodeAggregateId->value); $result = $this->client->runStatement( Statement::create( 'MATCH (:Node {aggregateId: $aggregateId})<-[:IS_CHILD*1.. {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]-(descendant:Node) RETURN count(DISTINCT descendant) as count', @@ -565,6 +582,7 @@ public function countDescendantNodes(NodeAggregateId $entryNodeAggregateId, Filt public function findSubtree(NodeAggregateId $entryNodeAggregateId, Filter\FindSubtreeFilter $filter): ?Subtree { + if ($this->debug) \Neos\Flow\var_dump('findSubtree called with entryNodeAggregateId: ' . $entryNodeAggregateId->value); $maxLevels = $filter->maximumLevels ?? 10; // Default to reasonable depth $query = NodeQueryBuilder::createForNodes() ->matchNodeForSubgraph( @@ -755,6 +773,7 @@ private function shouldIncludeNodeInSubtree(Node $node, Filter\FindSubtreeFilter public function findReferences(NodeAggregateId $nodeAggregateId, Filter\FindReferencesFilter $filter): References { + if ($this->debug) \Neos\Flow\var_dump('findReferences called with nodeAggregateId: ' . $nodeAggregateId->value); $query = $this->getReferencesQuery(false, $nodeAggregateId, $filter); $query->returns('target, ref'); $result = $this->client->runStatement($query->build()); @@ -772,6 +791,7 @@ public function findReferences(NodeAggregateId $nodeAggregateId, Filter\FindRefe public function countReferences(NodeAggregateId $nodeAggregateId, Filter\CountReferencesFilter $filter): int { + if ($this->debug) \Neos\Flow\var_dump('countReferences called with nodeAggregateId: ' . $nodeAggregateId->value); $query = $this->getReferencesQuery(false, $nodeAggregateId, $filter); $query->returns('COUNT(DISTINCT target AS count'); return $this->client->runStatement($query->build())->getAsCypherMap(0)->getAsInt('count'); @@ -779,6 +799,7 @@ public function countReferences(NodeAggregateId $nodeAggregateId, Filter\CountRe public function findBackReferences(NodeAggregateId $nodeAggregateId, Filter\FindBackReferencesFilter $filter): References { + if ($this->debug) \Neos\Flow\var_dump('findBackReferences called with nodeAggregateId: ' . $nodeAggregateId->value); $query = $this->getReferencesQuery(true, $nodeAggregateId, $filter); $query->returns('target, ref'); $result = $this->client->runStatement($query->build()); @@ -794,6 +815,7 @@ public function findBackReferences(NodeAggregateId $nodeAggregateId, Filter\Find public function countBackReferences(NodeAggregateId $nodeAggregateId, Filter\CountBackReferencesFilter $filter): int { + if ($this->debug) \Neos\Flow\var_dump('countBackReferences called with nodeAggregateId: ' . $nodeAggregateId->value); $query = $this->getReferencesQuery(true, $nodeAggregateId, $filter); $query->returns('COUNT(DISTINCT target AS count'); return $this->client->runStatement($query->build())->getAsCypherMap(0)->getAsInt('count'); @@ -888,6 +910,7 @@ private function getReferencesQuery( public function findNodeByPath(NodeName|NodePath $path, NodeAggregateId $startingNodeAggregateId): ?Node { + if ($this->debug) \Neos\Flow\var_dump('findNodeByPath called with path: ' . ' __ insert path here... __ ' . ' and startingNodeAggregateId: ' . $startingNodeAggregateId->value); $path = $path instanceof NodeName ? NodePath::fromNodeNames($path) : $path; return $this->findNodeByPathFromStartingNode($path, $startingNodeAggregateId); @@ -895,6 +918,7 @@ public function findNodeByPath(NodeName|NodePath $path, NodeAggregateId $startin public function findNodeByAbsolutePath(AbsoluteNodePath $path): ?Node { + if ($this->debug) \Neos\Flow\var_dump('findNodeByAbsolutePath called with path: ' . $path->path . ' and rootNodeTypeName: ' . $path->rootNodeTypeName); $startingNode = $this->findRootNodeByType($path->rootNodeTypeName); return $startingNode @@ -936,7 +960,7 @@ private function findNodeByPathFromStartingNode(NodePath $path, Node|NodeAggrega $lastNodeAlias = sprintf('childNode%s', $highestIndex); } - $query->returns(sprintf('childNode%s as node', $highestIndex)); + $query->returns(sprintf('childNode%s as node, childRel%s as rel', $highestIndex, $highestIndex)); $result = $this->client->runStatement($query->build()); @@ -945,7 +969,7 @@ private function findNodeByPathFromStartingNode(NodePath $path, Node|NodeAggrega } return $this->nodeFactory->mapResultToNode( - $result->getAsCypherMap(0)->getAsNode('node'), + $result->getAsCypherMap(0), $this->workspaceName, $this->dimensionSpacePoint, $this->visibilityConstraints, @@ -954,12 +978,14 @@ private function findNodeByPathFromStartingNode(NodePath $path, Node|NodeAggrega public function retrieveNodePath(NodeAggregateId $nodeAggregateId): AbsoluteNodePath { + if ($this->debug) \Neos\Flow\var_dump('retrieveNodePath called with nodeAggregateId: ' . $nodeAggregateId->value); // TODO: Implement retrieveNodePath() method. throw new \RuntimeException('Not implemented yet'); } public function countNodes(): int { + if ($this->debug) \Neos\Flow\var_dump('countNodes called'); /** @var SummarizedResult $result */ $result = $this->client->runStatement( Statement::create( diff --git a/Classes/Domain/Repository/Neo4jProjectionContentGraph.php b/Classes/Domain/Repository/Neo4jProjectionContentGraph.php index 1c7d33a..e0c8d7f 100644 --- a/Classes/Domain/Repository/Neo4jProjectionContentGraph.php +++ b/Classes/Domain/Repository/Neo4jProjectionContentGraph.php @@ -14,6 +14,7 @@ class Neo4jProjectionContentGraph { public function __construct( private readonly ClientInterface $client, + private bool $debug = false, ) { } @@ -22,8 +23,9 @@ public function determineHierarchyRelationPosition( ?NodeAggregateId $succeedingSiblingAggregateId, ContentStreamId $contentStreamId, DimensionSpacePoint $dimensionSpacePoint, + ?NodeAggregateId $childAggregateId = null, ): int { - if (!$parentAggregateId && !$succeedingSiblingAggregateId) { + if (!$parentAggregateId && !$childAggregateId) { throw new \InvalidArgumentException( 'You must specify either parent or child node anchor to determine a hierarchy relation position', 1519847447 diff --git a/Classes/Domain/Repository/NodeFactory.php b/Classes/Domain/Repository/NodeFactory.php index 440c2fd..96f1cb6 100644 --- a/Classes/Domain/Repository/NodeFactory.php +++ b/Classes/Domain/Repository/NodeFactory.php @@ -115,6 +115,10 @@ private function createNodeAggregateFromRecords(array $records, WorkspaceName $w $occupationByCovering = []; $nodeTagsByCoveredDimensionSpacePoint = []; + usort($records, function (CypherMap $a, CypherMap $b) { + // Sort by originDimensionSpacePointHash to ensure consistent ordering + return strcmp($a->get('dimensionSpacePointHash'), $b->get('dimensionSpacePointHash')); + }); foreach ($records as $record) { $originDimensionSpacePointHash = $record->get('originDimensionSpacePointHash'); $dimensionSpacePointHash = $record->get('dimensionSpacePointHash'); @@ -166,21 +170,40 @@ public function mapResultToNode( DimensionSpacePoint $dimensionSpacePoint, VisibilityConstraints $visibilityConstraints ): Node { - if ($record instanceof \Laudis\Neo4j\Types\Node) { - $record = $record->getProperties(); + // Handle case where we have a full record with both node and relationship data + if ($record instanceof CypherMap && (($record->hasKey('n') && $record->hasKey('rel')) || ($record->hasKey('node') && $record->hasKey('rel')))) { + $nodeKey = $record->hasKey('n') ? 'n' : 'node'; + $nodeData = $record->getAsNode($nodeKey)->getProperties(); + $relationshipData = $record->getAsRelationship('rel'); + $subtreeTagsJson = '{}'; + if ($relationshipData->getProperties()->offsetExists('subtreeTags')) { + $subtreeTagsJson = $relationshipData->getProperty('subtreeTags') ?: '{}'; + } + $nodeTags = self::extractNodeTagsFromJson($subtreeTagsJson); + } + // Handle case where we only have node data + elseif ($record instanceof \Laudis\Neo4j\Types\Node) { + $nodeData = $record->getProperties(); + $nodeTags = NodeTags::createEmpty(); + } + // Handle case where we have a CypherMap with node data + else { + $nodeData = $record; + $nodeTags = NodeTags::createEmpty(); } + return Node::create( $this->contentRepositoryId, $workspaceName, $dimensionSpacePoint, - NodeAggregateId::fromString($record->get('aggregateId')), - $this->resolveDimensionSpacePointFromHash($record->get('originDimensionSpacePointHash')), - NodeAggregateClassification::from($record->get('classification')), - NodeTypeName::fromString($record->get('nodeTypeName')), - $this->createPropertyCollectionFromJsonString($record->hasKey('properties') ? ($record->get('properties') ?: '{}') : '{}'), - ($record->hasKey('name') && !empty($record->get('name'))) ? NodeName::fromString($record->get('name')) : null, - NodeTags::createEmpty(), // TODO: Extract from Neo4j record if needed - $this->createTimestampsFromRecord($record), + NodeAggregateId::fromString($nodeData->get('aggregateId')), + $this->resolveDimensionSpacePointFromHash($nodeData->get('originDimensionSpacePointHash')), + NodeAggregateClassification::from($nodeData->get('classification')), + NodeTypeName::fromString($nodeData->get('nodeTypeName')), + $this->createPropertyCollectionFromJsonString($nodeData->hasKey('properties') ? ($nodeData->get('properties') ?: '{}') : '{}'), + ($nodeData->hasKey('name') && !empty($nodeData->get('name'))) ? NodeName::fromString($nodeData->get('name')) : null, + $nodeTags, + $this->createTimestampsFromRecord($nodeData), $visibilityConstraints ); } diff --git a/Classes/Neo4jContentGraphProjection.php b/Classes/Neo4jContentGraphProjection.php index 661892d..ecf5692 100644 --- a/Classes/Neo4jContentGraphProjection.php +++ b/Classes/Neo4jContentGraphProjection.php @@ -42,6 +42,7 @@ use Neos\ContentRepository\Core\Feature\NodeVariation\Event\NodeSpecializationVariantWasCreated; use Neos\ContentRepository\Core\Feature\RootNodeCreation\Event\RootNodeAggregateDimensionsWereUpdated; use Neos\ContentRepository\Core\Feature\RootNodeCreation\Event\RootNodeAggregateWithNodeWasCreated; +use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Event\SubtreeWasTagged; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Event\SubtreeWasUntagged; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\RootWorkspaceWasCreated; @@ -352,6 +353,7 @@ private function whenNodeAggregateTypeWasChanged(NodeAggregateTypeWasChanged $ev private function whenNodeAggregateWasMoved(NodeAggregateWasMoved $event): void { + $affectedDimensionSpacePoints = $event->succeedingSiblingsForCoverage->toDimensionSpacePointSet(); foreach ($event->succeedingSiblingsForCoverage as $succeedingSiblingForCoverage) { $nodesToBeMovedResult = $this->client->runStatement( NodeQueryBuilder::createForNodes() @@ -371,7 +373,7 @@ private function whenNodeAggregateWasMoved(NodeAggregateWasMoved $event): void } if ($event->newParentNodeAggregateId) { $this->client->runStatement( - NodeQueryBuilder::createForNodes() + $statement = NodeQueryBuilder::createForNodes() ->matchNodeForSubgraph( $event->contentStreamId, $succeedingSiblingForCoverage->dimensionSpacePoint, @@ -382,8 +384,9 @@ private function whenNodeAggregateWasMoved(NodeAggregateWasMoved $event): void $succeedingSiblingForCoverage->dimensionSpacePoint, $event->newParentNodeAggregateId, 'newParent', - '', - '', + 'parentRel', + 'grandparent', + 'parentAlias' ) ->call('apoc.refactor.to(rel, newParent)') ->yield('output as newParentRel') @@ -413,9 +416,10 @@ private function whenNodeAggregateWasMoved(NodeAggregateWasMoved $event): void ) ->setProperty('position', $this->projectionContentGraph->determineHierarchyRelationPosition( parentAggregateId: null, - succeedingSiblingAggregateId: $event->nodeAggregateId, + succeedingSiblingAggregateId: $succeedingSiblingForCoverage->nodeAggregateId, contentStreamId: $event->contentStreamId, - dimensionSpacePoint: $succeedingSiblingForCoverage->dimensionSpacePoint + dimensionSpacePoint: $succeedingSiblingForCoverage->dimensionSpacePoint, + childAggregateId: $event->nodeAggregateId, ), 'rel') ->returns('*') ->build() @@ -463,6 +467,11 @@ private function whenNodeAggregateWasMoved(NodeAggregateWasMoved $event): void // FIGURE OUT WHAT TO DO: Find topmost node in parent and place as last child == highest position + default } } + $this->updateInheritedSubtreeTags( + $event->contentStreamId, + $event->nodeAggregateId, + $affectedDimensionSpacePoints, + ); } private function cloneNode(Node $node, ContentStreamId $contentStreamId): Node @@ -594,6 +603,7 @@ classification: $classification, succeedingSiblingAggregateId: $sibling->nodeAggregateId, contentStreamId: $event->contentStreamId, dimensionSpacePoint: $sibling->dimensionSpacePoint, + childAggregateId: NodeAggregateId::fromString($newlyCreatedNode->getProperty('aggregateId')), ), $eventEnvelope->recordedAt, self::initiatingDateTime($eventEnvelope) @@ -994,10 +1004,61 @@ private function whenNodeReferencesWereSet(NodeReferencesWereSet $event, EventEn } } + private function copyNodeToOrigin(OriginDimensionSpacePoint $sourceOrigin, OriginDimensionSpacePoint $targetOrigin, ContentStreamId $contentStreamId, NodeAggregateId $aggregateId): void + { + $result = $this->client->runStatement( + $statement = NodeQueryBuilder::createForNodes() + ->matchNodeForSubgraph( + $contentStreamId, + $sourceOrigin->toDimensionSpacePoint(), + $aggregateId, + ) + ->call('apoc.refactor.cloneNodes([n], false)') + ->yield('output as newNode') + ->call('apoc.refactor.from(rel, newNode)') + ->yield('output as newRel') + ->returns('newNode, newRel') + ->build() + ); + \Neos\Flow\var_dump($statement); + \Neos\Flow\var_dump($result); + } private function whenNodeSpecializationVariantWasCreated( NodeSpecializationVariantWasCreated $event, EventEnvelope $eventEnvelope ): void { + // TODO: + // 1. Clone the node + // 2. Copy existing reference relations to the new node + // 2. Reassign outgoing IS_CHILD relationships + // 3. Reassign ingoing IS_CHILD relationships + + /* + $nodeCloneResults = $this->client->runStatement( + NodeQueryBuilder::createForNodes() + ->matchNodeForSubgraph( + $event->contentStreamId, + $event->sourceOrigin->toDimensionSpacePoint(), + $event->nodeAggregateId, + ) + ->call('apoc.refactor.cloneNodes([n], true)') + ->yield('output AS generalizedNode') + ->setProperty('originDimensionSpacePointHash', $event->specializationOrigin->toDimensionSpacePoint()->hash, + 'generalizedNode') + ->setProperty('created', $eventEnvelope->recordedAt->format(DateTimeInterface::ATOM), 'generalizedNode') + ->setProperty('originalCreated', self::initiatingDateTime($eventEnvelope)->format(DateTimeInterface::ATOM), + 'generalizedNode') + ->returns('*') + ->build() + ); + + $this->copyReferenceRelations( + $nodeCloneResults->getAsCypherMap(0)->getAsNode('n'), + $nodeCloneResults->getAsCypherMap(0)->getAsNode('generalizedNode') + ); + + return; + */ $nodeCloneResults = $this->client->runStatement( NodeQueryBuilder::createForNodes() ->matchNodeForSubgraph( @@ -1017,6 +1078,8 @@ private function whenNodeSpecializationVariantWasCreated( ->returns('*') ->build() ); + + if (empty($nodeCloneResults->getAsCypherMap(0)) || empty($nodeCloneResults->getAsCypherMap(0)->get('p'))) { throw new \RuntimeException(sprintf('Failed to create node generalization variant for node "%s" in sub graph %s@%s because the source parent node is missing', $event->nodeAggregateId->value, $event->sourceOrigin->toJson(), $event->contentStreamId->value), 1749910013); @@ -1060,6 +1123,7 @@ private function whenNodeSpecializationVariantWasCreated( succeedingSiblingAggregateId: $sibling->nodeAggregateId, contentStreamId: $event->contentStreamId, dimensionSpacePoint: $sibling->dimensionSpacePoint, + childAggregateId: NodeAggregateId::fromString($generalizedNode->getProperty('aggregateId')), ), ); } @@ -1082,6 +1146,7 @@ private function whenNodeSpecializationVariantWasCreated( succeedingSiblingAggregateId: $sibling->nodeAggregateId, contentStreamId: $event->contentStreamId, dimensionSpacePoint: $sibling->dimensionSpacePoint, + childAggregateId: NodeAggregateId::fromString($generalizedNode->getProperty('aggregateId')), ), ); } @@ -1115,15 +1180,26 @@ private function whenNodeSpecializationVariantWasCreated( $generalizationParentNode, $unassignedIngoingDimensionSpacePoint, $this->projectionContentGraph->determineHierarchyRelationPosition( - //parentAggregateId: $generalizationParentNode, - parentAggregateId: null, + parentAggregateId: NodeAggregateId::fromString($generalizationParentNode->getProperty('aggregateId')), succeedingSiblingAggregateId: $generalizationSucceedingSiblingNodeAggregateId, contentStreamId: $event->contentStreamId, - dimensionSpacePoint: $unassignedIngoingDimensionSpacePoint + dimensionSpacePoint: $unassignedIngoingDimensionSpacePoint, + childAggregateId: NodeAggregateId::fromString($generalizedNode->getProperty('aggregateId')), ), + false, ); } } + +// \Neos\Flow\var_dump($sourceRelationship); + /* + $subtreeTags = json_decode($sourceRelationship->getProperty('subtreeTags') ?? '{}', true); + foreach ($subtreeTags as $subtreeTag) { + $tag = SubtreeTag::fromString($subtreeTag); + \Neos\Flow\var_dump($tag); + } + */ + } private function whenRootNodeAggregateDimensionsWereUpdated(RootNodeAggregateDimensionsWereUpdated $event): void From c52f679669352bcb6766c834e2f61fa91f988a4f Mon Sep 17 00:00:00 2001 From: Michel Loew Date: Mon, 1 Sep 2025 17:39:08 +0200 Subject: [PATCH 5/9] WIP: Satisfy Feature/08-NodeMove --- Classes/Domain/Projection/Feature/Subtree.php | 123 ++++++++++++------ 1 file changed, 84 insertions(+), 39 deletions(-) diff --git a/Classes/Domain/Projection/Feature/Subtree.php b/Classes/Domain/Projection/Feature/Subtree.php index f0e47b5..d5ee076 100644 --- a/Classes/Domain/Projection/Feature/Subtree.php +++ b/Classes/Domain/Projection/Feature/Subtree.php @@ -161,45 +161,90 @@ private function updateInheritedSubtreeTags( DimensionSpacePointSet $affectedDimensionSpacePoints, ): void { $affectedDimensionSpacePointHashes = $affectedDimensionSpacePoints->getPointHashes(); - $this->client->runStatement( - Statement::create(' - MATCH (currentNode:Node {aggregateId: $aggregateId})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->() - WHERE rel.dimensionSpacePointHash IN $affectedDimensionSpacePointHashes - - // Get existing tags for this specific relationship and merge with new tag - WITH currentNode, rel, - CASE WHEN rel.subtreeTags IS NOT NULL - THEN apoc.convert.fromJsonMap(rel.subtreeTags) - ELSE {} END as existingTags - - // Set the new tag on the current node, preserving existing tags - SET rel.subtreeTags = apoc.convert.toJson(existingTags) - - // Handle child nodes - find all descendants - WITH currentNode - OPTIONAL MATCH (childNode)-[childRels:IS_CHILD*.. {contentStreamId: $contentStreamId}]->(currentNode) - WHERE all(childRel IN childRels WHERE childRel.contentStreamId = $contentStreamId - AND childRel.dimensionSpacePointHash IN $affectedDimensionSpacePointHashes) - - // Get the direct parent relationship for each child (the one closest to the child) - WITH currentNode, childNode, childRels, - childRels[size(childRels)-1] as directChildRel - // Get existing child tags and add inherited tag, preserving existing tags - WITH currentNode, childNode, directChildRel, - CASE WHEN directChildRel.subtreeTags IS NOT NULL - THEN apoc.convert.fromJsonMap(directChildRel.subtreeTags) - ELSE {} END as existingChildTags - - // Set the inherited tag on child nodes, preserving existing tags - SET directChildRel.subtreeTags = apoc.convert.toJson(existingChildTags) - - RETURN count(*)', - [ - 'aggregateId' => $nodeAggregateId->value, - 'contentStreamId' => $contentStreamId->value, - 'affectedDimensionSpacePointHashes' => $affectedDimensionSpacePointHashes, - ] - )); + foreach ($affectedDimensionSpacePointHashes as $dimensionSpacePointHash) { + $this->client->runStatement( + Statement::create(' + // Find the node and its parent relationship to get inherited tags + MATCH (currentNode:Node {aggregateId: $aggregateId})-[currentRel:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->(parentNode) + + // Get parent\'s relationship to understand what tags should be inherited + OPTIONAL MATCH (parentNode)-[parentRel:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->() + + // Get current and parent tags + WITH currentNode, currentRel, parentRel, + CASE WHEN currentRel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(currentRel.subtreeTags) + ELSE {} END as currentTags, + CASE WHEN parentRel IS NOT NULL AND parentRel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(parentRel.subtreeTags) + ELSE {} END as parentTags + + // Convert parent tags to inherited tags (true -> inherit, inherit -> inherit) + WITH currentNode, currentRel, currentTags, parentTags, + apoc.map.fromPairs([key in keys(parentTags) WHERE parentTags[key] IN [true, "inherit"] | [key, "inherit"]]) as inheritedTags + + // Filter out inherited tags that are no longer present on parent, keep explicit tags + WITH currentNode, currentRel, currentTags, inheritedTags, parentTags, + apoc.map.fromPairs([key in keys(currentTags) WHERE currentTags[key] = true OR (currentTags[key] = "inherit" AND key IN keys(parentTags) AND parentTags[key] IN [true, "inherit"]) | [key, currentTags[key]]]) as filteredCurrentTags + + // Merge inherited tags with filtered current tags + WITH currentNode, currentRel, filteredCurrentTags, inheritedTags, + apoc.map.merge(inheritedTags, filteredCurrentTags) as updatedTags + + // Update the relationship with proper inherited tags + SET currentRel.subtreeTags = CASE WHEN size(keys(updatedTags)) > 0 + THEN apoc.convert.toJson(updatedTags) + ELSE null END + + // Now handle all descendant nodes recursively + WITH currentNode + OPTIONAL MATCH (descendantNode)-[descendantRels:IS_CHILD*1.. {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->(currentNode) + + // For each descendant, get its direct parent relationship + WITH descendantNode, descendantRels, + descendantRels[0] as directDescendantRel + + // Find the direct parent node and its relationship + WITH descendantNode, directDescendantRel, + endNode(directDescendantRel) as directParentNode + + OPTIONAL MATCH (directParentNode)-[directParentRel:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->() + + // Get descendant current tags and parent tags + WITH descendantNode, directDescendantRel, directParentRel, + CASE WHEN directDescendantRel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(directDescendantRel.subtreeTags) + ELSE {} END as descendantCurrentTags, + CASE WHEN directParentRel IS NOT NULL AND directParentRel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(directParentRel.subtreeTags) + ELSE {} END as descendantParentTags + + // Convert parent tags to inherited tags for descendant + WITH descendantNode, directDescendantRel, descendantCurrentTags, descendantParentTags, + apoc.map.fromPairs([key in keys(descendantParentTags) WHERE descendantParentTags[key] IN [true, "inherit"] | [key, "inherit"]]) as descendantInheritedTags + + // Filter out inherited tags that are no longer present on parent, keep explicit tags + WITH descendantNode, directDescendantRel, descendantCurrentTags, descendantInheritedTags, descendantParentTags, + apoc.map.fromPairs([key in keys(descendantCurrentTags) WHERE descendantCurrentTags[key] = true OR (descendantCurrentTags[key] = "inherit" AND key IN keys(descendantParentTags) AND descendantParentTags[key] IN [true, "inherit"]) | [key, descendantCurrentTags[key]]]) as filteredDescendantCurrentTags + + // Merge inherited tags with filtered current explicit tags + WITH descendantNode, directDescendantRel, filteredDescendantCurrentTags, descendantInheritedTags, + apoc.map.merge(descendantInheritedTags, filteredDescendantCurrentTags) as descendantUpdatedTags + + // Update descendant relationship with proper inherited tags + SET directDescendantRel.subtreeTags = CASE WHEN size(keys(descendantUpdatedTags)) > 0 + THEN apoc.convert.toJson(descendantUpdatedTags) + ELSE null END + + RETURN count(*)', + [ + 'aggregateId' => $nodeAggregateId->value, + 'contentStreamId' => $contentStreamId->value, + 'dimensionSpacePointHash' => $dimensionSpacePointHash, + ] + ) + ); + } } } From 70d5837b8795c1f1d6ab801d5cbf0e107df27bcd Mon Sep 17 00:00:00 2001 From: Michel Loew Date: Tue, 2 Sep 2025 10:52:31 +0200 Subject: [PATCH 6/9] WIP: Satisfy Feature/ContentStreamForking --- Classes/Neo4jContentGraphProjection.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Classes/Neo4jContentGraphProjection.php b/Classes/Neo4jContentGraphProjection.php index ecf5692..fab7c59 100644 --- a/Classes/Neo4jContentGraphProjection.php +++ b/Classes/Neo4jContentGraphProjection.php @@ -913,7 +913,7 @@ private function whenNodePropertiesWereSet(NodePropertiesWereSet $event, EventEn ); } - $affectedNode = $this->cloneNodeIfRequired($event->contentStreamId, $affectedNode, true); + $affectedNode = $this->cloneNodeIfRequired($event->contentStreamId, $affectedNode); $this->client->runStatement( Statement::create( 'MATCH (n) WHERE ID(n) = $nodeId @@ -945,7 +945,7 @@ private function cloneNodeIfRequired( NodeQueryBuilder::createForNodes() ->match('(n:Node {aggregateId: $aggregateId})-[rel:IS_CHILD]->()') ->withParameter('aggregateId', $affectedNode->getProperty('aggregateId')) - ->returns('rel as rels, COUNT(DISTINCT rel) as count') + ->returns('COUNT(DISTINCT rel) as count') ->build() ); From c0c85da59ac591109274260596a36f8e2b98440a Mon Sep 17 00:00:00 2001 From: Michel Loew Date: Tue, 2 Sep 2025 11:04:20 +0200 Subject: [PATCH 7/9] WIP: Satisfy Features/D2-UpdateRootNodeAggregateDimensions --- Classes/Neo4jContentGraphProjection.php | 74 ++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/Classes/Neo4jContentGraphProjection.php b/Classes/Neo4jContentGraphProjection.php index fab7c59..e0e53dd 100644 --- a/Classes/Neo4jContentGraphProjection.php +++ b/Classes/Neo4jContentGraphProjection.php @@ -1204,7 +1204,79 @@ private function whenNodeSpecializationVariantWasCreated( private function whenRootNodeAggregateDimensionsWereUpdated(RootNodeAggregateDimensionsWereUpdated $event): void { - // TODO: Implement + // Find the root node + $rootNodeResult = $this->client->runStatement( + Statement::create( + 'MATCH (rootNode:Node {aggregateId: $aggregateId}) + RETURN rootNode', + [ + 'aggregateId' => $event->nodeAggregateId->value, + ] + ) + ); + + if ($rootNodeResult->isEmpty()) { + // Root node not found - should never happen + return; + } + + $rootNode = $rootNodeResult->getAsCypherMap(0)->getAsNode('rootNode'); + + // Get currently covered dimension space points + $currentRelationsResult = $this->client->runStatement( + Statement::create( + 'MATCH (rootNode:Node {aggregateId: $aggregateId})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->(root:Root) + RETURN COLLECT(DISTINCT rel.dimensionSpacePointHash) as currentDimensionSpacePointHashes', + [ + 'aggregateId' => $event->nodeAggregateId->value, + 'contentStreamId' => $event->contentStreamId->value, + ] + ) + ); + + $currentDimensionSpacePointHashes = []; + if (!$currentRelationsResult->isEmpty()) { + $cypherList = $currentRelationsResult->getAsCypherMap(0)->get('currentDimensionSpacePointHashes'); + if ($cypherList !== null) { + // Convert CypherList to PHP array + $currentDimensionSpacePointHashes = $cypherList->toArray(); + } + } + + // Determine newly covered dimension space points + $newlyCoveredDimensionSpacePoints = []; + foreach ($event->coveredDimensionSpacePoints as $dimensionSpacePoint) { + if (!in_array($dimensionSpacePoint->hash, $currentDimensionSpacePointHashes, true)) { + $newlyCoveredDimensionSpacePoints[] = $dimensionSpacePoint; + } + } + + // Add new IS_CHILD relationships for newly covered dimension space points + foreach ($newlyCoveredDimensionSpacePoints as $dimensionSpacePoint) { + $this->client->runStatement( + Statement::create( + 'MATCH (rootNode:Node) WHERE ID(rootNode) = $rootNodeId + MERGE (root:Root) + CREATE (rootNode)-[:IS_CHILD { + contentStreamId: $contentStreamId, + dimensionSpacePointHash: $dimensionSpacePointHash, + position: $position + }]->(root)', + [ + 'rootNodeId' => $rootNode->getId(), + 'contentStreamId' => $event->contentStreamId->value, + 'dimensionSpacePointHash' => $dimensionSpacePoint->hash, + 'position' => $this->projectionContentGraph->determineRootNodePosition( + $event->contentStreamId, + $dimensionSpacePoint, + ), + ] + ) + ); + + // Register the dimension space point if not already registered + $this->dimensionSpacePointsRepository->insertDimensionSpacePoint($dimensionSpacePoint); + } } private function whenRootNodeAggregateWithNodeWasCreated( From 37f1ae0450db8e24555f0a9f2f0769f549ec975e Mon Sep 17 00:00:00 2001 From: Michel Loew Date: Mon, 8 Sep 2025 09:43:33 +0200 Subject: [PATCH 8/9] WIP: Satisfy Feature/D1-AddDimensionShineThrough --- Classes/Domain/Query/NodeQueryBuilder.php | 2 +- Classes/Domain/Query/QueryBuilder.php | 4 +- Classes/Neo4jContentGraphProjection.php | 115 +++++++++++++++--- Classes/Neo4jContentGraphReadModelAdapter.php | 2 +- 4 files changed, 103 insertions(+), 20 deletions(-) diff --git a/Classes/Domain/Query/NodeQueryBuilder.php b/Classes/Domain/Query/NodeQueryBuilder.php index daa169a..b9fb50e 100644 --- a/Classes/Domain/Query/NodeQueryBuilder.php +++ b/Classes/Domain/Query/NodeQueryBuilder.php @@ -96,7 +96,7 @@ public function matchChildrenForSubgraph( string $relationAlias = 'rel', string $parentAlias = 'p' ): self { - return $this->match("({$parentAlias}:Node|Root {aggregateId: \$aggregateId})<-[{$relationAlias}:IS_CHILD {contentStreamId: \$contentStreamId, dimensionSpacePointHash: \$dimensionSpacePointHash}]-({$nodeAlias}:Node)") + return $this->match("({$parentAlias}:Node {aggregateId: \$aggregateId})<-[{$relationAlias}:IS_CHILD {contentStreamId: \$contentStreamId, dimensionSpacePointHash: \$dimensionSpacePointHash}]-({$nodeAlias}:Node)") ->withParameter('aggregateId', $parentNodeAggregateId instanceof NodeAggregateId ? $parentNodeAggregateId->value : $parentNodeAggregateId->getProperty('aggregateId') ) diff --git a/Classes/Domain/Query/QueryBuilder.php b/Classes/Domain/Query/QueryBuilder.php index c048a9e..6cf9ae4 100644 --- a/Classes/Domain/Query/QueryBuilder.php +++ b/Classes/Domain/Query/QueryBuilder.php @@ -29,7 +29,9 @@ public function optionalMatch(string $pattern): self public function where(string $condition): self { - $this->addClause('WHERE', $condition); + if (!empty($condition)) { + $this->addClause('WHERE', $condition); + } return $this; } diff --git a/Classes/Neo4jContentGraphProjection.php b/Classes/Neo4jContentGraphProjection.php index e0e53dd..e0936a6 100644 --- a/Classes/Neo4jContentGraphProjection.php +++ b/Classes/Neo4jContentGraphProjection.php @@ -30,6 +30,7 @@ use Neos\ContentRepository\Core\Feature\ContentStreamForking\Event\ContentStreamWasForked; use Neos\ContentRepository\Core\Feature\ContentStreamRemoval\Event\ContentStreamWasRemoved; use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Event\DimensionShineThroughWasAdded; +use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Event\DimensionSpacePointWasMoved; use Neos\ContentRepository\Core\Feature\NodeCreation\Event\NodeAggregateWithNodeWasCreated; use Neos\ContentRepository\Core\Feature\NodeModification\Event\NodePropertiesWereSet; use Neos\ContentRepository\Core\Feature\NodeMove\Event\NodeAggregateWasMoved; @@ -151,7 +152,7 @@ public function apply(EventInterface $event, EventEnvelope $eventEnvelope): void ContentStreamWasRemoved::class => $this->whenContentStreamWasRemoved($event), ContentStreamWasReopened::class => $this->whenContentStreamWasReopened($event), DimensionShineThroughWasAdded::class => $this->whenDimensionShineThroughWasAdded($event), -// DimensionSpacePointWasMoved::class => $this->whenDimensionSpacePointWasMoved($event), + DimensionSpacePointWasMoved::class => $this->whenDimensionSpacePointWasMoved($event), NodeAggregateNameWasChanged::class => $this->whenNodeAggregateNameWasChanged($event, $eventEnvelope), NodeAggregateTypeWasChanged::class => $this->whenNodeAggregateTypeWasChanged($event, $eventEnvelope), NodeAggregateWasMoved::class => $this->whenNodeAggregateWasMoved($event), @@ -254,7 +255,7 @@ private function whenDimensionShineThroughWasAdded(DimensionShineThroughWasAdded Statement::create( ' MATCH (childNode:Node)-[r:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $sourceDimensionSpacePointHash}]->(parentNode:Node|Root) - CREATE (childNode)-[rNew:IS_CHILD {contentStreamId: r.contentStream, dimensionSpacePointHash: $targetDimensionSpacePointHash, position: r.position}]->(parentNode) + CREATE (childNode)-[rNew:IS_CHILD {contentStreamId: r.contentStreamId, dimensionSpacePointHash: $targetDimensionSpacePointHash, position: r.position}]->(parentNode) FOREACH (_ IN CASE WHEN r.subtreeTags IS NOT NULL THEN [1] ELSE [] END | SET rNew.subtreeTags = r.subtreeTags ) @@ -268,6 +269,64 @@ private function whenDimensionShineThroughWasAdded(DimensionShineThroughWasAdded ); } + private function whenDimensionSpacePointWasMoved(DimensionSpacePointWasMoved $event): void + { + // Register the target dimension space point + $this->dimensionSpacePointsRepository->insertDimensionSpacePoint($event->target); + + // 1) Find all nodes with origin at source dimension space point that need updating + // We need to find nodes in the specific content stream that have their origin at the source dimension space point + $nodesWithOriginAtSource = $this->client->runStatement( + Statement::create( + 'MATCH (n:Node {originDimensionSpacePointHash: $sourceDimensionSpacePointHash}) + WHERE EXISTS { + MATCH (n)-[:IS_CHILD {contentStreamId: $contentStreamId}]->() + } OR EXISTS { + MATCH (n)<-[:IS_CHILD {contentStreamId: $contentStreamId}]-() + } + RETURN DISTINCT n', + [ + 'sourceDimensionSpacePointHash' => $event->source->hash, + 'contentStreamId' => $event->contentStreamId->value, + ] + ) + ); + + // Update each node's origin dimension space point with copy-on-write + foreach ($nodesWithOriginAtSource as $nodeResult) { + $node = $nodeResult->getAsNode('n'); + $affectedNode = $this->cloneNodeIfRequired($event->contentStreamId, $node); + + // Update the node's origin dimension space point + $this->client->runStatement( + Statement::create( + 'MATCH (n) WHERE ID(n) = $nodeId + SET n.originDimensionSpacePointHash = $targetDimensionSpacePointHash', + [ + 'nodeId' => $affectedNode->getId(), + 'targetDimensionSpacePointHash' => $event->target->hash, + ] + ) + ); + } + + // 2) Update all IS_CHILD relationships from source to target dimension space point + $this->client->runStatement( + Statement::create( + 'MATCH ()-[rel:IS_CHILD { + contentStreamId: $contentStreamId, + dimensionSpacePointHash: $sourceDimensionSpacePointHash + }]->() + SET rel.dimensionSpacePointHash = $targetDimensionSpacePointHash', + [ + 'contentStreamId' => $event->contentStreamId->value, + 'sourceDimensionSpacePointHash' => $event->source->hash, + 'targetDimensionSpacePointHash' => $event->target->hash, + ] + ) + ); + } + private function whenNodeAggregateNameWasChanged(NodeAggregateNameWasChanged $event, EventEnvelope $eventEnvelope): void { $affectedNodesInContentStream = $this->client->runStatement( @@ -311,21 +370,35 @@ private function whenNodeAggregateNameWasChanged(NodeAggregateNameWasChanged $ev private function whenNodeAggregateTypeWasChanged(NodeAggregateTypeWasChanged $event, EventEnvelope $eventEnvelope): void { - // TODO: Use correct copy on write handling here + // Find all nodes with this aggregate ID that are in the affected content stream $affectedNodesInContentStream = $this->client->runStatement( Statement::create( - 'MATCH (node:Node {aggregateId: $aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId}]-() RETURN DISTINCT node', + 'MATCH (node:Node {aggregateId: $aggregateId}) + WHERE EXISTS((node)-[:IS_CHILD {contentStreamId: $contentStreamId}]-()) + OR EXISTS((node)<-[:IS_CHILD {contentStreamId: $contentStreamId}]-()) + RETURN DISTINCT node', [ 'aggregateId' => $event->nodeAggregateId->value, 'contentStreamId' => $event->contentStreamId->value, ] ) ); + foreach ($affectedNodesInContentStream as $affectedNodeCypher) { $affectedNode = $affectedNodeCypher->getAsNode('node'); + + // Check if this node exists in multiple content streams (copy-on-write check) $nodeContentStreams = $this->client->runStatement( Statement::create( - 'MATCH (n)-[rel:IS_CHILD]->() WHERE ID(n) = $nodeId RETURN DISTINCT rel.contentStreamId as contentStreamId', + 'MATCH (n) + WHERE ID(n) = $nodeId + OPTIONAL MATCH (n)-[r1:IS_CHILD]->() + OPTIONAL MATCH (n)<-[r2:IS_CHILD]-() + WITH COLLECT(DISTINCT r1.contentStreamId) + COLLECT(DISTINCT r2.contentStreamId) AS allStreams + UNWIND allStreams AS stream + WITH DISTINCT stream + WHERE stream IS NOT NULL + RETURN stream as contentStreamId', [ 'nodeId' => $affectedNode->getId(), ] @@ -942,11 +1015,19 @@ private function cloneNodeIfRequired( ): Node { // Get all content streams where the node is referenced $nodeContentStreams = $this->client->runStatement( - NodeQueryBuilder::createForNodes() - ->match('(n:Node {aggregateId: $aggregateId})-[rel:IS_CHILD]->()') - ->withParameter('aggregateId', $affectedNode->getProperty('aggregateId')) - ->returns('COUNT(DISTINCT rel) as count') - ->build() + Statement::create( + 'MATCH (n:Node {aggregateId: $aggregateId}) + OPTIONAL MATCH (n)-[r1:IS_CHILD]->() + OPTIONAL MATCH (n)<-[r2:IS_CHILD]-() + WITH COLLECT(DISTINCT r1.contentStreamId) + COLLECT(DISTINCT r2.contentStreamId) AS allStreams + UNWIND allStreams AS stream + WITH DISTINCT stream + WHERE stream IS NOT NULL + RETURN COUNT(DISTINCT stream) as count', + [ + 'aggregateId' => $affectedNode->getProperty('aggregateId'), + ] + ) ); if ($nodeContentStreams->getAsCypherMap(0)->getAsInt('count') > 1) { @@ -1214,14 +1295,14 @@ private function whenRootNodeAggregateDimensionsWereUpdated(RootNodeAggregateDim ] ) ); - + if ($rootNodeResult->isEmpty()) { // Root node not found - should never happen return; } - + $rootNode = $rootNodeResult->getAsCypherMap(0)->getAsNode('rootNode'); - + // Get currently covered dimension space points $currentRelationsResult = $this->client->runStatement( Statement::create( @@ -1233,7 +1314,7 @@ private function whenRootNodeAggregateDimensionsWereUpdated(RootNodeAggregateDim ] ) ); - + $currentDimensionSpacePointHashes = []; if (!$currentRelationsResult->isEmpty()) { $cypherList = $currentRelationsResult->getAsCypherMap(0)->get('currentDimensionSpacePointHashes'); @@ -1242,7 +1323,7 @@ private function whenRootNodeAggregateDimensionsWereUpdated(RootNodeAggregateDim $currentDimensionSpacePointHashes = $cypherList->toArray(); } } - + // Determine newly covered dimension space points $newlyCoveredDimensionSpacePoints = []; foreach ($event->coveredDimensionSpacePoints as $dimensionSpacePoint) { @@ -1250,7 +1331,7 @@ private function whenRootNodeAggregateDimensionsWereUpdated(RootNodeAggregateDim $newlyCoveredDimensionSpacePoints[] = $dimensionSpacePoint; } } - + // Add new IS_CHILD relationships for newly covered dimension space points foreach ($newlyCoveredDimensionSpacePoints as $dimensionSpacePoint) { $this->client->runStatement( @@ -1273,7 +1354,7 @@ private function whenRootNodeAggregateDimensionsWereUpdated(RootNodeAggregateDim ] ) ); - + // Register the dimension space point if not already registered $this->dimensionSpacePointsRepository->insertDimensionSpacePoint($dimensionSpacePoint); } diff --git a/Classes/Neo4jContentGraphReadModelAdapter.php b/Classes/Neo4jContentGraphReadModelAdapter.php index d4c6208..1da3a88 100644 --- a/Classes/Neo4jContentGraphReadModelAdapter.php +++ b/Classes/Neo4jContentGraphReadModelAdapter.php @@ -119,7 +119,7 @@ public function findWorkspaceByName(WorkspaceName $workspaceName): ?Workspace WorkspaceName::fromString($properties['name']), $baseWorkspaceName ? WorkspaceName::fromString($baseWorkspaceName) : null, ContentStreamId::fromString($contentStream->getProperty('contentStreamId')), - $contentStream->getProperty('hasChanges') === 0 || $baseWorkspaceName !== null ? + $contentStream->getProperty('hasChanges') === 0 ? WorkspaceStatus::UP_TO_DATE : WorkspaceStatus::OUTDATED, $contentStream->getProperty('hasChanges') !== 0 && $baseWorkspaceName !== null, From 30f62722436c31f7109e77433e0e3a31d009fc2e Mon Sep 17 00:00:00 2001 From: Michel Loew Date: Thu, 18 Sep 2025 16:51:12 +0200 Subject: [PATCH 9/9] WIP: Refactor multiple queries --- .../Projection/Feature/ContentStream.php | 12 +- .../Projection/Feature/HierarchyRelation.php | 60 +- .../Projection/Feature/ReferenceRelation.php | 4 - .../Domain/Projection/Feature/Workspace.php | 2 +- Classes/Domain/Query/NodeQueryBuilder.php | 7 + Classes/Domain/Query/QueryBuilder.php | 86 ++- .../Domain/Repository/Neo4jContentGraph.php | 54 +- .../Repository/Neo4jContentSubgraph.php | 657 +++++++++++++----- .../Neo4jProjectionContentGraph.php | 7 +- Classes/Domain/Repository/NodeFactory.php | 2 +- Classes/Neo4jContentGraphProjection.php | 17 +- Classes/Neo4jContentGraphReadModelAdapter.php | 40 +- 12 files changed, 658 insertions(+), 290 deletions(-) diff --git a/Classes/Domain/Projection/Feature/ContentStream.php b/Classes/Domain/Projection/Feature/ContentStream.php index 3c9053c..514dfc1 100644 --- a/Classes/Domain/Projection/Feature/ContentStream.php +++ b/Classes/Domain/Projection/Feature/ContentStream.php @@ -16,11 +16,11 @@ private function createContentStream(ContentStreamId $contentStreamId, ?ContentS { return $this->client->runStatement( Statement::create( - 'CREATE (contentStream:ContentStream {contentStreamId: $contentStreamId, version: 0, sourceContentStreamVersion: $sourceVersion, closed: 0, hasChanges: 0}) + 'CREATE (contentStream:ContentStream {contentStreamId: $contentStreamId, version: 0, closed: 0, hasChanges: 0}) WITH contentStream WHERE $sourceContentStreamId IS NOT NULL OPTIONAL MATCH (sourceContentStream:ContentStream {contentStreamId: $sourceContentStreamId}) - MERGE (contentStream)-[:SOURCE_CONTENT_STREAM]->(sourceContentStream) + MERGE (contentStream)-[:SOURCE_CONTENT_STREAM {sourceContentStreamVersion: sourceContentStream.version}]->(sourceContentStream) RETURN contentStream', [ 'contentStreamId' => $contentStreamId->value, @@ -31,11 +31,11 @@ private function createContentStream(ContentStreamId $contentStreamId, ?ContentS ); } - private function closeContentStream(ContentStreamId $contentStreamId): SummarizedResult + private function closeContentStream(ContentStreamId $contentStreamId): void { - return $this->client->runStatement( + $this->client->runStatement( Statement::create( - 'MATCH (contentStream:ContentStream {contentStreamId: $contentStreamId}) SET contentStream.closed = 1 RETURN contentStream', + 'MATCH (contentStream:ContentStream {contentStreamId: $contentStreamId}) SET contentStream.closed = 1', ['contentStreamId' => $contentStreamId->value] ) ); @@ -65,7 +65,7 @@ private function updateContentStreamVersion(ContentStreamId $contentStreamId, Ve { $this->client->runStatement( Statement::create( - 'MATCH (contentStream:ContentStream {contentStreamId: $contentStreamId}) SET contentStream.version = $version, contentStream.hasChanges = $hasChanges', + 'MATCH (contentStream:ContentStream {contentStreamId: $contentStreamId}) SET contentStream.version = $version, contentStream.hasChanges = CASE WHEN contentStream.hasChanges = 1 THEN 1 ELSE $hasChanges END', [ 'contentStreamId' => $contentStreamId->value, 'version' => $version->value, diff --git a/Classes/Domain/Projection/Feature/HierarchyRelation.php b/Classes/Domain/Projection/Feature/HierarchyRelation.php index 4e145b6..23163ea 100644 --- a/Classes/Domain/Projection/Feature/HierarchyRelation.php +++ b/Classes/Domain/Projection/Feature/HierarchyRelation.php @@ -23,42 +23,36 @@ private function addParentHierarchyRelation( ContentStreamId $contentStreamId, DimensionSpacePoint $dimensionSpacePoint, int $position, - \DateTimeImmutable $lastModified, - \DateTimeImmutable $originalLastModified ): void { $this->client->runStatement( Statement::create( 'MATCH (childNode:Node) WHERE ID(childNode) = $childNodeAggregateId MATCH (parentNode:Node|Root {aggregateId: $parentNodeAggregateId})-[parentRel:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->() - + // Get parent subtree tags and convert them to inherited tags for the child WITH childNode, parentNode, parentRel, - CASE WHEN parentRel.subtreeTags IS NOT NULL - THEN apoc.convert.fromJsonMap(parentRel.subtreeTags) + CASE WHEN parentRel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(parentRel.subtreeTags) ELSE {} END as parentTags - + // Convert parent tags to inherited tags for child (true -> inherit, inherit -> inherit) WITH childNode, parentNode, parentRel, parentTags, apoc.map.fromPairs([key in keys(parentTags) WHERE parentTags[key] IN [true, "inherit"] | [key, "inherit"]]) as inheritedTags - + CREATE (childNode)-[:IS_CHILD { contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash, position: $position, - subtreeTags: CASE WHEN size(keys(inheritedTags)) > 0 + subtreeTags: CASE WHEN size(keys(inheritedTags)) > 0 THEN apoc.convert.toJson(inheritedTags) ELSE null END - }]->(parentNode) - SET childNode.lastModified = $lastModified - SET childNode.originalLastModified = $originalLastModified', + }]->(parentNode)', [ 'childNodeAggregateId' => $childNode->getId(), 'parentNodeAggregateId' => $parentNodeAggregateId->value, 'contentStreamId' => $contentStreamId->value, 'dimensionSpacePointHash' => $dimensionSpacePoint->hash, 'position' => $position, - 'lastModified' => $lastModified->format(\DateTimeInterface::ATOM), - 'originalLastModified' => $originalLastModified->format(\DateTimeInterface::ATOM), ] ) ); @@ -99,34 +93,34 @@ private function copyHierarchyRelation( MATCH (newChildNode:Node) MATCH (newParentNode:Node) WHERE ID(newChildNode) = $newChildNodeId AND ID(newParentNode) = $newParentNodeId - + // Find parent node\'s relationship for tag inheritance using the same contentStreamId and dimensionSpacePointHash OPTIONAL MATCH (newParentNode)-[parentRel:IS_CHILD {contentStreamId: rOld.contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->() - + // Get parent subtree tags and convert them to inherited tags for the child WITH rOld, newChildNode, newParentNode, parentRel, - CASE WHEN parentRel.subtreeTags IS NOT NULL - THEN apoc.convert.fromJsonMap(parentRel.subtreeTags) + CASE WHEN parentRel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(parentRel.subtreeTags) ELSE {} END as parentTags, CASE WHEN rOld.subtreeTags IS NOT NULL AND $copySubtreeTags = TRUE THEN apoc.convert.fromJsonMap(rOld.subtreeTags) ELSE {} END as oldTags - + // Convert parent tags to inherited tags for child (true -> inherit, inherit -> inherit) WITH rOld, newChildNode, newParentNode, parentTags, oldTags, apoc.map.fromPairs([key in keys(parentTags) WHERE parentTags[key] IN [true, "inherit"] | [key, "inherit"]]) as inheritedTags - + // Merge inherited tags with old tags, with old tags taking precedence WITH rOld, newChildNode, newParentNode, inheritedTags, oldTags, - apoc.map.merge(inheritedTags, CASE WHEN $copyDisabledState = TRUE + apoc.map.merge(inheritedTags, CASE WHEN $copyDisabledState = TRUE THEN oldTags ELSE apoc.map.removeKey(oldTags, "disabled") END) as finalTags - + CREATE (newChildNode)-[rNew:IS_CHILD { contentStreamId: rOld.contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash, position: $position, - subtreeTags: CASE WHEN size(keys(finalTags)) > 0 + subtreeTags: CASE WHEN size(keys(finalTags)) > 0 THEN apoc.convert.toJson(finalTags) ELSE null END }]->(newParentNode)', @@ -154,33 +148,33 @@ private function moveChildHierarchyRelation( Statement::create( 'MATCH (newChildNode:Node) WHERE ID(newChildNode) = $newChildNodeId MATCH ()-[relationship]->(parentNode) WHERE ID(relationship) = $relationshipId - + // Find parent node\'s relationship for tag inheritance using the same contentStreamId and dimensionSpacePointHash OPTIONAL MATCH (parentNode)-[parentRel:IS_CHILD {contentStreamId: relationship.contentStreamId, dimensionSpacePointHash: relationship.dimensionSpacePointHash}]->() - + // Get parent subtree tags and existing relationship tags WITH newChildNode, relationship, parentNode, parentRel, - CASE WHEN parentRel.subtreeTags IS NOT NULL - THEN apoc.convert.fromJsonMap(parentRel.subtreeTags) + CASE WHEN parentRel.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(parentRel.subtreeTags) ELSE {} END as parentTags, - CASE WHEN relationship.subtreeTags IS NOT NULL - THEN apoc.convert.fromJsonMap(relationship.subtreeTags) + CASE WHEN relationship.subtreeTags IS NOT NULL + THEN apoc.convert.fromJsonMap(relationship.subtreeTags) ELSE {} END as existingTags - + // Convert parent tags to inherited tags for child (true -> inherit, inherit -> inherit) WITH newChildNode, relationship, parentNode, parentTags, existingTags, apoc.map.fromPairs([key in keys(parentTags) WHERE parentTags[key] IN [true, "inherit"] | [key, "inherit"]]) as inheritedTags - + // Merge inherited tags with existing tags, with existing tags taking precedence WITH newChildNode, relationship, inheritedTags, existingTags, - apoc.map.merge(inheritedTags, CASE WHEN $copyDisabledState = TRUE + apoc.map.merge(inheritedTags, CASE WHEN $copyDisabledState = TRUE THEN existingTags ELSE apoc.map.removeKey(existingTags, "disabled") END) as finalTags - + CALL apoc.refactor.from(relationship, newChildNode) YIELD output as newRelationship SET newRelationship.position = $position, - newRelationship.subtreeTags = CASE WHEN size(keys(finalTags)) > 0 + newRelationship.subtreeTags = CASE WHEN size(keys(finalTags)) > 0 THEN apoc.convert.toJson(finalTags) ELSE null END', [ diff --git a/Classes/Domain/Projection/Feature/ReferenceRelation.php b/Classes/Domain/Projection/Feature/ReferenceRelation.php index d38b041..872fd68 100644 --- a/Classes/Domain/Projection/Feature/ReferenceRelation.php +++ b/Classes/Domain/Projection/Feature/ReferenceRelation.php @@ -60,8 +60,6 @@ private function createReferenceRelations( 'MATCH (sourceNode:Node {aggregateId: $aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->() MATCH (targetNode:Node {aggregateId: $referencedNodeAggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->() MERGE (sourceNode)-[newRef:REFERENCE {referenceName: $referenceName, position: $position}]->(targetNode) - SET sourceNode.lastModified = $lastModified - SET sourceNode.originalLastModified = $originalLastModified RETURN newRef', [ 'aggregateId' => $sourceNodeAggregateId->value, @@ -70,8 +68,6 @@ private function createReferenceRelations( 'referenceName' => $reference->referenceName->value, 'position' => $position, 'referencedNodeAggregateId' => $nodeReference->targetNodeAggregateId->value, - 'lastModified' => $lastModified->format(\DateTimeInterface::ATOM), - 'originalLastModified' => $originalLastModified->format(\DateTimeInterface::ATOM), ], )); if (empty($result) || !$result->hasKey(0) || !$result->getAsCypherMap(0)->hasKey('newRef')) { diff --git a/Classes/Domain/Projection/Feature/Workspace.php b/Classes/Domain/Projection/Feature/Workspace.php index 50edc73..7513478 100644 --- a/Classes/Domain/Projection/Feature/Workspace.php +++ b/Classes/Domain/Projection/Feature/Workspace.php @@ -68,7 +68,7 @@ private function updateBaseWorkspace( Statement::create( 'MATCH (workspace:Workspace {name: $workspaceName}) MATCH (workspace)-[bwRel:BASE_WORKSPACE]->() - MATCH (workspace-[csRel:CONTENT_STREAM]->() + MATCH (workspace)-[csRel:CONTENT_STREAM]->() DELETE bwRel, csRel WITH workspace MATCH (newBw:Workspace {name: $baseWorkspaceName}) diff --git a/Classes/Domain/Query/NodeQueryBuilder.php b/Classes/Domain/Query/NodeQueryBuilder.php index b9fb50e..5d57323 100644 --- a/Classes/Domain/Query/NodeQueryBuilder.php +++ b/Classes/Domain/Query/NodeQueryBuilder.php @@ -168,6 +168,13 @@ public function whereNodeInDimensionSpace( ->withParameter('dimensionSpacePointHash', $dimensionSpacePointHash); } + public function whereNodeInContentStream( + ContentStreamId $contentStreamId, + string $relationAlias = 'rel' + ): self { + return $this->where("{$relationAlias}.contentStreamId = \$contentStreamId") + ->withParameter('contentStreamId', $contentStreamId->value); + } public function returnStandardNodeFields(string $nodeAlias = 'n', string $relationAlias = 'rel'): self { return $this->returns( diff --git a/Classes/Domain/Query/QueryBuilder.php b/Classes/Domain/Query/QueryBuilder.php index 6cf9ae4..6dfe60e 100644 --- a/Classes/Domain/Query/QueryBuilder.php +++ b/Classes/Domain/Query/QueryBuilder.php @@ -15,19 +15,29 @@ class QueryBuilder private array $clauses = []; private array $parameters = []; - public function match(string $pattern): self + public function debug(?string $title = null): static + { + $text = $this->build()->getText(); + foreach ($this->getParameters() as $parameter => $value) { + $replacement = is_string($value) ? "'" . $value . "'" : (is_array($value) ? '["' . implode('", "', $value) . '"]' : (is_null($value) ? 'NULL' : (is_bool($value) ? ($value ? 'TRUE' : 'FALSE') : $value))); + $text = str_replace('$' . $parameter, (string)$replacement, $text); + } + \Neos\Flow\var_dump($text, $title); + return $this; + } + public function match(string $pattern): static { $this->addClause('MATCH', $pattern); return $this; } - public function optionalMatch(string $pattern): self + public function optionalMatch(string $pattern): static { $this->addClause('OPTIONAL MATCH', $pattern); return $this; } - public function where(string $condition): self + public function where(string $condition): static { if (!empty($condition)) { $this->addClause('WHERE', $condition); @@ -35,115 +45,129 @@ public function where(string $condition): self return $this; } - public function with(string $expression): self + public function with(string $expression): static { $this->addClause('WITH', $expression); return $this; } - public function returns(string $expression): self + public function returns(string $expression): static { $this->addClause('RETURN', $expression); return $this; } - public function returnDistinct(string $expression): self + public function returnDistinct(string $expression): static { $this->addClause('RETURN', 'DISTINCT ' . $expression); return $this; } - public function orderBy(string $expression, string $direction = 'ASC'): self + public function orderBy(string $expression, string $direction = 'ASC'): static { $this->addClause('ORDER BY', $expression . ' ' . strtoupper($direction)); return $this; } - public function create(string $pattern): self + public function create(string $pattern): static { $this->addClause('CREATE', $pattern); return $this; } - public function merge(string $pattern): self + public function merge(string $pattern): static { $this->addClause('MERGE', $pattern); return $this; } - public function delete(string $expression): self + public function delete(string $expression): static { $this->addClause('DELETE', $expression); return $this; } - public function detachDelete(string $expression): self + public function detachDelete(string $expression): static { $this->addClause('DETACH DELETE', $expression); return $this; } - public function set(string $expression): self + public function set(string $expression): static { $this->addClause('SET', $expression); return $this; } - public function remove(string $expression): self + public function remove(string $expression): static { $this->addClause('REMOVE', $expression); return $this; } - public function limit(int $limit): self + public function limit(int $limit): static { $this->addClause('LIMIT', (string)$limit); return $this; } - public function skip(int $skip): self + public function skip(int $skip): static { $this->addClause('SKIP', (string)$skip); return $this; } - public function foreach(string $expression): self + public function foreach(string $expression): static { $this->addClause('FOREACH', $expression); return $this; } - public function call(string $procedure): self + public function call(string $procedure): static { $this->addClause('CALL', $procedure); return $this; } - public function yield(string $expression): self + public function yield(string $expression): static { $this->addClause('YIELD', $expression); return $this; } - public function unwind(string $expression): self + public function unwind(string $expression): static { $this->addClause('UNWIND', $expression); return $this; } - public function union(bool $all = false): self + public function union(bool $all = false): static { $clause = $all ? 'UNION ALL' : 'UNION'; $this->addClause($clause, ''); return $this; } - public function rawClause(string $clause, string $expression = ''): self + public function rawClause(string $clause, string $expression = ''): static { $this->addClause($clause, $expression); return $this; } + /** + * @param callable(static): static $subClauses + */ + public function rawClauseBuilder(callable $subClauses): static + { + $statement = $subClauses(new static())->build(); + $text = $statement->getText(); + foreach ($statement->getParameters() as $parameter => $value) { + $replacement = is_string($value) ? "'" . $value . "'" : (is_array($value) ? '["' . implode('", "', $value) . '"]' : (is_null($value) ? 'NULL' : (is_bool($value) ? ($value ? 'TRUE' : 'FALSE') : $value))); + $text = str_replace('$' . $parameter, $replacement, $text); + } + return $this->rawClause($text); + } + private function addClause(string $type, string $expression): void { $this->clauses[] = [ @@ -152,13 +176,13 @@ private function addClause(string $type, string $expression): void ]; } - public function withParameters(array $parameters): self + public function withParameters(array $parameters): static { $this->parameters = array_merge($this->parameters, $parameters); return $this; } - public function withParameter(string $key, mixed $value): self + public function withParameter(string $key, mixed $value): static { $this->parameters[$key] = $value; return $this; @@ -168,6 +192,7 @@ private function buildCypher(): string { $cypher = []; $lastClause = []; + $appendClauses = []; foreach ($this->clauses as $clause) { $type = $clause['type']; @@ -184,10 +209,17 @@ private function buildCypher(): string $type = ','; } } + if ($type === 'RETURN') { + $appendClauses[] = $clause; + continue; + } $cypher[] = $type . ' ' . $expression; } $lastClause = $clause; } + foreach ($appendClauses as $appendClause) { + $cypher[] = $appendClause['type'] . ' ' . $appendClause['expression']; + } return implode("\n", array_filter($cypher)); } @@ -209,14 +241,12 @@ public function getClauses(): array /** * @param callable(static): static $subClauses - * @param string $groupAlias - * @return self */ - public function whereAll(callable $subClauses, string $groupAlias = 'rels'): self + public function whereAll(callable $subClauses, string $groupAlias = 'rels'): static { - $statement = $subClauses(new QueryBuilder())->build(); + $statement = $subClauses(new static())->build(); $this - ->where(sprintf('all(r IN %s %s)', $groupAlias, $statement->getText())) + ->where(sprintf('all(rel IN %s %s)', $groupAlias, $statement->getText())) ->withParameters($statement->getParameters()); return $this; } diff --git a/Classes/Domain/Repository/Neo4jContentGraph.php b/Classes/Domain/Repository/Neo4jContentGraph.php index b74dcf7..65cbdfe 100644 --- a/Classes/Domain/Repository/Neo4jContentGraph.php +++ b/Classes/Domain/Repository/Neo4jContentGraph.php @@ -38,6 +38,7 @@ public function __construct( private readonly NodeFactory $nodeFactory, private readonly Neo4jDimensionSpacePointsRepository $dimensionSpacePointsRepository, private readonly NodeTypeManager $nodeTypeManager, + private readonly bool $debug = false, ) { } @@ -46,6 +47,7 @@ public function __construct( */ public function getContentRepositoryId(): ContentRepositoryId { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); return $this->contentRepositoryId; } @@ -54,6 +56,7 @@ public function getContentRepositoryId(): ContentRepositoryId */ public function getWorkspaceName(): WorkspaceName { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); return $this->workspaceName; } @@ -64,6 +67,7 @@ public function getSubgraph( DimensionSpacePoint $dimensionSpacePoint, VisibilityConstraints $visibilityConstraints ): ContentSubgraphInterface { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); return new Neo4jContentSubgraph( $this->contentRepositoryId, $this->workspaceName, @@ -81,6 +85,7 @@ public function getSubgraph( */ public function findRootNodeAggregateByType(NodeTypeName $nodeTypeName): ?NodeAggregate { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); $filter = Filter\FindRootNodeAggregatesFilter::create($nodeTypeName); $aggregates = $this->findRootNodeAggregates($filter); if ($aggregates->isEmpty()) { @@ -94,6 +99,7 @@ public function findRootNodeAggregateByType(NodeTypeName $nodeTypeName): ?NodeAg */ public function findRootNodeAggregates(Filter\FindRootNodeAggregatesFilter $filter): NodeAggregates { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); $statement = NodeQueryBuilder::createForNodes() ->matchNodeWithRootRelation($this->getContentStreamId()) ->where(!empty($filter->nodeTypeName) ? 'n.nodeTypeName = $nodeTypeName' : '') @@ -116,6 +122,7 @@ public function findRootNodeAggregates(Filter\FindRootNodeAggregatesFilter $filt */ public function findNodeAggregatesByType(NodeTypeName $nodeTypeName): NodeAggregates { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); $result = $this->client->runStatement( NodeQueryBuilder::createForNodes() ->match('(n:Node {nodeTypeName: $nodeTypeName})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->()') @@ -138,6 +145,7 @@ public function findNodeAggregatesByType(NodeTypeName $nodeTypeName): NodeAggreg */ public function findNodeAggregateById(NodeAggregateId $nodeAggregateId): ?NodeAggregate { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); $result = $this->client->runStatement( NodeQueryBuilder::createForNodes() ->match('(n:Node {aggregateId: $aggregateId})-[rel:IS_CHILD {contentStreamId: $contentStreamId}]->()') @@ -159,25 +167,24 @@ public function findNodeAggregateById(NodeAggregateId $nodeAggregateId): ?NodeAg */ public function findNodeAggregatesByIds(NodeAggregateIds $nodeAggregateIds): NodeAggregates { - $nodeAggregates = []; - foreach ($nodeAggregateIds as $nodeAggregateId) { - $result = $this->client->runStatement( + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); + return $this->nodeFactory->mapResultToNodeAggregates( + $this->client->runStatement( NodeQueryBuilder::createForNodes() - ->matchNodeForContentStream($this->contentStreamId, $nodeAggregateId) + ->matchNodesForContentStream($this->contentStreamId) + ->where('n.aggregateId IN $aggregateIds') + ->withParameter('aggregateIds', $nodeAggregateIds->toStringArray()) + ->orderBy('ID(n)', 'DESC') ->returnStandardNodeFields() ->build() - ); - $nodeAggregates[] = $this->nodeFactory->mapResultToNodeAggregate( - $result, - $this->workspaceName, - ); - } - - return NodeAggregates::fromArray($nodeAggregates); + ), + $this->workspaceName + ); } public function findUsedNodeTypeNames(): NodeTypeNames { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); $result = $this->client->runStatement( Statement::create( 'MATCH (n:Node) RETURN DISTINCT n.nodeTypeName AS nodeTypeName', @@ -197,6 +204,7 @@ public function findParentNodeAggregateByChildOriginDimensionSpacePoint( NodeAggregateId $childNodeAggregateId, OriginDimensionSpacePoint $childOriginDimensionSpacePoint ): ?NodeAggregate { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); $result = $this->client->runStatement( NodeQueryBuilder::createForNodes() ->matchNodeForSubgraph($this->contentStreamId, $childOriginDimensionSpacePoint->toDimensionSpacePoint(), $childNodeAggregateId, '', '', 'parentNode') @@ -219,6 +227,7 @@ public function findParentNodeAggregateByChildOriginDimensionSpacePoint( */ public function findParentNodeAggregates(NodeAggregateId $childNodeAggregateId): NodeAggregates { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); $result = $this->client->runStatement( NodeQueryBuilder::createForNodes() ->match('(:Node {aggregateId: $aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId}]->(parentNode:Node)') @@ -228,6 +237,7 @@ public function findParentNodeAggregates(NodeAggregateId $childNodeAggregateId): 'contentStreamId' => $this->getContentStreamId()->value, ]) ->returnStandardNodeFields() + ->orderBy('ID(n)', 'ASC') ->build() ); @@ -245,13 +255,16 @@ public function findParentNodeAggregates(NodeAggregateId $childNodeAggregateId): */ public function findAncestorNodeAggregateIds(NodeAggregateId $entryNodeAggregateId): NodeAggregateIds { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); + + // Find all ancestor aggregate IDs across all dimension space points in this content stream $result = $this->client->runStatement( Statement::create( - 'MATCH (n:Node {aggregateId: $aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId}]->(ancestorNode:Node) + 'MATCH (n:Node {aggregateId: $aggregateId})-[rels:IS_CHILD*1..]->(ancestorNode:Node|Root) + WHERE ancestorNode:Node RETURN DISTINCT ancestorNode.aggregateId AS aggregateId', [ 'aggregateId' => $entryNodeAggregateId->value, - 'contentStreamId' => $this->getContentStreamId()->value, ] ) ); @@ -266,6 +279,7 @@ public function findAncestorNodeAggregateIds(NodeAggregateId $entryNodeAggregate */ public function findChildNodeAggregates(NodeAggregateId $parentNodeAggregateId): NodeAggregates { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); $result = $this->client->runStatement( NodeQueryBuilder::createForNodes() ->matchNodeForContentStream($this->contentStreamId, $parentNodeAggregateId, 'original', '') @@ -286,6 +300,7 @@ public function findChildNodeAggregates(NodeAggregateId $parentNodeAggregateId): */ public function findChildNodeAggregateByName(NodeAggregateId $parentNodeAggregateId, NodeName $name): ?NodeAggregate { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); $result = $this->client->runStatement( NodeQueryBuilder::createForNodes() ->match('(original:Node {aggregateId: $aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId}]->()') @@ -313,6 +328,7 @@ public function findChildNodeAggregateByName(NodeAggregateId $parentNodeAggregat */ public function findTetheredChildNodeAggregates(NodeAggregateId $parentNodeAggregateId): NodeAggregates { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); $result = $this->client->runStatement( NodeQueryBuilder::createForNodes() ->matchNodeForContentStream($this->contentStreamId, $parentNodeAggregateId, 'original', '') @@ -338,6 +354,7 @@ public function getDimensionSpacePointsOccupiedByChildNodeName( OriginDimensionSpacePoint $parentNodeOriginDimensionSpacePoint, DimensionSpacePointSet $dimensionSpacePointsToCheck ): DimensionSpacePointSet { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); $result = $this->client->runStatement( Statement::create( 'MATCH (parentNode:Node {aggregateId: $aggregateId, originDimensionSpacePointHash: $originDimensionSpacePointHash})-[:IS_CHILD {contentStreamId: $contentStreamId}]->() @@ -370,18 +387,22 @@ public function getDimensionSpacePointsOccupiedByChildNodeName( */ public function findNodeAggregatesTaggedBy(SubtreeTag $subtreeTag): NodeAggregates { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); $result = $this->client->runStatement( NodeQueryBuilder::createForNodes() ->matchNodesForContentStream($this->contentStreamId) - ->where(sprintf('apoc.convert.fromJsonMap(rel.subtreeTags).%s', $subtreeTag->value)) + ->where(sprintf('apoc.convert.fromJsonMap(rel.subtreeTags).%s = true', $subtreeTag->value)) + ->with('COLLECT(DISTINCT n) AS matchingNodes') + ->unwind('matchingNodes AS n') + ->match('(n)-[rel:IS_CHILD {contentStreamId: "cs-identifier"}]->(:Node|Root)') ->returnStandardNodeFields() + ->orderBy('ID(n)', 'DESC') ->build() ); if ($result->isEmpty()) { return NodeAggregates::createEmpty(); } - return $this->nodeFactory->mapResultToNodeAggregates($result, $this->workspaceName); } @@ -390,6 +411,7 @@ public function findNodeAggregatesTaggedBy(SubtreeTag $subtreeTag): NodeAggregat */ public function getContentStreamId(): ContentStreamId { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); return $this->contentStreamId; } } diff --git a/Classes/Domain/Repository/Neo4jContentSubgraph.php b/Classes/Domain/Repository/Neo4jContentSubgraph.php index 3b4cec4..103d991 100644 --- a/Classes/Domain/Repository/Neo4jContentSubgraph.php +++ b/Classes/Domain/Repository/Neo4jContentSubgraph.php @@ -3,11 +3,13 @@ namespace JvMTECH\ContentGraph\Neo4jAdapter\Domain\Repository; +use Doctrine\ORM\Query; use JvMTECH\ContentGraph\Neo4jAdapter\Domain\Query\NodeQueryBuilder; use JvMTECH\ContentGraph\Neo4jAdapter\Domain\Query\QueryBuilder; use Laudis\Neo4j\Contracts\ClientInterface; use Laudis\Neo4j\Databags\Statement; use Laudis\Neo4j\Databags\SummarizedResult; +use Laudis\Neo4j\Types\CypherList; use Laudis\Neo4j\Types\CypherMap; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags; @@ -28,6 +30,7 @@ use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateIds; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; +use Neos\ContentRepository\Core\SharedModel\Node\PropertyName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -73,7 +76,7 @@ public function getVisibilityConstraints(): VisibilityConstraints public function findNodeById(NodeAggregateId $nodeAggregateId): ?Node { - if ($this->debug) \Neos\Flow\var_dump('findNodeById called with' . $nodeAggregateId->value); + if ($this->debug) \Neos\Flow\var_dump('findNodeById called with ' . $nodeAggregateId->value); $result = $this->client->runStatement( NodeQueryBuilder::createForNodes() ->matchNodeForSubgraph( @@ -181,14 +184,8 @@ private function getChildNodesQuery( $this->dimensionSpacePoint, $parentNodeAggregateId, parentAlias: '', - ); - /** @var SubtreeTags $constraint */ - foreach ($this->visibilityConstraints as $constraint) { - foreach ($constraint as $subtreeTag) { - $query->with(sprintf('child, rel, COALESCE(apoc.convert.fromJsonMap(rel.subtreeTags).%s, false) AS %s', $subtreeTag->value, $subtreeTag->value)); - $query->where(sprintf('%s <> true', $subtreeTag->value)); - } - } + ) + ->withVisibilityConstraints($this->visibilityConstraints); if (!empty($filter->nodeTypes)) { $expandedNodeTypeCriteria = ExpandedNodeTypeCriteria::create($filter->nodeTypes, $this->nodeTypeManager); @@ -201,10 +198,10 @@ private function getChildNodesQuery( } } - if (!empty($filter->searchTerm)) { + if (!empty($filter->searchTerm) && $filter->searchTerm->term !== '') { $query - ->where('(child.name CONTAINS $searchTerm OR any(prop in keys(child.properties) WHERE child.properties[prop] CONTAINS $searchTerm))') - ->withParameter('searchTerm', $filter->searchTerm->term); + ->where('(child.name CONTAINS $searchTerm OR any(prop in keys(apoc.convert.fromJsonMap(child.properties)) WHERE toLower(toString(apoc.convert.fromJsonMap(child.properties)[prop]["value"])) CONTAINS $searchTerm))') + ->withParameter('searchTerm', mb_strtolower($filter->searchTerm->term)); } if (!empty($filter->propertyValue)) { @@ -216,35 +213,57 @@ private function getChildNodesQuery( ->withParameters($propertyParams); } } - if ($filter->ordering !== null) { - foreach ($filter->ordering as $ordering) - $query->orderBy('rel.'.$ordering->field->value, $ordering->direction->value); + if ($filter instanceof Filter\FindChildNodesFilter && $filter->ordering !== null) { + foreach ($filter->ordering as $ordering) { + if ($ordering->field instanceof PropertyName) { + $query->orderBy('apoc.convert.fromJsonMap(child.properties)["' . $ordering->field->value . '"]["value"] = ""', + $ordering->direction->value === 'ASCENDING' ? 'DESCENDING' : ($ordering->direction->value === 'DESCENDING' ? 'ASCENDING' : 'DESCENDING')); + $query->orderBy('apoc.convert.fromJsonMap(child.properties)["' . $ordering->field->value . '"]["value"]', + $ordering->direction->value); + } else { + $direction = $ordering->direction; + if ($ordering->field instanceof Filter\Ordering\TimestampField) { + $direction = $ordering->direction === Filter\Ordering\OrderingDirection::ASCENDING ? Filter\Ordering\OrderingDirection::DESCENDING : Filter\Ordering\OrderingDirection::ASCENDING; + } + $fieldValue = lcfirst(str_replace(' ', '', ucwords(mb_strtolower(str_replace('_', ' ', $ordering->field->value))))); + $query->orderBy('child.' . $fieldValue, $direction->value); + } + } } $query ->orderBy('rel.position') ->orderBy('child.aggregateId'); - if ($filter->pagination !== null) { - $query - ->limit($filter->pagination->limit) - ->skip($filter->pagination->offset); + if ($filter instanceof Filter\FindChildNodesFilter && $filter->pagination !== null) { + if ($filter->pagination->limit === PHP_INT_MAX) { + $query->skip($filter->pagination->offset); + } else { + $query + ->limit($filter->pagination->limit + $filter->pagination->offset) + ->skip($filter->pagination->offset); + } } return $query; } public function findParentNode(NodeAggregateId $childNodeAggregateId): ?Node { + if ($this->debug) \Neos\Flow\var_dump('findParentNode called with childNodeAggregateId: ' . $childNodeAggregateId->value); - $result = $this->client->runStatement( - NodeQueryBuilder::createForNodes() + $query = NodeQueryBuilder::createForNodes() ->matchNodeForSubgraph( $this->contentStreamId, $this->dimensionSpacePoint, $childNodeAggregateId, ) + ->withVisibilityConstraints($this->visibilityConstraints) ->where('"Node" IN labels(p)') - ->returns('p as parent') - ->build() - ); + ->returns('p as parent'); + foreach ($this->visibilityConstraints as $constraint) { + foreach ($constraint as $subtreeTag) { + $query->where(sprintf('COALESCE(apoc.convert.fromJsonMap(rel.subtreeTags).%s, false) <> true', $subtreeTag->value)); + } + } + $result = $this->client->runStatement($query->build()); if ($result->isEmpty() || !$result->hasKey(0) || !$result->getAsCypherMap(0)->hasKey('parent')) { return null; @@ -338,16 +357,6 @@ private function getSiblingNodesQuery( } } - if ($filter instanceof Filter\FindSucceedingSiblingNodesFilter) { - $query - ->where('otherSiblingRel.position > rel.position') - ->orderBy('otherSiblingRel.position'); - } else { - $query - ->where('otherSiblingRel.position < rel.position') - ->orderBy('otherSiblingRel.position', 'DESC'); - } - if (!empty($filter->nodeTypes)) { $expandedNodeTypeCriteria = ExpandedNodeTypeCriteria::create($filter->nodeTypes, $this->nodeTypeManager); @@ -361,7 +370,7 @@ private function getSiblingNodesQuery( if (!empty($filter->searchTerm)) { $query - ->where('(otherSibling.name CONTAINS $searchTerm OR any(prop in keys(otherSibling.properties) WHERE otherSibling.properties[prop] CONTAINS $searchTerm))') + ->where('(otherSibling.name CONTAINS $searchTerm OR any(prop in keys(apoc.convert.fromJsonMap(otherSibling.properties)) WHERE toLower(toString(apoc.convert.fromJsonMap(otherSibling.properties)[prop]["value"])) CONTAINS $searchTerm))') ->withParameter('searchTerm', $filter->searchTerm->term); } @@ -374,6 +383,16 @@ private function getSiblingNodesQuery( ->withParameters($propertyParams); } } + if ($filter instanceof Filter\FindSucceedingSiblingNodesFilter) { + $query + ->where('otherSiblingRel.position > rel.position') + ->orderBy('otherSiblingRel.position'); + } else { + $query + ->where('otherSiblingRel.position < rel.position') + ->orderBy('otherSiblingRel.position', 'DESC'); + } + if ($filter->ordering !== null) { foreach ($filter->ordering as $ordering) { $query->orderBy('ref.' . $ordering->field->value, $ordering->direction->value); @@ -384,7 +403,7 @@ private function getSiblingNodesQuery( ->orderBy('otherSibling.aggregateid'); if ($filter->pagination !== null) { $query - ->limit($filter->pagination->limit) + ->limit($filter->pagination->limit + $filter->pagination->offset) ->skip($filter->pagination->offset); } $query->returns('DISTINCT otherSibling'); @@ -394,37 +413,44 @@ private function getSiblingNodesQuery( public function findAncestorNodes(NodeAggregateId $entryNodeAggregateId, Filter\FindAncestorNodesFilter $filter): Nodes { if ($this->debug) \Neos\Flow\var_dump('findAncestorNodes called with entryNodeAggregateId: ' . $entryNodeAggregateId->value); + $query = NodeQueryBuilder::createForNodes() ->matchRootPath( $entryNodeAggregateId, $this->contentStreamId, $this->dimensionSpacePoint, - ); - $query->returns('nodes(path) as nodes'); + 'path', + 'n', + 'rels' + ) + ->whereAll(fn(NodeQueryBuilder $qb) => $qb->withVisibilityConstraints($this->visibilityConstraints)); + + // Return nodes(path)[1..] to exclude the entry node from ancestors + $query->returns('nodes(path)[1..] as nodes'); $result = $this->client->runStatement($query->build()); + if ($result->isEmpty() || !$result->hasKey(0)) { return Nodes::fromArray([]); } $nodes = $result->getAsCypherMap(0)->getAsCypherList('nodes'); - // TODO: Why filter later? What is this function supposed to do??? - $nodes = array_filter($nodes->toArray(), function (\Laudis\Neo4j\Types\Node $node) use ($filter) { + // Filter nodes based on node type criteria using ExpandedNodeTypeCriteria + $nodeTypeCriteria = null; + if (!empty($filter->nodeTypes)) { + $nodeTypeCriteria = ExpandedNodeTypeCriteria::create($filter->nodeTypes, $this->nodeTypeManager); + } + + $filteredNodes = array_filter($nodes->toArray(), function (\Laudis\Neo4j\Types\Node $node) use ($nodeTypeCriteria) { if (!$node->getProperties()->offsetExists('nodeTypeName')) { return false; } - $nodeTypeName = $node->getProperty('nodeTypeName'); - return is_string($nodeTypeName) - && ($filter->nodeTypes === null || $filter->nodeTypes->explicitlyAllowedNodeTypeNames->isEmpty() || count(array_filter($filter->nodeTypes->explicitlyAllowedNodeTypeNames->map(fn( - $explicitlyAllowedNodeTypeName - ) => $explicitlyAllowedNodeTypeName->value === $nodeTypeName))) > 0) - && ($filter->nodeTypes === null || $filter->nodeTypes->explicitlyDisallowedNodeTypeNames->isEmpty() || count(array_filter($filter->nodeTypes->explicitlyDisallowedNodeTypeNames->map(fn( - $explicitlyDisallowedNodeTypeName - ) => $explicitlyDisallowedNodeTypeName->value === $nodeTypeName))) === 0); + $nodeTypeName = NodeTypeName::fromString($node->getProperty('nodeTypeName')); + return $nodeTypeCriteria === null || $nodeTypeCriteria->matches($nodeTypeName); }); return $this->nodeFactory->mapResultToNodes( - $nodes, + $filteredNodes, $this->workspaceName, $this->dimensionSpacePoint, $this->visibilityConstraints, @@ -434,22 +460,65 @@ public function findAncestorNodes(NodeAggregateId $entryNodeAggregateId, Filter\ public function countAncestorNodes(NodeAggregateId $entryNodeAggregateId, Filter\CountAncestorNodesFilter $filter): int { if ($this->debug) \Neos\Flow\var_dump('countAncestorNodes called with entryNodeAggregateId: ' . $entryNodeAggregateId->value); - $result = $this->client->runStatement( - Statement::create( - 'MATCH (n:Node {aggregateId: $aggregateId})-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->() - MATCH p = SHORTEST 1 (n)-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]-+(:Root) RETURN length(p) as length', - [ - 'aggregateId' => $entryNodeAggregateId->value, - 'contentStreamId' => $this->contentStreamId->value, - 'dimensionSpacePointHash' => $this->dimensionSpacePoint->hash, - ] - ), - ); + + // If there are node type filters, we need to get the nodes and filter them + if (!empty($filter->nodeTypes)) { + $query = NodeQueryBuilder::createForNodes() + ->matchRootPath( + $entryNodeAggregateId, + $this->contentStreamId, + $this->dimensionSpacePoint, + 'path', + 'n', + 'rels' + ) + ->whereAll(fn(NodeQueryBuilder $qb) => $qb->withVisibilityConstraints($this->visibilityConstraints)); + + // Return nodes(path)[1..] to exclude the entry node from ancestors + $query->returns('nodes(path)[1..] as nodes'); + $result = $this->client->runStatement($query->build()); + + if ($result->isEmpty() || !$result->hasKey(0)) { + return 0; + } + + $nodes = $result->getAsCypherMap(0)->getAsCypherList('nodes'); + + // Filter nodes based on node type criteria using ExpandedNodeTypeCriteria + $nodeTypeCriteria = ExpandedNodeTypeCriteria::create($filter->nodeTypes, $this->nodeTypeManager); + + $filteredNodes = array_filter($nodes->toArray(), function (\Laudis\Neo4j\Types\Node $node) use ($nodeTypeCriteria) { + if (!$node->getProperties()->offsetExists('nodeTypeName')) { + return false; + } + $nodeTypeName = NodeTypeName::fromString($node->getProperty('nodeTypeName')); + return $nodeTypeCriteria->matches($nodeTypeName); + }); + + return count($filteredNodes); + } + + // If no node type filters, we can use the more efficient length-based approach + $query = NodeQueryBuilder::createForNodes() + ->matchRootPath( + $entryNodeAggregateId, + $this->contentStreamId, + $this->dimensionSpacePoint, + 'path', + 'n', + 'rels' + ) + ->whereAll(fn(NodeQueryBuilder $qb) => $qb->withVisibilityConstraints($this->visibilityConstraints)); + + // Return length(path) - 1 to exclude the entry node from count + $query->returns('length(path) - 1 as length'); + $result = $this->client->runStatement($query->build()); + if ($result->isEmpty() || !$result->hasKey(0)) { return 0; } - return $result->getAsCypherMap(0)->getAsInt('length') ; + return $result->getAsCypherMap(0)->getAsInt('length'); } public function findClosestNode(NodeAggregateId $entryNodeAggregateId, Filter\FindClosestNodeFilter $filter): ?Node @@ -502,58 +571,29 @@ public function findClosestNode(NodeAggregateId $entryNodeAggregateId, Filter\Fi public function findDescendantNodes(NodeAggregateId $entryNodeAggregateId, Filter\FindDescendantNodesFilter $filter): Nodes { if ($this->debug) \Neos\Flow\var_dump('findDescendantNodes called with entryNodeAggregateId: ' . $entryNodeAggregateId->value); - $query = NodeQueryBuilder::createForNodes() - ->matchNodeForSubgraph( - $this->contentStreamId, - $this->dimensionSpacePoint, - $entryNodeAggregateId, - ) - ->match('path = (n)<-[descendantRel:IS_CHILD*1..]-(descendant:Node)') - ->where('all(r in descendantRel WHERE r.contentStreamId = $contentStreamId AND r.dimensionSpacePointHash = $dimensionSpacePointHash)'); - $query->returns('nodes(path) as nodes'); - $result = $this->client->runStatement( - $query->build() - ); - $nodeTypeCriteria = null; - if (!empty($filter->nodeTypes)) { - $nodeTypeCriteria = ExpandedNodeTypeCriteria::create( - $filter->nodeTypes, - $this->nodeTypeManager, - ); + // Build the base query to find all descendant nodes with their hierarchy information + $query = $this->buildDescendantNodesQuery($entryNodeAggregateId, $filter); + + // Apply ordering: first by level (depth), then by position within each level + $query->orderBy('level') + ->orderBy('position') + ->orderBy('descendant.aggregateId'); // Tie-breaker for consistent ordering + + $result = $this->client->runStatement($query->build()); + + if ($result->isEmpty()) { + return Nodes::fromArray([]); } - $results = []; - foreach ($result->toArray() as $resultMap) { - $nodes = $resultMap->getAsCypherList('nodes'); - foreach ($nodes as $distance => $node) { - if (!array_key_exists($distance, $results)) { - $results[$distance] = []; - } - if (array_key_exists($node->getId(), $results[$distance])) { - continue; // Skip if node already exists at this distance - } - // We have to filter after the request due to the nature of neo4j-paths - // TODO: Look into gds.path.filter() - if ($nodeTypeCriteria?->matches(NodeTypeName::fromString($node->getProperty('nodeTypeName'))) === false) { - continue; // Skip if node type does not match criteria - } - if (!empty($filter->searchTerm) && !( - str_contains($node->getProperty('name'), $filter->searchTerm->term) || - array_reduce( - $node->getProperties(), - fn(bool $carry, $propertyValue) => $carry || (is_string($propertyValue) && str_contains($propertyValue, $filter->searchTerm->term)), - false - ) - )) { - continue; // Skip if search term does not match - } - $results[$distance][$node->getId()] = $node; - } + // Map the results to nodes - they're already properly sorted by the database + $nodes = []; + foreach ($result->toArray() as $row) { + $nodes[] = $row->getAsNode('descendant'); } return $this->nodeFactory->mapResultToNodes( - array_merge(...$results), + $nodes, $this->workspaceName, $this->dimensionSpacePoint, $this->visibilityConstraints, @@ -563,21 +603,8 @@ public function findDescendantNodes(NodeAggregateId $entryNodeAggregateId, Filte public function countDescendantNodes(NodeAggregateId $entryNodeAggregateId, Filter\CountDescendantNodesFilter $filter): int { if ($this->debug) \Neos\Flow\var_dump('countDescendantNodes called with entryNodeAggregateId: ' . $entryNodeAggregateId->value); - $result = $this->client->runStatement( - Statement::create( - 'MATCH (:Node {aggregateId: $aggregateId})<-[:IS_CHILD*1.. {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]-(descendant:Node) RETURN count(DISTINCT descendant) as count', - [ - 'aggregateId' => $entryNodeAggregateId->value, - 'contentStreamId' => $this->contentStreamId->value, - 'dimensionSpacePointHash' => $this->dimensionSpacePoint->hash, - ] - ) - ); - - if ($result->isEmpty() || !$result->hasKey(0)) { - return 0; - } - return $result->getAsCypherMap(0)->getAsInt('count'); + $findDescendantNodesFilter = Filter\FindDescendantNodesFilter::create($filter->nodeTypes,$filter->searchTerm, $filter->propertyValue); + return $this->findDescendantNodes($entryNodeAggregateId, $findDescendantNodesFilter)->count(); } public function findSubtree(NodeAggregateId $entryNodeAggregateId, Filter\FindSubtreeFilter $filter): ?Subtree @@ -589,23 +616,19 @@ public function findSubtree(NodeAggregateId $entryNodeAggregateId, Filter\FindSu $this->contentStreamId, $this->dimensionSpacePoint, $entryNodeAggregateId, + relationAlias: 'nodeRel' ) - ->match(sprintf('path = (n:Node)<-[rels:IS_CHILD*0..%d]-(descendant)', $maxLevels)); + ->withVisibilityConstraints($this->visibilityConstraints, 'nodeRel') + ->optionalMatch(sprintf('path = (n:Node)<-[rels:IS_CHILD*0..%d]-(descendant)', $maxLevels)); $query->whereAll( - function(QueryBuilder $qb) { + fn(NodeQueryBuilder $qb) => $qb - ->where('r.contentStreamId = $contentStreamId') + ->where('rel.contentStreamId = $contentStreamId') ->withParameter('contentStreamId', $this->contentStreamId->value) - ->where('r.dimensionSpacePointHash = $dimensionSpacePointHash') - ->withParameter('dimensionSpacePointHash', $this->dimensionSpacePoint->hash); - foreach ($this->visibilityConstraints as $constraint) { - foreach ($constraint as $subtreeTag) { - $qb->where(sprintf('COALESCE(apoc.convert.fromJsonMap(r.subtreeTags).%s, false) <> true', $subtreeTag->value)); - } - } - return $qb; - }, + ->where('rel.dimensionSpacePointHash = $dimensionSpacePointHash') + ->withParameter('dimensionSpacePointHash', $this->dimensionSpacePoint->hash) + ->withVisibilityConstraints($this->visibilityConstraints) ); if (!empty($filter->nodeTypes)) { @@ -618,17 +641,28 @@ function(QueryBuilder $qb) { $query->whereNodeTypeIn($expandedNodeTypeCriteria->explicitlyDisallowedNodeTypeNames, 'descendant', 'disallowedNodeTypeNames', negate: true); } } - $query->returns('path'); + + $query->returns('path,n'); $result = $this->client->runStatement($query->build()); if ($result->isEmpty()) { return null; } + $path = $result->getAsCypherMap(0)->get('path'); + if (empty($path)) { + $node = $result->getAsCypherMap(0)->getAsNode('n'); + return Subtree::create(0, $this->nodeFactory->mapResultToNode( + $node, + $this->workspaceName, + $this->dimensionSpacePoint, + $this->visibilityConstraints, + ), Subtrees::createEmpty()); + } return $this->transformPathsToSubtree($result, $filter); } - private function transformPathsToSubtree(SummarizedResult $result, Filter\FindSubtreeFilter $filter): ?Subtree + private function transformPathsToSubtree(SummarizedResult|CypherList $result, Filter\FindSubtreeFilter $filter): ?Subtree { // Build a tree structure from the paths $nodesByAggregateId = []; @@ -785,7 +819,9 @@ public function findReferences(NodeAggregateId $nodeAggregateId, Filter\FindRefe $this->dimensionSpacePoint, $this->visibilityConstraints, $map->getAsRelationship('ref'), - )), $result->toArray())); + )), + $result->toArray()) + ); } @@ -793,7 +829,7 @@ public function countReferences(NodeAggregateId $nodeAggregateId, Filter\CountRe { if ($this->debug) \Neos\Flow\var_dump('countReferences called with nodeAggregateId: ' . $nodeAggregateId->value); $query = $this->getReferencesQuery(false, $nodeAggregateId, $filter); - $query->returns('COUNT(DISTINCT target AS count'); + $query->returns('COUNT(ref) AS count'); return $this->client->runStatement($query->build())->getAsCypherMap(0)->getAsInt('count'); } @@ -817,7 +853,7 @@ public function countBackReferences(NodeAggregateId $nodeAggregateId, Filter\Cou { if ($this->debug) \Neos\Flow\var_dump('countBackReferences called with nodeAggregateId: ' . $nodeAggregateId->value); $query = $this->getReferencesQuery(true, $nodeAggregateId, $filter); - $query->returns('COUNT(DISTINCT target AS count'); + $query->returns('COUNT(DISTINCT target) AS count'); return $this->client->runStatement($query->build())->getAsCypherMap(0)->getAsInt('count'); } @@ -835,12 +871,8 @@ private function getReferencesQuery( $query->match('(source)-[sourceChild:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->(:Node)'); $query->match('(target)-[targetChild:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->(:Node)'); - foreach ($this->visibilityConstraints as $constraint) { - foreach ($constraint as $subtreeTag) { - $query->where(sprintf('COALESCE(apoc.convert.fromJsonMap(sourceChild.subtreeTags).%s, false) <> true', $subtreeTag->value)); - $query->where(sprintf('COALESCE(apoc.convert.fromJsonMap(targetChild.subtreeTags).%s, false) <> true', $subtreeTag->value)); - } - } + $query->withVisibilityConstraints($this->visibilityConstraints, 'sourceChild'); + $query->withVisibilityConstraints($this->visibilityConstraints, 'targetChild'); $query->match('(source)-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->(:Node)'); $query->match('(target)-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->(:Node)'); @@ -861,7 +893,7 @@ private function getReferencesQuery( } if ($filter->nodeSearchTerm) { - $query->where('(target.name CONTAINS $searchTerm OR any(prop in keys(target.properties) WHERE target.properties[prop] CONTAINS $searchTerm))') + $query->where('(target.name CONTAINS $searchTerm OR any(prop in keys(apoc.convert.fromJsonMap(target.properties)) WHERE toLower(toString(apoc.convert.fromJsonMap(target.properties)[prop]["value"])) CONTAINS $searchTerm))') ->withParameter('searchTerm', $filter->nodeSearchTerm->term); } if ($filter->nodePropertyValue) { @@ -874,7 +906,7 @@ private function getReferencesQuery( } } if ($filter->referenceSearchTerm) { - $query->where('(target.name CONTAINS $referenceSearchTerm OR any(prop in keys(target.properties) WHERE target.properties[prop] CONTAINS $referenceSearchTerm))') + $query->where('(ref.name CONTAINS $referenceSearchTerm OR any(prop in keys(apoc.convert.fromJsonMap(ref.properties)) WHERE toLower(toString(apoc.convert.fromJsonMap(ref.properties)[prop]["value"])) CONTAINS $referenceSearchTerm))') ->withParameter('referenceSearchTerm', $filter->referenceSearchTerm->term); } if ($filter->referencePropertyValue) { @@ -898,10 +930,11 @@ private function getReferencesQuery( } elseif ($filter->referenceName === null) { $query->orderBy('ref.referenceName'); } - $query->orderBy('ref.position') - ->orderBy('target.aggregateid'); + $query + ->orderBy('ref.position') + ->orderBy('source.aggregateId', 'DESC'); if ($filter->pagination !== null) { - $query->limit($filter->pagination->limit) + $query->limit($filter->pagination->limit + $filter->pagination->offset) ->skip($filter->pagination->offset); } } @@ -932,13 +965,15 @@ private function findNodeByPathFromStartingNode(NodePath $path, Node|NodeAggrega ->matchNodeForSubgraph( $this->contentStreamId, $this->dimensionSpacePoint, - $startingNode, + $startingNode instanceof NodeAggregateId ? $startingNode : $startingNode->aggregateId, nodeAlias: 'startingNode', ); + $highestIndex = -1; + if ($path->getLength() > 0) { + $query->rawClause('MATCH path = '); $lastNodeAlias = 'startingNode'; - $highestIndex = -1; foreach ($path->getParts() as $part) { if ($part->value === 'sites') continue; $highestIndex++; @@ -951,16 +986,15 @@ private function findNodeByPathFromStartingNode(NodePath $path, Node|NodeAggrega ->where(sprintf('childNode%s.name = $name%s', $highestIndex, $highestIndex)) ->withParameter('name' . $highestIndex, $part->value) ->where(sprintf('childRel%s.contentStreamId = $contentStreamId', $highestIndex)) - ->where(sprintf('childRel%s.dimensionSpacePointHash = $dimensionSpacePointHash', $highestIndex)); - foreach ($this->visibilityConstraints as $constraint) { - foreach ($constraint as $subtreeTag) { - $query->where(sprintf('COALESCE(apoc.convert.fromJsonMap(childRel%s.subtreeTags).%s, false) <> true', $highestIndex, $subtreeTag->value)); - } - } + ->where(sprintf('childRel%s.dimensionSpacePointHash = $dimensionSpacePointHash', $highestIndex)) + ->withVisibilityConstraints($this->visibilityConstraints, sprintf('childRel%s', $highestIndex)); $lastNodeAlias = sprintf('childNode%s', $highestIndex); } - $query->returns(sprintf('childNode%s as node, childRel%s as rel', $highestIndex, $highestIndex)); + $query->returns(sprintf('childNode%s as node, childRel%s as rel', $highestIndex, $highestIndex)); + } else { + $query->returns('startingNode as node, rel'); + } $result = $this->client->runStatement($query->build()); @@ -979,14 +1013,75 @@ private function findNodeByPathFromStartingNode(NodePath $path, Node|NodeAggrega public function retrieveNodePath(NodeAggregateId $nodeAggregateId): AbsoluteNodePath { if ($this->debug) \Neos\Flow\var_dump('retrieveNodePath called with nodeAggregateId: ' . $nodeAggregateId->value); - // TODO: Implement retrieveNodePath() method. - throw new \RuntimeException('Not implemented yet'); + $result = $this->client->runStatement( + NodeQueryBuilder::createForNodes() + ->matchNodeForSubgraph( + $this->contentStreamId, + $this->dimensionSpacePoint, + $nodeAggregateId, + ) + ->rawClauseBuilder(fn(NodeQueryBuilder $qb) => $qb->withVisibilityConstraints($this->visibilityConstraints)) + ->optionalMatch('path = (root:Node)<-[rels:IS_CHILD*0..]-(n)') + ->whereAll(fn(NodeQueryBuilder $qb) => + $qb + ->withVisibilityConstraints($this->visibilityConstraints) + ->where('rel.contentStreamId = $contentStreamId') + ->where('rel.dimensionSpacePointHash = $dimensionSpacePointHash') + ) + ->with('n,path, length(path) as pathLength') + ->orderBy('pathLength', 'DESC') + ->limit(1) + ->returns('n,path') + ->build() + ); + if ($result->isEmpty()) { + throw new \InvalidArgumentException( + 'Failed to retrieve node path for node "' . $nodeAggregateId->value . '"', + 1758028693, + ); + } + if (!$result->hasKey(0)) { + throw new \InvalidArgumentException( + 'Failed to retrieve node path for node "' . $nodeAggregateId->value . '"', + 1758028693, + ); + } + $resultMap = $result->getAsCypherMap(0); + if (!$resultMap->hasKey('path') || $resultMap->get('path') === null) { + if ($resultMap->hasKey('n')) { + $nodes = CypherList::fromIterable([$resultMap->getAsNode('n')]); + } else { + throw new \InvalidArgumentException( + 'Failed to retrieve node path for node "' . $nodeAggregateId->value . '"', + 1758032572, + ); + } + } else { + $nodes = $resultMap->getAsPath('path')->getNodes(); + } + $path = ['']; + /** @var \Laudis\Neo4j\Types\Node $node */ + foreach ($nodes as $node) { + if ($node->getProperties()->offsetExists('name') && !empty($node->getProperty('name'))) { + $path[] = $node->getProperty('name'); + } elseif ( + $node->getProperties()->offsetExists('classification') && $node->getProperty('classification') === 'root' && + $node->getProperties()->offsetExists('nodeTypeName') && !empty($node->getProperty('nodeTypeName')) + ) { + $path[] = sprintf('<%s>', $node->getProperty('nodeTypeName')); + } else { + throw new \InvalidArgumentException( + 'Failed to retrieve node path for node "' . $nodeAggregateId->value . '"', + 1758030449, + ); + } + } + return AbsoluteNodePath::fromString(implode('/', $path)); } public function countNodes(): int { if ($this->debug) \Neos\Flow\var_dump('countNodes called'); - /** @var SummarizedResult $result */ $result = $this->client->runStatement( Statement::create( 'MATCH (n)-[:IS_CHILD {contentStreamId: $contentStreamId, dimensionSpacePointHash: $dimensionSpacePointHash}]->() RETURN count(DISTINCT n) AS nodeCount', @@ -1000,6 +1095,69 @@ public function countNodes(): int return $result->getAsCypherMap(0)->getAsInt('nodeCount'); } + /** + * Builds the base query for finding descendant nodes similar to the Doctrine CTE approach. + * Uses recursive pattern matching with proper level calculation and position tracking. + */ + private function buildDescendantNodesQuery(NodeAggregateId $entryNodeAggregateId, Filter\FindDescendantNodesFilter $filter): NodeQueryBuilder + { + // Build a query that finds all descendant nodes with their hierarchy level and position + // This mimics the Doctrine CTE approach but uses Neo4j's recursive pattern matching + $query = NodeQueryBuilder::createForNodes() + ->match('(entry:Node {aggregateId: $entryNodeAggregateId})') + ->withParameter('entryNodeAggregateId', $entryNodeAggregateId->value) + // Match all descendant paths starting from entry node + ->match('path = (entry)<-[rels:IS_CHILD*1..]-(descendant:Node)') + // Ensure all relationships in the path belong to the correct content stream and dimension + ->whereAll(fn(NodeQueryBuilder $qb) => + $qb + ->where('rel.contentStreamId = $contentStreamId') + ->withParameter('contentStreamId', $this->contentStreamId->value) + ->where('rel.dimensionSpacePointHash = $dimensionSpacePointHash') + ->withParameter('dimensionSpacePointHash', $this->dimensionSpacePoint->hash) + ->withVisibilityConstraints($this->visibilityConstraints) + ); + + // Add node type filtering if specified + if (!empty($filter->nodeTypes)) { + $expandedNodeTypeCriteria = ExpandedNodeTypeCriteria::create($filter->nodeTypes, $this->nodeTypeManager); + + if (!$expandedNodeTypeCriteria->explicitlyAllowedNodeTypeNames->isEmpty()) { + $query->whereNodeTypeIn($expandedNodeTypeCriteria->explicitlyAllowedNodeTypeNames, 'descendant'); + } + if (!$expandedNodeTypeCriteria->explicitlyDisallowedNodeTypeNames->isEmpty()) { + $query->whereNodeTypeIn($expandedNodeTypeCriteria->explicitlyDisallowedNodeTypeNames, 'descendant', 'disallowedNodeTypeNames', negate: true); + } + } + + // Add search term filtering if specified + if (!empty($filter->searchTerm) && $filter->searchTerm->term !== '') { + $query + ->where('(descendant.name CONTAINS $searchTerm OR any(prop in keys(apoc.convert.fromJsonMap(descendant.properties)) WHERE toLower(toString(apoc.convert.fromJsonMap(descendant.properties)[prop]["value"])) CONTAINS $searchTerm))') + ->withParameter('searchTerm', mb_strtolower($filter->searchTerm->term)); + } + + // Add property value filtering if specified - apply to ALL nodes in the path + if (!empty($filter->propertyValue)) { + $propertyParams = []; + $pathPropertyCondition = $this->buildPathPropertyValueCondition($filter->propertyValue, $propertyParams); + if ($pathPropertyCondition !== null) { + $query + ->where($pathPropertyCondition) + ->withParameters($propertyParams); + } + } + + // Calculate level (distance from entry node) and position + // Level = length of path - 1 (since path includes the entry node) + // Position = the position property of the last relationship in the path (the one leading to this descendant) + + $query->with('descendant, (length(path) - 1) AS level, last(rels).position AS position'); + $query->returns('descendant, level, position'); + + return $query; + } + private function buildPropertyValueCondition(Filter\PropertyValue\Criteria\PropertyValueCriteriaInterface $criteria, string $nodeVariable, array &$queryParams): ?string { return match (get_class($criteria)) { @@ -1021,48 +1179,48 @@ private function buildPropertyValueCondition(Filter\PropertyValue\Criteria\Prope private function buildPropertyValueEqualsCondition(Filter\PropertyValue\Criteria\PropertyValueEquals $criteria, string $nodeVariable, array &$queryParams): string { $paramName = 'propValue_' . uniqid(); - $queryParams[$paramName] = $criteria->value; + $queryParams[$paramName] = is_numeric($criteria->value) ? (string)$criteria->value : (is_bool($criteria->value) ? ($criteria->value === true ? 'true' : 'false') : $criteria->value); if ($criteria->caseSensitive) { - return sprintf('%s.properties["%s"] = $%s', $nodeVariable, $criteria->propertyName->value, $paramName); + return sprintf('toString(apoc.convert.fromJsonMap(%s.properties)["%s"]["value"]) = $%s', $nodeVariable, $criteria->propertyName->value, $paramName); } else { - return sprintf('toLower(toString(%s.properties["%s"])) = toLower(toString($%s))', $nodeVariable, $criteria->propertyName->value, $paramName); + return sprintf('toString(toLower(apoc.convert.fromJsonMap(%s.properties)["%s"]["value"])) = toLower(toString($%s))', $nodeVariable, $criteria->propertyName->value, $paramName); } } private function buildPropertyValueContainsCondition(Filter\PropertyValue\Criteria\PropertyValueContains $criteria, string $nodeVariable, array &$queryParams): string { $paramName = 'propValue_' . uniqid(); - $queryParams[$paramName] = $criteria->value; + $queryParams[$paramName] = (string)$criteria->value; if ($criteria->caseSensitive) { - return sprintf('toString(%s.properties["%s"]) CONTAINS $%s', $nodeVariable, $criteria->propertyName->value, $paramName); + return sprintf('toString(apoc.convert.fromJsonMap(%s.properties)["%s"]["value"]) CONTAINS $%s', $nodeVariable, $criteria->propertyName->value, $paramName); } else { - return sprintf('toLower(toString(%s.properties["%s"])) CONTAINS toLower($%s)', $nodeVariable, $criteria->propertyName->value, $paramName); + return sprintf('toString(toLower(apoc.convert.fromJsonMap(%s.properties)["%s"]["value"])) CONTAINS toLower($%s)', $nodeVariable, $criteria->propertyName->value, $paramName); } } private function buildPropertyValueStartsWithCondition(Filter\PropertyValue\Criteria\PropertyValueStartsWith $criteria, string $nodeVariable, array &$queryParams): string { $paramName = 'propValue_' . uniqid(); - $queryParams[$paramName] = $criteria->value; + $queryParams[$paramName] = (string)$criteria->value; if ($criteria->caseSensitive) { - return sprintf('toString(%s.properties["%s"]) STARTS WITH $%s', $nodeVariable, $criteria->propertyName->value, $paramName); + return sprintf('toString(apoc.convert.fromJsonMap(%s.properties)["%s"]["value"]) STARTS WITH $%s', $nodeVariable, $criteria->propertyName->value, $paramName); } else { - return sprintf('toLower(toString(%s.properties["%s"])) STARTS WITH toLower($%s)', $nodeVariable, $criteria->propertyName->value, $paramName); + return sprintf('toString(toLower(apoc.convert.fromJsonMap(%s.properties)["%s"]["value"])) STARTS WITH toLower($%s)', $nodeVariable, $criteria->propertyName->value, $paramName); } } private function buildPropertyValueEndsWithCondition(Filter\PropertyValue\Criteria\PropertyValueEndsWith $criteria, string $nodeVariable, array &$queryParams): string { $paramName = 'propValue_' . uniqid(); - $queryParams[$paramName] = $criteria->value; + $queryParams[$paramName] = (string)$criteria->value; if ($criteria->caseSensitive) { - return sprintf('toString(%s.properties["%s"]) ENDS WITH $%s', $nodeVariable, $criteria->propertyName->value, $paramName); + return sprintf('toString(apoc.convert.fromJsonMap(%s.properties)["%s"]["value"]) ENDS WITH $%s', $nodeVariable, $criteria->propertyName->value, $paramName); } else { - return sprintf('toLower(toString(%s.properties["%s"])) ENDS WITH toLower($%s)', $nodeVariable, $criteria->propertyName->value, $paramName); + return sprintf('toString(toLower(apoc.convert.fromJsonMap(%s.properties)["%s"]["value"])) ENDS WITH toLower($%s)', $nodeVariable, $criteria->propertyName->value, $paramName); } } @@ -1071,7 +1229,7 @@ private function buildPropertyValueGreaterThanCondition(Filter\PropertyValue\Cri $paramName = 'propValue_' . uniqid(); $queryParams[$paramName] = $criteria->value; - return sprintf('%s.properties["%s"] > $%s', $nodeVariable, $criteria->propertyName->value, $paramName); + return sprintf('apoc.convert.fromJsonMap(%s.properties)["%s"]["value"] > $%s', $nodeVariable, $criteria->propertyName->value, $paramName); } private function buildPropertyValueGreaterThanOrEqualCondition(Filter\PropertyValue\Criteria\PropertyValueGreaterThanOrEqual $criteria, string $nodeVariable, array &$queryParams): string @@ -1079,7 +1237,7 @@ private function buildPropertyValueGreaterThanOrEqualCondition(Filter\PropertyVa $paramName = 'propValue_' . uniqid(); $queryParams[$paramName] = $criteria->value; - return sprintf('%s.properties["%s"] >= $%s', $nodeVariable, $criteria->propertyName->value, $paramName); + return sprintf('apoc.convert.fromJsonMap(%s.properties)["%s"]["value"] >= $%s', $nodeVariable, $criteria->propertyName->value, $paramName); } private function buildPropertyValueLessThanCondition(Filter\PropertyValue\Criteria\PropertyValueLessThan $criteria, string $nodeVariable, array &$queryParams): string @@ -1087,7 +1245,7 @@ private function buildPropertyValueLessThanCondition(Filter\PropertyValue\Criter $paramName = 'propValue_' . uniqid(); $queryParams[$paramName] = $criteria->value; - return sprintf('%s.properties["%s"] < $%s', $nodeVariable, $criteria->propertyName->value, $paramName); + return sprintf('apoc.convert.fromJsonMap(%s.properties)["%s"]["value"] < $%s', $nodeVariable, $criteria->propertyName->value, $paramName); } private function buildPropertyValueLessThanOrEqualCondition(Filter\PropertyValue\Criteria\PropertyValueLessThanOrEqual $criteria, string $nodeVariable, array &$queryParams): string @@ -1095,7 +1253,7 @@ private function buildPropertyValueLessThanOrEqualCondition(Filter\PropertyValue $paramName = 'propValue_' . uniqid(); $queryParams[$paramName] = $criteria->value; - return sprintf('%s.properties["%s"] <= $%s', $nodeVariable, $criteria->propertyName->value, $paramName); + return sprintf('apoc.convert.fromJsonMap(%s.properties)["%s"]["value"] <= $%s', $nodeVariable, $criteria->propertyName->value, $paramName); } private function buildAndCriteriaCondition(Filter\PropertyValue\Criteria\AndCriteria $criteria, string $nodeVariable, array &$queryParams): string @@ -1136,10 +1294,151 @@ private function buildNegateCriteriaCondition(Filter\PropertyValue\Criteria\Nega return $condition !== null ? 'NOT (' . $condition . ')' : ''; } + /** + * Builds a property value condition that applies to ALL nodes in a path. + * This ensures that a descendant node is only included if all nodes in its path + * from the entry node satisfy the property criteria. + */ + private function buildPathPropertyValueCondition(Filter\PropertyValue\Criteria\PropertyValueCriteriaInterface $criteria, array &$queryParams): ?string + { + return match (get_class($criteria)) { + Filter\PropertyValue\Criteria\PropertyValueEquals::class => $this->buildPathPropertyValueEqualsCondition($criteria, $queryParams), + Filter\PropertyValue\Criteria\PropertyValueContains::class => $this->buildPathPropertyValueContainsCondition($criteria, $queryParams), + Filter\PropertyValue\Criteria\PropertyValueStartsWith::class => $this->buildPathPropertyValueStartsWithCondition($criteria, $queryParams), + Filter\PropertyValue\Criteria\PropertyValueEndsWith::class => $this->buildPathPropertyValueEndsWithCondition($criteria, $queryParams), + Filter\PropertyValue\Criteria\PropertyValueGreaterThan::class => $this->buildPathPropertyValueGreaterThanCondition($criteria, $queryParams), + Filter\PropertyValue\Criteria\PropertyValueGreaterThanOrEqual::class => $this->buildPathPropertyValueGreaterThanOrEqualCondition($criteria, $queryParams), + Filter\PropertyValue\Criteria\PropertyValueLessThan::class => $this->buildPathPropertyValueLessThanCondition($criteria, $queryParams), + Filter\PropertyValue\Criteria\PropertyValueLessThanOrEqual::class => $this->buildPathPropertyValueLessThanOrEqualCondition($criteria, $queryParams), + Filter\PropertyValue\Criteria\AndCriteria::class => $this->buildPathAndCriteriaCondition($criteria, $queryParams), + Filter\PropertyValue\Criteria\OrCriteria::class => $this->buildPathOrCriteriaCondition($criteria, $queryParams), + Filter\PropertyValue\Criteria\NegateCriteria::class => $this->buildPathNegateCriteriaCondition($criteria, $queryParams), + default => null, + }; + } + + private function buildPathPropertyValueEqualsCondition(Filter\PropertyValue\Criteria\PropertyValueEquals $criteria, array &$queryParams): string + { + $paramName = 'pathPropValue_' . uniqid(); + $queryParams[$paramName] = is_numeric($criteria->value) ? (string)$criteria->value : (is_bool($criteria->value) ? ($criteria->value === true ? 'true' : 'false') : $criteria->value); + + if ($criteria->caseSensitive) { + return sprintf('all(pathNode in nodes(path) WHERE toString(apoc.convert.fromJsonMap(pathNode.properties)["%s"]["value"]) = $%s)', $criteria->propertyName->value, $paramName); + } else { + return sprintf('all(pathNode in nodes(path) WHERE toString(toLower(apoc.convert.fromJsonMap(pathNode.properties)["%s"]["value"])) = toLower(toString($%s)))', $criteria->propertyName->value, $paramName); + } + } + + private function buildPathPropertyValueContainsCondition(Filter\PropertyValue\Criteria\PropertyValueContains $criteria, array &$queryParams): string + { + $paramName = 'pathPropValue_' . uniqid(); + $queryParams[$paramName] = (string)$criteria->value; + + if ($criteria->caseSensitive) { + return sprintf('all(pathNode in nodes(path) WHERE toString(apoc.convert.fromJsonMap(pathNode.properties)["%s"]["value"]) CONTAINS $%s)', $criteria->propertyName->value, $paramName); + } else { + return sprintf('all(pathNode in nodes(path) WHERE toString(toLower(apoc.convert.fromJsonMap(pathNode.properties)["%s"]["value"])) CONTAINS toLower($%s))', $criteria->propertyName->value, $paramName); + } + } + + private function buildPathPropertyValueStartsWithCondition(Filter\PropertyValue\Criteria\PropertyValueStartsWith $criteria, array &$queryParams): string + { + $paramName = 'pathPropValue_' . uniqid(); + $queryParams[$paramName] = (string)$criteria->value; + + if ($criteria->caseSensitive) { + return sprintf('all(pathNode in nodes(path) WHERE toString(apoc.convert.fromJsonMap(pathNode.properties)["%s"]["value"]) STARTS WITH $%s)', $criteria->propertyName->value, $paramName); + } else { + return sprintf('all(pathNode in nodes(path) WHERE toString(toLower(apoc.convert.fromJsonMap(pathNode.properties)["%s"]["value"])) STARTS WITH toLower($%s))', $criteria->propertyName->value, $paramName); + } + } + + private function buildPathPropertyValueEndsWithCondition(Filter\PropertyValue\Criteria\PropertyValueEndsWith $criteria, array &$queryParams): string + { + $paramName = 'pathPropValue_' . uniqid(); + $queryParams[$paramName] = (string)$criteria->value; + + if ($criteria->caseSensitive) { + return sprintf('all(pathNode in nodes(path) WHERE toString(apoc.convert.fromJsonMap(pathNode.properties)["%s"]["value"]) ENDS WITH $%s)', $criteria->propertyName->value, $paramName); + } else { + return sprintf('all(pathNode in nodes(path) WHERE toString(toLower(apoc.convert.fromJsonMap(pathNode.properties)["%s"]["value"])) ENDS WITH toLower($%s))', $criteria->propertyName->value, $paramName); + } + } + + private function buildPathPropertyValueGreaterThanCondition(Filter\PropertyValue\Criteria\PropertyValueGreaterThan $criteria, array &$queryParams): string + { + $paramName = 'pathPropValue_' . uniqid(); + $queryParams[$paramName] = $criteria->value; + + return sprintf('all(pathNode in nodes(path) WHERE apoc.convert.fromJsonMap(pathNode.properties)["%s"]["value"] > $%s)', $criteria->propertyName->value, $paramName); + } + + private function buildPathPropertyValueGreaterThanOrEqualCondition(Filter\PropertyValue\Criteria\PropertyValueGreaterThanOrEqual $criteria, array &$queryParams): string + { + $paramName = 'pathPropValue_' . uniqid(); + $queryParams[$paramName] = $criteria->value; + + return sprintf('all(pathNode in nodes(path) WHERE apoc.convert.fromJsonMap(pathNode.properties)["%s"]["value"] >= $%s)', $criteria->propertyName->value, $paramName); + } + + private function buildPathPropertyValueLessThanCondition(Filter\PropertyValue\Criteria\PropertyValueLessThan $criteria, array &$queryParams): string + { + $paramName = 'pathPropValue_' . uniqid(); + $queryParams[$paramName] = $criteria->value; + + return sprintf('all(pathNode in nodes(path) WHERE apoc.convert.fromJsonMap(pathNode.properties)["%s"]["value"] < $%s)', $criteria->propertyName->value, $paramName); + } + + private function buildPathPropertyValueLessThanOrEqualCondition(Filter\PropertyValue\Criteria\PropertyValueLessThanOrEqual $criteria, array &$queryParams): string + { + $paramName = 'pathPropValue_' . uniqid(); + $queryParams[$paramName] = $criteria->value; + + return sprintf('all(pathNode in nodes(path) WHERE apoc.convert.fromJsonMap(pathNode.properties)["%s"]["value"] <= $%s)', $criteria->propertyName->value, $paramName); + } + + private function buildPathAndCriteriaCondition(Filter\PropertyValue\Criteria\AndCriteria $criteria, array &$queryParams): string + { + $condition1 = $this->buildPathPropertyValueCondition($criteria->criteria1, $queryParams); + $condition2 = $this->buildPathPropertyValueCondition($criteria->criteria2, $queryParams); + + $conditions = []; + if ($condition1 !== null) { + $conditions[] = $condition1; + } + if ($condition2 !== null) { + $conditions[] = $condition2; + } + + return '(' . implode(' AND ', $conditions) . ')'; + } + + private function buildPathOrCriteriaCondition(Filter\PropertyValue\Criteria\OrCriteria $criteria, array &$queryParams): string + { + $condition1 = $this->buildPathPropertyValueCondition($criteria->criteria1, $queryParams); + $condition2 = $this->buildPathPropertyValueCondition($criteria->criteria2, $queryParams); + + $conditions = []; + if ($condition1 !== null) { + $conditions[] = $condition1; + } + if ($condition2 !== null) { + $conditions[] = $condition2; + } + + return '(' . implode(' OR ', $conditions) . ')'; + } + + private function buildPathNegateCriteriaCondition(Filter\PropertyValue\Criteria\NegateCriteria $criteria, array &$queryParams): string + { + $condition = $this->buildPathPropertyValueCondition($criteria->criteria, $queryParams); + return $condition !== null ? 'NOT (' . $condition . ')' : ''; + } + private function buildOrderingFieldExpression(Filter\Ordering\OrderingField $orderingField, string $nodeVariable): ?string { if ($orderingField->field instanceof \Neos\ContentRepository\Core\SharedModel\Node\PropertyName) { - return sprintf('%s.properties["%s"]', $nodeVariable, $orderingField->field->value); + return sprintf('apoc.convert.fromJsonMap(%s.properties)["%s"]["value"]', $nodeVariable, $orderingField->field->value); } elseif ($orderingField->field instanceof Filter\Ordering\TimestampField) { return match ($orderingField->field) { Filter\Ordering\TimestampField::CREATED => $nodeVariable . '.created', diff --git a/Classes/Domain/Repository/Neo4jProjectionContentGraph.php b/Classes/Domain/Repository/Neo4jProjectionContentGraph.php index e0c8d7f..800b8da 100644 --- a/Classes/Domain/Repository/Neo4jProjectionContentGraph.php +++ b/Classes/Domain/Repository/Neo4jProjectionContentGraph.php @@ -44,8 +44,7 @@ public function determineHierarchyRelationPosition( ); if ($succeedingNode->hasKey(0) && $succeedingNode->getAsCypherMap(0)->hasKey('rel')) { $node = $succeedingNode->getAsCypherMap(0)->getAsRelationship('rel'); - /** @var int $succeedingSiblingNodePosition */ - $succeedingSiblingNodePosition = $node->getProperty('position'); + $succeedingSiblingNodePosition = $node->getProperties()->getAsInt('position'); $parentNode = $succeedingNode->getAsCypherMap(0)->getAsNode('p'); $parentNodeAggregateId = NodeAggregateId::fromString($parentNode->getProperty('aggregateId')); $precedingSiblingNode = $this->client->runStatement( @@ -62,7 +61,7 @@ public function determineHierarchyRelationPosition( ->build() ); if ($precedingSiblingNode->hasKey(0) && $precedingSiblingNode->getAsCypherMap(0)->hasKey('rel')) { - $preceedingSiblingNodePosition = $precedingSiblingNode->getAsCypherMap(0)->getAsRelationship('rel')->getProperty('position'); + $preceedingSiblingNodePosition = $precedingSiblingNode->getAsCypherMap(0)->getAsRelationship('rel')->getProperties()->getAsInt('position'); return ($succeedingSiblingNodePosition + $preceedingSiblingNodePosition) / 2; } else { return $succeedingSiblingNodePosition - Neo4jContentGraphProjection::RELATION_DEFAULT_OFFSET; @@ -86,7 +85,7 @@ public function determineHierarchyRelationPosition( ->build() ); if ($childNodeRelationResult->hasKey(0) && $childNodeRelationResult->getAsCypherMap(0)->hasKey('rel')) { - return $childNodeRelationResult->getAsCypherMap(0)->getAsRelationship('rel')->getProperty('position') + Neo4jContentGraphProjection::RELATION_DEFAULT_OFFSET; + return $childNodeRelationResult->getAsCypherMap(0)->getAsRelationship('rel')->getProperties()->getAsInt('position') + Neo4jContentGraphProjection::RELATION_DEFAULT_OFFSET; } else { // WHAT TO DO HERE? } diff --git a/Classes/Domain/Repository/NodeFactory.php b/Classes/Domain/Repository/NodeFactory.php index 96f1cb6..fcd0410 100644 --- a/Classes/Domain/Repository/NodeFactory.php +++ b/Classes/Domain/Repository/NodeFactory.php @@ -191,7 +191,7 @@ public function mapResultToNode( $nodeData = $record; $nodeTags = NodeTags::createEmpty(); } - + return Node::create( $this->contentRepositoryId, $workspaceName, diff --git a/Classes/Neo4jContentGraphProjection.php b/Classes/Neo4jContentGraphProjection.php index e0936a6..c19e374 100644 --- a/Classes/Neo4jContentGraphProjection.php +++ b/Classes/Neo4jContentGraphProjection.php @@ -613,7 +613,7 @@ private function whenNodeAggregateWasRemoved(NodeAggregateWasRemoved $event): vo ); } - private function whenNodeAggregateWithNodeWasCreated( + private function whenNodeAggregateWithNodeWasCreated( NodeAggregateWithNodeWasCreated $event, EventEnvelope $eventEnvelope ): void { @@ -644,7 +644,7 @@ classification: $classification, 'originalCreated' => self::initiatingDateTime($eventEnvelope)->format(DateTimeInterface::ATOM), 'lastModified' => null, 'originalLastModified' => null, - 'properties' => json_encode($event->initialPropertyValues->jsonSerialize()), + 'properties' => $event->initialPropertyValues->count() === 0 ? '{}' : json_encode($event->initialPropertyValues->jsonSerialize()), ] ) ); @@ -678,8 +678,6 @@ classification: $classification, dimensionSpacePoint: $sibling->dimensionSpacePoint, childAggregateId: NodeAggregateId::fromString($newlyCreatedNode->getProperty('aggregateId')), ), - $eventEnvelope->recordedAt, - self::initiatingDateTime($eventEnvelope) ); } } @@ -990,12 +988,12 @@ private function whenNodePropertiesWereSet(NodePropertiesWereSet $event, EventEn $this->client->runStatement( Statement::create( 'MATCH (n) WHERE ID(n) = $nodeId - SET n.properties = $nodeTypeName + SET n.properties = $properties SET n.lastModified = $lastModified SET n.originalLastModified = $originalLastModified', [ 'nodeId' => $affectedNode->getId(), - 'nodeTypeName' => $this->mergeNodeProperties( + 'properties' => $this->mergeNodeProperties( $event->propertyValues, $event->propertiesToUnset, $event->getNodeAggregateId(), @@ -1101,8 +1099,6 @@ private function copyNodeToOrigin(OriginDimensionSpacePoint $sourceOrigin, Origi ->returns('newNode, newRel') ->build() ); - \Neos\Flow\var_dump($statement); - \Neos\Flow\var_dump($result); } private function whenNodeSpecializationVariantWasCreated( NodeSpecializationVariantWasCreated $event, @@ -1154,6 +1150,8 @@ private function whenNodeSpecializationVariantWasCreated( ->setProperty('created', $eventEnvelope->recordedAt->format(DateTimeInterface::ATOM), 'generalizedNode') ->setProperty('originalCreated', self::initiatingDateTime($eventEnvelope)->format(DateTimeInterface::ATOM), 'generalizedNode') + ->setProperty('lastModified', null, 'generalizedNode') + ->setProperty('originalLastModified', null, 'generalizedNode') ->with('generalizedNode, n, p, rel') ->optionalMatch('(generalizedNode)-[generalizedRel:IS_CHILD]-() DELETE generalizedRel') ->returns('*') @@ -1505,6 +1503,9 @@ private function mergeNodeProperties( unset($existingSerializedProperties[$propertyName->value]); } + if ($existingSerializedProperties === []) { + return '{}'; + } return json_encode($existingSerializedProperties); } diff --git a/Classes/Neo4jContentGraphReadModelAdapter.php b/Classes/Neo4jContentGraphReadModelAdapter.php index 1da3a88..34a0973 100644 --- a/Classes/Neo4jContentGraphReadModelAdapter.php +++ b/Classes/Neo4jContentGraphReadModelAdapter.php @@ -7,7 +7,6 @@ use JvMTECH\ContentGraph\Neo4jAdapter\Domain\Repository\NodeFactory; use Laudis\Neo4j\Contracts\ClientInterface; use Laudis\Neo4j\Databags\Statement; -use Laudis\Neo4j\Databags\SummarizedResult; use Laudis\Neo4j\Types\CypherMap; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; @@ -30,12 +29,14 @@ public function __construct( private readonly NodeFactory $nodeFactory, private readonly Neo4jDimensionSpacePointsRepository $dimensionSpacePointsRepository, private readonly NodeTypeManager $nodeTypeManager, + private readonly bool $debug = false, ) { } public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); $result = $this->client->runStatement( Statement::create( 'MATCH (:Workspace {name: $workspaceName})-[:CONTENT_STREAM]->(contentStream:ContentStream) RETURN contentStream.contentStreamId AS contentStreamId', @@ -60,6 +61,7 @@ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInter public function findContentStreamById(ContentStreamId $contentStreamId): ?ContentStream { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__ . ' ' . $contentStreamId->value); $result = $this->client->runStatement( Statement::create( 'MATCH (contentStream:ContentStream {contentStreamId: $contentStreamId}) @@ -86,6 +88,7 @@ public function findContentStreamById(ContentStreamId $contentStreamId): ?Conten public function countNodes(): int { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); $result = $this->client->runStatement( Statement::create('MATCH (n:Node) RETURN count(n) AS nodeCount') ); @@ -97,42 +100,53 @@ public function countNodes(): int public function findWorkspaceByName(WorkspaceName $workspaceName): ?Workspace { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__ . ' ' . $workspaceName->value); $result = $this->client->runStatement( Statement::create( 'MATCH (workspace:Workspace {name: $workspaceName}) OPTIONAL MATCH (workspace)-[:BASE_WORKSPACE]->(baseWorkspace:Workspace) MATCH (workspace)-[:CONTENT_STREAM]->(contentStream:ContentStream) - RETURN workspace, baseWorkspace, contentStream', + OPTIONAL MATCH (contentStream)-[sourceContentStreamRel:SOURCE_CONTENT_STREAM]->(sourceContentStream:ContentStream) + RETURN workspace, baseWorkspace, contentStream, sourceContentStreamRel.sourceContentStreamVersion = sourceContentStream.version AS upToDateWithBase', ['workspaceName' => $workspaceName->value] ) ); - if (!$result->hasKey(0)) { return null; } + $entry = $result->getAsCypherMap(0); $properties = $entry->getAsNode('workspace')->getProperties(); $baseWorkspaceName = $entry->hasKey('baseWorkspace') && $entry->get('baseWorkspace') !== null ? $entry->getAsNode('baseWorkspace')->getProperty('name') : null; $contentStream = $entry->getAsNode('contentStream'); + $upToDateWithBase = $entry->hasKey('upToDateWithBase') ? $entry->get('upToDateWithBase') : false; + if ($baseWorkspaceName === null) { + $status = WorkspaceStatus::UP_TO_DATE; + } elseif ($upToDateWithBase === true) { + $status = WorkspaceStatus::UP_TO_DATE; + } else { + $status = WorkspaceStatus::OUTDATED; + } return Workspace::create( WorkspaceName::fromString($properties['name']), $baseWorkspaceName ? WorkspaceName::fromString($baseWorkspaceName) : null, ContentStreamId::fromString($contentStream->getProperty('contentStreamId')), - $contentStream->getProperty('hasChanges') === 0 ? - WorkspaceStatus::UP_TO_DATE : - WorkspaceStatus::OUTDATED, + $status, $contentStream->getProperty('hasChanges') !== 0 && $baseWorkspaceName !== null, ); } public function findWorkspaces(): Workspaces { + if ($this->debug) \Neos\Flow\var_dump(__METHOD__); $result = $this->client->runStatement( Statement::create( 'MATCH (workspace:Workspace)-[:CONTENT_STREAM]->(contentStream:ContentStream) OPTIONAL MATCH (workspace)-[:BASE_WORKSPACE]->(baseWorkspace:Workspace) - RETURN workspace, baseWorkspace, contentStream', + OPTIONAL MATCH (contentStream)-[sourceContentStreamRel:SOURCE_CONTENT_STREAM]->(sourceContentStream:ContentStream) + RETURN workspace, baseWorkspace, contentStream, sourceContentStreamRel.sourceContentStreamVersion = sourceContentStream.version AS upToDateWithBase + ORDER BY workspace.name', ) ); return Workspaces::fromArray( @@ -140,14 +154,20 @@ public function findWorkspaces(): Workspaces $properties = $entry->getAsNode('workspace')->getProperties(); $baseWorkspaceName = $entry->hasKey('baseWorkspace') && $entry->get('baseWorkspace') !== null ? $entry->getAsNode('baseWorkspace')->getProperty('name') : null; $contentStream = $entry->getAsNode('contentStream'); + $upToDateWithBase = $entry->hasKey('upToDateWithBase') ? $entry->get('upToDateWithBase') : false; + if ($baseWorkspaceName === null) { + $status = WorkspaceStatus::UP_TO_DATE; + } elseif ($upToDateWithBase === true) { + $status = WorkspaceStatus::UP_TO_DATE; + } else { + $status = WorkspaceStatus::OUTDATED; + } return Workspace::create( WorkspaceName::fromString($properties['name']), $baseWorkspaceName ? WorkspaceName::fromString($baseWorkspaceName) : null, ContentStreamId::fromString($contentStream->getProperty('contentStreamId')), - $contentStream->getProperty('hasChanges') === 0 || $baseWorkspaceName !== null ? - WorkspaceStatus::UP_TO_DATE : - WorkspaceStatus::OUTDATED, + $status, $contentStream->getProperty('hasChanges') !== 0 && $baseWorkspaceName !== null, ); })->toArray()