Version 1
[yaffs-website] / web / modules / contrib / migrate_plus / src / Plugin / migrate_plus / data_parser / Xml.php
1 <?php
2
3 namespace Drupal\migrate_plus\Plugin\migrate_plus\data_parser;
4
5 use Drupal\migrate\MigrateException;
6 use Drupal\migrate_plus\DataParserPluginBase;
7
8 /**
9  * Obtain XML data for migration using the XMLReader pull parser.
10  *
11  * @DataParser(
12  *   id = "xml",
13  *   title = @Translation("XML")
14  * )
15  */
16 class Xml extends DataParserPluginBase {
17
18   use XmlTrait;
19
20   /**
21    * The XMLReader we are encapsulating.
22    *
23    * @var \XMLReader
24    */
25   protected $reader;
26
27   /**
28    * Array of the element names from the query.
29    *
30    * 0-based from the first (root) element. For example, '//file/article' would
31    * be stored as [0 => 'file', 1 => 'article'].
32    *
33    * @var array
34    */
35   protected $elementsToMatch = [];
36
37   /**
38    * An optional xpath predicate.
39    *
40    * Restricts the matching elements based on values in their children. Parsed
41    * from the element query at construct time.
42    *
43    * @var string
44    */
45   protected $xpathPredicate = NULL;
46
47   /**
48    * Array representing the path to the current element as we traverse the XML.
49    *
50    * For example, if in an XML string like '<file><article>...</article></file>'
51    * we are positioned within the article element, currentPath will be
52    * [0 => 'file', 1 => 'article'].
53    *
54    * @var array
55    */
56   protected $currentPath = [];
57
58   /**
59    * Retains all elements with a given name to support extraction from parents.
60    *
61    * This is a hack to support field extraction of values in parents
62    * of the 'context node' - ie, if $this->fields() has something like '..\nid'.
63    * Since we are using a streaming xml processor, it is too late to snoop
64    * around parent elements again once we've located an element of interest. So,
65    * grab elements with matching names and their depths, and refer back to it
66    * when building the source row.
67    *
68    * @var array
69    */
70   protected $parentXpathCache = [];
71
72   /**
73    * Hash of the element names that should be captured into $parentXpathCache.
74    *
75    * @var array
76    */
77   protected $parentElementsOfInterest = [];
78
79   /**
80    * Element name matching mode.
81    *
82    * When matching element names, whether to compare to the namespace-prefixed
83    * name, or the local name.
84    *
85    * @var bool
86    */
87   protected $prefixedName = FALSE;
88
89   /**
90    * {@inheritdoc}
91    */
92   public function __construct(array $configuration, $plugin_id, $plugin_definition) {
93     parent::__construct($configuration, $plugin_id, $plugin_definition);
94
95     $this->reader = new \XMLReader();
96
97     // Suppress errors during parsing, so we can pick them up after.
98     libxml_use_internal_errors(TRUE);
99
100     // Parse the element query. First capture group is the element path, second
101     // (if present) is the attribute.
102     preg_match_all('|^/([^\[]+)\[?(.*?)]?$|', $configuration['item_selector'], $matches);
103     $element_path = $matches[1][0];
104     $this->elementsToMatch = explode('/', $element_path);
105     $predicate = $matches[2][0];
106     if ($predicate) {
107       $this->xpathPredicate = $predicate;
108     }
109
110     // If the element path contains any colons, it must be specifying
111     // namespaces, so we need to compare using the prefixed element
112     // name in next().
113     if (strpos($element_path, ':')) {
114       $this->prefixedName = TRUE;
115     }
116
117     foreach ($this->fieldSelectors() as $field_name => $xpath) {
118       $prefix = substr($xpath, 0, 3);
119       if ($prefix === '../') {
120         $this->parentElementsOfInterest[] = str_replace('../', '', $xpath);
121       }
122       elseif ($prefix === '..\\') {
123         $this->parentElementsOfInterest[] = str_replace('..\\', '', $xpath);
124       }
125     }
126   }
127
128   /**
129    * Builds a \SimpleXmlElement rooted at the iterator's current location.
130    *
131    * The resulting SimpleXmlElement also contains any child nodes of the current
132    * element.
133    *
134    * @return \SimpleXmlElement|false
135    *   A \SimpleXmlElement when the document is parseable, or false if a
136    *   parsing error occurred.
137    *
138    * @throws MigrateException
139    */
140   protected function getSimpleXml() {
141     $node = $this->reader->expand();
142     if ($node) {
143       // We must associate the DOMNode with a DOMDocument to be able to import
144       // it into SimpleXML. Despite appearances, this is almost twice as fast as
145       // simplexml_load_string($this->readOuterXML());
146       $dom = new \DOMDocument();
147       $node = $dom->importNode($node, TRUE);
148       $dom->appendChild($node);
149       $sxml_elem = simplexml_import_dom($node);
150       $this->registerNamespaces($sxml_elem);
151       return $sxml_elem;
152     }
153     else {
154       foreach (libxml_get_errors() as $error) {
155         $error_string = self::parseLibXmlError($error);
156         throw new MigrateException($error_string);
157       }
158       return FALSE;
159     }
160   }
161
162   /**
163    * {@inheritdoc}
164    */
165   public function rewind() {
166     // Reset our path tracker.
167     $this->currentPath = [];
168     parent::rewind();
169   }
170
171   /**
172    * {@inheritdoc}
173    */
174   protected function openSourceUrl($url) {
175     // (Re)open the provided URL.
176     $this->reader->close();
177     return $this->reader->open($url, NULL, \LIBXML_NOWARNING);
178   }
179
180   /**
181    * {@inheritdoc}
182    */
183   protected function fetchNextRow() {
184     $target_element = NULL;
185
186     // Loop over each node in the XML file, looking for elements at a path
187     // matching the input query string (represented in $this->elementsToMatch).
188     while ($this->reader->read()) {
189       if ($this->reader->nodeType == \XMLReader::ELEMENT) {
190         if ($this->prefixedName) {
191           $this->currentPath[$this->reader->depth] = $this->reader->name;
192           if (in_array($this->reader->name, $this->parentElementsOfInterest)) {
193             $this->parentXpathCache[$this->reader->depth][$this->reader->name][] = $this->getSimpleXml();
194           }
195         }
196         else {
197           $this->currentPath[$this->reader->depth] = $this->reader->localName;
198           if (in_array($this->reader->localName, $this->parentElementsOfInterest)) {
199             $this->parentXpathCache[$this->reader->depth][$this->reader->name][] = $this->getSimpleXml();
200           }
201         }
202         if ($this->currentPath == $this->elementsToMatch) {
203           // We're positioned to the right element path - build the SimpleXML
204           // object to enable proper xpath predicate evaluation.
205           $target_element = $this->getSimpleXml();
206           if ($target_element !== FALSE) {
207             if (empty($this->xpathPredicate) || $this->predicateMatches($target_element)) {
208               break;
209             }
210           }
211         }
212       }
213       elseif ($this->reader->nodeType == \XMLReader::END_ELEMENT) {
214         // Remove this element and any deeper ones from the current path.
215         foreach ($this->currentPath as $depth => $name) {
216           if ($depth >= $this->reader->depth) {
217             unset($this->currentPath[$depth]);
218           }
219         }
220         foreach ($this->parentXpathCache as $depth => $elements) {
221           if ($depth > $this->reader->depth) {
222             unset($this->parentXpathCache[$depth]);
223           }
224         }
225       }
226     }
227
228     // If we've found the desired element, populate the currentItem and
229     // currentId with its data.
230     if ($target_element !== FALSE && !is_null($target_element)) {
231       foreach ($this->fieldSelectors() as $field_name => $xpath) {
232         $prefix = substr($xpath, 0, 3);
233         if (in_array($prefix, ['../', '..\\'])) {
234           $name = str_replace($prefix, '', $xpath);
235           $up = substr_count($xpath, $prefix);
236           $values = $this->getAncestorElements($up, $name);
237         }
238         else {
239           $values = $target_element->xpath($xpath);
240         }
241         foreach ($values as $value) {
242           // If the SimpleXMLElement doesn't render to a string of any sort,
243           // and has children then return the whole object for the process
244           // plugin or other row manipulation.
245           if ($value->children() && !trim((string) $value)) {
246             $this->currentItem[$field_name] = $value;
247           }
248           else {
249             $this->currentItem[$field_name][] = (string) $value;
250           }
251         }
252       }
253       // Reduce single-value results to scalars.
254       foreach ($this->currentItem as $field_name => $values) {
255         if (count($values) == 1) {
256           $this->currentItem[$field_name] = reset($values);
257         }
258       }
259     }
260   }
261
262   /**
263    * Tests whether the iterator's xpath predicate matches the provided element.
264    *
265    * Has some limitations esp. in that it is easy to write predicates that
266    * reference things outside this SimpleXmlElement's tree, but "simpler"
267    * predicates should work as expected.
268    *
269    * @param \SimpleXMLElement $elem
270    *   The element to test.
271    *
272    * @return bool
273    *   True if the element matches the predicate, false if not.
274    */
275   protected function predicateMatches(\SimpleXMLElement $elem) {
276     return !empty($elem->xpath('/*[' . $this->xpathPredicate . ']'));
277   }
278
279   /**
280    * Gets an ancestor SimpleXMLElement, if the element name was registered.
281    *
282    * Gets the SimpleXMLElement some number of levels above the iterator
283    * having the given name, but only for element names that this
284    * Xml data parser was told to retain for future reference through the
285    * constructor's $parent_elements_of_interest.
286    *
287    * @param int $levels_up
288    *   The number of levels back towards the root of the DOM tree to ascend
289    *   before searching for the named element.
290    * @param string $name
291    *   The name of the desired element.
292    *
293    * @return \SimpleXMLElement|false
294    *   The element matching the level and name requirements, or false if it is
295    *   not present or was not retained.
296    */
297   public function getAncestorElements($levels_up, $name) {
298     if ($levels_up > 0) {
299       $levels_up *= -1;
300     }
301     $ancestor_depth = $this->reader->depth + $levels_up + 1;
302     if ($ancestor_depth < 0) {
303       return FALSE;
304     }
305
306     if (array_key_exists($ancestor_depth, $this->parentXpathCache) && array_key_exists($name, $this->parentXpathCache[$ancestor_depth])) {
307       return $this->parentXpathCache[$ancestor_depth][$name];
308     }
309     else {
310       return FALSE;
311     }
312   }
313
314 }