Passed
Push — master ( 659531...28cbe9 )
by Aimeos
03:02
created

Standard::importNodes()   A

Complexity

Conditions 6
Paths 9

Size

Total Lines 25
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 14
c 1
b 0
f 0
nc 9
nop 2
dl 0
loc 25
rs 9.2222
1
<?php
2
3
/**
4
 * @license LGPLv3, http://opensource.org/licenses/LGPL-3.0
5
 * @copyright Aimeos (aimeos.org), 2019-2021
6
 * @package Controller
7
 * @subpackage Jobs
8
 */
9
10
11
namespace Aimeos\Controller\Jobs\Product\Import\Xml;
12
13
14
/**
15
 * Job controller for XML product imports
16
 *
17
 * @package Controller
18
 * @subpackage Jobs
19
 */
20
class Standard
21
	extends \Aimeos\Controller\Jobs\Base
22
	implements \Aimeos\Controller\Jobs\Iface
23
{
24
	use \Aimeos\Controller\Common\Common\Import\Traits;
25
	use \Aimeos\Controller\Common\Common\Import\Xml\Traits;
26
27
28
	/**
29
	 * Returns the localized name of the job.
30
	 *
31
	 * @return string Name of the job
32
	 */
33
	public function getName() : string
34
	{
35
		return $this->context()->translate( 'controller/jobs', 'Product import XML' );
36
	}
37
38
39
	/**
40
	 * Returns the localized description of the job.
41
	 *
42
	 * @return string Description of the job
43
	 */
44
	public function getDescription() : string
45
	{
46
		return $this->context()->translate( 'controller/jobs', 'Imports new and updates existing products from XML files' );
47
	}
48
49
50
	/**
51
	 * Executes the job.
52
	 *
53
	 * @throws \Aimeos\Controller\Jobs\Exception If an error occurs
54
	 */
55
	public function run()
56
	{
57
		$context = $this->context();
58
		$config = $context->config();
59
		$logger = $context->logger();
60
61
62
		/** controller/jobs/product/import/xml/location
63
		 * File or directory where the content is stored which should be imported
64
		 *
65
		 * You need to configure the XML file or directory with the XML files that
66
		 * should be imported. It should be an absolute path to be sure but can be
67
		 * relative path if you absolutely know from where the job will be executed
68
		 * from.
69
		 *
70
		 * @param string Absolute file or directory path
71
		 * @since 2019.04
72
		 * @category Developer
73
		 * @category User
74
		 * @see controller/jobs/product/import/xml/container/type
75
		 * @see controller/jobs/product/import/xml/container/content
76
		 * @see controller/jobs/product/import/xml/container/options
77
		 */
78
		$location = $config->get( 'controller/jobs/product/import/xml/location' );
79
80
		try
81
		{
82
			$logger->info( sprintf( 'Started product import from "%1$s"', $location ), 'import/xml/product' );
83
84
			if( !file_exists( $location ) )
85
			{
86
				$msg = sprintf( 'File or directory "%1$s" doesn\'t exist', $location );
87
				throw new \Aimeos\Controller\Jobs\Exception( $msg );
88
			}
89
90
			$files = [];
91
92
			if( is_dir( $location ) )
93
			{
94
				foreach( new \DirectoryIterator( $location ) as $entry )
95
				{
96
					if( strncmp( $entry->getFilename(), 'product', 7 ) === 0 && $entry->getExtension() === 'xml' ) {
97
						$files[] = $entry->getPathname();
98
					}
99
				}
100
			}
101
			else
102
			{
103
				$files[] = $location;
104
			}
105
106
			sort( $files );
107
			$context->__sleep();
108
109
			$fcn = function( $filepath ) {
110
				$this->import( $filepath );
111
			};
112
113
			foreach( $files as $filepath ) {
114
				$context->process()->start( $fcn, [$filepath] );
115
			}
116
117
			$context->process()->wait();
118
119
			$logger->info( sprintf( 'Finished product import from "%1$s"', $location ), 'import/xml/product' );
120
		}
121
		catch( \Exception $e )
122
		{
123
			$logger->error( 'Product import error: ' . $e->getMessage() . "\n" . $e->getTraceAsString(), 'import/xml/product' );
124
			$this->mail( 'Product XML import error', $e->getMessage() . "\n" . $e->getTraceAsString() );
125
			throw $e;
126
		}
127
	}
128
129
130
	/**
131
	 * Imports the XML file given by its path
132
	 *
133
	 * @param string $filename Absolute or relative path to the XML file
134
	 */
135
	protected function import( string $filename )
136
	{
137
		$context = $this->context();
138
		$config = $context->config();
139
		$logger = $context->logger();
140
141
142
		/** controller/jobs/product/import/xml/domains
143
		 * List of item domain names that should be retrieved along with the product items
144
		 *
145
		 * This configuration setting overwrites the shared option
146
		 * "controller/common/product/import/xml/domains" if you need a
147
		 * specific setting for the job controller. Otherwise, you should
148
		 * use the shared option for consistency.
149
		 *
150
		 * @param array Associative list of MShop item domain names
151
		 * @since 2019.04
152
		 * @category Developer
153
		 * @see controller/jobs/product/import/xml/backup
154
		 * @see controller/jobs/product/import/xml/max-query
155
		 */
156
		$domains = $config->get( 'controller/jobs/product/import/xml/domains', [] );
157
158
		/** controller/jobs/product/import/xml/backup
159
		 * Name of the backup for sucessfully imported files
160
		 *
161
		 * After a XML file was imported successfully, you can move it to another
162
		 * location, so it won't be imported again and isn't overwritten by the
163
		 * next file that is stored at the same location in the file system.
164
		 *
165
		 * You should use an absolute path to be sure but can be relative path
166
		 * if you absolutely know from where the job will be executed from. The
167
		 * name of the new backup location can contain placeholders understood
168
		 * by the PHP DateTime::format() method (with percent signs prefix) to
169
		 * create dynamic paths, e.g. "backup/%Y-%m-%d" which would create
170
		 * "backup/2000-01-01". For more information about the date() placeholders,
171
		 * please have a look  into the PHP documentation of the
172
		 * {@link https://www.php.net/manual/en/datetime.format.php format() method}.
173
		 *
174
		 * **Note:** If no backup name is configured, the file or directory
175
		 * won't be moved away. Please make also sure that the parent directory
176
		 * and the new directory are writable so the file or directory could be
177
		 * moved.
178
		 *
179
		 * @param integer Name of the backup file, optionally with date/time placeholders
180
		 * @since 2019.04
181
		 * @category Developer
182
		 * @see controller/jobs/product/import/xml/domains
183
		 * @see controller/jobs/product/import/xml/max-query
184
		 */
185
		$backup = $config->get( 'controller/jobs/product/import/xml/backup' );
186
187
		/** controller/jobs/product/import/xml/max-query
188
		 * Maximum number of XML nodes processed at once
189
		 *
190
		 * Processing and fetching several product items at once speeds up importing
191
		 * the XML files. The more items can be processed at once, the faster the
192
		 * import. More items also increases the memory usage of the importer and
193
		 * thus, this parameter should be low enough to avoid reaching the memory
194
		 * limit of the PHP process.
195
		 *
196
		 * @param integer Number of XML nodes
197
		 * @since 2019.04
198
		 * @category Developer
199
		 * @category User
200
		 * @see controller/jobs/product/import/xml/domains
201
		 * @see controller/jobs/product/import/xml/backup
202
		 */
203
		$maxquery = $config->get( 'controller/jobs/product/import/xml/max-query', 100 );
204
205
206
		$slice = 0;
207
		$nodes = [];
208
		$xml = new \XMLReader();
209
210
		if( $xml->open( $filename, LIBXML_COMPACT | LIBXML_PARSEHUGE ) === false ) {
211
			throw new \Aimeos\Controller\Jobs\Exception( sprintf( 'No XML file "%1$s" found', $filename ) );
212
		}
213
214
		$logger->info( sprintf( 'Started product import from file "%1$s"', $filename ), 'import/xml/product' );
215
216
		while( $xml->read() === true )
217
		{
218
			if( $xml->depth === 1 && $xml->nodeType === \XMLReader::ELEMENT && $xml->name === 'productitem' )
219
			{
220
				if( ( $dom = $xml->expand() ) === false )
221
				{
222
					$msg = sprintf( 'Expanding "%1$s" node failed', 'productitem' );
223
					throw new \Aimeos\Controller\Jobs\Exception( $msg );
224
				}
225
226
				$nodes[] = $dom;
227
228
				if( $slice++ >= $maxquery )
229
				{
230
					$this->importNodes( $nodes, $domains );
231
					unset( $nodes );
232
					$nodes = [];
233
					$slice = 0;
234
				}
235
			}
236
		}
237
238
		$this->importNodes( $nodes, $domains );
239
		unset( $nodes );
240
241
		$this->saveTypes();
242
243
		foreach( $this->getProcessors() as $proc ) {
244
			$proc->finish();
245
		}
246
247
		$logger->info( sprintf( 'Finished product import from file "%1$s"', $filename ), 'import/xml/product' );
248
249
		if( !empty( $backup ) && @rename( $filename, $backup = \Aimeos\MW\Str::strtime( $backup ) ) === false )
250
		{
251
			$msg = sprintf( 'Unable to move imported file "%1$s" to "%2$s"', $filename, $backup );
252
			throw new \Aimeos\Controller\Jobs\Exception( $msg );
253
		}
254
	}
255
256
257
	/**
258
	 * Imports the given DOM nodes
259
	 *
260
	 * @param \DomElement[] $nodes List of nodes to import
261
	 * @param string[] $ref List of domain names whose referenced items will be updated in the product items
262
	 */
263
	protected function importNodes( array $nodes, array $ref )
264
	{
265
		$codes = [];
266
267
		foreach( $nodes as $node )
268
		{
269
			if( ( $attr = $node->attributes->getNamedItem( 'ref' ) ) !== null ) {
0 ignored issues
show
Bug introduced by
The method getNamedItem() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

269
			if( ( $attr = $node->attributes->/** @scrutinizer ignore-call */ getNamedItem( 'ref' ) ) !== null ) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
270
				$codes[$attr->nodeValue] = null;
271
			}
272
		}
273
274
		$manager = \Aimeos\MShop::create( $this->context(), 'product' );
275
		$search = $manager->filter()->slice( 0, count( $codes ) )->add( ['product.code' => array_keys( $codes )] );
276
		$map = $manager->search( $search, $ref )->col( null, 'product.code' );
277
278
		foreach( $nodes as $node )
279
		{
280
			if( ( $attr = $node->attributes->getNamedItem( 'ref' ) ) !== null && isset( $map[$attr->nodeValue] ) ) {
281
				$item = $this->process( $map[$attr->nodeValue], $node );
0 ignored issues
show
Bug introduced by
It seems like $map[$attr->nodeValue] can also be of type null; however, parameter $item of Aimeos\Controller\Jobs\P...Xml\Standard::process() does only seem to accept Aimeos\MShop\Product\Item\Iface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

281
				$item = $this->process( /** @scrutinizer ignore-type */ $map[$attr->nodeValue], $node );
Loading history...
282
			} else {
283
				$item = $this->process( $manager->create(), $node );
284
			}
285
286
			$manager->save( $item );
287
			$this->addType( 'product/type', 'product', $item->getType() );
0 ignored issues
show
Bug introduced by
It seems like $item->getType() can also be of type null; however, parameter $code of Aimeos\Controller\Jobs\P...Xml\Standard::addType() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

287
			$this->addType( 'product/type', 'product', /** @scrutinizer ignore-type */ $item->getType() );
Loading history...
288
		}
289
	}
290
291
292
	/**
293
	 * Updates the product item and its referenced items using the given DOM node
294
	 *
295
	 * @param \Aimeos\MShop\Product\Item\Iface $item Product item object to update
296
	 * @param \DomElement $node DOM node used for updateding the product item
297
	 * @return \Aimeos\MShop\Product\Item\Iface $item Updated product item object
298
	 */
299
	protected function process( \Aimeos\MShop\Product\Item\Iface $item, \DomElement $node ) : \Aimeos\MShop\Product\Item\Iface
300
	{
301
		try
302
		{
303
			$list = [];
304
305
			foreach( $node->attributes as $attr ) {
306
				$list[$attr->nodeName] = $attr->nodeValue;
307
			}
308
309
			foreach( $node->childNodes as $tag )
310
			{
311
				if( in_array( $tag->nodeName, ['lists', 'property'] ) ) {
312
					$item = $this->getProcessor( $tag->nodeName )->process( $item, $tag );
313
				} elseif( $tag->nodeName[0] !== '#' ) {
314
					$list[$tag->nodeName] = $tag->nodeValue;
315
				}
316
			}
317
318
			$list['product.config'] = isset( $list['product.config'] ) ? json_decode( $list['product.config'], true ) : [];
319
			$item->fromArray( $list, true );
320
		}
321
		catch( \Exception $e )
322
		{
323
			$msg = 'Product import error: ' . $e->getMessage() . "\n" . $e->getTraceAsString();
324
			$this->context()->logger()->error( $msg, 'import/xml/product' );
325
		}
326
327
		return $item;
328
	}
329
}
330