diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/InMemoryContentGraphProjection.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/InMemoryContentGraphProjection.php new file mode 100644 index 00000000000..5604410a05c --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/InMemoryContentGraphProjection.php @@ -0,0 +1,443 @@ +contentStreamRegistry->reset(); + $this->workspaceRegistry->reset(); + $this->graphStructure->reset(); + } + + public function getState(): ContentGraphReadModelInterface + { + return $this->contentGraphReadModel; + } + + public function apply(EventInterface $event, EventEnvelope $eventEnvelope): void + { + match ($event::class) { + ContentStreamWasClosed::class => $this->whenContentStreamWasClosed($event), + ContentStreamWasCreated::class => $this->whenContentStreamWasCreated($event), + ContentStreamWasForked::class => $this->whenContentStreamWasForked($event), + ContentStreamWasRemoved::class => $this->whenContentStreamWasRemoved($event), + ContentStreamWasReopened::class => $this->whenContentStreamWasReopened($event), + DimensionShineThroughWasAdded::class => $this->whenDimensionShineThroughWasAdded($event), + DimensionSpacePointWasMoved::class => $this->whenDimensionSpacePointWasMoved($event), + NodeAggregateNameWasChanged::class => $this->whenNodeAggregateNameWasChanged($event, $eventEnvelope), + NodeAggregateTypeWasChanged::class => $this->whenNodeAggregateTypeWasChanged($event, $eventEnvelope), + NodeAggregateWasMoved::class => $this->whenNodeAggregateWasMoved($event), + NodeAggregateWasRemoved::class => $this->whenNodeAggregateWasRemoved($event), + NodeAggregateWithNodeWasCreated::class => $this->whenNodeAggregateWithNodeWasCreated($event, $eventEnvelope), + NodeGeneralizationVariantWasCreated::class => $this->whenNodeGeneralizationVariantWasCreated($event, $eventEnvelope), + NodePeerVariantWasCreated::class => $this->whenNodePeerVariantWasCreated($event, $eventEnvelope), + NodePropertiesWereSet::class => $this->whenNodePropertiesWereSet($event, $eventEnvelope), + NodeReferencesWereSet::class => $this->whenNodeReferencesWereSet($event, $eventEnvelope), + NodeSpecializationVariantWasCreated::class => $this->whenNodeSpecializationVariantWasCreated($event, $eventEnvelope), + RootNodeAggregateDimensionsWereUpdated::class => $this->whenRootNodeAggregateDimensionsWereUpdated($event), + RootNodeAggregateWithNodeWasCreated::class => $this->whenRootNodeAggregateWithNodeWasCreated($event, $eventEnvelope), + RootWorkspaceWasCreated::class => $this->whenRootWorkspaceWasCreated($event), + SubtreeWasTagged::class => $this->whenSubtreeWasTagged($event), + SubtreeWasUntagged::class => $this->whenSubtreeWasUntagged($event), + WorkspaceBaseWorkspaceWasChanged::class => $this->whenWorkspaceBaseWorkspaceWasChanged($event), + WorkspaceRebaseFailed::class => $this->whenWorkspaceRebaseFailed($event), + WorkspaceWasCreated::class => $this->whenWorkspaceWasCreated($event), + WorkspaceWasDiscarded::class => $this->whenWorkspaceWasDiscarded($event), + WorkspaceWasPublished::class => $this->whenWorkspaceWasPublished($event), + WorkspaceWasRebased::class => $this->whenWorkspaceWasRebased($event), + WorkspaceWasRemoved::class => $this->whenWorkspaceWasRemoved($event), + default => null, + }; + if ( + $event instanceof EmbedsContentStreamId + && ContentStreamEventStreamName::isContentStreamStreamName($eventEnvelope->streamName) + && !( + // special case as we don't need to update anything. The handling above takes care of setting the version to 0 + $event instanceof ContentStreamWasForked + || $event instanceof ContentStreamWasCreated + ) + ) { + $this->contentStreamRegistry->updateContentStreamVersion($event->getContentStreamId(), $eventEnvelope->version, $event instanceof PublishableToWorkspaceInterface); + } + } + + public function inSimulation(\Closure $fn): mixed + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + /** @todo just copy stuff, apply stuff and roll back or keep stuff */ + } + + private function whenContentStreamWasClosed(ContentStreamWasClosed $event): void + { + $this->contentStreamRegistry->closeContentStream($event->contentStreamId); + } + + private function whenContentStreamWasCreated(ContentStreamWasCreated $event): void + { + $this->contentStreamRegistry->createContentStream($event->contentStreamId); + } + + private function whenContentStreamWasForked(ContentStreamWasForked $event): void + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + /** @todo copy hierarchy relations if they are implemented */ + #$this->contentStreamRegistry->createContentStream($event->newContentStreamId, $event->sourceContentStreamId, $event->versionOfSourceContentStream); + } + + private function whenContentStreamWasRemoved(ContentStreamWasRemoved $event): void + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + /** @todo remove hierarchy relations if they are implemented */ + #$this->contentStreamRegistry->removeContentStream($event->contentStreamId); + } + + private function whenContentStreamWasReopened(ContentStreamWasReopened $event): void + { + $this->contentStreamRegistry->reopenContentStream($event->contentStreamId); + } + + private function whenDimensionShineThroughWasAdded(DimensionShineThroughWasAdded $event): void + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + /** @todo add hierarchy relations if they are implemented */ + } + + private function whenDimensionSpacePointWasMoved(DimensionSpacePointWasMoved $event): void + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + // the ordering is important - we first update the OriginDimensionSpacePoints, as we need the + // hierarchy relations for this query. Then, we update the Hierarchy Relations. + + // 1) originDimensionSpacePoint on Node + /** @todo adjust nodes with copy on write */ + + // 2) hierarchy relations + /** @todo adjust hierarchy relations */ + } + + private function whenNodeAggregateNameWasChanged(NodeAggregateNameWasChanged $event, EventEnvelope $eventEnvelope): void + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + /** @todo adjust nodes with copy on write */ + } + + private function whenNodeAggregateTypeWasChanged(NodeAggregateTypeWasChanged $event, EventEnvelope $eventEnvelope): void + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + /** @todo adjust nodes with copy on write */ + } + + private function whenNodeAggregateWasMoved(NodeAggregateWasMoved $event): void + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + /** @todo restructure in-memory graph structure */ + } + + private function whenNodeAggregateWasRemoved(NodeAggregateWasRemoved $event): void + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + /** @todo recursively remove stuff */ + } + + private function whenRootNodeAggregateWithNodeWasCreated(RootNodeAggregateWithNodeWasCreated $event, EventEnvelope $eventEnvelope): void + { + $origin = OriginDimensionSpacePoint::createWithoutDimensions(); + $nodeRecord = new InMemoryNodeRecord( + $event->nodeAggregateId, + $origin, + SerializedPropertyValues::createEmpty(), + $event->nodeTypeName, + $event->nodeAggregateClassification, + null, + Timestamps::create($eventEnvelope->recordedAt, self::resolveInitiatingDateTime($eventEnvelope), null, null), + [], + [], + SubtreeTags::createEmpty(), + SubtreeTags::createEmpty(), + ); + $parentRelation = new InMemoryHierarchyHyperrelationRecordSet(); + foreach ($event->coveredDimensionSpacePoints as $coveredDimensionSpacePoint) { + $parentRelation->attach( + new InMemoryHierarchyHyperrelationRecord( + null, $coveredDimensionSpacePoint, InMemoryNodeRecords::create($nodeRecord) + ), + ); + } + $nodeRecord->parentsByContentStreamId[$event->contentStreamId->value] = $parentRelation; + $nodeRecord->childrenByContentStream[$event->contentStreamId->value] = new InMemoryHierarchyHyperrelationRecordSet(); + + $this->graphStructure->rootNodes[$event->contentStreamId->value][$event->nodeTypeName->value] = $nodeRecord; + $this->graphStructure->addNodeRecord($nodeRecord, $event->contentStreamId); + } + + private function whenNodeAggregateWithNodeWasCreated(NodeAggregateWithNodeWasCreated $event, EventEnvelope $eventEnvelope): void + { + $parentRelations = new InMemoryHierarchyHyperrelationRecordSet(); + $parentNodeRecords = $this->graphStructure->nodes[$event->contentStreamId->value][$event->parentNodeAggregateId->value] ?? null; + if ($parentNodeRecords === null) { + throw new \RuntimeException('Failed to resolve parent node records', 1745180042); + } + + $nodeRecord = new InMemoryNodeRecord( + $event->nodeAggregateId, + $event->originDimensionSpacePoint, + $event->initialPropertyValues, + $event->nodeTypeName, + $event->nodeAggregateClassification, + $event->nodeName, + Timestamps::create($eventEnvelope->recordedAt, self::resolveInitiatingDateTime($eventEnvelope), null, null), + [ + $event->contentStreamId->value => $parentRelations, + ], + [ + $event->contentStreamId->value => new InMemoryHierarchyHyperrelationRecordSet(), + ], + SubtreeTags::createEmpty(), + SubtreeTags::createEmpty(), + ); + + foreach ($parentNodeRecords as $parentNodeRecord) { + $relevantDimensionSpacePoints = $parentNodeRecord->getCoveredDimensionSpacePointSet($event->contentStreamId) + ->getIntersection($event->succeedingSiblingsForCoverage->toDimensionSpacePointSet()); + foreach ($relevantDimensionSpacePoints as $relevantDimensionSpacePoint) { + $parentRelation = $parentNodeRecord->childrenByContentStream[$event->contentStreamId->value]->getHierarchyHyperrelation($relevantDimensionSpacePoint); + if ($parentRelation === null) { + $parentRelation = new InMemoryHierarchyHyperrelationRecord( + $parentNodeRecord, + $relevantDimensionSpacePoint, + InMemoryNodeRecords::create($nodeRecord), + ); + $parentNodeRecord->childrenByContentStream[$event->contentStreamId->value]->attach($parentRelation); + } else { + $parentRelation->children->insert($nodeRecord, $event->succeedingSiblingsForCoverage->getSucceedingSiblingIdForDimensionSpacePoint($relevantDimensionSpacePoint)); + } + $parentRelations->attach($parentRelation); + } + } + + $this->graphStructure->addNodeRecord($nodeRecord, $event->contentStreamId); + } + + private function whenNodeSpecializationVariantWasCreated(NodeSpecializationVariantWasCreated $event, EventEnvelope $eventEnvelope): void + { + $originRecord = $this->graphStructure->nodes[$event->contentStreamId->value][$event->nodeAggregateId->value][$event->sourceOrigin->hash] ?? null; + if ($originRecord === null) { + throw new \RuntimeException('Failed to resolve origin node record', 1745229966); + } + $originParentRecord = $originRecord->parentsByContentStreamId[$event->contentStreamId->value] + ->getHierarchyHyperrelation($originRecord->originDimensionSpacePoint->toDimensionSpacePoint()) + ->parent; + if ($originParentRecord === null) { + throw new \RuntimeException('Failed to resolve parent node record of origin', 1745361216); + } + $parentNodeRecords = $this->graphStructure->nodes[$event->contentStreamId->value][$originParentRecord->nodeAggregateId->value] ?? null; + if ($parentNodeRecords === null) { + throw new \RuntimeException('Failed to resolve parent node records', 1745180042); + } + $parentRelations = new InMemoryHierarchyHyperrelationRecordSet(); + + $aggregateNodeRecords = $this->graphStructure->nodes[$event->contentStreamId->value][$event->nodeAggregateId->value]; + $childRelations = new InMemoryHierarchyHyperrelationRecordSet(); + + $specializationRecord = new InMemoryNodeRecord( + $event->nodeAggregateId, + $event->specializationOrigin, + $originRecord->properties, + $originRecord->nodeTypeName, + $originRecord->classification, + $originRecord->name, + Timestamps::create($eventEnvelope->recordedAt, self::resolveInitiatingDateTime($eventEnvelope), null, null), + [ + $event->contentStreamId->value => $parentRelations, + ], + [ + $event->contentStreamId->value => $childRelations, + ], + SubtreeTags::createEmpty(), + SubtreeTags::createEmpty(), + ); + + foreach ($event->specializationSiblings as $specializationSibling) { + // reassign child relations of the specialization's new parents + foreach ($parentNodeRecords as $parentNodeRecord) { + if ($parentNodeRecord->coversDimensionSpacePoint($event->contentStreamId, $specializationSibling->dimensionSpacePoint)) { + $specializationParentRelation = $parentNodeRecord->childrenByContentStream[$event->contentStreamId->value] + ->getHierarchyHyperrelation($specializationSibling->dimensionSpacePoint); + if ($specializationParentRelation) { + $specializationParentRelation->children->removeIfContained($originRecord); + $specializationParentRelation->children->insert($specializationRecord, $specializationSibling->nodeAggregateId); + $originRecord->parentsByContentStreamId[$event->contentStreamId->value]->detach($specializationParentRelation); + } else { + $specializationParentRelation = new InMemoryHierarchyHyperrelationRecord( + $parentNodeRecord, + $specializationSibling->dimensionSpacePoint, + InMemoryNodeRecords::create($specializationRecord) + ); + $parentNodeRecord->childrenByContentStream[$event->contentStreamId->value]->attach($specializationParentRelation); + } + $parentRelations->attach($specializationParentRelation); + } + } + + // reassign parent relations of the specialization's new children + foreach ($aggregateNodeRecords as $aggregateNodeRecord) { + if ($aggregateNodeRecord->coversDimensionSpacePoint($event->contentStreamId, $specializationSibling->dimensionSpacePoint)) { + $childRelation = $aggregateNodeRecord->childrenByContentStream[$event->contentStreamId->value] + ->getHierarchyHyperrelation($specializationSibling->dimensionSpacePoint); + if ($childRelation) { + $childRelation->parent = $specializationRecord; + $aggregateNodeRecord->childrenByContentStream[$event->contentStreamId->value]->detach($childRelation); + $childRelations->attach($childRelation); + } + // else there are no children yet, which is unchanged by this operation + } + } + } + + /** @todo copy reference relations */ + $this->graphStructure->addNodeRecord($specializationRecord, $event->contentStreamId); + } + + private function whenNodeGeneralizationVariantWasCreated(NodeGeneralizationVariantWasCreated $event, EventEnvelope $eventEnvelope): void + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + /** @todo create and reconnect */ + } + + private function whenNodePeerVariantWasCreated(NodePeerVariantWasCreated $event, EventEnvelope $eventEnvelope): void + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + /** @todo create and reconnect */ + } + + private function whenNodePropertiesWereSet(NodePropertiesWereSet $event, EventEnvelope $eventEnvelope): void + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + /** @todo adjust with copy on write including timestamps */ + } + + private function whenNodeReferencesWereSet(NodeReferencesWereSet $event, EventEnvelope $eventEnvelope): void + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + /** @todo adjust with copy on write including timestamps */ + } + + private function whenRootNodeAggregateDimensionsWereUpdated(RootNodeAggregateDimensionsWereUpdated $event): void + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + /** @todo adjust root node coverage */ + } + + private function whenSubtreeWasTagged(SubtreeWasTagged $event): void + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + /** @todo add subtree tag */ + #$this->addSubtreeTag($event->contentStreamId, $event->nodeAggregateId, $event->affectedDimensionSpacePoints, $event->tag); + } + + private function whenSubtreeWasUntagged(SubtreeWasUntagged $event): void + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + /** @todo remove subtree tag */ + } + + private function whenWorkspaceRebaseFailed(WorkspaceRebaseFailed $event): void + { + // legacy handling: + // before https://github.com/neos/neos-development-collection/pull/4965 this event was emitted and set the content stream status to `REBASE_ERROR` + // instead of setting the error state on replay for old events we make it almost behave like if the rebase had failed today: reopen the workspaces content stream id + // the candidateContentStreamId will be removed by the ContentStreamPruner + $this->contentStreamRegistry->reopenContentStream($event->sourceContentStreamId); + } + + private static function resolveInitiatingDateTime(EventEnvelope $eventEnvelope): \DateTimeImmutable + { + if ($eventEnvelope->event->metadata?->has(InitiatingEventMetadata::INITIATING_TIMESTAMP) !== true) { + return $eventEnvelope->recordedAt; + } + $initiatingTimestamp = InitiatingEventMetadata::getInitiatingTimestamp($eventEnvelope->event->metadata); + if ($initiatingTimestamp === null) { + throw new \RuntimeException(sprintf('Failed to extract initiating timestamp from event "%s"', $eventEnvelope->event->id->value), 1678902291); + } + return $initiatingTimestamp; + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/InMemoryContentGraphProjectionFactory.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/InMemoryContentGraphProjectionFactory.php new file mode 100644 index 00000000000..ff09c1c2610 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/InMemoryContentGraphProjectionFactory.php @@ -0,0 +1,49 @@ +contentRepositoryId, + $projectionFactoryDependencies->getPropertyConverter(), + ); + + $graphStructure = InMemoryContentGraphStructure::getInstance(); + $workspaceRegistry = InMemoryWorkspaceRegistry::getInstance(); + $contentStreamRegistry = InMemoryContentStreamRegistry::getInstance(); + + $contentGraphReadModel = new InMemoryContentGraphReadModelAdapter( + $projectionFactoryDependencies->contentRepositoryId, + $projectionFactoryDependencies->nodeTypeManager, + $graphStructure, + $workspaceRegistry, + $contentStreamRegistry, + $nodeFactory, + ); + + return new InMemoryContentGraphProjection( + $contentGraphReadModel, + $graphStructure, + $workspaceRegistry, + $contentStreamRegistry, + ); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/InMemoryContentGraphReadModelAdapter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/InMemoryContentGraphReadModelAdapter.php new file mode 100644 index 00000000000..ed6a0b93922 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/InMemoryContentGraphReadModelAdapter.php @@ -0,0 +1,113 @@ +workspaceRegistry->workspaces[$workspaceName->value] ?? null; + if ($workspace === null) { + throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); + } + return new InMemoryContentGraph( + $this->graphStructure, + $this->nodeFactory, + $this->contentRepositoryId, + $this->nodeTypeManager, + $workspaceName, + $workspace->currentContentStreamId, + ); + } + + public function findWorkspaceByName(WorkspaceName $workspaceName): ?Workspace + { + $workspaceRecord = $this->workspaceRegistry->workspaces[$workspaceName->value] ?? null; + + return $workspaceRecord + ? self::mapWorkspaceRecordToWorkspace($workspaceRecord) + : null; + } + + public function findWorkspaces(): Workspaces + { + return Workspaces::fromArray(array_map( + fn (InMemoryWorkspaceRecord $workspaceRecord): Workspace + => self::mapWorkspaceRecordToWorkspace($workspaceRecord), + $this->workspaceRegistry->workspaces + )); + } + + public function findContentStreamById(ContentStreamId $contentStreamId): ?ContentStream + { + $contentStreamRecord = $this->contentStreamRegistry->contentStreams[$contentStreamId->value] ?? null; + + return $contentStreamRecord + ? self::mapContentStreamRecordToContentStream($contentStreamRecord) + : null; + } + + public function countNodes(): int + { + return $this->graphStructure->totalNodeCount; + } + + private static function mapWorkspaceRecordToWorkspace(InMemoryWorkspaceRecord $workspaceRecord): Workspace + { + return Workspace::create( + $workspaceRecord->workspaceName, + $workspaceRecord->baseWorkspaceName, + $workspaceRecord->currentContentStreamId, + $workspaceRecord->baseWorkspaceName === null || $workspaceRecord->isUpToDateWithBase + ? WorkspaceStatus::UP_TO_DATE + : WorkspaceStatus::OUTDATED, + $workspaceRecord->baseWorkspaceName !== null && $workspaceRecord->hasChanges + ); + } + + private static function mapContentStreamRecordToContentStream(InMemoryContentStreamRecord $contentStreamRecord): ContentStream + { + return ContentStream::create( + $contentStreamRecord->id, + $contentStreamRecord->sourceContentStreamId, + $contentStreamRecord->version, + $contentStreamRecord->isClosed, + ); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/Feature/NodeMove.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/Feature/NodeMove.php new file mode 100644 index 00000000000..ac917262aca --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/Feature/NodeMove.php @@ -0,0 +1,198 @@ +projectionContentGraph->findNodeInAggregate( + $contentStreamId, + $nodeAggregateId, + $succeedingSiblingForCoverage->dimensionSpacePoint + ); + + if (is_null($nodeToBeMoved)) { + throw new \RuntimeException(sprintf('Failed to move node "%s" in sub graph %s@%s because it does not exist', $nodeAggregateId->value, $succeedingSiblingForCoverage->dimensionSpacePoint->toJson(), $contentStreamId->value), 1716471638); + } + + if ($newParentNodeAggregateId) { + $this->moveNodeBeneathParent( + $contentStreamId, + $nodeToBeMoved, + $newParentNodeAggregateId, + $succeedingSiblingForCoverage + ); + $this->moveSubtreeTags( + $contentStreamId, + $newParentNodeAggregateId, + $succeedingSiblingForCoverage->dimensionSpacePoint + ); + } else { + $this->moveNodeBeforeSucceedingSibling( + $contentStreamId, + $nodeToBeMoved, + $succeedingSiblingForCoverage, + ); + // subtree tags stay the same if the parent doesn't change + } + } + } + + /** + * This helper is responsible for moving a single incoming HierarchyRelation of $nodeToBeMoved + * to a new location without changing the parent. $succeedingSiblingForCoverage specifies + * which incoming HierarchyRelation should be moved and where exactly. + * + * The move target is given as $succeedingSiblingNodeMoveTarget. This also specifies the new parent node. + */ + private function moveNodeBeforeSucceedingSibling( + ContentStreamId $contentStreamId, + NodeRecord $nodeToBeMoved, + InterdimensionalSibling $succeedingSiblingForCoverage, + ): void { + // find the single ingoing hierarchy relation which we want to move + $ingoingHierarchyRelation = $this->findIngoingHierarchyRelationToBeMoved( + $nodeToBeMoved, + $contentStreamId, + $succeedingSiblingForCoverage->dimensionSpacePoint + ); + + $newSucceedingSibling = null; + if ($succeedingSiblingForCoverage->nodeAggregateId) { + // find the new succeeding sibling NodeRecord; We need this record because we'll use its RelationAnchorPoint later. + $newSucceedingSibling = $this->projectionContentGraph->findNodeInAggregate( + $contentStreamId, + $succeedingSiblingForCoverage->nodeAggregateId, + $succeedingSiblingForCoverage->dimensionSpacePoint + ); + if ($newSucceedingSibling === null) { + throw new \RuntimeException(sprintf('Failed to move node "%s" in sub graph %s@%s because target succeeding sibling node "%s" is missing', $nodeToBeMoved->nodeAggregateId->value, $succeedingSiblingForCoverage->dimensionSpacePoint->toJson(), $contentStreamId->value, $succeedingSiblingForCoverage->nodeAggregateId->value), 1716471881); + } + } + + // fetch... + $newPosition = $this->getRelationPosition( + $ingoingHierarchyRelation->parentNodeAnchor, + null, + $newSucceedingSibling?->relationAnchorPoint, + $contentStreamId, + $succeedingSiblingForCoverage->dimensionSpacePoint + ); + + // ...and assign the new position + $ingoingHierarchyRelation->assignNewPosition( + $newPosition, + $this->dbal, + $this->tableNames + ); + } + + /** + * This helper is responsible for moving a single incoming HierarchyRelation of $nodeToBeMoved + * to a new location including a change of parent. $succeedingSiblingForCoverage specifies + * which incoming HierarchyRelation should be moved and where exactly. + * + * The move target is given as $parentNodeAggregateId and $succeedingSiblingForCoverage. + * We always move beneath the parent before the succeeding sibling if given (or to the end) + */ + private function moveNodeBeneathParent( + ContentStreamId $contentStreamId, + NodeRecord $nodeToBeMoved, + NodeAggregateId $parentNodeAggregateId, + InterdimensionalSibling $succeedingSiblingForCoverage, + ): void { + // find the single ingoing hierarchy relation which we want to move + $ingoingHierarchyRelation = $this->findIngoingHierarchyRelationToBeMoved( + $nodeToBeMoved, + $contentStreamId, + $succeedingSiblingForCoverage->dimensionSpacePoint + ); + + // find the new parent NodeRecord; We need this record because we'll use its RelationAnchorPoints later. + $newParent = $this->projectionContentGraph->findNodeInAggregate( + $contentStreamId, + $parentNodeAggregateId, + $succeedingSiblingForCoverage->dimensionSpacePoint + ); + if ($newParent === null) { + throw new \RuntimeException(sprintf('Failed to move node "%s" in sub graph %s@%s because target parent node is missing', $nodeToBeMoved->nodeAggregateId->value, $succeedingSiblingForCoverage->dimensionSpacePoint->toJson(), $contentStreamId->value), 1716471955); + } + + $newSucceedingSibling = null; + if ($succeedingSiblingForCoverage->nodeAggregateId) { + // find the new succeeding sibling NodeRecord; We need this record because we'll use its RelationAnchorPoint later. + $newSucceedingSibling = $this->projectionContentGraph->findNodeInAggregate( + $contentStreamId, + $succeedingSiblingForCoverage->nodeAggregateId, + $succeedingSiblingForCoverage->dimensionSpacePoint + ); + if ($newSucceedingSibling === null) { + throw new \RuntimeException(sprintf('Failed to move node "%s" in sub graph %s@%s because target succeeding sibling node is missing', $nodeToBeMoved->nodeAggregateId->value, $succeedingSiblingForCoverage->dimensionSpacePoint->toJson(), $contentStreamId->value), 1716471995); + } + } + + // assign new position + $newPosition = $this->getRelationPosition( + $newParent->relationAnchorPoint, + null, + $newSucceedingSibling?->relationAnchorPoint, + $contentStreamId, + $succeedingSiblingForCoverage->dimensionSpacePoint + ); + + // this is the actual move + $ingoingHierarchyRelation->assignNewParentNode( + $newParent->relationAnchorPoint, + $newPosition, + $this->dbal, + $this->tableNames + ); + } + + /** + * Helper for the move methods. + * + * @param NodeRecord $nodeToBeMoved + * @param ContentStreamId $contentStreamId + * @param DimensionSpacePoint $coveredDimensionSpacePointWhereMoveShouldHappen + * @return HierarchyRelation + */ + private function findIngoingHierarchyRelationToBeMoved( + NodeRecord $nodeToBeMoved, + ContentStreamId $contentStreamId, + DimensionSpacePoint $coveredDimensionSpacePointWhereMoveShouldHappen + ): HierarchyRelation { + $restrictToSet = DimensionSpacePointSet::fromArray([$coveredDimensionSpacePointWhereMoveShouldHappen]); + $ingoingHierarchyRelations = $this->projectionContentGraph->findIngoingHierarchyRelationsForNode( + $nodeToBeMoved->relationAnchorPoint, + $contentStreamId, + $restrictToSet, + ); + if (count($ingoingHierarchyRelations) !== 1) { + // there should always be exactly one incoming relation in the given DimensionSpacePoint; everything + // else would be a totally wrong behavior of findIngoingHierarchyRelationsForNode(). + throw new \RuntimeException(sprintf('Failed move node "%s" in sub graph %s@%s because ingoing source hierarchy relation is missing', $nodeToBeMoved->nodeAggregateId->value, $restrictToSet->toJson(), $contentStreamId->value), 1716472138); + } + return reset($ingoingHierarchyRelations); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/Feature/NodeRemoval.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/Feature/NodeRemoval.php new file mode 100644 index 00000000000..ba50ba2a0ce --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/Feature/NodeRemoval.php @@ -0,0 +1,72 @@ +projectionContentGraph->findIngoingHierarchyRelationsForNodeAggregate( + $contentStreamId, + $nodeAggregateId, + $affectedCoveredDimensionSpacePoints + ); + + foreach ($ingoingRelations as $ingoingRelation) { + $this->removeRelationRecursivelyFromDatabaseIncludingNonReferencedNodes($ingoingRelation); + } + } + + private function removeRelationRecursivelyFromDatabaseIncludingNonReferencedNodes( + HierarchyRelation $ingoingRelation + ): void { + $ingoingRelation->removeFromDatabase($this->dbal, $this->tableNames); + + foreach ( + $this->projectionContentGraph->findOutgoingHierarchyRelationsForNode( + $ingoingRelation->childNodeAnchor, + $ingoingRelation->contentStreamId, + new DimensionSpacePointSet([$ingoingRelation->dimensionSpacePoint]) + ) as $outgoingRelation + ) { + $this->removeRelationRecursivelyFromDatabaseIncludingNonReferencedNodes($outgoingRelation); + } + + // remove node itself if it does not have any incoming hierarchy relations anymore + // also remove outbound reference relations + $deleteRelationsStatement = <<tableNames->node()} n + LEFT JOIN {$this->tableNames->referenceRelation()} r ON r.nodeanchorpoint = n.relationanchorpoint + LEFT JOIN {$this->tableNames->hierarchyRelation()} h ON h.childnodeanchor = n.relationanchorpoint + WHERE + n.relationanchorpoint = :anchorPointForNode + -- the following line means "left join leads to NO MATCHING hierarchyrelation" + AND h.contentstreamid IS NULL + SQL; + try { + $this->dbal->executeStatement($deleteRelationsStatement, [ + 'anchorPointForNode' => $ingoingRelation->childNodeAnchor->value, + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to remove relations from database: %s', $e->getMessage()), 1716473385, $e); + } + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/Feature/NodeVariation.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/Feature/NodeVariation.php new file mode 100644 index 00000000000..233309f003c --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/Feature/NodeVariation.php @@ -0,0 +1,341 @@ +projectionContentGraph->findNodeInAggregate( + $contentStreamId, + $nodeAggregateId, + $sourceOrigin->toDimensionSpacePoint() + ); + if (is_null($sourceNode)) { + throw new \RuntimeException(sprintf('Failed to create node specialization variant for node "%s" in sub graph %s@%s because the source node is missing', $nodeAggregateId->value, $sourceOrigin->toJson(), $contentStreamId->value), 1716498651); + } + + $specializedNode = $this->copyNodeToDimensionSpacePoint( + $sourceNode, + $specializationOrigin, + $eventEnvelope + ); + + $uncoveredDimensionSpacePoints = $specializationSiblings->toDimensionSpacePointSet()->points; + foreach ( + $this->projectionContentGraph->findIngoingHierarchyRelationsForNodeAggregate( + $contentStreamId, + $sourceNode->nodeAggregateId, + $specializationSiblings->toDimensionSpacePointSet() + ) as $hierarchyRelation + ) { + $hierarchyRelation->assignNewChildNode( + $specializedNode->relationAnchorPoint, + $this->dbal, + $this->tableNames + ); + unset($uncoveredDimensionSpacePoints[$hierarchyRelation->dimensionSpacePointHash]); + } + if (!empty($uncoveredDimensionSpacePoints)) { + $sourceParent = $this->projectionContentGraph->findParentNode( + $contentStreamId, + $nodeAggregateId, + $sourceOrigin, + ); + if (is_null($sourceParent)) { + throw new \RuntimeException(sprintf('Failed to create node specialization variant for node "%s" in sub graph %s@%s because the source parent node is missing', $nodeAggregateId->value, $sourceOrigin->toJson(), $contentStreamId->value), 1716498695); + } + foreach ($uncoveredDimensionSpacePoints as $uncoveredDimensionSpacePoint) { + $parentNode = $this->projectionContentGraph->findNodeInAggregate( + $contentStreamId, + $sourceParent->nodeAggregateId, + $uncoveredDimensionSpacePoint + ); + if (is_null($parentNode)) { + throw new \RuntimeException(sprintf('Failed to create node specialization variant for node "%s" in sub graph %s@%s because the target parent node "%s" is missing', $nodeAggregateId->value, $sourceOrigin->toJson(), $contentStreamId->value, $sourceParent->nodeAggregateId->value), 1716498734); + } + $parentSubtreeTags = $this->subtreeTagsForHierarchyRelation($contentStreamId, $parentNode->relationAnchorPoint, $uncoveredDimensionSpacePoint); + + $specializationSucceedingSiblingNodeAggregateId = $specializationSiblings + ->getSucceedingSiblingIdForDimensionSpacePoint($uncoveredDimensionSpacePoint); + $specializationSucceedingSiblingNode = $specializationSucceedingSiblingNodeAggregateId + ? $this->projectionContentGraph->findNodeInAggregate( + $contentStreamId, + $specializationSucceedingSiblingNodeAggregateId, + $uncoveredDimensionSpacePoint + ) + : null; + + $hierarchyRelation = new HierarchyRelation( + $parentNode->relationAnchorPoint, + $specializedNode->relationAnchorPoint, + $contentStreamId, + $uncoveredDimensionSpacePoint, + $uncoveredDimensionSpacePoint->hash, + $this->projectionContentGraph->determineHierarchyRelationPosition( + $parentNode->relationAnchorPoint, + $specializedNode->relationAnchorPoint, + $specializationSucceedingSiblingNode?->relationAnchorPoint, + $contentStreamId, + $uncoveredDimensionSpacePoint + ), + NodeTags::create(SubtreeTags::createEmpty(), $parentSubtreeTags->all()), + ); + $hierarchyRelation->addToDatabase($this->dbal, $this->tableNames); + } + } + + foreach ( + $this->projectionContentGraph->findOutgoingHierarchyRelationsForNodeAggregate( + $contentStreamId, + $sourceNode->nodeAggregateId, + $specializationSiblings->toDimensionSpacePointSet() + ) as $hierarchyRelation + ) { + $hierarchyRelation->assignNewParentNode( + $specializedNode->relationAnchorPoint, + null, + $this->dbal, + $this->tableNames + ); + } + + // Copy Reference Edges + $this->copyReferenceRelations( + $sourceNode->relationAnchorPoint, + $specializedNode->relationAnchorPoint + ); + } + + public function createNodeGeneralizationVariant(ContentStreamId $contentStreamId, NodeAggregateId $nodeAggregateId, OriginDimensionSpacePoint $sourceOrigin, OriginDimensionSpacePoint $generalizationOrigin, InterdimensionalSiblings $variantSucceedingSiblings, EventEnvelope $eventEnvelope): void + { + // do the generalization + $sourceNode = $this->projectionContentGraph->findNodeInAggregate( + $contentStreamId, + $nodeAggregateId, + $sourceOrigin->toDimensionSpacePoint() + ); + if (is_null($sourceNode)) { + throw new \RuntimeException(sprintf('Failed to create node generalization variant for node "%s" in sub graph %s@%s because the source node is missing', $nodeAggregateId->value, $sourceOrigin->toJson(), $contentStreamId->value), 1716498802); + } + $sourceParentNode = $this->projectionContentGraph->findParentNode( + $contentStreamId, + $nodeAggregateId, + $sourceOrigin + ); + if (is_null($sourceParentNode)) { + 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', $nodeAggregateId->value, $sourceOrigin->toJson(), $contentStreamId->value), 1716498857); + } + $generalizedNode = $this->copyNodeToDimensionSpacePoint( + $sourceNode, + $generalizationOrigin, + $eventEnvelope + ); + + $unassignedIngoingDimensionSpacePoints = $variantSucceedingSiblings->toDimensionSpacePointSet(); + foreach ( + $this->projectionContentGraph->findIngoingHierarchyRelationsForNodeAggregate( + $contentStreamId, + $nodeAggregateId, + $variantSucceedingSiblings->toDimensionSpacePointSet() + ) as $existingIngoingHierarchyRelation + ) { + $existingIngoingHierarchyRelation->assignNewChildNode( + $generalizedNode->relationAnchorPoint, + $this->dbal, + $this->tableNames + ); + $unassignedIngoingDimensionSpacePoints = $unassignedIngoingDimensionSpacePoints->getDifference( + new DimensionSpacePointSet([ + $existingIngoingHierarchyRelation->dimensionSpacePoint + ]) + ); + } + + foreach ( + $this->projectionContentGraph->findOutgoingHierarchyRelationsForNodeAggregate( + $contentStreamId, + $nodeAggregateId, + $variantSucceedingSiblings->toDimensionSpacePointSet() + ) as $existingOutgoingHierarchyRelation + ) { + $existingOutgoingHierarchyRelation->assignNewParentNode( + $generalizedNode->relationAnchorPoint, + null, + $this->dbal, + $this->tableNames + ); + } + + if (count($unassignedIngoingDimensionSpacePoints) > 0) { + $ingoingSourceHierarchyRelation = $this->projectionContentGraph->findIngoingHierarchyRelationsForNode( + $sourceNode->relationAnchorPoint, + $contentStreamId, + new DimensionSpacePointSet([$sourceOrigin->toDimensionSpacePoint()]) + )[$sourceOrigin->hash] ?? null; + if (is_null($ingoingSourceHierarchyRelation)) { + throw new \RuntimeException(sprintf('Failed to create node generalization variant for node "%s" in sub graph %s@%s because the ingoing hierarchy relation is missing', $nodeAggregateId->value, $sourceOrigin->toJson(), $contentStreamId->value), 1716498940); + } + // the null case is caught by the NodeAggregate or its command handler + foreach ($unassignedIngoingDimensionSpacePoints as $unassignedDimensionSpacePoint) { + // The parent node aggregate might be varied as well, + // so we need to find a parent node for each covered dimension space point + $generalizationParentNode = $this->projectionContentGraph->findNodeInAggregate( + $contentStreamId, + $sourceParentNode->nodeAggregateId, + $unassignedDimensionSpacePoint + ); + if (is_null($generalizationParentNode)) { + throw new \RuntimeException(sprintf( + 'Failed to assign node generalization relation for node "%s" from dimension space point %s to dimension space point %s in content stream %s because the target parent node "%s" is missing', + $nodeAggregateId->value, + $sourceOrigin->toJson(), + $unassignedDimensionSpacePoint->toJson(), + $contentStreamId->value, + $sourceParentNode->nodeAggregateId->value + ), 1716498961); + } + + $generalizationSucceedingSiblingNodeAggregateId = $variantSucceedingSiblings + ->getSucceedingSiblingIdForDimensionSpacePoint($unassignedDimensionSpacePoint); + $generalizationSucceedingSiblingNode = $generalizationSucceedingSiblingNodeAggregateId + ? $this->projectionContentGraph->findNodeInAggregate( + $contentStreamId, + $generalizationSucceedingSiblingNodeAggregateId, + $unassignedDimensionSpacePoint + ) + : null; + + $this->copyHierarchyRelationToDimensionSpacePoint( + $ingoingSourceHierarchyRelation, + $contentStreamId, + $unassignedDimensionSpacePoint, + $generalizationParentNode->relationAnchorPoint, + $generalizedNode->relationAnchorPoint, + $generalizationSucceedingSiblingNode?->relationAnchorPoint + ); + } + } + + // Copy Reference Edges + $this->copyReferenceRelations( + $sourceNode->relationAnchorPoint, + $generalizedNode->relationAnchorPoint + ); + } + + public function createNodePeerVariant(ContentStreamId $contentStreamId, NodeAggregateId $nodeAggregateId, OriginDimensionSpacePoint $sourceOrigin, OriginDimensionSpacePoint $peerOrigin, InterdimensionalSiblings $peerSucceedingSiblings, EventEnvelope $eventEnvelope): void + { + // Do the peer variant creation itself + $sourceNode = $this->projectionContentGraph->findNodeInAggregate( + $contentStreamId, + $nodeAggregateId, + $sourceOrigin->toDimensionSpacePoint() + ); + if (is_null($sourceNode)) { + throw new \RuntimeException(sprintf('Failed to create node peer variant for node "%s" in sub graph %s@%s because the source node is missing', $nodeAggregateId->value, $sourceOrigin->toJson(), $contentStreamId->value), 1716498802); + } + $peerNode = $this->copyNodeToDimensionSpacePoint( + $sourceNode, + $peerOrigin, + $eventEnvelope + ); + + $unassignedIngoingDimensionSpacePoints = $peerSucceedingSiblings->toDimensionSpacePointSet(); + foreach ( + $this->projectionContentGraph->findIngoingHierarchyRelationsForNodeAggregate( + $contentStreamId, + $nodeAggregateId, + $peerSucceedingSiblings->toDimensionSpacePointSet() + ) as $existingIngoingHierarchyRelation + ) { + $existingIngoingHierarchyRelation->assignNewChildNode( + $peerNode->relationAnchorPoint, + $this->dbal, + $this->tableNames + ); + $unassignedIngoingDimensionSpacePoints = $unassignedIngoingDimensionSpacePoints->getDifference( + new DimensionSpacePointSet([ + $existingIngoingHierarchyRelation->dimensionSpacePoint + ]) + ); + } + + foreach ( + $this->projectionContentGraph->findOutgoingHierarchyRelationsForNodeAggregate( + $contentStreamId, + $nodeAggregateId, + $peerSucceedingSiblings->toDimensionSpacePointSet() + ) as $existingOutgoingHierarchyRelation + ) { + $existingOutgoingHierarchyRelation->assignNewParentNode( + $peerNode->relationAnchorPoint, + null, + $this->dbal, + $this->tableNames + ); + } + + $sourceParentNode = $this->projectionContentGraph->findParentNode( + $contentStreamId, + $nodeAggregateId, + $sourceOrigin + ); + if (is_null($sourceParentNode)) { + throw new \RuntimeException(sprintf('Failed to create node peer variant for node "%s" in sub graph %s@%s because the source parent node is missing', $nodeAggregateId->value, $sourceOrigin->toJson(), $contentStreamId->value), 1716498881); + } + foreach ($unassignedIngoingDimensionSpacePoints as $coveredDimensionSpacePoint) { + // The parent node aggregate might be varied as well, + // so we need to find a parent node for each covered dimension space point + $peerParentNode = $this->projectionContentGraph->findNodeInAggregate( + $contentStreamId, + $sourceParentNode->nodeAggregateId, + $coveredDimensionSpacePoint + ); + if (is_null($peerParentNode)) { + throw new \RuntimeException(sprintf('Failed to create node peer variant for node "%s" in sub graph %s@%s because the target parent node "%s" is missing', $nodeAggregateId->value, $sourceOrigin->toJson(), $contentStreamId->value, $sourceParentNode->nodeAggregateId->value), 1716499016); + } + $peerSucceedingSiblingNodeAggregateId = $peerSucceedingSiblings + ->getSucceedingSiblingIdForDimensionSpacePoint($coveredDimensionSpacePoint); + $peerSucceedingSiblingNode = $peerSucceedingSiblingNodeAggregateId + ? $this->projectionContentGraph->findNodeInAggregate( + $contentStreamId, + $peerSucceedingSiblingNodeAggregateId, + $coveredDimensionSpacePoint + ) + : null; + + $this->connectHierarchy( + $contentStreamId, + $peerParentNode->relationAnchorPoint, + $peerNode->relationAnchorPoint, + new DimensionSpacePointSet([$coveredDimensionSpacePoint]), + $peerSucceedingSiblingNode?->relationAnchorPoint, + ); + } + + // Copy Reference Edges + $this->copyReferenceRelations( + $sourceNode->relationAnchorPoint, + $peerNode->relationAnchorPoint + ); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/Feature/SubtreeTagging.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/Feature/SubtreeTagging.php new file mode 100644 index 00000000000..b64b20c9a67 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/Feature/SubtreeTagging.php @@ -0,0 +1,236 @@ +tableNames->hierarchyRelation()} h + SET h.subtreetags = JSON_INSERT(h.subtreetags, :tagPath, null) + WHERE h.childnodeanchor IN ( + WITH RECURSIVE cte (id) AS ( + SELECT ch.childnodeanchor + FROM {$this->tableNames->hierarchyRelation()} ch + INNER JOIN {$this->tableNames->node()} n ON n.relationanchorpoint = ch.parentnodeanchor + WHERE + n.nodeaggregateid = :nodeAggregateId + AND ch.contentstreamid = :contentStreamId + AND ch.dimensionspacepointhash in (:dimensionSpacePointHashes) + AND NOT JSON_CONTAINS_PATH(ch.subtreetags, 'one', :tagPath) + UNION ALL + SELECT + dh.childnodeanchor + FROM + cte + JOIN {$this->tableNames->hierarchyRelation()} dh ON dh.parentnodeanchor = cte.id + AND dh.contentstreamid = :contentStreamId + AND dh.dimensionspacepointhash in (:dimensionSpacePointHashes) + WHERE + NOT JSON_CONTAINS_PATH(dh.subtreetags, 'one', :tagPath) + ) + SELECT DISTINCT id FROM cte + ) + AND h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash in (:dimensionSpacePointHashes) + SQL; + try { + $this->dbal->executeStatement($addTagToDescendantsStatement, [ + 'contentStreamId' => $contentStreamId->value, + 'nodeAggregateId' => $nodeAggregateId->value, + 'dimensionSpacePointHashes' => $affectedDimensionSpacePoints->getPointHashes(), + 'tagPath' => '$."' . $tag->value . '"', + ], [ + 'dimensionSpacePointHashes' => ArrayParameterType::STRING, + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to add subtree tag %s for content stream %s, node aggregate id %s and dimension space points %s: %s', $tag->value, $contentStreamId->value, $nodeAggregateId->value, $affectedDimensionSpacePoints->toJson(), $e->getMessage()), 1716479749, $e); + } + + $addTagToNodeStatement = <<tableNames->hierarchyRelation()} h + INNER JOIN {$this->tableNames->node()} n ON n.relationanchorpoint = h.childnodeanchor + SET h.subtreetags = JSON_SET(h.subtreetags, :tagPath, true) + WHERE + n.nodeaggregateid = :nodeAggregateId + AND h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash in (:dimensionSpacePointHashes) + SQL; + try { + $this->dbal->executeStatement($addTagToNodeStatement, [ + 'contentStreamId' => $contentStreamId->value, + 'nodeAggregateId' => $nodeAggregateId->value, + 'dimensionSpacePointHashes' => $affectedDimensionSpacePoints->getPointHashes(), + 'tagPath' => '$."' . $tag->value . '"', + ], [ + 'dimensionSpacePointHashes' => ArrayParameterType::STRING, + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to add subtree tag %s for content stream %s, node aggregate id %s and dimension space points %s: %s', $tag->value, $contentStreamId->value, $nodeAggregateId->value, $affectedDimensionSpacePoints->toJson(), $e->getMessage()), 1716479840, $e); + } + } + + private function removeSubtreeTag(ContentStreamId $contentStreamId, NodeAggregateId $nodeAggregateId, DimensionSpacePointSet $affectedDimensionSpacePoints, SubtreeTag $tag): void + { + $removeTagStatement = <<tableNames->hierarchyRelation()} h + SET subtreetags = IF(( + SELECT containsTag FROM (SELECT + JSON_CONTAINS_PATH(gph.subtreetags, 'one', :tagPath) as containsTag + FROM + {$this->tableNames->hierarchyRelation()} gph + INNER JOIN {$this->tableNames->hierarchyRelation()} ph ON ph.parentnodeanchor = gph.childnodeanchor + INNER JOIN {$this->tableNames->node()} n ON n.relationanchorpoint = ph.childnodeanchor + WHERE + ph.parentnodeanchor = gph.childnodeanchor + AND n.nodeaggregateid = :nodeAggregateId + AND gph.contentstreamid = :contentStreamId + AND gph.dimensionspacepointhash in (:dimensionSpacePointHashes) + LIMIT 1) as containsTagSubQuery + ), JSON_SET(subtreetags, :tagPath, null), JSON_REMOVE(subtreetags, :tagPath)) + WHERE childnodeanchor IN ( + WITH RECURSIVE cte (id) AS ( + SELECT ph.childnodeanchor + FROM {$this->tableNames->hierarchyRelation()} ph + INNER JOIN {$this->tableNames->node()} n ON n.relationanchorpoint = ph.childnodeanchor + WHERE + n.nodeaggregateid = :nodeAggregateId + AND ph.contentstreamid = :contentStreamId + AND ph.dimensionspacepointhash in (:dimensionSpacePointHashes) + UNION ALL + SELECT + dh.childnodeanchor + FROM + cte + JOIN {$this->tableNames->hierarchyRelation()} dh ON dh.parentnodeanchor = cte.id + AND dh.contentstreamid = :contentStreamId + AND dh.dimensionspacepointhash in (:dimensionSpacePointHashes) + WHERE + JSON_EXTRACT(dh.subtreetags, :tagPath) != TRUE + ) + SELECT DISTINCT id FROM cte + ) + AND contentstreamid = :contentStreamId + AND dimensionspacepointhash in (:dimensionSpacePointHashes) + SQL; + try { + $this->dbal->executeStatement($removeTagStatement, [ + 'contentStreamId' => $contentStreamId->value, + 'nodeAggregateId' => $nodeAggregateId->value, + 'dimensionSpacePointHashes' => $affectedDimensionSpacePoints->getPointHashes(), + 'tagPath' => '$."' . $tag->value . '"', + ], [ + 'dimensionSpacePointHashes' => ArrayParameterType::STRING, + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to remove subtree tag %s for content stream %s, node aggregate id %s and dimension space points %s: %s', $tag->value, $contentStreamId->value, $nodeAggregateId->value, $affectedDimensionSpacePoints->toJson(), $e->getMessage()), 1716482293, $e); + } + } + + private function moveSubtreeTags(ContentStreamId $contentStreamId, NodeAggregateId $newParentNodeAggregateId, DimensionSpacePoint $coveredDimensionSpacePoint): void + { + $moveSubtreeTagsStatement = <<tableNames->hierarchyRelation()} h, + ( + WITH RECURSIVE cte AS ( + SELECT + JSON_KEYS(th.subtreetags) subtreeTagsToInherit, th.childnodeanchor + FROM + {$this->tableNames->hierarchyRelation()} th + INNER JOIN {$this->tableNames->node()} tn ON tn.relationanchorpoint = th.childnodeanchor + WHERE + tn.nodeaggregateid = :newParentNodeAggregateId + AND th.contentstreamid = :contentStreamId + AND th.dimensionspacepointhash = :dimensionSpacePointHash + UNION + SELECT + JSON_MERGE_PRESERVE( + cte.subtreeTagsToInherit, + JSON_KEYS(JSON_MERGE_PATCH( + '{}', + dh.subtreetags + )) + ) AS subtreeTagsToInherit, + dh.childnodeanchor + FROM + cte + JOIN {$this->tableNames->hierarchyRelation()} dh + ON + dh.parentnodeanchor = cte.childnodeanchor + AND dh.contentstreamid = :contentStreamId + AND dh.dimensionspacepointhash = :dimensionSpacePointHash + ) + SELECT * FROM cte + ) AS r + SET h.subtreetags = ( + SELECT + JSON_MERGE_PATCH( + IFNULL(JSON_OBJECTAGG(htk.k, null), '{}'), + JSON_MERGE_PATCH('{}', h.subtreetags) + ) + FROM + JSON_TABLE(r.subtreeTagsToInherit, '\$[*]' COLUMNS (k VARCHAR(36) PATH '\$')) htk + ) + WHERE + h.childnodeanchor = r.childnodeanchor + AND h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash = :dimensionSpacePointHash + SQL; + try { + // Mysql hack, too eager to optimize https://dev.mysql.com/doc/refman/8.4/en/derived-table-optimization.html + $this->dbal->executeQuery('set optimizer_switch="derived_merge=off"'); + $this->dbal->executeStatement($moveSubtreeTagsStatement, [ + 'contentStreamId' => $contentStreamId->value, + 'newParentNodeAggregateId' => $newParentNodeAggregateId->value, + 'dimensionSpacePointHash' => $coveredDimensionSpacePoint->hash, + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to move subtree tags for content stream %s, new parent node aggregate id %s and dimension space point %s: %s', $contentStreamId->value, $newParentNodeAggregateId->value, $coveredDimensionSpacePoint->toJson(), $e->getMessage()), 1716482574, $e); + } + } + + private function subtreeTagsForHierarchyRelation(ContentStreamId $contentStreamId, NodeRelationAnchorPoint $parentNodeAnchorPoint, DimensionSpacePoint $dimensionSpacePoint): NodeTags + { + if ($parentNodeAnchorPoint->equals(NodeRelationAnchorPoint::forRootEdge())) { + return NodeTags::createEmpty(); + } + try { + $subtreeTagsJson = $this->dbal->fetchOne(' + SELECT h.subtreetags FROM ' . $this->tableNames->hierarchyRelation() . ' h + WHERE + h.childnodeanchor = :parentNodeAnchorPoint + AND h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash = :dimensionSpacePointHash + ', [ + 'parentNodeAnchorPoint' => $parentNodeAnchorPoint->value, + 'contentStreamId' => $contentStreamId->value, + 'dimensionSpacePointHash' => $dimensionSpacePoint->hash, + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to fetch subtree tags for hierarchy parent anchor point "%s" in content subgraph "%s@%s": %s', $parentNodeAnchorPoint->value, $dimensionSpacePoint->toJson(), $contentStreamId->value, $e->getMessage()), 1716478760, $e); + } + if (!is_string($subtreeTagsJson)) { + throw new \RuntimeException(sprintf('Failed to fetch subtree tags for hierarchy parent anchor point "%s" in content subgraph "%s@%s"', $parentNodeAnchorPoint->value, $dimensionSpacePoint->toJson(), $contentStreamId->value), 1704199847); + } + return NodeFactory::extractNodeTagsFromJson($subtreeTagsJson); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/Feature/Workspaces.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/Feature/Workspaces.php new file mode 100644 index 00000000000..4082e926b8f --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/Feature/Workspaces.php @@ -0,0 +1,60 @@ +workspaceRegistry->createWorkspace($event->workspaceName, null, $event->newContentStreamId); + $this->graphStructure->initializeContentStream($event->newContentStreamId); + } + + private function whenWorkspaceWasCreated(WorkspaceWasCreated $event): void + { + $this->workspaceRegistry->createWorkspace($event->workspaceName, $event->baseWorkspaceName, $event->newContentStreamId); + $this->graphStructure->initializeContentStream($event->newContentStreamId); + } + + private function whenWorkspaceBaseWorkspaceWasChanged(WorkspaceBaseWorkspaceWasChanged $event): void + { + $this->workspaceRegistry->updateBaseWorkspace($event->workspaceName, $event->baseWorkspaceName, $event->newContentStreamId); + } + private function whenWorkspaceWasDiscarded(WorkspaceWasDiscarded $event): void + { + $this->workspaceRegistry->updateWorkspaceContentStreamId($event->workspaceName, $event->newContentStreamId); + } + + private function whenWorkspaceWasPublished(WorkspaceWasPublished $event): void + { + $this->workspaceRegistry->updateWorkspaceContentStreamId($event->sourceWorkspaceName, $event->newSourceContentStreamId); + } + + private function whenWorkspaceWasRebased(WorkspaceWasRebased $event): void + { + $this->workspaceRegistry->updateWorkspaceContentStreamId($event->workspaceName, $event->newContentStreamId); + } + + private function whenWorkspaceWasRemoved(WorkspaceWasRemoved $event): void + { + $contentStreamToBeRemoved = $this->workspaceRegistry->workspaces[$event->workspaceName->value]->currentContentStreamId; + $this->contentStreamRegistry->removeContentStream($contentStreamToBeRemoved); + $this->graphStructure->removeContentStream($contentStreamToBeRemoved); + $this->workspaceRegistry->removeWorkspace($event->workspaceName); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/HierarchyRelation.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/HierarchyRelation.php new file mode 100644 index 00000000000..0b34a8249e1 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/HierarchyRelation.php @@ -0,0 +1,144 @@ +insertDimensionSpacePoint($this->dimensionSpacePoint); + try { + $subtreeTagsJson = json_encode($this->subtreeTags, JSON_THROW_ON_ERROR | JSON_FORCE_OBJECT); + } catch (\JsonException $e) { + throw new \RuntimeException(sprintf('Failed to JSON-encode Subtree Tags: %s', $e->getMessage()), 1716484752, $e); + } + + try { + $databaseConnection->insert($tableNames->hierarchyRelation(), [ + 'parentnodeanchor' => $this->parentNodeAnchor->value, + 'childnodeanchor' => $this->childNodeAnchor->value, + 'contentstreamid' => $this->contentStreamId->value, + 'dimensionspacepointhash' => $this->dimensionSpacePointHash, + 'position' => $this->position, + 'subtreetags' => $subtreeTagsJson, + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to add hierarchy relation to database: %s', $e->getMessage()), 1716484789, $e); + } + } + + public function removeFromDatabase(Connection $databaseConnection, ContentGraphTableNames $tableNames): void + { + try { + $databaseConnection->delete($tableNames->hierarchyRelation(), $this->getDatabaseId()); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to remove hierarchy relation from database: %s', $e->getMessage()), 1716484823, $e); + } + } + + public function assignNewChildNode( + NodeRelationAnchorPoint $childAnchorPoint, + Connection $databaseConnection, + ContentGraphTableNames $tableNames + ): void { + try { + $databaseConnection->update( + $tableNames->hierarchyRelation(), + [ + 'childnodeanchor' => $childAnchorPoint->value + ], + $this->getDatabaseId() + ); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to update hierarchy relation: %s', $e->getMessage()), 1716484843, $e); + } + } + + public function assignNewParentNode( + NodeRelationAnchorPoint $parentAnchorPoint, + ?int $position, + Connection $databaseConnection, + ContentGraphTableNames $tableNames + ): void { + $data = [ + 'parentnodeanchor' => $parentAnchorPoint->value + ]; + if (!is_null($position)) { + $data['position'] = $position; + } + try { + $databaseConnection->update( + $tableNames->hierarchyRelation(), + $data, + $this->getDatabaseId() + ); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to update hierarchy relation: %s', $e->getMessage()), 1716478609, $e); + } + } + + public function assignNewPosition(int $position, Connection $databaseConnection, ContentGraphTableNames $tableNames): void + { + try { + $databaseConnection->update( + $tableNames->hierarchyRelation(), + [ + 'position' => $position + ], + $this->getDatabaseId() + ); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to update hierarchy relation: %s', $e->getMessage()), 1716485014, $e); + } + } + + /** + * @return array + */ + public function getDatabaseId(): array + { + return [ + 'parentnodeanchor' => $this->parentNodeAnchor->value, + 'childnodeanchor' => $this->childNodeAnchor->value, + 'contentstreamid' => $this->contentStreamId->value, + 'dimensionspacepointhash' => $this->dimensionSpacePointHash + ]; + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryContentGraphStructure.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryContentGraphStructure.php new file mode 100644 index 00000000000..e9d96340f1f --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryContentGraphStructure.php @@ -0,0 +1,70 @@ +>> $nodes + * indexed by content stream ID, node aggregate ID and origin dimension space point hash + * @param array> $rootNodes + * indexed by content stream ID and (root) node type name + * @param array $references + * indexed by content stream ID + */ + private function __construct( + public array $nodes = [], + public array $rootNodes = [], + public array $references = [], + public int $totalNodeCount = 0, + ) { + } + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + + return self::$instance; + } + + public function reset(): void + { + $this->nodes = []; + $this->rootNodes = []; + $this->references = []; + $this->totalNodeCount = 0; + } + + public function initializeContentStream(ContentStreamId $contentStreamId): void + { + $this->nodes[$contentStreamId->value] = []; + $this->rootNodes[$contentStreamId->value] = []; + $this->references[$contentStreamId->value] = new InMemoryReferenceHyperrelation(); + } + + public function removeContentStream(ContentStreamId $contentStreamId): void + { + unset($this->nodes[$contentStreamId->value]); + unset($this->rootNodes[$contentStreamId->value]); + unset($this->references[$contentStreamId->value]); + } + + public function addNodeRecord(InMemoryNodeRecord $nodeRecord, ContentStreamId $contentStreamId): void + { + $this->nodes[$contentStreamId->value][$nodeRecord->nodeAggregateId->value][$nodeRecord->originDimensionSpacePoint->hash] = $nodeRecord; + $this->totalNodeCount++; + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryContentStreamRecord.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryContentStreamRecord.php new file mode 100644 index 00000000000..8be86604767 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryContentStreamRecord.php @@ -0,0 +1,26 @@ + + * @internal + */ +final class InMemoryHierarchyHyperrelationRecordSet extends \SplObjectStorage +{ + public function getCoveredDimensionSpacePointSet(): DimensionSpacePointSet + { + $dimensionSpacePoints = []; + foreach ($this as $relation) { + $dimensionSpacePoints[] = $relation->dimensionSpacePoint; + } + + return DimensionSpacePointSet::fromArray($dimensionSpacePoints); + } + + public function getHierarchyHyperrelation(DimensionSpacePoint $dimensionSpacePoint): ?InMemoryHierarchyHyperrelationRecord + { + foreach ($this as $relation) { + if ($relation->dimensionSpacePoint === $dimensionSpacePoint) { + return $relation; + } + } + + return null; + } + + public function getParentNodeAggregateIds(): NodeAggregateIds + { + return NodeAggregateIds::fromArray(array_filter(array_map( + fn (InMemoryHierarchyHyperrelationRecord $relation): ?NodeAggregateId => $relation->parent?->nodeAggregateId, + iterator_to_array($this), + ))); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryNodeRecord.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryNodeRecord.php new file mode 100644 index 00000000000..3ee1b5859b2 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryNodeRecord.php @@ -0,0 +1,56 @@ + $parentsByContentStreamId + * @param array $childrenByContentStream + */ + public function __construct( + public NodeAggregateId $nodeAggregateId, + public OriginDimensionSpacePoint $originDimensionSpacePoint, + public SerializedPropertyValues $properties, + public NodeTypeName $nodeTypeName, + public NodeAggregateClassification $classification, + public ?NodeName $name, + public Timestamps $timestamps, + public array $parentsByContentStreamId, + public array $childrenByContentStream, + public SubtreeTags $tags, + public SubtreeTags $inheritedTags, + ) { + } + + public function coversDimensionSpacePoint(ContentStreamId $contentStreamId, DimensionSpacePoint $dimensionSpacePoint): bool + { + return $this->getCoveredDimensionSpacePointSet($contentStreamId) + ->contains($dimensionSpacePoint); + } + + public function getCoveredDimensionSpacePointSet(ContentStreamId $contentStreamId): DimensionSpacePointSet + { + return $this->parentsByContentStreamId[$contentStreamId->value] + ->getCoveredDimensionSpacePointSet(); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryNodeRecords.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryNodeRecords.php new file mode 100644 index 00000000000..c6e0d0b6a8d --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryNodeRecords.php @@ -0,0 +1,71 @@ + + * @internal + */ +final class InMemoryNodeRecords implements \IteratorAggregate +{ + /** + * @var InMemoryNodeRecord[] + */ + private array $items; + + public function __construct(InMemoryNodeRecord ...$items) + { + $this->items = $items; + } + + public static function create(InMemoryNodeRecord ...$items): self + { + return new self(...$items); + } + + public function reverse(): self + { + return new self(...array_reverse($this->items)); + } + + public function removeIfContained(InMemoryNodeRecord $record): void + { + foreach ($this->items as $i => $item) { + if ($item === $record) { + unset($this->items[$i]); + $this->items = array_values($this->items); + return; + } + } + } + + public function insert(InMemoryNodeRecord $nodeRecord, ?NodeAggregateId $succeedingSiblingId): void + { + if (!$succeedingSiblingId) { + $this->items[] = $nodeRecord; + } else { + $nodeAggregateIds = array_map( + fn (InMemoryNodeRecord $nodeRecord): NodeAggregateId => $nodeRecord->nodeAggregateId, + array_values($this->items), + ); + $succeedingSiblingPosition = array_search($succeedingSiblingId, $nodeAggregateIds); + if ($succeedingSiblingPosition !== false) { + array_splice($this->items, $succeedingSiblingPosition, 0, [$nodeRecord]); + } + } + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + yield from $this->items; + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryReferenceHyperrelation.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryReferenceHyperrelation.php new file mode 100644 index 00000000000..46829adb18a --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryReferenceHyperrelation.php @@ -0,0 +1,17 @@ + + * @internal + */ +final class InMemoryReferenceHyperrelation extends \SplObjectStorage +{ +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryReferenceRecord.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryReferenceRecord.php new file mode 100644 index 00000000000..0142b998952 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryReferenceRecord.php @@ -0,0 +1,24 @@ + + * @internal + */ +final class InMemoryReferenceRecords implements \IteratorAggregate +{ + /** + * @var InMemoryReferenceRecord[] + */ + private array $items; + + public function __construct(InMemoryReferenceRecord ...$items) + { + $this->items = $items; + } + + public static function create(InMemoryReferenceRecord ...$items): self + { + return new self(...$items); + } + + public function reverse(): self + { + return new self(...array_reverse($this->items)); + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + yield from $this->items; + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryWorkspaceRecord.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryWorkspaceRecord.php new file mode 100644 index 00000000000..85b819f3cc4 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/InMemoryWorkspaceRecord.php @@ -0,0 +1,25 @@ +value; + } + + public function equals(self $other): bool + { + return $other->value === $this->value; + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/ProjectionIntegrityViolationDetector.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/ProjectionIntegrityViolationDetector.php new file mode 100644 index 00000000000..fb3cf40baec --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Projection/ProjectionIntegrityViolationDetector.php @@ -0,0 +1,720 @@ +tableNames->hierarchyRelation()} h + LEFT JOIN {$this->tableNames->node()} p ON h.parentnodeanchor = p.relationanchorpoint + LEFT JOIN {$this->tableNames->node()} c ON h.childnodeanchor = c.relationanchorpoint + WHERE h.parentnodeanchor != :rootNodeAnchor + AND ( + p.relationanchorpoint IS NULL + OR c.relationanchorpoint IS NULL + ) + SQL; + try { + $disconnectedHierarchyRelationRecords = $this->dbal->executeQuery($disconnectedHierarchyRelationStatement, [ + 'rootNodeAnchor' => NodeRelationAnchorPoint::forRootEdge()->value + ])->fetchAllAssociative(); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load disconnected hierarchy relations: %s', $e->getMessage()), 1716491735, $e); + } + + foreach ($disconnectedHierarchyRelationRecords as $record) { + $result->addError(new Error( + 'Hierarchy relation ' . \json_encode($record) + . ' is disconnected.', + self::ERROR_CODE_HIERARCHY_INTEGRITY_IS_COMPROMISED + )); + } + + $invalidlyHashedHierarchyRelationStatement = <<tableNames->hierarchyRelation()} h + LEFT JOIN {$this->tableNames->dimensionSpacePoints()} dsp + ON dsp.hash = h.dimensionspacepointhash + WHERE dsp.dimensionspacepoint IS NULL + SQL; + try { + $invalidlyHashedHierarchyRelationRecords = $this->dbal->fetchAllAssociative($invalidlyHashedHierarchyRelationStatement); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load invalid hashed hierarchy relations: %s', $e->getMessage()), 1716491994, $e); + } + + foreach ($invalidlyHashedHierarchyRelationRecords as $record) { + $result->addError(new Error( + 'Hierarchy relation ' . \json_encode($record) + . ' has an invalid dimension space point hash.', + self::ERROR_CODE_HIERARCHY_INTEGRITY_IS_COMPROMISED + )); + } + + $hierarchyRelationsAppearingMultipleTimesStatement = <<tableNames->hierarchyRelation()} h + LEFT JOIN {$this->tableNames->node()} p ON h.parentnodeanchor = p.relationanchorpoint + LEFT JOIN {$this->tableNames->node()} c ON h.childnodeanchor = c.relationanchorpoint + WHERE + h.parentnodeanchor != :rootNodeAnchor + GROUP BY + p.nodeaggregateid, c.nodeaggregateid, + h.dimensionspacepointhash, h.contentstreamid + HAVING uniquenessCounter > 1 + SQL; + try { + $hierarchyRelationRecordsAppearingMultipleTimes = $this->dbal->fetchAllAssociative($hierarchyRelationsAppearingMultipleTimesStatement, [ + 'rootNodeAnchor' => NodeRelationAnchorPoint::forRootEdge()->value + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load hierarchy relations that appear multiple times: %s', $e->getMessage()), 1716495277, $e); + } + foreach ($hierarchyRelationRecordsAppearingMultipleTimes as $record) { + $result->addError(new Error( + 'Hierarchy relation ' . \json_encode($record) + . ' appears multiple times.', + self::ERROR_CODE_HIERARCHY_INTEGRITY_IS_COMPROMISED + )); + } + + return $result; + } + + public function siblingsAreDistinctlySorted(): Result + { + $result = new Result(); + + $ambiguouslySortedHierarchyRelationStatement = <<tableNames->hierarchyRelation()} + GROUP BY + position, + parentnodeanchor, + contentstreamid, + dimensionspacepointhash + HAVING + COUNT(position) > 1 + SQL; + try { + $ambiguouslySortedHierarchyRelationRecords = $this->dbal->executeQuery($ambiguouslySortedHierarchyRelationStatement); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load ambiguously sorted hierarchy relations: %s', $e->getMessage()), 1716492251, $e); + } + if ($ambiguouslySortedHierarchyRelationRecords->columnCount() === 0) { + return $result; + } + + $dimensionSpacePoints = $this->findProjectedDimensionSpacePoints(); + + $ambiguouslySortedNodesStatement = <<tableNames->node()} n + LEFT JOIN {$this->tableNames->hierarchyRelation()} ph + ON ph.childnodeanchor = n.relationanchorpoint + WHERE ph.parentnodeanchor = :relationAnchorPoint + SQL; + foreach ($ambiguouslySortedHierarchyRelationRecords->iterateAssociative() as $hierarchyRelationRecord) { + try { + $ambiguouslySortedNodeRecords = $this->dbal->fetchAllAssociative($ambiguouslySortedNodesStatement, [ + 'relationAnchorPoint' => $hierarchyRelationRecord['parentnodeanchor'], + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load ambiguously sorted nodes: %s', $e->getMessage()), 1716492358, $e); + } + + $result->addError(new Error( + 'Siblings ' . implode(', ', array_map(static fn (array $record) => $record['nodeaggregateid'], $ambiguouslySortedNodeRecords)) + . ' are ambiguously sorted in content stream ' . $hierarchyRelationRecord['contentstreamid'] + . ' and dimension space point ' . $dimensionSpacePoints[$hierarchyRelationRecord['dimensionspacepointhash']]?->toJson(), + self::ERROR_CODE_SIBLINGS_ARE_AMBIGUOUSLY_SORTED + )); + } + + return $result; + } + + public function tetheredNodesAreNamed(): Result + { + $result = new Result(); + $unnamedTetheredNodesStatement = <<tableNames->node()} n + INNER JOIN {$this->tableNames->hierarchyRelation()} h ON h.childnodeanchor = n.relationanchorpoint + WHERE + n.classification = :tethered + AND n.name IS NULL + GROUP BY + n.nodeaggregateid, h.contentstreamid + SQL; + try { + $unnamedTetheredNodeRecords = $this->dbal->fetchAllAssociative($unnamedTetheredNodesStatement, [ + 'tethered' => NodeAggregateClassification::CLASSIFICATION_TETHERED->value + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load unnamed tethered nodes: %s', $e->getMessage()), 1716492549, $e); + } + + foreach ($unnamedTetheredNodeRecords as $unnamedTetheredNodeRecord) { + $result->addError(new Error( + 'Node aggregate ' . $unnamedTetheredNodeRecord['nodeaggregateid'] + . ' is unnamed in content stream ' . $unnamedTetheredNodeRecord['contentstreamid'] . '.', + self::ERROR_CODE_TETHERED_NODE_IS_UNNAMED + )); + } + + return $result; + } + + public function subtreeTagsAreInherited(): Result + { + $result = new Result(); + + // NOTE: + // This part determines if a parent hierarchy relation contains subtree tags that are not existing in the child relation. + // This could probably be solved with JSON_ARRAY_INTERSECT(JSON_KEYS(ph.subtreetags), JSON_KEYS(h.subtreetags) but unfortunately that's only available with MariaDB 11.2+ according to https://mariadb.com/kb/en/json_array_intersect/ + $hierarchyRelationsWithMissingSubtreeTagsStatement = <<tableNames->hierarchyRelation()} h + INNER JOIN {$this->tableNames->hierarchyRelation()} ph + ON ph.childnodeanchor = h.parentnodeanchor + AND ph.contentstreamid = h.contentstreamid + AND ph.dimensionspacepointhash = h.dimensionspacepointhash + WHERE + EXISTS ( + SELECT t.tag FROM JSON_TABLE(JSON_KEYS(ph.subtreetags), '\$[*]' COLUMNS(tag VARCHAR(30) PATH '\$')) t WHERE JSON_CONTAINS_PATH(h.subtreetags, 'all', CONCAT('\$.', t.tag)) = 0 + ) + SQL; + try { + $hierarchyRelationsWithMissingSubtreeTags = $this->dbal->fetchAllAssociative($hierarchyRelationsWithMissingSubtreeTagsStatement); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load hierarchy relations with missing subtree tags: %s', $e->getMessage()), 1716492658, $e); + } + + foreach ($hierarchyRelationsWithMissingSubtreeTags as $hierarchyRelation) { + $result->addError(new Error( + 'Hierarchy relation ' . \json_encode($hierarchyRelation) + . ' is missing inherited subtree tags from the parent relation.', + self::ERROR_CODE_NODE_HAS_MISSING_SUBTREE_TAG + )); + } + + return $result; + } + + public function referenceIntegrityIsProvided(): Result + { + $result = new Result(); + + $referenceRelationRecordsDetachedFromSourceStatement = <<tableNames->referenceRelation()} + WHERE + nodeanchorpoint NOT IN ( + SELECT relationanchorpoint FROM {$this->tableNames->node()} + ) + SQL; + try { + $referenceRelationRecordsDetachedFromSource = $this->dbal->fetchAllAssociative($referenceRelationRecordsDetachedFromSourceStatement); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load detached reference relations: %s', $e->getMessage()), 1716492786, $e); + } + + foreach ($referenceRelationRecordsDetachedFromSource as $record) { + $result->addError(new Error( + 'Reference relation ' . \json_encode($record) + . ' is detached from its origin.', + self::ERROR_CODE_REFERENCE_INTEGRITY_IS_COMPROMISED + )); + } + + $referenceRelationRecordsWithInvalidTargetStatement = <<tableNames->referenceRelation()} r + INNER JOIN {$this->tableNames->node()} s ON r.nodeanchorpoint = s.relationanchorpoint + INNER JOIN {$this->tableNames->hierarchyRelation()} sh ON r.nodeanchorpoint = sh.childnodeanchor + LEFT JOIN ( + {$this->tableNames->node()} d + INNER JOIN {$this->tableNames->hierarchyRelation()} dh ON d.relationanchorpoint = dh.childnodeanchor + ) ON r.destinationnodeaggregateid = d.nodeaggregateid + AND sh.contentstreamid = dh.contentstreamid + AND sh.dimensionspacepointhash = dh.dimensionspacepointhash + WHERE + d.nodeaggregateid IS NULL + GROUP BY + s.nodeaggregateid, + sh.contentstreamid, + r.destinationnodeaggregateid + SQL; + try { + $referenceRelationRecordsWithInvalidTarget = $this->dbal->fetchAllAssociative($referenceRelationRecordsWithInvalidTargetStatement); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load reference relations with invalid target: %s', $e->getMessage()), 1716492909, $e); + } + + foreach ($referenceRelationRecordsWithInvalidTarget as $record) { + $result->addError(new Error( + 'Destination node aggregate ' . $record['destinationNodeAggregateId'] + . ' does not cover any dimension space points the source ' . $record['sourceNodeAggregateId'] + . ' does in content stream ' . $record['contentstreamId'], + self::ERROR_CODE_REFERENCE_INTEGRITY_IS_COMPROMISED + )); + } + + return $result; + } + + /** + * This is provided by the database structure: + * reference relations with the same source and same name must have distinct positions + */ + public function referencesAreDistinctlySorted(): Result + { + // TODO implement + return new Result(); + } + + public function allNodesAreConnectedToARootNodePerSubgraph(): Result + { + $result = new Result(); + + $nodeAggregateIdsInCyclesStatement = <<tableNames->hierarchyRelation()} h + WHERE + h.parentnodeanchor = :rootAnchorPoint + AND h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash = :dimensionSpacePointHash + UNION + -- -------------------------------- + -- RECURSIVE query: do one "child" query step + -- -------------------------------- + SELECT + h.childnodeanchor + FROM + subgraph p + INNER JOIN {$this->tableNames->hierarchyRelation()} h + on h.parentnodeanchor = p.childnodeanchor + WHERE + h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash = :dimensionSpacePointHash + ) + SELECT nodeaggregateid FROM {$this->tableNames->node()} n + INNER JOIN {$this->tableNames->hierarchyRelation()} h + ON h.childnodeanchor = n.relationanchorpoint + WHERE + h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash = :dimensionSpacePointHash + AND relationanchorpoint NOT IN (SELECT * FROM subgraph) + SQL; + + foreach ($this->findProjectedContentStreamIds() as $contentStreamId) { + foreach ($this->findProjectedDimensionSpacePoints() as $dimensionSpacePoint) { + try { + $nodeAggregateIdsInCycles = $this->dbal->fetchFirstColumn($nodeAggregateIdsInCyclesStatement, [ + 'rootAnchorPoint' => NodeRelationAnchorPoint::forRootEdge()->value, + 'contentStreamId' => $contentStreamId->value, + 'dimensionSpacePointHash' => $dimensionSpacePoint->hash + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load cyclic node relations: %s', $e->getMessage()), 1716493090, $e); + } + + if (!empty($nodeAggregateIdsInCycles)) { + $result->addError(new Error( + 'Subgraph defined by content stream ' . $contentStreamId->value + . ' and dimension space point ' . $dimensionSpacePoint->toJson() + . ' is cyclic for node aggregates ' + . implode(',', $nodeAggregateIdsInCycles), + self::ERROR_CODE_NODE_IS_DISCONNECTED_FROM_THE_ROOT + )); + } + } + } + + return $result; + } + + /** + * There are two cases here: + * a) The node has no ingoing hierarchy relations -> covered by allNodesCoverTheirOrigin + * b) The node's ingoing hierarchy edges are detached from their parent -> covered by hierarchyIntegrityIsProvided + */ + public function nonRootNodesHaveParents(): Result + { + // TODO implement + return new Result(); + } + + public function nodeAggregateIdsAreUniquePerSubgraph(): Result + { + $result = new Result(); + $ambiguousNodeAggregatesStatement = <<tableNames->node()} n + INNER JOIN {$this->tableNames->hierarchyRelation()} h ON h.childnodeanchor = n.relationanchorpoint + WHERE + h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash = :dimensionSpacePointHash + GROUP BY + n.nodeaggregateid + HAVING + COUNT(DISTINCT(n.relationanchorpoint)) > 1 + SQL; + + foreach ($this->findProjectedContentStreamIds() as $contentStreamId) { + foreach ($this->findProjectedDimensionSpacePoints() as $dimensionSpacePoint) { + try { + $ambiguousNodeAggregateRecords = $this->dbal->fetchAllAssociative($ambiguousNodeAggregatesStatement, [ + 'contentStreamId' => $contentStreamId->value, + 'dimensionSpacePointHash' => $dimensionSpacePoint->hash + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load ambiguous node aggregates: %s', $e->getMessage()), 1716494110, $e); + } + foreach ($ambiguousNodeAggregateRecords as $ambiguousRecord) { + $result->addError(new Error( + 'Node aggregate ' . $ambiguousRecord['nodeaggregateid'] + . ' is ambiguous in content stream ' . $contentStreamId->value + . ' and dimension space point ' . $dimensionSpacePoint->toJson(), + self::ERROR_CODE_AMBIGUOUS_NODE_AGGREGATE_IN_SUBGRAPH + )); + } + } + } + + return $result; + } + + public function allNodesHaveAtMostOneParentPerSubgraph(): Result + { + $result = new Result(); + $nodeRecordsWithMultipleParentsStatement = <<tableNames->node()} c + INNER JOIN {$this->tableNames->hierarchyRelation()} h ON h.childnodeanchor = c.relationanchorpoint + WHERE + h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash = :dimensionSpacePointHash + GROUP BY + c.relationanchorpoint + HAVING + COUNT(DISTINCT(h.parentnodeanchor)) > 1 + SQL; + + foreach ($this->findProjectedContentStreamIds() as $contentStreamId) { + foreach ($this->findProjectedDimensionSpacePoints() as $dimensionSpacePoint) { + try { + $nodeRecordsWithMultipleParents = $this->dbal->fetchAllAssociative($nodeRecordsWithMultipleParentsStatement, [ + 'contentStreamId' => $contentStreamId->value, + 'dimensionSpacePointHash' => $dimensionSpacePoint->hash + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load nodes with multiple parents: %s', $e->getMessage()), 1716494223, $e); + } + + foreach ($nodeRecordsWithMultipleParents as $record) { + $result->addError(new Error( + 'Node aggregate ' . $record['nodeaggregateid'] + . ' has multiple parents in content stream ' . $contentStreamId->value + . ' and dimension space point ' . $dimensionSpacePoint->toJson(), + self::ERROR_CODE_NODE_HAS_MULTIPLE_PARENTS + )); + } + } + } + + return $result; + } + + public function nodeAggregatesAreConsistentlyTypedPerContentStream(): Result + { + $result = new Result(); + $nodeAggregatesStatement = <<tableNames->node()} n + INNER JOIN {$this->tableNames->hierarchyRelation()} h ON h.childnodeanchor = n.relationanchorpoint + WHERE + h.contentstreamid = :contentStreamId + AND n.nodeaggregateid = :nodeAggregateId + SQL; + foreach ($this->findProjectedContentStreamIds() as $contentStreamId) { + foreach ( + $this->findProjectedNodeAggregateIdsInContentStream( + $contentStreamId + ) as $nodeAggregateId + ) { + try { + $nodeTypeNames = $this->dbal->fetchFirstColumn($nodeAggregatesStatement, [ + 'contentStreamId' => $contentStreamId->value, + 'nodeAggregateId' => $nodeAggregateId->value + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load node type names: %s', $e->getMessage()), 1716494446, $e); + } + + if (count($nodeTypeNames) > 1) { + $result->addError(new Error( + 'Node aggregate ' . $nodeAggregateId->value + . ' in content stream ' . $contentStreamId->value + . ' is of ambiguous type ("' . implode('","', $nodeTypeNames) . '")', + self::ERROR_CODE_NODE_AGGREGATE_IS_AMBIGUOUSLY_TYPED + )); + } + } + } + + return $result; + } + + public function nodeAggregatesAreConsistentlyClassifiedPerContentStream(): Result + { + $result = new Result(); + $nodeAggregatesStatement = <<tableNames->node()} n + INNER JOIN {$this->tableNames->hierarchyRelation()} h ON h.childnodeanchor = n.relationanchorpoint + WHERE + h.contentstreamid = :contentStreamId + AND n.nodeaggregateid = :nodeAggregateId + SQL; + foreach ($this->findProjectedContentStreamIds() as $contentStreamId) { + foreach ( + $this->findProjectedNodeAggregateIdsInContentStream( + $contentStreamId + ) as $nodeAggregateId + ) { + try { + $classifications = $this->dbal->fetchFirstColumn($nodeAggregatesStatement, [ + 'contentStreamId' => $contentStreamId->value, + 'nodeAggregateId' => $nodeAggregateId->value + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load node classifications: %s', $e->getMessage()), 1716494466, $e); + } + + if (count($classifications) > 1) { + $result->addError(new Error( + 'Node aggregate ' . $nodeAggregateId->value + . ' in content stream ' . $contentStreamId->value + . ' is ambiguously classified ("' . implode('","', $classifications) . '")', + self::ERROR_CODE_NODE_AGGREGATE_IS_AMBIGUOUSLY_CLASSIFIED + )); + } + } + } + + return $result; + } + + public function childNodeCoverageIsASubsetOfParentNodeCoverage(): Result + { + $result = new Result(); + $excessivelyCoveringStatement = <<tableNames->hierarchyRelation()} c + INNER JOIN {$this->tableNames->node()} n ON c.childnodeanchor = n.relationanchorpoint + LEFT JOIN {$this->tableNames->hierarchyRelation()} p ON c.parentnodeanchor = p.childnodeanchor + WHERE + c.contentstreamid = :contentStreamId + AND p.contentstreamid = :contentStreamId + AND c.dimensionspacepointhash = p.dimensionspacepointhash + AND p.childnodeanchor IS NULL + SQL; + foreach ($this->findProjectedContentStreamIds() as $contentStreamId) { + try { + $excessivelyCoveringNodeRecords = $this->dbal->fetchAllAssociative($excessivelyCoveringStatement, [ + 'contentStreamId' => $contentStreamId->value + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load excessively covering nodes: %s', $e->getMessage()), 1716494618, $e); + } + foreach ($excessivelyCoveringNodeRecords as $excessivelyCoveringNodeRecord) { + $result->addError(new Error( + 'Node aggregate ' . $excessivelyCoveringNodeRecord['nodeaggregateid'] + . ' in content stream ' . $contentStreamId->value + . ' covers dimension space point hash ' . $excessivelyCoveringNodeRecord['dimensionspacepointhash'] + . ' but its parent does not.', + self::ERROR_CODE_CHILD_NODE_COVERAGE_IS_NO_SUBSET_OF_PARENT_NODE_COVERAGE + )); + } + } + + return $result; + } + + public function allNodesCoverTheirOrigin(): Result + { + $result = new Result(); + $nodesWithMissingOriginCoverageStatement = <<tableNames->node()} n + INNER JOIN {$this->tableNames->hierarchyRelation()} h ON h.childnodeanchor = n.relationanchorpoint + WHERE + h.contentstreamid = :contentStreamId + AND nodeaggregateid NOT IN ( + -- this query finds all nodes whose origin *IS COVERED* by an incoming hierarchy relation. + SELECT + n.nodeaggregateid + FROM + {$this->tableNames->node()} n + LEFT JOIN {$this->tableNames->hierarchyRelation()} p ON + p.childnodeanchor = n.relationanchorpoint + AND p.dimensionspacepointhash = n.origindimensionspacepointhash + WHERE + p.contentstreamid = :contentStreamId + ) + AND classification != :rootClassification + SQL; + foreach ($this->findProjectedContentStreamIds() as $contentStreamId) { + try { + $nodeRecordsWithMissingOriginCoverage = $this->dbal->fetchAllAssociative($nodesWithMissingOriginCoverageStatement, [ + 'contentStreamId' => $contentStreamId->value, + 'rootClassification' => NodeAggregateClassification::CLASSIFICATION_ROOT->value + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load nodes with missing origin coverage: %s', $e->getMessage()), 1716494752, $e); + } + + foreach ($nodeRecordsWithMissingOriginCoverage as $nodeRecord) { + $result->addError(new Error( + 'Node aggregate ' . $nodeRecord['nodeaggregateid'] + . ' in content stream ' . $contentStreamId->value + . ' does not cover its origin dimension space point hash ' . $nodeRecord['origindimensionspacepointhash'] + . '.', + self::ERROR_CODE_NODE_DOES_NOT_COVER_ITS_ORIGIN + )); + } + } + + return $result; + } + + /** + * Returns all content stream ids + * + * @return iterable + */ + private function findProjectedContentStreamIds(): iterable + { + $contentStreamIdsStatement = <<tableNames->hierarchyRelation()} + SQL; + try { + $contentStreamIds = $this->dbal->fetchFirstColumn($contentStreamIdsStatement); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load content stream ids: %s', $e->getMessage()), 1716494814, $e); + } + return array_map(ContentStreamId::fromString(...), $contentStreamIds); + } + + /** + * Returns all projected dimension space points + */ + private function findProjectedDimensionSpacePoints(): DimensionSpacePointSet + { + $dimensionSpacePointsStatement = <<tableNames->dimensionSpacePoints()} + SQL; + try { + $dimensionSpacePoints = $this->dbal->fetchFirstColumn($dimensionSpacePointsStatement); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load dimension space points: %s', $e->getMessage()), 1716494888, $e); + } + return new DimensionSpacePointSet(array_map(DimensionSpacePoint::fromJsonString(...), $dimensionSpacePoints)); + } + + /** + * @return array + */ + protected function findProjectedNodeAggregateIdsInContentStream( + ContentStreamId $contentStreamId + ): array { + $nodeAggregateIdsStatement = <<tableNames->node()} n + INNER JOIN {$this->tableNames->hierarchyRelation()} h ON h.childnodeanchor = n.relationanchorpoint + WHERE + h.contentstreamid = :contentStreamId + SQL; + try { + $nodeAggregateIds = $this->dbal->fetchFirstColumn($nodeAggregateIdsStatement, [ + 'contentStreamId' => $contentStreamId->value, + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load node aggregate ids for content stream: %s', $e->getMessage()), 1716495988, $e); + } + return array_map(NodeAggregateId::fromString(...), $nodeAggregateIds); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/InMemoryContentGraph.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/InMemoryContentGraph.php new file mode 100644 index 00000000000..27cf076babb --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/InMemoryContentGraph.php @@ -0,0 +1,275 @@ +contentRepositoryId; + } + + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } + + public function getSubgraph( + DimensionSpacePoint $dimensionSpacePoint, + VisibilityConstraints $visibilityConstraints + ): ContentSubgraphInterface { + return new InMemoryContentSubgraph( + $this->contentRepositoryId, + $this->workspaceName, + $this->contentStreamId, + $dimensionSpacePoint, + $visibilityConstraints, + $this->nodeFactory, + $this->nodeTypeManager, + $this->graphStructure, + ); + } + + public function findRootNodeAggregateByType( + NodeTypeName $nodeTypeName + ): ?NodeAggregate { + $rootNodeAggregates = $this->findRootNodeAggregates( + FindRootNodeAggregatesFilter::create(nodeTypeName: $nodeTypeName) + ); + + if ($rootNodeAggregates->count() > 1) { + // todo drop this check as this is enforced by the write side? https://github.com/neos/neos-development-collection/pull/4339 + $ids = []; + foreach ($rootNodeAggregates as $rootNodeAggregate) { + $ids[] = $rootNodeAggregate->nodeAggregateId->value; + } + + // We throw if multiple root node aggregates of the given $nodeTypeName were found, + // as this would lead to nondeterministic results. Must not happen. + throw new \RuntimeException(sprintf( + 'More than one root node aggregate of type "%s" found (IDs: %s).', + $nodeTypeName->value, + implode(', ', $ids) + )); + } + + return $rootNodeAggregates->first(); + } + + public function findRootNodeAggregates( + FindRootNodeAggregatesFilter $filter, + ): NodeAggregates { + if ($filter->nodeTypeName) { + $rootNodes = $this->graphStructure->rootNodes[$this->contentStreamId->value][$filter->nodeTypeName->value] ?? null; + if ($rootNodes === null) { + return NodeAggregates::createEmpty(); + } + $rootNodeRecords = [$filter->nodeTypeName->value => [$rootNodes]]; + } else { + $rootNodeRecords = $this->graphStructure->rootNodes; + } + return NodeAggregates::fromArray( + array_map( + fn (array $nodeRecords): NodeAggregate => $this->nodeFactory->mapNodeRecordsToNodeAggregate($nodeRecords, $this->workspaceName, $this->contentStreamId), + $rootNodeRecords + ) + ); + } + + public function findNodeAggregatesByType( + NodeTypeName $nodeTypeName + ): NodeAggregates { + throw new \Exception(__METHOD__ . ' not implemented yet'); + } + + public function findNodeAggregateById( + NodeAggregateId $nodeAggregateId + ): ?NodeAggregate { + $nodeRecords = $this->graphStructure->nodes[$this->contentStreamId->value][$nodeAggregateId->value] ?? null; + + return $nodeRecords === null + ? null + : $this->nodeFactory->mapNodeRecordsToNodeAggregate($nodeRecords, $this->workspaceName, $this->contentStreamId); + } + + public function findNodeAggregatesByIds( + NodeAggregateIds $nodeAggregateIds + ): NodeAggregates { + $nodeAggregates = []; + foreach ($nodeAggregateIds as $nodeAggregateId) { + $nodeAggregate = $this->findNodeAggregateById($nodeAggregateId); + if ($nodeAggregate !== null) { + $nodeAggregates[] = $nodeAggregate; + } + } + + return NodeAggregates::fromArray($nodeAggregates); + } + + /** + * Parent node aggregates can have a greater dimension space coverage than the given child. + * Thus, it is not enough to just resolve them from the nodes and edges connected to the given child node aggregate. + * Instead, we resolve all parent node aggregate ids instead and fetch the complete aggregates from there. + */ + public function findParentNodeAggregates( + NodeAggregateId $childNodeAggregateId + ): NodeAggregates { + $parentNodeAggregateIds = NodeAggregateIds::createEmpty(); + foreach ($this->graphStructure->nodes[$this->contentStreamId->value][$childNodeAggregateId->value] as $nodeRecord) { + $parentNodeAggregateIds = $parentNodeAggregateIds->merge( + $nodeRecord->parentsByContentStreamId[$this->contentStreamId->value]->getParentNodeAggregateIds() + ); + } + + return $this->findNodeAggregatesByIds(NodeAggregateIds::create(...$parentNodeAggregateIds)); + } + + public function findAncestorNodeAggregateIds(NodeAggregateId $entryNodeAggregateId): NodeAggregateIds + { + $nodeAggregateIds = NodeAggregateIds::create(); + foreach ($this->graphStructure->nodes[$this->contentStreamId->value][$entryNodeAggregateId->value] as $nodeRecord) { + foreach ($nodeRecord->parentsByContentStreamId[$this->contentStreamId->value] as $parentHierarchyRelation) { + if ($parentHierarchyRelation->parent === null) { + continue; + } + $nodeAggregateIds = $nodeAggregateIds->merge(NodeAggregateIds::create($parentHierarchyRelation->parent->nodeAggregateId)); + $nodeAggregateIds = $nodeAggregateIds->merge($this->findAncestorNodeAggregateIds($parentHierarchyRelation->parent->nodeAggregateId)); + } + } + + return $nodeAggregateIds; + } + + public function findChildNodeAggregates( + NodeAggregateId $parentNodeAggregateId + ): NodeAggregates { + $childNodeAggregateIds = []; + foreach ($this->graphStructure->nodes[$this->contentStreamId->value][$parentNodeAggregateId->value] ?? [] as $nodeRecord) { + foreach ($nodeRecord->childrenByContentStream[$this->contentStreamId->value] as $childRelation) { + foreach ($childRelation->children as $childNodeRecord) { + $childNodeAggregateIds[$childNodeRecord->nodeAggregateId->value] = $childNodeRecord->nodeAggregateId; + } + } + } + + return $this->findNodeAggregatesByIds(NodeAggregateIds::create(...$childNodeAggregateIds)); + } + + public function findParentNodeAggregateByChildOriginDimensionSpacePoint(NodeAggregateId $childNodeAggregateId, OriginDimensionSpacePoint $childOriginDimensionSpacePoint): ?NodeAggregate + { + $childNodeRecord = $this->graphStructure->nodes[$this->contentStreamId->value][$childNodeAggregateId->value][$childOriginDimensionSpacePoint->hash] ?? null; + + $parentNode = $childNodeRecord?->parentsByContentStreamId[$this->contentStreamId->value] + ->getHierarchyHyperrelation($childOriginDimensionSpacePoint->toDimensionSpacePoint()) + ->parent; + + if ($parentNode instanceof InMemoryNodeRecord) { + return $this->findNodeAggregateById($parentNode->nodeAggregateId); + } + + return null; + } + + public function findTetheredChildNodeAggregates(NodeAggregateId $parentNodeAggregateId): NodeAggregates + { + $nodeRecords = []; + foreach ($this->graphStructure->nodes[$this->contentStreamId->value][$parentNodeAggregateId->value] ?? [] as $nodeRecord) { + foreach ($nodeRecord->childrenByContentStream[$this->contentStreamId->value] as $childRelation) { + foreach ($childRelation->children as $childNodeRecord) { + if ($childNodeRecord->classification === NodeAggregateClassification::CLASSIFICATION_TETHERED) { + $nodeRecords[] = $childNodeRecord; + } + } + } + } + if ($nodeRecords === []) { + return NodeAggregates::createEmpty(); + } + + return $this->nodeFactory->mapNodeRecordsToNodeAggregates($nodeRecords, $this->workspaceName, $this->contentStreamId); + } + + public function findChildNodeAggregateByName( + NodeAggregateId $parentNodeAggregateId, + NodeName $name + ): ?NodeAggregate { + $nodeRecords = []; + foreach ($this->graphStructure->nodes[$this->contentStreamId->value][$parentNodeAggregateId->value] ?? [] as $nodeRecord) { + foreach ($nodeRecord->childrenByContentStream[$this->contentStreamId->value] as $childRelation) { + foreach ($childRelation->children as $childNodeRecord) { + if ($childNodeRecord?->name->equals($name)) { + $nodeRecords[] = $childNodeRecord; + break; + } + } + } + } + if ($nodeRecords === []) { + return null; + } + + return $this->nodeFactory->mapNodeRecordsToNodeAggregate($nodeRecords, $this->workspaceName, $this->contentStreamId); + } + + public function getDimensionSpacePointsOccupiedByChildNodeName(NodeName $nodeName, NodeAggregateId $parentNodeAggregateId, OriginDimensionSpacePoint $parentNodeOriginDimensionSpacePoint, DimensionSpacePointSet $dimensionSpacePointsToCheck): DimensionSpacePointSet + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + } + + public function findNodeAggregatesTaggedBy(SubtreeTag $subtreeTag): NodeAggregates + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + } + + public function findUsedNodeTypeNames(): NodeTypeNames + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + } + + public function getContentStreamId(): ContentStreamId + { + return $this->contentStreamId; + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/InMemoryContentStreamRegistry.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/InMemoryContentStreamRegistry.php new file mode 100644 index 00000000000..ef4ebbbf2a3 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/InMemoryContentStreamRegistry.php @@ -0,0 +1,93 @@ + $contentStreams indexed by id + */ + private function __construct( + public array $contentStreams = [] + ) { + } + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + + return self::$instance; + } + + public function reset(): void + { + $this->contentStreams = []; + } + + public function createContentStream(ContentStreamId $contentStreamId, ?ContentStreamId $sourceContentStreamId = null, ?Version $sourceVersion = null): void + { + if (array_key_exists($contentStreamId->value, $this->contentStreams)) { + throw new \Exception('Content stream with ID ' . $contentStreamId->value . ' already exists.', 1745098891); + } + + $this->contentStreams[$contentStreamId->value] = new InMemoryContentStreamRecord( + $contentStreamId, + Version::first(), + $sourceContentStreamId, + $sourceVersion, + ); + } + + public function closeContentStream(ContentStreamId $contentStreamId): void + { + $this->requireContentStreamToExist($contentStreamId); + $this->contentStreams[$contentStreamId->value]->isClosed = true; + } + + public function reopenContentStream(ContentStreamId $contentStreamId): void + { + $this->requireContentStreamToExist($contentStreamId); + $this->contentStreams[$contentStreamId->value]->isClosed = false; + } + + public function removeContentStream(ContentStreamId $contentStreamId): void + { + if (array_key_exists($contentStreamId->value, $this->contentStreams)) { + unset($this->contentStreams[$contentStreamId->value]); + } + } + + public function updateContentStreamVersion(ContentStreamId $contentStreamId, Version $version, bool $markAsDirty): void + { + $this->requireContentStreamToExist($contentStreamId); + + $this->contentStreams[$contentStreamId->value]->version = $version; + if ($markAsDirty) { + $this->contentStreams[$contentStreamId->value]->hasChanges = true; + } + } + + private function requireContentStreamToExist(ContentStreamId $contentStreamId): void + { + if (!array_key_exists($contentStreamId->value, $this->contentStreams)) { + throw new \Exception('Content stream with ID ' . $contentStreamId->value . ' does not exist.', 1745098990); + } + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/InMemoryContentSubgraph.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/InMemoryContentSubgraph.php new file mode 100644 index 00000000000..38f014fea6f --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/InMemoryContentSubgraph.php @@ -0,0 +1,456 @@ +contentRepositoryId; + } + + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } + + public function getDimensionSpacePoint(): DimensionSpacePoint + { + return $this->dimensionSpacePoint; + } + + public function getVisibilityConstraints(): VisibilityConstraints + { + return $this->visibilityConstraints; + } + + public function findChildNodes(NodeAggregateId $parentNodeAggregateId, FindChildNodesFilter $filter): Nodes + { + /** @todo apply filters */ + foreach ($this->graphStructure->nodes[$this->contentStreamId->value][$parentNodeAggregateId->value] as $parentNodeRecord) { + $relation = $parentNodeRecord->childrenByContentStream[$this->contentStreamId->value]->getHierarchyHyperrelation($this->dimensionSpacePoint); + if ($relation !== null) { + return $this->mapNodeRecordsToNodes($relation->children); + } + } + + return Nodes::createEmpty(); + } + + public function countChildNodes(NodeAggregateId $parentNodeAggregateId, CountChildNodesFilter $filter): int + { + return count($this->findChildNodes($parentNodeAggregateId, FindChildNodesFilter::create( + $filter->nodeTypes, + $filter->searchTerm, + $filter->propertyValue, + ))); + } + + public function findReferences(NodeAggregateId $nodeAggregateId, FindReferencesFilter $filter): References + { + /** @todo apply filters */ + $references = []; + $referenceRelations = $this->graphStructure->references[$this->contentStreamId->value] ?? null; + if ($referenceRelations === null) { + return References::fromArray([]); + } + foreach ($referenceRelations as $referenceRecords) { + if ($referenceRelations->getInfo() === $this->dimensionSpacePoint) { + foreach ($referenceRecords as $referenceRecord) { + if ($referenceRecord->source->nodeAggregateId === $nodeAggregateId) { + $references[] = $this->mapReferenceRecordsToReferences($referenceRecord, false); + } + } + break; + } + } + + return References::fromArray($references); + } + + public function countReferences(NodeAggregateId $nodeAggregateId, CountReferencesFilter $filter): int + { + return count($this->findReferences($nodeAggregateId, FindReferencesFilter::create( + $filter->nodeTypes, + $filter->nodeSearchTerm, + $filter->nodePropertyValue, + $filter->referenceSearchTerm, + $filter->referencePropertyValue, + $filter->referenceName, + ))); + } + + public function findBackReferences(NodeAggregateId $nodeAggregateId, FindBackReferencesFilter $filter): References + { + /** @todo apply filters */ + $references = []; + $referenceRelations = $this->graphStructure->references[$this->contentStreamId->value] ?? null; + if ($referenceRelations === null) { + return References::fromArray([]); + } + foreach ($referenceRelations as $referenceRecords) { + if ($referenceRelations->getInfo() === $this->dimensionSpacePoint) { + foreach ($referenceRecords as $referenceRecord) { + if ($referenceRecord->target->nodeAggregateId === $nodeAggregateId) { + $references[] = $this->mapReferenceRecordsToReferences($referenceRecord, true); + } + } + break; + } + } + + return References::fromArray($references); + } + + public function countBackReferences(NodeAggregateId $nodeAggregateId, CountBackReferencesFilter $filter): int + { + return count($this->findBackReferences($nodeAggregateId, FindBackReferencesFilter::create( + $filter->nodeTypes, + $filter->nodeSearchTerm, + $filter->nodePropertyValue, + $filter->referenceSearchTerm, + $filter->referencePropertyValue, + $filter->referenceName, + ))); + } + + public function findNodeById(NodeAggregateId $nodeAggregateId): ?Node + { + $nodeRecord = $this->findNodeRecord($nodeAggregateId); + + return $nodeRecord !== null + ? $this->mapNodeRecordToNode($nodeRecord) + : null; + } + + public function findNodesByIds(NodeAggregateIds $nodeAggregateIds): Nodes + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + } + + public function findRootNodeByType(NodeTypeName $nodeTypeName): ?Node + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + } + + public function findParentNode(NodeAggregateId $childNodeAggregateId): ?Node + { + $parentNodeRecord = $this->findParentNodeRecord($childNodeAggregateId); + + return $parentNodeRecord + ? $this->mapNodeRecordToNode($parentNodeRecord) + : null; + } + + public function findNodeByPath(NodePath|NodeName $path, NodeAggregateId $startingNodeAggregateId): ?Node + { + $path = $path instanceof NodeName ? NodePath::fromNodeNames($path) : $path; + + $startingNode = $this->findNodeById($startingNodeAggregateId); + + return $startingNode + ? $this->findNodeByPathFromStartingNode($path, $startingNode) + : null; + } + + public function findNodeByAbsolutePath(AbsoluteNodePath $path): ?Node + { + $startingNode = $this->findRootNodeByType($path->rootNodeTypeName); + + return $startingNode + ? $this->findNodeByPathFromStartingNode($path->path, $startingNode) + : null; + } + + /** + * Find a single child node by its name + * + * @return Node|null the node that is connected to its parent with the specified $nodeName, or NULL if no matching node exists or the parent node is not accessible + */ + private function findChildNodeConnectedThroughEdgeName(NodeAggregateId $parentNodeAggregateId, NodeName $nodeName): ?Node + { + foreach ($this->graphStructure->nodes[$this->contentStreamId->value][$parentNodeAggregateId->value] as $parentNodeRecord) { + if ($parentNodeRecord->coversDimensionSpacePoint($this->contentStreamId, $this->dimensionSpacePoint)) { + $relation = $parentNodeRecord->childrenByContentStream[$this->contentStreamId->value]->getHierarchyHyperrelation($this->dimensionSpacePoint); + if ($relation !== null) { + foreach ($relation->children as $childRecord) { + if ($childRecord->name?->equals($nodeName)) { + return $this->mapNodeRecordToNode($childRecord); + } + } + } + } + } + + return null; + } + + public function findSucceedingSiblingNodes(NodeAggregateId $siblingNodeAggregateId, FindSucceedingSiblingNodesFilter $filter): Nodes + { + /** @todo apply filters */ + $parentNodeRecord = $this->findParentNodeRecord($siblingNodeAggregateId); + $siblingFound = false; + $succeedingSiblingRecords = []; + if ($parentNodeRecord !== null) { + $childRelation = $parentNodeRecord->childrenByContentStream[$this->contentStreamId->value]->getHierarchyHyperrelation($this->dimensionSpacePoint); + foreach ($childRelation?->children ?: [] as $childRecord) { + if ($childRecord->nodeAggregateId->equals($siblingNodeAggregateId)) { + $siblingFound = true; + continue; + } + if ($siblingFound) { + $succeedingSiblingRecords[] = $childRecord; + } + } + } else { + // this is a root node + foreach ($this->graphStructure->rootNodes[$this->contentStreamId->value] as $rootNodeRecord) { + if ($rootNodeRecord->coversDimensionSpacePoint($this->contentStreamId, $this->dimensionSpacePoint)) { + if ($rootNodeRecord->nodeAggregateId->equals($siblingNodeAggregateId)) { + $siblingFound = true; + continue; + } + if ($siblingFound) { + $succeedingSiblingRecords[] = $rootNodeRecord; + } + } + } + } + return $this->mapNodeRecordsToNodes($succeedingSiblingRecords); + } + + public function findPrecedingSiblingNodes(NodeAggregateId $siblingNodeAggregateId, FindPrecedingSiblingNodesFilter $filter): Nodes + { + /** @todo apply filters */ + $parentNodeRecord = $this->findParentNodeRecord($siblingNodeAggregateId); + $siblingFound = false; + $precedingSiblingRecords = []; + if ($parentNodeRecord !== null) { + $childRelation = $parentNodeRecord->childrenByContentStream[$this->contentStreamId->value]->getHierarchyHyperrelation($this->dimensionSpacePoint); + foreach ($childRelation?->children->reverse() ?: [] as $childRecord) { + if ($childRecord->nodeAggregateId->equals($siblingNodeAggregateId)) { + $siblingFound = true; + continue; + } + if ($siblingFound) { + $precedingSiblingRecords[] = $childRecord; + } + } + } else { + // this is a root node + $rootNodeRecords = InMemoryNodeRecords::create(...$this->graphStructure->rootNodes[$this->contentStreamId->value]); + $rootNodeRecords = array_reverse(iterator_to_array($rootNodeRecords)); + foreach ($rootNodeRecords as $rootNodeRecord) { + if ($rootNodeRecord->coversDimensionSpacePoint($this->contentStreamId, $this->dimensionSpacePoint)) { + if ($rootNodeRecord->nodeAggregateId->equals($siblingNodeAggregateId)) { + $siblingFound = true; + continue; + } + if ($siblingFound) { + $precedingSiblingRecords[] = $rootNodeRecord; + } + } + } + } + return $this->mapNodeRecordsToNodes($precedingSiblingRecords); + } + + public function retrieveNodePath(NodeAggregateId $nodeAggregateId): AbsoluteNodePath + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + } + + public function findSubtree(NodeAggregateId $entryNodeAggregateId, FindSubtreeFilter $filter): ?Subtree + { + $entryNodeRecord = $this->findNodeRecord($entryNodeAggregateId); + + return $entryNodeRecord !== null + ? $this->nodeFactory->mapNodeRecordToSubtree( + $entryNodeRecord, + $this->workspaceName, + $this->contentStreamId, + $this->dimensionSpacePoint, + $this->visibilityConstraints, + 0, + $filter, + ) + : null; + } + + public function findAncestorNodes(NodeAggregateId $entryNodeAggregateId, FindAncestorNodesFilter $filter): Nodes + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + } + + public function countAncestorNodes(NodeAggregateId $entryNodeAggregateId, CountAncestorNodesFilter $filter): int + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + } + + public function findClosestNode(NodeAggregateId $entryNodeAggregateId, FindClosestNodeFilter $filter): ?Node + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + } + + public function findDescendantNodes(NodeAggregateId $entryNodeAggregateId, FindDescendantNodesFilter $filter): Nodes + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + } + + public function countDescendantNodes(NodeAggregateId $entryNodeAggregateId, CountDescendantNodesFilter $filter): int + { + throw new \Exception(__METHOD__ . ' not implemented yet'); + } + + public function countNodes(): int + { + $numberOfNodes = 0; + foreach ($this->graphStructure->nodes[$this->contentStreamId->value] as $nodeRecords) { + foreach ($nodeRecords as $nodeRecord) { + if ( + $nodeRecord->coversDimensionSpacePoint($this->contentStreamId, $this->dimensionSpacePoint) + ) { + $numberOfNodes++; + } + } + } + + return $numberOfNodes; + } + + /** ------------------------------------------- */ + + private function findNodeByPathFromStartingNode(NodePath $path, Node $startingNode): ?Node + { + $currentNode = $startingNode; + + foreach ($path->getParts() as $edgeName) { + $currentNode = $this->findChildNodeConnectedThroughEdgeName($currentNode->aggregateId, $edgeName); + if ($currentNode === null) { + return null; + } + } + return $currentNode; + } + + private function findNodeRecord(NodeAggregateId $nodeAggregateId): ?InMemoryNodeRecord + { + $nodeRecords = $this->graphStructure->nodes[$this->contentStreamId->value][$nodeAggregateId->value]; + foreach ($nodeRecords as $nodeRecord) { + if ($nodeRecord->coversDimensionSpacePoint($this->contentStreamId, $this->dimensionSpacePoint)) { + return $nodeRecord; + } + } + + return null; + } + + private function findParentNodeRecord(NodeAggregateId $childNodeAggregateId): ?InMemoryNodeRecord + { + $nodeRecords = $this->graphStructure->nodes[$this->contentStreamId->value][$childNodeAggregateId->value]; + foreach ($nodeRecords as $nodeRecord) { + $relation = $nodeRecord->parentsByContentStreamId[$this->contentStreamId->value]->getHierarchyHyperrelation($this->dimensionSpacePoint); + if ($relation !== null) { + return $relation->parent; + } + } + + return null; + } + + private function mapNodeRecordToNode(InMemoryNodeRecord $nodeRecord): Node + { + return $this->nodeFactory->mapNodeRecordToNode( + $nodeRecord, + $this->workspaceName, + $this->dimensionSpacePoint, + $this->visibilityConstraints, + ); + } + + /** + * @param iterable $nodeRecords + */ + private function mapNodeRecordsToNodes(iterable $nodeRecords): Nodes + { + return $this->nodeFactory->mapNodeRecordsToNodes( + $nodeRecords, + $this->workspaceName, + $this->dimensionSpacePoint, + $this->visibilityConstraints, + ); + } + + /** + * @param array $referenceRecords + */ + private function mapReferenceRecordsToReferences( + array $referenceRecords, + bool $backwards, + ): References { + return $this->nodeFactory->mapReferenceRecordsToReferences( + $referenceRecords, + $this->workspaceName, + $this->dimensionSpacePoint, + $this->visibilityConstraints, + $backwards, + ); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/InMemoryWorkspaceRegistry.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/InMemoryWorkspaceRegistry.php new file mode 100644 index 00000000000..09bfb031b36 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/InMemoryWorkspaceRegistry.php @@ -0,0 +1,87 @@ + $workspaces indexed by workspace name + */ + private function __construct( + public array $workspaces = [] + ) { + } + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + + return self::$instance; + } + + public function reset(): void + { + $this->workspaces = []; + } + + public function createWorkspace(WorkspaceName $workspaceName, ?WorkspaceName $baseWorkspaceName, ContentStreamId $contentStreamId): void + { + if (array_key_exists($workspaceName->value, $this->workspaces)) { + throw new \Exception('Workspace ' . $workspaceName->value . ' already exists.', 1745102226); + } + + $this->workspaces[$workspaceName->value] = new InMemoryWorkspaceRecord( + $workspaceName, + $baseWorkspaceName, + $contentStreamId, + false, + false, + ); + } + + public function updateBaseWorkspace(WorkspaceName $workspaceName, WorkspaceName $baseWorkspaceName, ContentStreamId $newContentStreamId): void + { + $this->requireWorkspaceToExist($workspaceName); + $this->workspaces[$workspaceName->value]->baseWorkspaceName = $baseWorkspaceName; + $this->workspaces[$workspaceName->value]->currentContentStreamId = $newContentStreamId; + } + + public function removeWorkspace(WorkspaceName $workspaceName): void + { + if (array_key_exists($workspaceName->value, $this->workspaces)) { + unset($this->workspaces[$workspaceName->value]); + } + } + + public function updateWorkspaceContentStreamId( + WorkspaceName $workspaceName, + ContentStreamId $contentStreamId, + ): void { + $this->requireWorkspaceToExist($workspaceName); + $this->workspaces[$workspaceName->value]->currentContentStreamId = $contentStreamId; + } + + private function requireWorkspaceToExist(WorkspaceName $workspaceName): void + { + if (!array_key_exists($workspaceName->value, $this->workspaces)) { + throw new \Exception('Workspace with name ' . $workspaceName->value . ' does not exist.', 1745102331); + } + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/NodeFactory.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/NodeFactory.php new file mode 100644 index 00000000000..d9394e7a525 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/NodeFactory.php @@ -0,0 +1,241 @@ +contentRepositoryId, + $workspaceName, + $dimensionSpacePoint, + $nodeRecord->nodeAggregateId, + $nodeRecord->originDimensionSpacePoint, + $nodeRecord->classification, + $nodeRecord->nodeTypeName, + new PropertyCollection( + $nodeRecord->properties, + $this->propertyConverter + ), + $nodeRecord->name, + NodeTags::create( + $nodeRecord->tags, + $nodeRecord->inheritedTags, + ), + $nodeRecord->timestamps, + $visibilityConstraints + ); + } + + /** + * @param iterable $nodeRecords + */ + public function mapNodeRecordsToNodes( + iterable $nodeRecords, + WorkspaceName $workspaceName, + DimensionSpacePoint $dimensionSpacePoint, + VisibilityConstraints $visibilityConstraints + ): Nodes { + return Nodes::fromArray( + array_map( + fn (InMemoryNodeRecord $nodeRecord) => $this->mapNodeRecordToNode( + $nodeRecord, + $workspaceName, + $dimensionSpacePoint, + $visibilityConstraints + ), + iterator_to_array($nodeRecords) + ) + ); + } + + /** + * @param array $referenceRecords + */ + public function mapReferenceRecordsToReferences( + array $referenceRecords, + WorkspaceName $workspaceName, + DimensionSpacePoint $dimensionSpacePoint, + VisibilityConstraints $visibilityConstraints, + bool $backwards, + ): References { + $result = []; + foreach ($referenceRecords as $referenceRecord) { + $node = $this->mapNodeRecordToNode( + $backwards ? $referenceRecord->source : $referenceRecord->target, + $workspaceName, + $dimensionSpacePoint, + $visibilityConstraints + ); + $result[] = new Reference( + $node, + $referenceRecord->name, + $referenceRecord->properties + ? new PropertyCollection( + $referenceRecord->properties, + $this->propertyConverter + ) + : null, + ); + } + + return References::fromArray($result); + } + + /** + * @param non-empty-array $nodeRecords + */ + public function mapNodeRecordsToNodeAggregate( + array $nodeRecords, + WorkspaceName $workspaceName, + ContentStreamId $contentStreamId + ): NodeAggregate { + $nodeAggregateId = null; + $classification = null; + $nodeTypeName = null; + $nodeName = null; + $occupiedDimensionSpacePoints = []; + $nodesByOccupiedDimensionSpacePoint = []; + $coverageByOccupant = []; + $totalCoveredDimensionSpacePoints = DimensionSpacePointSet::fromArray([]); + $occupationByCovered = []; + $nodeTagsByCoveredDimensionSpacePoint = []; + foreach ($nodeRecords as $nodeRecord) { + $nodeAggregateId = $nodeRecord->nodeAggregateId; + $classification = $nodeRecord->classification; + $nodeTypeName = $nodeRecord->nodeTypeName; + $nodeName = $nodeRecord->name; + $occupiedDimensionSpacePoints[] = $nodeRecord->originDimensionSpacePoint; + $nodesByOccupiedDimensionSpacePoint[$nodeRecord->originDimensionSpacePoint->hash] = $this->mapNodeRecordToNode( + $nodeRecord, + $workspaceName, + $nodeRecord->originDimensionSpacePoint->toDimensionSpacePoint(), + VisibilityConstraints::createEmpty() + ); + $coveredDimensionSpacePoints = $nodeRecord->parentsByContentStreamId[$contentStreamId->value]->getCoveredDimensionSpacePointSet(); + $coverageByOccupant[$nodeRecord->originDimensionSpacePoint->hash] = iterator_to_array($coveredDimensionSpacePoints); + $totalCoveredDimensionSpacePoints = $totalCoveredDimensionSpacePoints->getUnion($coveredDimensionSpacePoints); + foreach ($coveredDimensionSpacePoints as $coveredDimensionSpacePoint) { + $occupationByCovered[$coveredDimensionSpacePoint->hash] = $nodeRecord->originDimensionSpacePoint; + $nodeTagsByCoveredDimensionSpacePoint[$coveredDimensionSpacePoint->hash] = NodeTags::create( + $nodeRecord->tags, + $nodeRecord->inheritedTags + ); + } + } + + return NodeAggregate::create( + $this->contentRepositoryId, + $workspaceName, + $nodeAggregateId, + $classification, + $nodeTypeName, + $nodeName, + new OriginDimensionSpacePointSet($occupiedDimensionSpacePoints), + $nodesByOccupiedDimensionSpacePoint, + CoverageByOrigin::fromArray($coverageByOccupant), + $totalCoveredDimensionSpacePoints, + OriginByCoverage::fromArray($occupationByCovered), + /** @phpstan-ignore argument.type (never empty) */ + $nodeTagsByCoveredDimensionSpacePoint, + ); + } + + /** + * @param array $nodeRecords + */ + public function mapNodeRecordsToNodeAggregates( + array $nodeRecords, + WorkspaceName $workspaceName, + ContentStreamId $contentStreamId + ): NodeAggregates { + $nodeRecordsByAggregateId = []; + foreach ($nodeRecords as $nodeRecord) { + $nodeRecordsByAggregateId[$nodeRecord->nodeAggregateId->value][] = $nodeRecord; + } + + return NodeAggregates::fromArray(array_map( + fn (array $nodeRecords): NodeAggregate + => $this->mapNodeRecordsToNodeAggregate($nodeRecords, $workspaceName, $contentStreamId), + $nodeRecordsByAggregateId + )); + } + + public function mapNodeRecordToSubtree( + InMemoryNodeRecord $nodeRecord, + WorkspaceName $workspaceName, + ContentStreamId $contentStreamId, + DimensionSpacePoint $dimensionSpacePoint, + VisibilityConstraints $visibilityConstraints, + int $level, + FindSubtreeFilter $filter, + ): Subtree { + $continue = $filter->maximumLevels === null || $level <= $filter->maximumLevels; + /** @todo apply node type filters */ + return Subtree::create( + $level, + $this->mapNodeRecordToNode($nodeRecord, $workspaceName, $dimensionSpacePoint, $visibilityConstraints), + $continue + ? Subtrees::fromArray(array_map( + fn (InMemoryNodeRecord $nodeRecord): Subtree => $this->mapNodeRecordToSubtree( + $nodeRecord, + $workspaceName, + $contentStreamId, + $dimensionSpacePoint, + $visibilityConstraints, + $level + 1, + $filter, + ), + iterator_to_array( + $nodeRecord->childrenByContentStream[$contentStreamId->value] + ->getHierarchyHyperrelation($dimensionSpacePoint) + ?->children ?: [] + ) + )) + : Subtrees::createEmpty() + ); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/ProjectionContentGraph.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/ProjectionContentGraph.php new file mode 100644 index 00000000000..01c4df388ad --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/InMemoryContentGraph/Repository/ProjectionContentGraph.php @@ -0,0 +1,600 @@ +tableNames->node()} p + INNER JOIN {$this->tableNames->hierarchyRelation()} ph ON ph.childnodeanchor = p.relationanchorpoint + INNER JOIN {$this->tableNames->hierarchyRelation()} ch ON ch.parentnodeanchor = p.relationanchorpoint + INNER JOIN {$this->tableNames->node()} c ON ch.childnodeanchor = c.relationanchorpoint + INNER JOIN {$this->tableNames->dimensionSpacePoints()} dsp ON p.origindimensionspacepointhash = dsp.hash + WHERE + c.nodeaggregateid = :childNodeAggregateId + AND c.origindimensionspacepointhash = :originDimensionSpacePointHash + AND ph.contentstreamid = :contentStreamId + AND ch.contentstreamid = :contentStreamId + AND ph.dimensionspacepointhash = :coveredDimensionSpacePointHash + AND ch.dimensionspacepointhash = :coveredDimensionSpacePointHash + SQL; + try { + $nodeRow = $this->dbal->fetchAssociative($parentNodeStatement, [ + 'contentStreamId' => $contentStreamId->value, + 'childNodeAggregateId' => $childNodeAggregateId->value, + 'originDimensionSpacePointHash' => $originDimensionSpacePoint->hash, + 'coveredDimensionSpacePointHash' => $coveredDimensionSpacePoint->hash ?? $originDimensionSpacePoint->hash + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load parent node for content stream %s, child node aggregate id %s, origin dimension space point %s from database: %s', $contentStreamId->value, $childNodeAggregateId->value, $originDimensionSpacePoint->toJson(), $e->getMessage()), 1716475976, $e); + } + + return $nodeRow ? NodeRecord::fromDatabaseRow($nodeRow) : null; + } + + public function findNodeInAggregate( + ContentStreamId $contentStreamId, + NodeAggregateId $nodeAggregateId, + DimensionSpacePoint $coveredDimensionSpacePoint + ): ?NodeRecord { + $nodeInAggregateStatement = <<tableNames->node()} n + INNER JOIN {$this->tableNames->hierarchyRelation()} h ON h.childnodeanchor = n.relationanchorpoint + INNER JOIN {$this->tableNames->dimensionSpacePoints()} dsp ON n.origindimensionspacepointhash = dsp.hash + WHERE + n.nodeaggregateid = :nodeAggregateId + AND h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash = :dimensionSpacePointHash + SQL; + try { + $nodeRow = $this->dbal->fetchAssociative($nodeInAggregateStatement, [ + 'contentStreamId' => $contentStreamId->value, + 'nodeAggregateId' => $nodeAggregateId->value, + 'dimensionSpacePointHash' => $coveredDimensionSpacePoint->hash + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load node for content stream %s, aggregate id %s and covered dimension space point %s from database: %s', $contentStreamId->value, $nodeAggregateId->value, $coveredDimensionSpacePoint->toJson(), $e->getMessage()), 1716474165, $e); + } + + return $nodeRow ? NodeRecord::fromDatabaseRow($nodeRow) : null; + } + + public function getAnchorPointForNodeAndOriginDimensionSpacePointAndContentStream( + NodeAggregateId $nodeAggregateId, + OriginDimensionSpacePoint $originDimensionSpacePoint, + ContentStreamId $contentStreamId + ): ?NodeRelationAnchorPoint { + $relationAnchorPointsStatement = <<tableNames->node()} n + INNER JOIN {$this->tableNames->hierarchyRelation()} h ON h.childnodeanchor = n.relationanchorpoint + WHERE + n.nodeaggregateid = :nodeAggregateId + AND n.origindimensionspacepointhash = :originDimensionSpacePointHash + AND h.contentstreamid = :contentStreamId + SQL; + try { + $relationAnchorPoints = $this->dbal->fetchFirstColumn($relationAnchorPointsStatement, [ + 'nodeAggregateId' => $nodeAggregateId->value, + 'originDimensionSpacePointHash' => $originDimensionSpacePoint->hash, + 'contentStreamId' => $contentStreamId->value, + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load node anchor points for content stream %s, node aggregate %s and origin dimension space point %s from database: %s', $contentStreamId->value, $nodeAggregateId->value, $originDimensionSpacePoint->toJson(), $e->getMessage()), 1716474224, $e); + } + + if (count($relationAnchorPoints) > 1) { + throw new \RuntimeException(sprintf('More than one node anchor point for content stream: %s, node aggregate id: %s and origin dimension space point: %s – this should not happen and might be a conceptual problem!', $contentStreamId->value, $nodeAggregateId->value, $originDimensionSpacePoint->toJson()), 1716474484); + } + return $relationAnchorPoints === [] ? null : NodeRelationAnchorPoint::fromInteger($relationAnchorPoints[0]); + } + + /** + * @return iterable + */ + public function getAnchorPointsForNodeAggregateInContentStream( + NodeAggregateId $nodeAggregateId, + ContentStreamId $contentStreamId + ): iterable { + $relationAnchorPointsStatement = <<tableNames->node()} n + INNER JOIN {$this->tableNames->hierarchyRelation()} h ON h.childnodeanchor = n.relationanchorpoint + WHERE + n.nodeaggregateid = :nodeAggregateId + AND h.contentstreamid = :contentStreamId + SQL; + try { + $relationAnchorPoints = $this->dbal->fetchFirstColumn($relationAnchorPointsStatement, [ + 'nodeAggregateId' => $nodeAggregateId->value, + 'contentStreamId' => $contentStreamId->value, + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load node anchor points for content stream %s and node aggregate id %s from database: %s', $contentStreamId->value, $nodeAggregateId->value, $e->getMessage()), 1716474706, $e); + } + + return array_map(NodeRelationAnchorPoint::fromInteger(...), $relationAnchorPoints); + } + + public function getNodeByAnchorPoint(NodeRelationAnchorPoint $nodeRelationAnchorPoint): ?NodeRecord + { + $nodeByAnchorPointStatement = <<tableNames->node()} n + INNER JOIN {$this->tableNames->dimensionSpacePoints()} dsp ON n.origindimensionspacepointhash = dsp.hash + WHERE + n.relationanchorpoint = :relationAnchorPoint + SQL; + try { + $nodeRow = $this->dbal->fetchAssociative($nodeByAnchorPointStatement, [ + 'relationAnchorPoint' => $nodeRelationAnchorPoint->value, + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load node for anchor point %s from database: %s', $nodeRelationAnchorPoint->value, $e->getMessage()), 1716474765, $e); + } + + return $nodeRow ? NodeRecord::fromDatabaseRow($nodeRow) : null; + } + + public function determineHierarchyRelationPosition( + ?NodeRelationAnchorPoint $parentAnchorPoint, + ?NodeRelationAnchorPoint $childAnchorPoint, + ?NodeRelationAnchorPoint $succeedingSiblingAnchorPoint, + ContentStreamId $contentStreamId, + DimensionSpacePoint $dimensionSpacePoint + ): int { + if (!$parentAnchorPoint && !$childAnchorPoint) { + throw new \InvalidArgumentException( + 'You must specify either parent or child node anchor to determine a hierarchy relation position', + 1519847447 + ); + } + if ($succeedingSiblingAnchorPoint) { + $succeedingSiblingRelationStatement = <<tableNames->hierarchyRelation()} h + WHERE + h.childnodeanchor = :succeedingSiblingAnchorPoint + AND h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash = :dimensionSpacePointHash + SQL; + try { + /** @var array $succeedingSiblingRelation */ + $succeedingSiblingRelation = $this->dbal->fetchAssociative($succeedingSiblingRelationStatement, [ + 'succeedingSiblingAnchorPoint' => $succeedingSiblingAnchorPoint->value, + 'contentStreamId' => $contentStreamId->value, + 'dimensionSpacePointHash' => $dimensionSpacePoint->hash + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load succeeding sibling relations for content stream %s, anchor point %s and dimension space point %s from database: %s', $contentStreamId->value, $succeedingSiblingAnchorPoint->value, $dimensionSpacePoint->toJson(), $e->getMessage()), 1716474854, $e); + } + + if (!$succeedingSiblingRelation) { + throw new \RuntimeException( + sprintf('Could not fetch succeeding sibling relation for anchor point: %s with dimensionSpacePointHash : %s', $succeedingSiblingAnchorPoint->value, $dimensionSpacePoint->hash), + 1696405259 + ); + } + + $succeedingSiblingPosition = (int)$succeedingSiblingRelation['position']; + $parentAnchorPoint = NodeRelationAnchorPoint::fromInteger($succeedingSiblingRelation['parentnodeanchor']); + + $precedingSiblingStatement = <<tableNames->hierarchyRelation()} h + WHERE + h.parentnodeanchor = :anchorPoint + AND h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash = :dimensionSpacePointHash + AND h.position < :position + SQL; + try { + $precedingSiblingData = $this->dbal->fetchAssociative($precedingSiblingStatement, [ + 'anchorPoint' => $parentAnchorPoint->value, + 'contentStreamId' => $contentStreamId->value, + 'dimensionSpacePointHash' => $dimensionSpacePoint->hash, + 'position' => $succeedingSiblingPosition + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load preceding sibling relations for content stream %s, anchor point %s and dimension space point %s from database: %s', $contentStreamId->value, $parentAnchorPoint->value, $dimensionSpacePoint->toJson(), $e->getMessage()), 1716474957, $e); + } + $precedingSiblingPosition = $precedingSiblingData ? ($precedingSiblingData['position'] ?? null) : null; + if (!is_null($precedingSiblingPosition)) { + $precedingSiblingPosition = (int)$precedingSiblingPosition; + } + + if (is_null($precedingSiblingPosition)) { + $position = $succeedingSiblingPosition - DoctrineDbalContentGraphProjection::RELATION_DEFAULT_OFFSET; + } else { + $position = ($succeedingSiblingPosition + $precedingSiblingPosition) / 2; + } + } else { + if (!$parentAnchorPoint) { + $childHierarchyRelationStatement = <<tableNames->hierarchyRelation()} h + WHERE + h.childnodeanchor = :childAnchorPoint + AND h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash = :dimensionSpacePointHash + SQL; + try { + /** @var array $childHierarchyRelationData */ + $childHierarchyRelationData = $this->dbal->fetchAssociative($childHierarchyRelationStatement, [ + 'childAnchorPoint' => $childAnchorPoint->value, + 'contentStreamId' => $contentStreamId->value, + 'dimensionSpacePointHash' => $dimensionSpacePoint->hash + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load child hierarchy relation for content stream %s, anchor point %s and dimension space point %s from database: %s', $contentStreamId->value, $childAnchorPoint->value, $dimensionSpacePoint->toJson(), $e->getMessage()), 1716475001, $e); + } + $parentAnchorPoint = NodeRelationAnchorPoint::fromInteger( + $childHierarchyRelationData['parentnodeanchor'] + ); + } + $rightmostSucceedingSiblingRelationStatement = <<tableNames->hierarchyRelation()} h + WHERE + h.parentnodeanchor = :parentAnchorPoint + AND h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash = :dimensionSpacePointHash + SQL; + try { + $rightmostSucceedingSiblingRelationData = $this->dbal->fetchAssociative($rightmostSucceedingSiblingRelationStatement, [ + 'parentAnchorPoint' => $parentAnchorPoint->value, + 'contentStreamId' => $contentStreamId->value, + 'dimensionSpacePointHash' => $dimensionSpacePoint->hash + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to right most succeeding relation for content stream %s, anchor point %s and dimension space point %s from database: %s', $contentStreamId->value, $parentAnchorPoint->value, $dimensionSpacePoint->toJson(), $e->getMessage()), 1716475046, $e); + } + + if ($rightmostSucceedingSiblingRelationData) { + $position = ((int)$rightmostSucceedingSiblingRelationData['position']) + + DoctrineDbalContentGraphProjection::RELATION_DEFAULT_OFFSET; + } else { + $position = 0; + } + } + + return $position; + } + + /** + * @return array + */ + public function getOutgoingHierarchyRelationsForNodeAndSubgraph( + NodeRelationAnchorPoint $parentAnchorPoint, + ContentStreamId $contentStreamId, + DimensionSpacePoint $dimensionSpacePoint + ): array { + $outgoingHierarchyRelationsStatement = <<tableNames->hierarchyRelation()} h + WHERE + h.parentnodeanchor = :parentAnchorPoint + AND h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash = :dimensionSpacePointHash + SQL; + try { + $rows = $this->dbal->fetchAllAssociative($outgoingHierarchyRelationsStatement, [ + 'parentAnchorPoint' => $parentAnchorPoint->value, + 'contentStreamId' => $contentStreamId->value, + 'dimensionSpacePointHash' => $dimensionSpacePoint->hash + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load outgoing hierarchy relations for content stream %s, parent anchor point %s and dimension space point %s from database: %s', $contentStreamId->value, $parentAnchorPoint->value, $dimensionSpacePoint->toJson(), $e->getMessage()), 1716475151, $e); + } + return array_map($this->mapRawDataToHierarchyRelation(...), $rows); + } + + /** + * @return array + */ + public function getIngoingHierarchyRelationsForNodeAndSubgraph( + NodeRelationAnchorPoint $childAnchorPoint, + ContentStreamId $contentStreamId, + DimensionSpacePoint $dimensionSpacePoint + ): array { + $ingoingHierarchyRelationsStatement = <<tableNames->hierarchyRelation()} h + WHERE + h.childnodeanchor = :childAnchorPoint + AND h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash = :dimensionSpacePointHash + SQL; + try { + $rows = $this->dbal->fetchAllAssociative($ingoingHierarchyRelationsStatement, [ + 'childAnchorPoint' => $childAnchorPoint->value, + 'contentStreamId' => $contentStreamId->value, + 'dimensionSpacePointHash' => $dimensionSpacePoint->hash + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load ingoing hierarchy relations for content stream %s, child anchor point %s and dimension space point %s from database: %s', $contentStreamId->value, $childAnchorPoint->value, $dimensionSpacePoint->toJson(), $e->getMessage()), 1716475151, $e); + } + return array_map($this->mapRawDataToHierarchyRelation(...), $rows); + } + + /** + * @return array indexed by the dimension space point hash: ['' => HierarchyRelation, ...] + */ + public function findIngoingHierarchyRelationsForNode( + NodeRelationAnchorPoint $childAnchorPoint, + ContentStreamId $contentStreamId, + ?DimensionSpacePointSet $restrictToSet = null + ): array { + $ingoingHierarchyRelationsStatement = <<tableNames->hierarchyRelation()} h + WHERE + h.childnodeanchor = :childAnchorPoint + AND h.contentstreamid = :contentStreamId + SQL; + $parameters = [ + 'childAnchorPoint' => $childAnchorPoint->value, + 'contentStreamId' => $contentStreamId->value + ]; + $types = []; + + if ($restrictToSet) { + $ingoingHierarchyRelationsStatement .= ' AND h.dimensionspacepointhash IN (:dimensionSpacePointHashes)'; + $parameters['dimensionSpacePointHashes'] = $restrictToSet->getPointHashes(); + $types['dimensionSpacePointHashes'] = ArrayParameterType::STRING; + } + try { + $rows = $this->dbal->fetchAllAssociative($ingoingHierarchyRelationsStatement, $parameters, $types); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load ingoing hierarchy relations for content stream %s, child anchor point %s and dimension space points %s from database: %s', $contentStreamId->value, $childAnchorPoint->value, $restrictToSet?->toJson() ?? '[any]', $e->getMessage()), 1716476299, $e); + } + $relations = []; + foreach ($rows as $row) { + $relations[(string)$row['dimensionspacepointhash']] = $this->mapRawDataToHierarchyRelation($row); + } + return $relations; + } + + /** + * @return array + */ + public function findOutgoingHierarchyRelationsForNode( + NodeRelationAnchorPoint $parentAnchorPoint, + ContentStreamId $contentStreamId, + ?DimensionSpacePointSet $restrictToSet = null + ): array { + $outgoingHierarchyRelationsStatement = <<tableNames->hierarchyRelation()} h + WHERE + h.parentnodeanchor = :parentAnchorPoint + AND h.contentstreamid = :contentStreamId + SQL; + $parameters = [ + 'parentAnchorPoint' => $parentAnchorPoint->value, + 'contentStreamId' => $contentStreamId->value + ]; + $types = []; + + if ($restrictToSet) { + $outgoingHierarchyRelationsStatement .= ' AND h.dimensionspacepointhash IN (:dimensionSpacePointHashes)'; + $parameters['dimensionSpacePointHashes'] = $restrictToSet->getPointHashes(); + $types['dimensionSpacePointHashes'] = ArrayParameterType::STRING; + } + try { + $rows = $this->dbal->fetchAllAssociative($outgoingHierarchyRelationsStatement, $parameters, $types); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load outgoing hierarchy relations for content stream %s, parent anchor point %s and dimension space points %s from database: %s', $contentStreamId->value, $parentAnchorPoint->value, $restrictToSet?->toJson() ?? '[any]', $e->getMessage()), 1716476573, $e); + } + $relations = []; + foreach ($rows as $row) { + $relations[] = $this->mapRawDataToHierarchyRelation($row); + } + return $relations; + } + + /** + * @return array + */ + public function findOutgoingHierarchyRelationsForNodeAggregate( + ContentStreamId $contentStreamId, + NodeAggregateId $nodeAggregateId, + DimensionSpacePointSet $dimensionSpacePointSet + ): array { + $outgoingHierarchyRelationsStatement = <<tableNames->hierarchyRelation()} h + INNER JOIN {$this->tableNames->node()} n ON h.parentnodeanchor = n.relationanchorpoint + WHERE + n.nodeaggregateid = :nodeAggregateId + AND h.contentstreamid = :contentStreamId + AND h.dimensionspacepointhash IN (:dimensionSpacePointHashes) + SQL; + try { + $rows = $this->dbal->fetchAllAssociative($outgoingHierarchyRelationsStatement, [ + 'nodeAggregateId' => $nodeAggregateId->value, + 'contentStreamId' => $contentStreamId->value, + 'dimensionSpacePointHashes' => $dimensionSpacePointSet->getPointHashes() + ], [ + 'dimensionSpacePointHashes' => ArrayParameterType::STRING + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load outgoing hierarchy relations for content stream %s, node aggregate id %s and dimension space points %s from database: %s', $contentStreamId->value, $nodeAggregateId->value, $dimensionSpacePointSet->toJson(), $e->getMessage()), 1716476690, $e); + } + return array_map($this->mapRawDataToHierarchyRelation(...), $rows); + } + + /** + * @return array + */ + public function findIngoingHierarchyRelationsForNodeAggregate( + ContentStreamId $contentStreamId, + NodeAggregateId $nodeAggregateId, + ?DimensionSpacePointSet $dimensionSpacePointSet = null + ): array { + $ingoingHierarchyRelationsStatement = <<tableNames->hierarchyRelation()} h + INNER JOIN {$this->tableNames->node()} n ON h.childnodeanchor = n.relationanchorpoint + WHERE + n.nodeaggregateid = :nodeAggregateId + AND h.contentstreamid = :contentStreamId + SQL; + $parameters = [ + 'nodeAggregateId' => $nodeAggregateId->value, + 'contentStreamId' => $contentStreamId->value, + ]; + $types = []; + if ($dimensionSpacePointSet !== null) { + $ingoingHierarchyRelationsStatement .= ' AND h.dimensionspacepointhash IN (:dimensionSpacePointHashes)'; + $parameters['dimensionSpacePointHashes'] = $dimensionSpacePointSet->getPointHashes(); + $types['dimensionSpacePointHashes'] = ArrayParameterType::STRING; + } + try { + $rows = $this->dbal->fetchAllAssociative($ingoingHierarchyRelationsStatement, $parameters, $types); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load ingoing hierarchy relations for content stream %s, node aggregate id %s and dimension space points %s from database: %s', $contentStreamId->value, $nodeAggregateId->value, $dimensionSpacePointSet?->toJson() ?? '[any]', $e->getMessage()), 1716476743, $e); + } + return array_map($this->mapRawDataToHierarchyRelation(...), $rows); + } + + /** + * @return array + */ + public function getAllContentStreamIdsAnchorPointIsContainedIn( + NodeRelationAnchorPoint $nodeRelationAnchorPoint + ): array { + $contentStreamIdsStatement = <<tableNames->hierarchyRelation()} h + WHERE + h.childnodeanchor = :nodeRelationAnchorPoint + SQL; + try { + $contentStreamIds = $this->dbal->fetchFirstColumn($contentStreamIdsStatement, [ + 'nodeRelationAnchorPoint' => $nodeRelationAnchorPoint->value, + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load content stream ids for relation anchor point %s from database: %s', $nodeRelationAnchorPoint->value, $e->getMessage()), 1716478504, $e); + } + return array_map(ContentStreamId::fromString(...), $contentStreamIds); + } + + /** + * @param array $rawData + */ + private function mapRawDataToHierarchyRelation(array $rawData): HierarchyRelation + { + $dimensionSpacePointStatement = <<tableNames->dimensionSpacePoints()} + WHERE + hash = :hash + SQL; + try { + $dimensionSpacePointJson = $this->dbal->fetchOne($dimensionSpacePointStatement, [ + 'hash' => $rawData['dimensionspacepointhash'] + ]); + } catch (DBALException $e) { + throw new \RuntimeException(sprintf('Failed to load dimension space point for hash %s from database: %s', $rawData['dimensionspacepointhash'], $e->getMessage()), 1716476830, $e); + } + + return new HierarchyRelation( + NodeRelationAnchorPoint::fromInteger((int)$rawData['parentnodeanchor']), + NodeRelationAnchorPoint::fromInteger((int)$rawData['childnodeanchor']), + ContentStreamId::fromString($rawData['contentstreamid']), + DimensionSpacePoint::fromJsonString($dimensionSpacePointJson), + $rawData['dimensionspacepointhash'], + (int)$rawData['position'], + NodeFactory::extractNodeTagsFromJson($rawData['subtreetags']), + ); + } +} diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/AssetExportProcessor.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/AssetExportProcessor.php index 57794d129eb..7e6cd3ead40 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/AssetExportProcessor.php +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/AssetExportProcessor.php @@ -51,9 +51,13 @@ public function run(ProcessingContext $context): void continue; } foreach ($properties as $propertyName => $propertyValue) { + if ($nodeType->hasReference($propertyName)) { + $context->dispatch(Severity::NOTICE, "Skipped node data processing for the property \"{$propertyName}\". The property is a reference. (Node: {$nodeDataRow['identifier']})"); + continue; + } try { $propertyType = $nodeType->getPropertyType($propertyName); - } catch (\InvalidArgumentException $e) { + } catch (\InvalidArgumentException) { $context->dispatch(Severity::WARNING, "Skipped node data processing for the property \"{$propertyName}\". The property name is not part of the NodeType schema for the NodeType \"{$nodeType->name->value}\". (Node: {$nodeDataRow['identifier']})"); continue; } diff --git a/Neos.Media.Browser/Resources/Private/Layouts/Default.html b/Neos.Media.Browser/Resources/Private/Layouts/Default.html index 63201d802b1..32f509bc20d 100644 --- a/Neos.Media.Browser/Resources/Private/Layouts/Default.html +++ b/Neos.Media.Browser/Resources/Private/Layouts/Default.html @@ -23,7 +23,7 @@
-
+
diff --git a/Neos.Media.Browser/Resources/Private/Layouts/EditImage.html b/Neos.Media.Browser/Resources/Private/Layouts/EditImage.html index fe934a8a71e..1964d48da5c 100644 --- a/Neos.Media.Browser/Resources/Private/Layouts/EditImage.html +++ b/Neos.Media.Browser/Resources/Private/Layouts/EditImage.html @@ -9,9 +9,10 @@ - - - +
+ +
+ diff --git a/Neos.Media.Browser/Resources/Private/Layouts/UploadImage.html b/Neos.Media.Browser/Resources/Private/Layouts/UploadImage.html index a00cfe6680c..143262f9a5c 100644 --- a/Neos.Media.Browser/Resources/Private/Layouts/UploadImage.html +++ b/Neos.Media.Browser/Resources/Private/Layouts/UploadImage.html @@ -10,8 +10,9 @@
- - +
+ +
diff --git a/Neos.Media.Browser/Resources/Private/Templates/Asset/Index.html b/Neos.Media.Browser/Resources/Private/Templates/Asset/Index.html index 9ecdbe4bd29..c3e2d9144b2 100644 --- a/Neos.Media.Browser/Resources/Private/Templates/Asset/Index.html +++ b/Neos.Media.Browser/Resources/Private/Templates/Asset/Index.html @@ -259,7 +259,7 @@

{neos:backend.translate(id: 'dropFiles', package: 'Neos.Media.Browser')} {neos:backend.translate(id: 'clickToUpload', package: 'Neos.Media.Browser')}
- + @@ -325,7 +325,7 @@

{neos:backend.translate(id: 'connectionError', package: 'Neos.Media.Browser'