Structure.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
  1. <?php
  2. /**
  3. * Copyright © Magento, Inc. All rights reserved.
  4. * See COPYING.txt for license details.
  5. */
  6. namespace Magento\Framework\Data;
  7. use Magento\Framework\Exception\LocalizedException;
  8. /**
  9. * An associative data structure, that features "nested set" parent-child relations
  10. */
  11. class Structure
  12. {
  13. /**
  14. * Reserved keys for storing structural relations
  15. */
  16. const PARENT = 'parent';
  17. const CHILDREN = 'children';
  18. const GROUPS = 'groups';
  19. /**
  20. * @var array
  21. */
  22. protected $_elements = [];
  23. /**
  24. * Set elements in constructor
  25. *
  26. * @param array $elements
  27. */
  28. public function __construct(array $elements = null)
  29. {
  30. if (null !== $elements) {
  31. $this->importElements($elements);
  32. }
  33. }
  34. /**
  35. * Set elements from external source
  36. *
  37. * @param array $elements
  38. * @return void
  39. * @throws LocalizedException if any format issues identified
  40. */
  41. public function importElements(array $elements)
  42. {
  43. $this->_elements = $elements;
  44. foreach ($elements as $elementId => $element) {
  45. if (is_numeric($elementId)) {
  46. throw new LocalizedException(
  47. new \Magento\Framework\Phrase("Element ID must not be numeric: '%1'.", [$elementId])
  48. );
  49. }
  50. $this->_assertParentRelation($elementId);
  51. if (isset($element[self::GROUPS])) {
  52. $groups = $element[self::GROUPS];
  53. $this->_assertArray($groups);
  54. foreach ($groups as $groupName => $group) {
  55. $this->_assertArray($group);
  56. if ($group !== array_flip($group)) {
  57. throw new LocalizedException(
  58. new \Magento\Framework\Phrase(
  59. '"%2" is an invalid format of "%1" group. Verify the format and try again.',
  60. [$groupName, var_export($group, 1)]
  61. )
  62. );
  63. }
  64. foreach ($group as $groupElementId) {
  65. $this->_assertElementExists($groupElementId);
  66. }
  67. }
  68. }
  69. }
  70. }
  71. /**
  72. * Verify relations of parent-child
  73. *
  74. * @param string $elementId
  75. * @return void
  76. * @throws LocalizedException
  77. */
  78. protected function _assertParentRelation($elementId)
  79. {
  80. $element = $this->_elements[$elementId];
  81. // element presence in its parent's nested set
  82. if (isset($element[self::PARENT])) {
  83. $parentId = $element[self::PARENT];
  84. $this->_assertElementExists($parentId);
  85. if (empty($this->_elements[$parentId][self::CHILDREN][$elementId])) {
  86. throw new LocalizedException(
  87. new \Magento\Framework\Phrase(
  88. 'The "%1" is not in the nested set of "%2", causing the parent-child relation to break. '
  89. . 'Verify and try again.',
  90. [$elementId, $parentId]
  91. )
  92. );
  93. }
  94. }
  95. // element presence in its children
  96. if (isset($element[self::CHILDREN])) {
  97. $children = $element[self::CHILDREN];
  98. $this->_assertArray($children);
  99. if ($children !== array_flip(array_flip($children))) {
  100. throw new LocalizedException(
  101. new \Magento\Framework\Phrase(
  102. 'The "%1" format of children is invalid. Verify and try again.',
  103. [var_export($children, 1)]
  104. )
  105. );
  106. }
  107. foreach (array_keys($children) as $childId) {
  108. $this->_assertElementExists($childId);
  109. if (!isset(
  110. $this->_elements[$childId][self::PARENT]
  111. ) || $elementId !== $this->_elements[$childId][self::PARENT]
  112. ) {
  113. throw new LocalizedException(
  114. new \Magento\Framework\Phrase(
  115. 'The "%1" doesn\'t have "%2" as parent, causing the parent-child relation to break. '
  116. . 'Verify and try again.',
  117. [$childId, $elementId]
  118. )
  119. );
  120. }
  121. }
  122. }
  123. }
  124. /**
  125. * Dump all elements
  126. *
  127. * @return array
  128. */
  129. public function exportElements()
  130. {
  131. return $this->_elements;
  132. }
  133. /**
  134. * Create new element
  135. *
  136. * @param string $elementId
  137. * @param array $data
  138. * @return void
  139. * @throws LocalizedException if an element with this id already exists
  140. */
  141. public function createElement($elementId, array $data)
  142. {
  143. if (isset($this->_elements[$elementId])) {
  144. throw new LocalizedException(
  145. new \Magento\Framework\Phrase('An element with a "%1" ID already exists.', [$elementId])
  146. );
  147. }
  148. $this->_elements[$elementId] = [];
  149. foreach ($data as $key => $value) {
  150. $this->setAttribute($elementId, $key, $value);
  151. }
  152. }
  153. /**
  154. * Get existing element
  155. *
  156. * @param string $elementId
  157. * @return array|bool
  158. */
  159. public function getElement($elementId)
  160. {
  161. return $this->_elements[$elementId] ?? false;
  162. }
  163. /**
  164. * Whether specified element exists
  165. *
  166. * @param string $elementId
  167. * @return bool
  168. */
  169. public function hasElement($elementId)
  170. {
  171. return isset($this->_elements[$elementId]);
  172. }
  173. /**
  174. * Remove element with specified ID from the structure
  175. *
  176. * Can recursively delete all child elements.
  177. * Returns false if there was no element found, therefore was nothing to delete.
  178. *
  179. * @param string $elementId
  180. * @param bool $recursive
  181. * @return bool
  182. */
  183. public function unsetElement($elementId, $recursive = true)
  184. {
  185. if (isset($this->_elements[$elementId][self::CHILDREN])) {
  186. foreach (array_keys($this->_elements[$elementId][self::CHILDREN]) as $childId) {
  187. $this->_assertElementExists($childId);
  188. if ($recursive) {
  189. $this->unsetElement($childId, $recursive);
  190. } else {
  191. unset($this->_elements[$childId][self::PARENT]);
  192. }
  193. }
  194. }
  195. $this->unsetChild($elementId);
  196. $wasFound = isset($this->_elements[$elementId]);
  197. unset($this->_elements[$elementId]);
  198. return $wasFound;
  199. }
  200. /**
  201. * Set an arbitrary value to specified element attribute
  202. *
  203. * @param string $elementId
  204. * @param string $attribute
  205. * @param mixed $value
  206. * @throws \InvalidArgumentException
  207. * @return $this
  208. */
  209. public function setAttribute($elementId, $attribute, $value)
  210. {
  211. $this->_assertElementExists($elementId);
  212. switch ($attribute) {
  213. case self::PARENT:
  214. // break is intentionally omitted
  215. case self::CHILDREN:
  216. case self::GROUPS:
  217. throw new \InvalidArgumentException("The '{$attribute}' attribute is reserved and can't be set.");
  218. default:
  219. $this->_elements[$elementId][$attribute] = $value;
  220. }
  221. return $this;
  222. }
  223. /**
  224. * Get element attribute
  225. *
  226. * @param string $elementId
  227. * @param string $attribute
  228. * @return mixed
  229. */
  230. public function getAttribute($elementId, $attribute)
  231. {
  232. $this->_assertElementExists($elementId);
  233. if (isset($this->_elements[$elementId][$attribute])) {
  234. return $this->_elements[$elementId][$attribute];
  235. }
  236. return false;
  237. }
  238. /**
  239. * Rename element ID
  240. *
  241. * @param string $oldId
  242. * @param string $newId
  243. * @return $this
  244. * @throws LocalizedException if trying to overwrite another element
  245. */
  246. public function renameElement($oldId, $newId)
  247. {
  248. $this->_assertElementExists($oldId);
  249. if (!$newId || isset($this->_elements[$newId])) {
  250. throw new LocalizedException(
  251. new \Magento\Framework\Phrase('An element with a "%1" ID is already defined.', [$newId])
  252. );
  253. }
  254. // rename in registry
  255. $this->_elements[$newId] = $this->_elements[$oldId];
  256. // rename references in children
  257. if (isset($this->_elements[$oldId][self::CHILDREN])) {
  258. foreach (array_keys($this->_elements[$oldId][self::CHILDREN]) as $childId) {
  259. $this->_assertElementExists($childId);
  260. $this->_elements[$childId][self::PARENT] = $newId;
  261. }
  262. }
  263. // rename key in its parent's children array
  264. if (isset($this->_elements[$oldId][self::PARENT]) && ($parentId = $this->_elements[$oldId][self::PARENT])) {
  265. $alias = $this->_elements[$parentId][self::CHILDREN][$oldId];
  266. $offset = $this->_getChildOffset($parentId, $oldId);
  267. unset($this->_elements[$parentId][self::CHILDREN][$oldId]);
  268. $this->setAsChild($newId, $parentId, $alias, $offset);
  269. }
  270. unset($this->_elements[$oldId]);
  271. return $this;
  272. }
  273. /**
  274. * Set element as a child to another element
  275. *
  276. * @param string $elementId
  277. * @param string $parentId
  278. * @param string $alias
  279. * @param int|null $position
  280. * @see _insertChild() for position explanation
  281. * @return void
  282. * @throws LocalizedException if attempting to set parent as child to its child (recursively)
  283. */
  284. public function setAsChild($elementId, $parentId, $alias = '', $position = null)
  285. {
  286. if ($elementId == $parentId) {
  287. throw new LocalizedException(
  288. new \Magento\Framework\Phrase(
  289. 'The "%1" was incorrectly set as a child to itself. Resolve the issue and try again.',
  290. [$elementId]
  291. )
  292. );
  293. }
  294. if ($this->_isParentRecursively($elementId, $parentId)) {
  295. throw new LocalizedException(
  296. new \Magento\Framework\Phrase(
  297. 'The "%3" cannot be set as child to "%1" because "%1" is a parent of "%2" recursively. '
  298. . 'Resolve the issue and try again.',
  299. [$elementId, $parentId, $elementId]
  300. )
  301. );
  302. }
  303. $this->unsetChild($elementId);
  304. unset($this->_elements[$parentId][self::CHILDREN][$elementId]);
  305. $this->_insertChild($parentId, $elementId, $position, $alias);
  306. }
  307. /**
  308. * Unset element as a child of another element
  309. *
  310. * Note that only parent-child relations will be deleted. Element itself will be retained.
  311. * The method is polymorphic:
  312. * 1 argument: element ID which is supposedly a child of some element
  313. * 2 arguments: parent element ID and child alias
  314. *
  315. * @param string $elementId ID of an element or its parent element
  316. * @param string|null $alias
  317. * @return $this
  318. */
  319. public function unsetChild($elementId, $alias = null)
  320. {
  321. if (null === $alias) {
  322. $childId = $elementId;
  323. } else {
  324. $childId = $this->getChildId($elementId, $alias);
  325. }
  326. $parentId = $this->getParentId($childId);
  327. unset($this->_elements[$childId][self::PARENT]);
  328. if ($parentId) {
  329. unset($this->_elements[$parentId][self::CHILDREN][$childId]);
  330. if (empty($this->_elements[$parentId][self::CHILDREN])) {
  331. unset($this->_elements[$parentId][self::CHILDREN]);
  332. }
  333. }
  334. return $this;
  335. }
  336. /**
  337. * Reorder a child element relatively to specified position
  338. *
  339. * Returns new position of the reordered element
  340. *
  341. * @param string $parentId
  342. * @param string $childId
  343. * @param int|null $position
  344. * @return int
  345. * @see _insertChild() for position explanation
  346. */
  347. public function reorderChild($parentId, $childId, $position)
  348. {
  349. $alias = $this->getChildAlias($parentId, $childId);
  350. $currentOffset = $this->_getChildOffset($parentId, $childId);
  351. $offset = $position;
  352. if ($position > 0) {
  353. if ($position >= $currentOffset + 1) {
  354. $offset -= 1;
  355. }
  356. } elseif ($position < 0) {
  357. if ($position < $currentOffset + 1 - count($this->_elements[$parentId][self::CHILDREN])) {
  358. if ($position === -1) {
  359. $offset = null;
  360. } else {
  361. $offset += 1;
  362. }
  363. }
  364. }
  365. $this->unsetChild($childId)->_insertChild($parentId, $childId, $offset, $alias);
  366. return $this->_getChildOffset($parentId, $childId) + 1;
  367. }
  368. /**
  369. * Reorder an element relatively to its sibling
  370. *
  371. * $offset possible values:
  372. * 1, 2 -- set after the sibling towards end -- by 1, by 2 positions, etc
  373. * -1, -2 -- set before the sibling towards start -- by 1, by 2 positions, etc...
  374. *
  375. * Both $childId and $siblingId must be children of the specified $parentId
  376. * Returns new position of the reordered element
  377. *
  378. * @param string $parentId
  379. * @param string $childId
  380. * @param string $siblingId
  381. * @param int $offset
  382. * @return int
  383. */
  384. public function reorderToSibling($parentId, $childId, $siblingId, $offset)
  385. {
  386. $this->_getChildOffset($parentId, $childId);
  387. if ($childId === $siblingId) {
  388. $newOffset = $this->_getRelativeOffset($parentId, $siblingId, $offset);
  389. return $this->reorderChild($parentId, $childId, $newOffset);
  390. }
  391. $alias = $this->getChildAlias($parentId, $childId);
  392. $newOffset = $this->unsetChild($childId)->_getRelativeOffset($parentId, $siblingId, $offset);
  393. $this->_insertChild($parentId, $childId, $newOffset, $alias);
  394. return $this->_getChildOffset($parentId, $childId) + 1;
  395. }
  396. /**
  397. * Calculate new offset for placing an element relatively specified sibling under the same parent
  398. *
  399. * @param string $parentId
  400. * @param string $siblingId
  401. * @param int $delta
  402. * @return int
  403. */
  404. private function _getRelativeOffset($parentId, $siblingId, $delta)
  405. {
  406. $newOffset = $this->_getChildOffset($parentId, $siblingId) + $delta;
  407. if ($delta < 0) {
  408. $newOffset += 1;
  409. }
  410. if ($newOffset < 0) {
  411. $newOffset = 0;
  412. }
  413. return $newOffset;
  414. }
  415. /**
  416. * Get child ID by parent ID and alias
  417. *
  418. * @param string $parentId
  419. * @param string $alias
  420. * @return string|bool
  421. */
  422. public function getChildId($parentId, $alias)
  423. {
  424. if (isset($this->_elements[$parentId][self::CHILDREN])) {
  425. return array_search($alias, $this->_elements[$parentId][self::CHILDREN]);
  426. }
  427. return false;
  428. }
  429. /**
  430. * Get all children
  431. *
  432. * Returns in format 'id' => 'alias'
  433. *
  434. * @param string $parentId
  435. * @return array
  436. */
  437. public function getChildren($parentId)
  438. {
  439. return $this->_elements[$parentId][self::CHILDREN] ?? [];
  440. }
  441. /**
  442. * Get name of parent element
  443. *
  444. * @param string $childId
  445. * @return string|bool
  446. */
  447. public function getParentId($childId)
  448. {
  449. return $this->_elements[$childId][self::PARENT] ?? false;
  450. }
  451. /**
  452. * Get element alias by name
  453. *
  454. * @param string $parentId
  455. * @param string $childId
  456. * @return string|bool
  457. */
  458. public function getChildAlias($parentId, $childId)
  459. {
  460. if (isset($this->_elements[$parentId][self::CHILDREN][$childId])) {
  461. return $this->_elements[$parentId][self::CHILDREN][$childId];
  462. }
  463. return false;
  464. }
  465. /**
  466. * Add element to parent group
  467. *
  468. * @param string $childId
  469. * @param string $groupName
  470. * @return bool
  471. */
  472. public function addToParentGroup($childId, $groupName)
  473. {
  474. $parentId = $this->getParentId($childId);
  475. if ($parentId) {
  476. $this->_assertElementExists($parentId);
  477. $this->_elements[$parentId][self::GROUPS][$groupName][$childId] = $childId;
  478. return true;
  479. }
  480. return false;
  481. }
  482. /**
  483. * Get element IDs for specified group
  484. *
  485. * Note that it is expected behavior if a child has been moved out from this parent,
  486. * but still remained in the group of old parent. The method will return only actual children.
  487. * This is intentional, in case if the child returns back to the old parent.
  488. *
  489. * @param string $parentId Name of an element containing group
  490. * @param string $groupName
  491. * @return array
  492. */
  493. public function getGroupChildNames($parentId, $groupName)
  494. {
  495. $result = [];
  496. if (isset($this->_elements[$parentId][self::GROUPS][$groupName])) {
  497. foreach ($this->_elements[$parentId][self::GROUPS][$groupName] as $childId) {
  498. if (isset($this->_elements[$parentId][self::CHILDREN][$childId])) {
  499. $result[] = $childId;
  500. }
  501. }
  502. }
  503. return $result;
  504. }
  505. /**
  506. * Calculate a relative offset of a child element in specified parent
  507. *
  508. * @param string $parentId
  509. * @param string $childId
  510. * @return int
  511. * @throws LocalizedException if specified elements have no parent-child relation
  512. */
  513. protected function _getChildOffset($parentId, $childId)
  514. {
  515. $index = array_search($childId, array_keys($this->getChildren($parentId)));
  516. if (false === $index) {
  517. throw new LocalizedException(
  518. new \Magento\Framework\Phrase(
  519. 'The "%1" is not a child of "%2". Resolve the issue and try again.',
  520. [$childId, $parentId]
  521. )
  522. );
  523. }
  524. return $index;
  525. }
  526. /**
  527. * Traverse through hierarchy and detect if the "potential parent" is a parent recursively to specified "child"
  528. *
  529. * @param string $childId
  530. * @param string $potentialParentId
  531. * @return bool
  532. */
  533. private function _isParentRecursively($childId, $potentialParentId)
  534. {
  535. $parentId = $this->getParentId($potentialParentId);
  536. if (!$parentId) {
  537. return false;
  538. }
  539. if ($parentId === $childId) {
  540. return true;
  541. }
  542. return $this->_isParentRecursively($childId, $parentId);
  543. }
  544. /**
  545. * Insert an existing element as a child to existing element
  546. *
  547. * The element must not be a child to any other element
  548. * The target parent element must not have it as a child already
  549. *
  550. * Offset -- into which position to insert:
  551. * 0 -- set as 1st
  552. * 1, 2 -- after 1st, second, etc...
  553. * -1, -2 -- before last, before second last, etc...
  554. * null -- set as last
  555. *
  556. * @param string $targetParentId
  557. * @param string $elementId
  558. * @param int|null $offset
  559. * @param string $alias
  560. * @return void
  561. * @throws LocalizedException
  562. */
  563. protected function _insertChild($targetParentId, $elementId, $offset, $alias)
  564. {
  565. $alias = $alias ?: $elementId;
  566. // validate
  567. $this->_assertElementExists($elementId);
  568. if (!empty($this->_elements[$elementId][self::PARENT])) {
  569. throw new LocalizedException(
  570. new \Magento\Framework\Phrase(
  571. 'The element "%1" can\'t have a parent because "%2" is already the parent of "%1".',
  572. [$elementId, $this->_elements[$elementId][self::PARENT]]
  573. )
  574. );
  575. }
  576. $this->_assertElementExists($targetParentId);
  577. $children = $this->getChildren($targetParentId);
  578. if (isset($children[$elementId])) {
  579. throw new LocalizedException(
  580. new \Magento\Framework\Phrase(
  581. 'The element "%1" is already a child of "%2".',
  582. [$elementId, $targetParentId]
  583. )
  584. );
  585. }
  586. if (false !== array_search($alias, $children)) {
  587. throw new LocalizedException(
  588. new \Magento\Framework\Phrase(
  589. 'The element "%1" can\'t have a child because "%1" already has a child with alias "%2".',
  590. [$targetParentId, $alias]
  591. )
  592. );
  593. }
  594. // insert
  595. if (null === $offset) {
  596. $offset = count($children);
  597. }
  598. $this->_elements[$targetParentId][self::CHILDREN] = array_merge(
  599. array_slice($children, 0, $offset),
  600. [$elementId => $alias],
  601. array_slice($children, $offset)
  602. );
  603. $this->_elements[$elementId][self::PARENT] = $targetParentId;
  604. }
  605. /**
  606. * Check if specified element exists
  607. *
  608. * @param string $elementId
  609. * @return void
  610. * @throws LocalizedException if doesn't exist
  611. */
  612. private function _assertElementExists($elementId)
  613. {
  614. if (!isset($this->_elements[$elementId])) {
  615. throw new \OutOfBoundsException(
  616. 'The element with the "' . $elementId . '" ID wasn\'t found. Verify the ID and try again.'
  617. );
  618. }
  619. }
  620. /**
  621. * Check if it is an array
  622. *
  623. * @param array $value
  624. * @return void
  625. * @throws LocalizedException
  626. */
  627. private function _assertArray($value)
  628. {
  629. if (!is_array($value)) {
  630. throw new LocalizedException(
  631. new \Magento\Framework\Phrase("An array expected: %1", [var_export($value, 1)])
  632. );
  633. }
  634. }
  635. }