DomMerger.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. <?php
  2. /**
  3. * Copyright © Magento, Inc. All rights reserved.
  4. * See COPYING.txt for license details.
  5. */
  6. namespace Magento\Framework\View\Element\UiComponent\Config;
  7. use Magento\Framework\Config\Dom;
  8. use Magento\Framework\Config\ValidationStateInterface;
  9. /**
  10. * Class DomMerger
  11. */
  12. class DomMerger implements DomMergerInterface
  13. {
  14. /**
  15. * Format of items in errors array to be used by default. Available placeholders - fields of \LibXMLError.
  16. */
  17. const ERROR_FORMAT_DEFAULT = "Message: %message%\nLine: %line%\n";
  18. /**
  19. * @var \Magento\Framework\Config\ValidationStateInterface
  20. */
  21. private $validationState;
  22. /**
  23. * Location schema file
  24. *
  25. * @var string
  26. */
  27. protected $schemaFilePath;
  28. /**
  29. * Result DOM document
  30. *
  31. * @var \DOMDocument
  32. */
  33. protected $domDocument;
  34. /**
  35. * Id attribute list
  36. *
  37. * @var array
  38. */
  39. protected $idAttributes = [];
  40. /**
  41. * Context XPath
  42. *
  43. * @var array
  44. */
  45. protected $contextXPath = [];
  46. /**
  47. * Is merge simple XML Element
  48. *
  49. * @var bool
  50. */
  51. protected $isMergeSimpleXMLElement;
  52. /**
  53. * @var string
  54. */
  55. private $schema;
  56. /**
  57. * Build DOM with initial XML contents and specifying identifier attributes for merging
  58. *
  59. * Format of $schema: Absolute schema file path or URN
  60. * Format of $idAttributes: array('name', 'id')
  61. * Format of $contextXPath: array('/config/ui')
  62. * The path to ID attribute name should not include any attribute notations or modifiers -- only node names
  63. *
  64. * @param ValidationStateInterface $validationState
  65. * @param string $schema
  66. * @param bool $isMergeSimpleXMLElement
  67. * @param array $contextXPath
  68. * @param array $idAttributes
  69. */
  70. public function __construct(
  71. ValidationStateInterface $validationState,
  72. $schema,
  73. $isMergeSimpleXMLElement = false,
  74. array $contextXPath = [],
  75. array $idAttributes = []
  76. ) {
  77. $this->validationState = $validationState;
  78. $this->schema = $schema;
  79. $this->isMergeSimpleXMLElement = $isMergeSimpleXMLElement;
  80. $this->contextXPath = $contextXPath;
  81. $this->idAttributes = $idAttributes;
  82. }
  83. /**
  84. * Is id attribute
  85. *
  86. * @param string $attributeName
  87. * @return bool
  88. */
  89. protected function isIdAttribute($attributeName)
  90. {
  91. return in_array($attributeName, $this->idAttributes);
  92. }
  93. /**
  94. * Is merge context
  95. *
  96. * @param string $xPath
  97. * @return bool
  98. */
  99. protected function isMergeContext($xPath)
  100. {
  101. foreach ($this->contextXPath as $context) {
  102. if (strpos($xPath, $context) === 0) {
  103. return true;
  104. }
  105. }
  106. return false;
  107. }
  108. /**
  109. * Is context XPath
  110. *
  111. * @param array $xPath
  112. * @return bool
  113. */
  114. protected function isContextXPath(array $xPath)
  115. {
  116. return count(array_intersect($xPath, $this->contextXPath)) === count($xPath);
  117. }
  118. /**
  119. * Merges attributes of the merge node to the base node
  120. *
  121. * @param \DOMElement $baseNode
  122. * @param \DOMNode $mergeNode
  123. * @return void
  124. */
  125. protected function mergeAttributes(\DOMElement $baseNode, \DOMNode $mergeNode)
  126. {
  127. foreach ($mergeNode->attributes as $name => $attribute) {
  128. $baseNode->setAttribute($name, $attribute->value);
  129. }
  130. }
  131. /**
  132. * Create XPath
  133. *
  134. * @param \DOMNode $node
  135. * @return string
  136. */
  137. protected function createXPath(\DOMNode $node)
  138. {
  139. $parentXPath = '';
  140. $currentXPath = $node->getNodePath();
  141. if ($node->parentNode !== null && !$node->isSameNode($node->parentNode)) {
  142. $parentXPath = $this->createXPath($node->parentNode);
  143. $pathParts = explode('/', $currentXPath);
  144. $currentXPath = '/' . end($pathParts);
  145. }
  146. $attributesXPath = '';
  147. if ($node->hasAttributes()) {
  148. $attributes = [];
  149. foreach ($node->attributes as $name => $attribute) {
  150. if ($this->isIdAttribute($name)) {
  151. $attributes[] = sprintf('@%s="%s"', $name, $attribute->value);
  152. break;
  153. }
  154. }
  155. if (!empty($attributes)) {
  156. if (substr($currentXPath, -1) === ']') {
  157. $currentXPath = substr($currentXPath, 0, strrpos($currentXPath, '['));
  158. }
  159. $attributesXPath = '[' . implode(' and ', $attributes) . ']';
  160. }
  161. }
  162. return '/' . trim($parentXPath . $currentXPath . $attributesXPath, '/');
  163. }
  164. /**
  165. * Merge nested xml nodes
  166. *
  167. * @param \DOMXPath $rootDomXPath
  168. * @param \DOMNodeList $insertedNodes
  169. * @param \DOMNode $contextNode
  170. * @return void
  171. *
  172. * @SuppressWarnings(PHPMD.CyclomaticComplexity)
  173. * @SuppressWarnings(PHPMD.NPathComplexity)
  174. */
  175. protected function nestedMerge(\DOMXPath $rootDomXPath, \DOMNodeList $insertedNodes, \DOMNode $contextNode)
  176. {
  177. for ($i = 0, $iLength = $insertedNodes->length; $i < $iLength; ++$i) {
  178. $insertedItem = $insertedNodes->item($i);
  179. switch ($insertedItem->nodeType) {
  180. case XML_TEXT_NODE:
  181. case XML_COMMENT_NODE:
  182. case XML_CDATA_SECTION_NODE:
  183. if (trim($insertedItem->textContent) !== '') {
  184. $this->insertBefore($contextNode, $insertedItem);
  185. }
  186. break;
  187. default:
  188. $insertedXPath = $this->createXPath($insertedItem);
  189. $rootMatchList = $rootDomXPath->query($insertedXPath, $contextNode);
  190. $jLength = $rootMatchList->length;
  191. if ($jLength > 0) {
  192. for ($j = 0; $j < $jLength; ++$j) {
  193. $rootItem = $rootMatchList->item($j);
  194. $rootItemXPath = $this->createXPath($rootItem);
  195. if ($this->isMergeContext($insertedXPath)) {
  196. if ($this->isTextNode($insertedItem) && $this->isTextNode($rootItem)) {
  197. $rootItem->nodeValue = $insertedItem->nodeValue;
  198. } elseif (!$this->isContextXPath([$rootItemXPath, $insertedXPath])
  199. && !$this->hasIdAttribute($rootItem)
  200. && !$this->hasIdAttribute($insertedItem)
  201. ) {
  202. if ($this->isMergeSimpleXMLElement) {
  203. $this->nestedMerge($rootDomXPath, $insertedItem->childNodes, $rootItem);
  204. $this->mergeAttributes($rootItem, $insertedItem);
  205. } else {
  206. $this->appendChild($contextNode, $insertedItem);
  207. }
  208. } else {
  209. $this->nestedMerge($rootDomXPath, $insertedItem->childNodes, $rootItem);
  210. $this->mergeAttributes($rootItem, $insertedItem);
  211. }
  212. } else {
  213. $this->appendChild($contextNode, $insertedItem);
  214. }
  215. }
  216. } else {
  217. $this->appendChild($contextNode, $insertedItem);
  218. }
  219. break;
  220. }
  221. }
  222. }
  223. /**
  224. * Append child node
  225. *
  226. * @param \DOMNode $parentNode
  227. * @param \DOMNode $childNode
  228. * @return void
  229. */
  230. protected function appendChild(\DOMNode $parentNode, \DOMNode $childNode)
  231. {
  232. $importNode = $this->getDom()->importNode($childNode, true);
  233. $parentNode->appendChild($importNode);
  234. }
  235. /**
  236. * Insert before
  237. *
  238. * @param \DOMNode $parentNode
  239. * @param \DOMNode $childNode
  240. * @return void
  241. */
  242. protected function insertBefore(\DOMNode $parentNode, \DOMNode $childNode)
  243. {
  244. $importNode = $this->getDom()->importNode($childNode, true);
  245. $parentNode->insertBefore($importNode);
  246. }
  247. /**
  248. * Check if the node content is text
  249. *
  250. * @param \DOMNode $node
  251. * @return bool
  252. */
  253. protected function isTextNode(\DOMNode $node)
  254. {
  255. return $node->childNodes->length == 1 && $node->childNodes->item(0) instanceof \DOMText;
  256. }
  257. /**
  258. * Has ID attribute
  259. *
  260. * @param \DOMNode $node
  261. * @return bool
  262. * @SuppressWarnings(PHPMD.UnusedLocalVariable)
  263. */
  264. protected function hasIdAttribute(\DOMNode $node)
  265. {
  266. if (!$node->hasAttributes()) {
  267. return false;
  268. }
  269. foreach ($node->attributes as $name => $attribute) {
  270. if (in_array($name, $this->idAttributes)) {
  271. return true;
  272. }
  273. }
  274. return false;
  275. }
  276. /**
  277. * Recursive merging of the \DOMElement into the original document
  278. *
  279. * Algorithm:
  280. * 1. Find the same node in original document
  281. * 2. Extend and override original document node attributes and scalar value if found
  282. * 3. Append new node if original document doesn't have the same node
  283. *
  284. * @param \DOMElement $node
  285. * @throws \Magento\Framework\Exception\LocalizedException
  286. * @return void
  287. */
  288. public function mergeNode(\DOMElement $node)
  289. {
  290. $parentDoom = $this->getDom();
  291. $this->nestedMerge(new \DOMXPath($parentDoom), $node->childNodes, $parentDoom->documentElement);
  292. }
  293. /**
  294. * Create DOM document based on $xml parameter
  295. *
  296. * @param string $xml
  297. * @return \DOMDocument
  298. * @throws \Magento\Framework\Exception\LocalizedException
  299. */
  300. public function createDomDocument($xml)
  301. {
  302. $domDocument = new \DOMDocument();
  303. $domDocument->loadXML($xml);
  304. if ($this->validationState->isValidationRequired() && $this->schema) {
  305. $errors = $this->validateDomDocument($domDocument);
  306. if (count($errors)) {
  307. throw new \Magento\Framework\Exception\LocalizedException(
  308. new \Magento\Framework\Phrase(implode("\n", $errors))
  309. );
  310. }
  311. }
  312. return $domDocument;
  313. }
  314. /**
  315. * Validate dom document
  316. *
  317. * @param \DOMDocument $domDocument
  318. * @param string|null $schema
  319. * @return array of errors
  320. * @throws \Exception
  321. */
  322. protected function validateDomDocument(\DOMDocument $domDocument, $schema = null)
  323. {
  324. $schema = $schema !== null ? $schema : $this->schema;
  325. libxml_use_internal_errors(true);
  326. try {
  327. $errors = \Magento\Framework\Config\Dom::validateDomDocument($domDocument, $schema);
  328. } catch (\Exception $exception) {
  329. libxml_use_internal_errors(false);
  330. throw $exception;
  331. }
  332. libxml_use_internal_errors(false);
  333. return $errors;
  334. }
  335. /**
  336. * Render error message string by replacing placeholders '%field%' with properties of \LibXMLError
  337. *
  338. * @param \LibXMLError $errorInfo
  339. * @return string
  340. * @throws \Magento\Framework\Exception\LocalizedException
  341. */
  342. protected function renderErrorMessage(\LibXMLError $errorInfo)
  343. {
  344. $result = static::ERROR_FORMAT_DEFAULT;
  345. foreach ($errorInfo as $field => $value) {
  346. $result = str_replace('%' . $field . '%', trim((string)$value), $result);
  347. }
  348. if (strpos($result, '%') !== false) {
  349. throw new \Magento\Framework\Exception\LocalizedException(
  350. new \Magento\Framework\Phrase(
  351. 'Error format "' . static::ERROR_FORMAT_DEFAULT . '" contains unsupported placeholders.'
  352. )
  353. );
  354. }
  355. return $result;
  356. }
  357. /**
  358. * Merge string $xml into DOM document
  359. *
  360. * @param string $xml
  361. * @return void
  362. */
  363. public function merge($xml)
  364. {
  365. if (!isset($this->domDocument)) {
  366. $this->domDocument = $this->createDomDocument($xml);
  367. } else {
  368. $this->mergeNode($this->createDomDocument($xml)->documentElement);
  369. }
  370. }
  371. /**
  372. * Get DOM document
  373. *
  374. * @return \DOMDocument
  375. * @throws \Magento\Framework\Exception\LocalizedException
  376. */
  377. public function getDom()
  378. {
  379. if (!isset($this->domDocument)) {
  380. throw new \Magento\Framework\Exception\LocalizedException(
  381. new \Magento\Framework\Phrase('Object DOMDocument should be created.')
  382. );
  383. }
  384. return $this->domDocument;
  385. }
  386. /**
  387. * Set DOM document
  388. *
  389. * @param \DOMDocument $domDocument
  390. * @return void
  391. */
  392. public function setDom(\DOMDocument $domDocument)
  393. {
  394. $this->domDocument = $domDocument;
  395. }
  396. /**
  397. * Unset DOM document
  398. *
  399. * @return void
  400. */
  401. public function unsetDom()
  402. {
  403. unset($this->domDocument);
  404. }
  405. /**
  406. * Validate self contents towards to specified schema
  407. *
  408. * @param string|null $schemaFilePath
  409. * @return array
  410. */
  411. public function validate($schemaFilePath = null)
  412. {
  413. if (!$this->validationState->isValidationRequired()) {
  414. return [];
  415. }
  416. return $this->validateDomDocument($this->getDom(), $schemaFilePath);
  417. }
  418. }