123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676 |
- <?php
- /**
- * Copyright © Magento, Inc. All rights reserved.
- * See COPYING.txt for license details.
- */
- namespace Magento\Framework\Data;
- use Magento\Framework\Exception\LocalizedException;
- /**
- * An associative data structure, that features "nested set" parent-child relations
- */
- class Structure
- {
- /**
- * Reserved keys for storing structural relations
- */
- const PARENT = 'parent';
- const CHILDREN = 'children';
- const GROUPS = 'groups';
- /**
- * @var array
- */
- protected $_elements = [];
- /**
- * Set elements in constructor
- *
- * @param array $elements
- */
- public function __construct(array $elements = null)
- {
- if (null !== $elements) {
- $this->importElements($elements);
- }
- }
- /**
- * Set elements from external source
- *
- * @param array $elements
- * @return void
- * @throws LocalizedException if any format issues identified
- */
- public function importElements(array $elements)
- {
- $this->_elements = $elements;
- foreach ($elements as $elementId => $element) {
- if (is_numeric($elementId)) {
- throw new LocalizedException(
- new \Magento\Framework\Phrase("Element ID must not be numeric: '%1'.", [$elementId])
- );
- }
- $this->_assertParentRelation($elementId);
- if (isset($element[self::GROUPS])) {
- $groups = $element[self::GROUPS];
- $this->_assertArray($groups);
- foreach ($groups as $groupName => $group) {
- $this->_assertArray($group);
- if ($group !== array_flip($group)) {
- throw new LocalizedException(
- new \Magento\Framework\Phrase(
- '"%2" is an invalid format of "%1" group. Verify the format and try again.',
- [$groupName, var_export($group, 1)]
- )
- );
- }
- foreach ($group as $groupElementId) {
- $this->_assertElementExists($groupElementId);
- }
- }
- }
- }
- }
- /**
- * Verify relations of parent-child
- *
- * @param string $elementId
- * @return void
- * @throws LocalizedException
- */
- protected function _assertParentRelation($elementId)
- {
- $element = $this->_elements[$elementId];
- // element presence in its parent's nested set
- if (isset($element[self::PARENT])) {
- $parentId = $element[self::PARENT];
- $this->_assertElementExists($parentId);
- if (empty($this->_elements[$parentId][self::CHILDREN][$elementId])) {
- throw new LocalizedException(
- new \Magento\Framework\Phrase(
- 'The "%1" is not in the nested set of "%2", causing the parent-child relation to break. '
- . 'Verify and try again.',
- [$elementId, $parentId]
- )
- );
- }
- }
- // element presence in its children
- if (isset($element[self::CHILDREN])) {
- $children = $element[self::CHILDREN];
- $this->_assertArray($children);
- if ($children !== array_flip(array_flip($children))) {
- throw new LocalizedException(
- new \Magento\Framework\Phrase(
- 'The "%1" format of children is invalid. Verify and try again.',
- [var_export($children, 1)]
- )
- );
- }
- foreach (array_keys($children) as $childId) {
- $this->_assertElementExists($childId);
- if (!isset(
- $this->_elements[$childId][self::PARENT]
- ) || $elementId !== $this->_elements[$childId][self::PARENT]
- ) {
- throw new LocalizedException(
- new \Magento\Framework\Phrase(
- 'The "%1" doesn\'t have "%2" as parent, causing the parent-child relation to break. '
- . 'Verify and try again.',
- [$childId, $elementId]
- )
- );
- }
- }
- }
- }
- /**
- * Dump all elements
- *
- * @return array
- */
- public function exportElements()
- {
- return $this->_elements;
- }
- /**
- * Create new element
- *
- * @param string $elementId
- * @param array $data
- * @return void
- * @throws LocalizedException if an element with this id already exists
- */
- public function createElement($elementId, array $data)
- {
- if (isset($this->_elements[$elementId])) {
- throw new LocalizedException(
- new \Magento\Framework\Phrase('An element with a "%1" ID already exists.', [$elementId])
- );
- }
- $this->_elements[$elementId] = [];
- foreach ($data as $key => $value) {
- $this->setAttribute($elementId, $key, $value);
- }
- }
- /**
- * Get existing element
- *
- * @param string $elementId
- * @return array|bool
- */
- public function getElement($elementId)
- {
- return $this->_elements[$elementId] ?? false;
- }
- /**
- * Whether specified element exists
- *
- * @param string $elementId
- * @return bool
- */
- public function hasElement($elementId)
- {
- return isset($this->_elements[$elementId]);
- }
- /**
- * Remove element with specified ID from the structure
- *
- * Can recursively delete all child elements.
- * Returns false if there was no element found, therefore was nothing to delete.
- *
- * @param string $elementId
- * @param bool $recursive
- * @return bool
- */
- public function unsetElement($elementId, $recursive = true)
- {
- if (isset($this->_elements[$elementId][self::CHILDREN])) {
- foreach (array_keys($this->_elements[$elementId][self::CHILDREN]) as $childId) {
- $this->_assertElementExists($childId);
- if ($recursive) {
- $this->unsetElement($childId, $recursive);
- } else {
- unset($this->_elements[$childId][self::PARENT]);
- }
- }
- }
- $this->unsetChild($elementId);
- $wasFound = isset($this->_elements[$elementId]);
- unset($this->_elements[$elementId]);
- return $wasFound;
- }
- /**
- * Set an arbitrary value to specified element attribute
- *
- * @param string $elementId
- * @param string $attribute
- * @param mixed $value
- * @throws \InvalidArgumentException
- * @return $this
- */
- public function setAttribute($elementId, $attribute, $value)
- {
- $this->_assertElementExists($elementId);
- switch ($attribute) {
- case self::PARENT:
- // break is intentionally omitted
- case self::CHILDREN:
- case self::GROUPS:
- throw new \InvalidArgumentException("The '{$attribute}' attribute is reserved and can't be set.");
- default:
- $this->_elements[$elementId][$attribute] = $value;
- }
- return $this;
- }
- /**
- * Get element attribute
- *
- * @param string $elementId
- * @param string $attribute
- * @return mixed
- */
- public function getAttribute($elementId, $attribute)
- {
- $this->_assertElementExists($elementId);
- if (isset($this->_elements[$elementId][$attribute])) {
- return $this->_elements[$elementId][$attribute];
- }
- return false;
- }
- /**
- * Rename element ID
- *
- * @param string $oldId
- * @param string $newId
- * @return $this
- * @throws LocalizedException if trying to overwrite another element
- */
- public function renameElement($oldId, $newId)
- {
- $this->_assertElementExists($oldId);
- if (!$newId || isset($this->_elements[$newId])) {
- throw new LocalizedException(
- new \Magento\Framework\Phrase('An element with a "%1" ID is already defined.', [$newId])
- );
- }
- // rename in registry
- $this->_elements[$newId] = $this->_elements[$oldId];
- // rename references in children
- if (isset($this->_elements[$oldId][self::CHILDREN])) {
- foreach (array_keys($this->_elements[$oldId][self::CHILDREN]) as $childId) {
- $this->_assertElementExists($childId);
- $this->_elements[$childId][self::PARENT] = $newId;
- }
- }
- // rename key in its parent's children array
- if (isset($this->_elements[$oldId][self::PARENT]) && ($parentId = $this->_elements[$oldId][self::PARENT])) {
- $alias = $this->_elements[$parentId][self::CHILDREN][$oldId];
- $offset = $this->_getChildOffset($parentId, $oldId);
- unset($this->_elements[$parentId][self::CHILDREN][$oldId]);
- $this->setAsChild($newId, $parentId, $alias, $offset);
- }
- unset($this->_elements[$oldId]);
- return $this;
- }
- /**
- * Set element as a child to another element
- *
- * @param string $elementId
- * @param string $parentId
- * @param string $alias
- * @param int|null $position
- * @see _insertChild() for position explanation
- * @return void
- * @throws LocalizedException if attempting to set parent as child to its child (recursively)
- */
- public function setAsChild($elementId, $parentId, $alias = '', $position = null)
- {
- if ($elementId == $parentId) {
- throw new LocalizedException(
- new \Magento\Framework\Phrase(
- 'The "%1" was incorrectly set as a child to itself. Resolve the issue and try again.',
- [$elementId]
- )
- );
- }
- if ($this->_isParentRecursively($elementId, $parentId)) {
- throw new LocalizedException(
- new \Magento\Framework\Phrase(
- 'The "%3" cannot be set as child to "%1" because "%1" is a parent of "%2" recursively. '
- . 'Resolve the issue and try again.',
- [$elementId, $parentId, $elementId]
- )
- );
- }
- $this->unsetChild($elementId);
- unset($this->_elements[$parentId][self::CHILDREN][$elementId]);
- $this->_insertChild($parentId, $elementId, $position, $alias);
- }
- /**
- * Unset element as a child of another element
- *
- * Note that only parent-child relations will be deleted. Element itself will be retained.
- * The method is polymorphic:
- * 1 argument: element ID which is supposedly a child of some element
- * 2 arguments: parent element ID and child alias
- *
- * @param string $elementId ID of an element or its parent element
- * @param string|null $alias
- * @return $this
- */
- public function unsetChild($elementId, $alias = null)
- {
- if (null === $alias) {
- $childId = $elementId;
- } else {
- $childId = $this->getChildId($elementId, $alias);
- }
- $parentId = $this->getParentId($childId);
- unset($this->_elements[$childId][self::PARENT]);
- if ($parentId) {
- unset($this->_elements[$parentId][self::CHILDREN][$childId]);
- if (empty($this->_elements[$parentId][self::CHILDREN])) {
- unset($this->_elements[$parentId][self::CHILDREN]);
- }
- }
- return $this;
- }
- /**
- * Reorder a child element relatively to specified position
- *
- * Returns new position of the reordered element
- *
- * @param string $parentId
- * @param string $childId
- * @param int|null $position
- * @return int
- * @see _insertChild() for position explanation
- */
- public function reorderChild($parentId, $childId, $position)
- {
- $alias = $this->getChildAlias($parentId, $childId);
- $currentOffset = $this->_getChildOffset($parentId, $childId);
- $offset = $position;
- if ($position > 0) {
- if ($position >= $currentOffset + 1) {
- $offset -= 1;
- }
- } elseif ($position < 0) {
- if ($position < $currentOffset + 1 - count($this->_elements[$parentId][self::CHILDREN])) {
- if ($position === -1) {
- $offset = null;
- } else {
- $offset += 1;
- }
- }
- }
- $this->unsetChild($childId)->_insertChild($parentId, $childId, $offset, $alias);
- return $this->_getChildOffset($parentId, $childId) + 1;
- }
- /**
- * Reorder an element relatively to its sibling
- *
- * $offset possible values:
- * 1, 2 -- set after the sibling towards end -- by 1, by 2 positions, etc
- * -1, -2 -- set before the sibling towards start -- by 1, by 2 positions, etc...
- *
- * Both $childId and $siblingId must be children of the specified $parentId
- * Returns new position of the reordered element
- *
- * @param string $parentId
- * @param string $childId
- * @param string $siblingId
- * @param int $offset
- * @return int
- */
- public function reorderToSibling($parentId, $childId, $siblingId, $offset)
- {
- $this->_getChildOffset($parentId, $childId);
- if ($childId === $siblingId) {
- $newOffset = $this->_getRelativeOffset($parentId, $siblingId, $offset);
- return $this->reorderChild($parentId, $childId, $newOffset);
- }
- $alias = $this->getChildAlias($parentId, $childId);
- $newOffset = $this->unsetChild($childId)->_getRelativeOffset($parentId, $siblingId, $offset);
- $this->_insertChild($parentId, $childId, $newOffset, $alias);
- return $this->_getChildOffset($parentId, $childId) + 1;
- }
- /**
- * Calculate new offset for placing an element relatively specified sibling under the same parent
- *
- * @param string $parentId
- * @param string $siblingId
- * @param int $delta
- * @return int
- */
- private function _getRelativeOffset($parentId, $siblingId, $delta)
- {
- $newOffset = $this->_getChildOffset($parentId, $siblingId) + $delta;
- if ($delta < 0) {
- $newOffset += 1;
- }
- if ($newOffset < 0) {
- $newOffset = 0;
- }
- return $newOffset;
- }
- /**
- * Get child ID by parent ID and alias
- *
- * @param string $parentId
- * @param string $alias
- * @return string|bool
- */
- public function getChildId($parentId, $alias)
- {
- if (isset($this->_elements[$parentId][self::CHILDREN])) {
- return array_search($alias, $this->_elements[$parentId][self::CHILDREN]);
- }
- return false;
- }
- /**
- * Get all children
- *
- * Returns in format 'id' => 'alias'
- *
- * @param string $parentId
- * @return array
- */
- public function getChildren($parentId)
- {
- return $this->_elements[$parentId][self::CHILDREN] ?? [];
- }
- /**
- * Get name of parent element
- *
- * @param string $childId
- * @return string|bool
- */
- public function getParentId($childId)
- {
- return $this->_elements[$childId][self::PARENT] ?? false;
- }
- /**
- * Get element alias by name
- *
- * @param string $parentId
- * @param string $childId
- * @return string|bool
- */
- public function getChildAlias($parentId, $childId)
- {
- if (isset($this->_elements[$parentId][self::CHILDREN][$childId])) {
- return $this->_elements[$parentId][self::CHILDREN][$childId];
- }
- return false;
- }
- /**
- * Add element to parent group
- *
- * @param string $childId
- * @param string $groupName
- * @return bool
- */
- public function addToParentGroup($childId, $groupName)
- {
- $parentId = $this->getParentId($childId);
- if ($parentId) {
- $this->_assertElementExists($parentId);
- $this->_elements[$parentId][self::GROUPS][$groupName][$childId] = $childId;
- return true;
- }
- return false;
- }
- /**
- * Get element IDs for specified group
- *
- * Note that it is expected behavior if a child has been moved out from this parent,
- * but still remained in the group of old parent. The method will return only actual children.
- * This is intentional, in case if the child returns back to the old parent.
- *
- * @param string $parentId Name of an element containing group
- * @param string $groupName
- * @return array
- */
- public function getGroupChildNames($parentId, $groupName)
- {
- $result = [];
- if (isset($this->_elements[$parentId][self::GROUPS][$groupName])) {
- foreach ($this->_elements[$parentId][self::GROUPS][$groupName] as $childId) {
- if (isset($this->_elements[$parentId][self::CHILDREN][$childId])) {
- $result[] = $childId;
- }
- }
- }
- return $result;
- }
- /**
- * Calculate a relative offset of a child element in specified parent
- *
- * @param string $parentId
- * @param string $childId
- * @return int
- * @throws LocalizedException if specified elements have no parent-child relation
- */
- protected function _getChildOffset($parentId, $childId)
- {
- $index = array_search($childId, array_keys($this->getChildren($parentId)));
- if (false === $index) {
- throw new LocalizedException(
- new \Magento\Framework\Phrase(
- 'The "%1" is not a child of "%2". Resolve the issue and try again.',
- [$childId, $parentId]
- )
- );
- }
- return $index;
- }
- /**
- * Traverse through hierarchy and detect if the "potential parent" is a parent recursively to specified "child"
- *
- * @param string $childId
- * @param string $potentialParentId
- * @return bool
- */
- private function _isParentRecursively($childId, $potentialParentId)
- {
- $parentId = $this->getParentId($potentialParentId);
- if (!$parentId) {
- return false;
- }
- if ($parentId === $childId) {
- return true;
- }
- return $this->_isParentRecursively($childId, $parentId);
- }
- /**
- * Insert an existing element as a child to existing element
- *
- * The element must not be a child to any other element
- * The target parent element must not have it as a child already
- *
- * Offset -- into which position to insert:
- * 0 -- set as 1st
- * 1, 2 -- after 1st, second, etc...
- * -1, -2 -- before last, before second last, etc...
- * null -- set as last
- *
- * @param string $targetParentId
- * @param string $elementId
- * @param int|null $offset
- * @param string $alias
- * @return void
- * @throws LocalizedException
- */
- protected function _insertChild($targetParentId, $elementId, $offset, $alias)
- {
- $alias = $alias ?: $elementId;
- // validate
- $this->_assertElementExists($elementId);
- if (!empty($this->_elements[$elementId][self::PARENT])) {
- throw new LocalizedException(
- new \Magento\Framework\Phrase(
- 'The element "%1" can\'t have a parent because "%2" is already the parent of "%1".',
- [$elementId, $this->_elements[$elementId][self::PARENT]]
- )
- );
- }
- $this->_assertElementExists($targetParentId);
- $children = $this->getChildren($targetParentId);
- if (isset($children[$elementId])) {
- throw new LocalizedException(
- new \Magento\Framework\Phrase(
- 'The element "%1" is already a child of "%2".',
- [$elementId, $targetParentId]
- )
- );
- }
- if (false !== array_search($alias, $children)) {
- throw new LocalizedException(
- new \Magento\Framework\Phrase(
- 'The element "%1" can\'t have a child because "%1" already has a child with alias "%2".',
- [$targetParentId, $alias]
- )
- );
- }
- // insert
- if (null === $offset) {
- $offset = count($children);
- }
- $this->_elements[$targetParentId][self::CHILDREN] = array_merge(
- array_slice($children, 0, $offset),
- [$elementId => $alias],
- array_slice($children, $offset)
- );
- $this->_elements[$elementId][self::PARENT] = $targetParentId;
- }
- /**
- * Check if specified element exists
- *
- * @param string $elementId
- * @return void
- * @throws LocalizedException if doesn't exist
- */
- private function _assertElementExists($elementId)
- {
- if (!isset($this->_elements[$elementId])) {
- throw new \OutOfBoundsException(
- 'The element with the "' . $elementId . '" ID wasn\'t found. Verify the ID and try again.'
- );
- }
- }
- /**
- * Check if it is an array
- *
- * @param array $value
- * @return void
- * @throws LocalizedException
- */
- private function _assertArray($value)
- {
- if (!is_array($value)) {
- throw new LocalizedException(
- new \Magento\Framework\Phrase("An array expected: %1", [var_export($value, 1)])
- );
- }
- }
- }
|