Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactored Source For ImportExport module, related to #7469 #9480

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d948d42
Add file parser interface to support various import formats, created …
IvanChepurnyi Apr 3, 2017
8b8bcae
Remove wrong license header and specify proper one
IvanChepurnyi Apr 3, 2017
8c86c17
New parser implementation with composite
IvanChepurnyi May 2, 2017
0a898be
Move CSV parser instantiation via ObjectManager in factory, add FakeO…
IvanChepurnyi May 2, 2017
9b542f6
Give up copyright to Magento Inc.
IvanChepurnyi May 2, 2017
43df7ef
Rename variable in UnsupportedPathExceptionTest to add more consistency
IvanChepurnyi May 2, 2017
ff04479
Add ZipParserFactory composite implementation
IvanChepurnyi May 2, 2017
2488330
Remove redundant properties from CSVParser
IvanChepurnyi May 2, 2017
2088f55
Remove un-used fixture
IvanChepurnyi May 2, 2017
99d6a17
Add relative file path resolution for CSV and ZIP parser factories
IvanChepurnyi May 2, 2017
71c5288
Add file import source implementation based on FileParser
IvanChepurnyi May 2, 2017
2ff1d0f
Replace Adapter implementation with FileFactory, mark previous implem…
IvanChepurnyi May 2, 2017
e8d21fc
Fix braces and spaces for PSR2 standard compliency
IvanChepurnyi May 3, 2017
1de2b68
Add extension points for source options retrieval
IvanChepurnyi May 3, 2017
f654f06
Add default value to extension point
IvanChepurnyi May 3, 2017
b1826bc
Merge remote-tracking branch 'upstream/develop' into 7468-catalog-imp…
IvanChepurnyi May 3, 2017
e5c6327
Fix static code analysis issues with test code
IvanChepurnyi May 18, 2017
6b93516
Improve readability of ZipParserFactoryTest
IvanChepurnyi May 18, 2017
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public function execute()
$import->uploadSource(),
$this->_objectManager->create(\Magento\Framework\Filesystem::class)
->getDirectoryWrite(DirectoryList::ROOT),
$data[$import::FIELD_FIELD_SEPARATOR]
$this->getSourceAdapterOptions($data)
);
$this->processValidationResult($import->validateSource($source), $resultBlock);
} catch (\Magento\Framework\Exception\LocalizedException $e) {
Expand Down Expand Up @@ -120,6 +120,19 @@ private function getImport()
return $this->import;
}

/**
* Returns options for source adapter
*
* @param $data
* @return array
*/
protected function getSourceAdapterOptions($data)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't recommend to use Inheritance Based API, so all the methods should be whether public or private

{
return [
'delimiter' => $data[Import::FIELD_FIELD_SEPARATOR] ?: ','
];
}

/**
* Add error message to Result block and allow 'Import' button
*
Expand Down
14 changes: 13 additions & 1 deletion app/code/Magento/ImportExport/Model/Import.php
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ protected function _getSourceAdapter($sourceFile)
return \Magento\ImportExport\Model\Import\Adapter::findAdapterFor(
$sourceFile,
$this->_filesystem->getDirectoryWrite(DirectoryList::ROOT),
$this->getData(self::FIELD_FIELD_SEPARATOR)
$this->getSourceAdapterOptions()
);
}

Expand Down Expand Up @@ -785,4 +785,16 @@ public function getDeletedItemsCount()
{
return $this->_getEntityAdapter()->getDeletedItemsCount();
}

/**
* Returns options for source adapter
*
* @return array
*/
protected function getSourceAdapterOptions()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't recommend to use Inheritance Based API, so all the methods should be whether public or private

{
return [
'delimiter' => $this->getData(self::FIELD_FIELD_SEPARATOR) ?: ','
];
}
}
101 changes: 80 additions & 21 deletions app/code/Magento/ImportExport/Model/Import/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
*/
namespace Magento\ImportExport\Model\Import;

use Magento\Framework\App\ObjectManager;
use Magento\Framework\Filesystem\Directory\Write;
use Magento\ImportExport\Model\Import\Source\FileFactory;
use Magento\ImportExport\Model\Import\Source\FileParser\CorruptedFileException;
use Magento\ImportExport\Model\Import\Source\FileParser\UnsupportedPathException;

/**
* Import adapter model
Expand All @@ -14,6 +18,15 @@
*/
class Adapter
{
/** @var FileFactory */
private $fileSourceFactory;

public function __construct(FileFactory $fileSourceFactory)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doc block absent for this constructor

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you see as a comment for a constructor?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DocBlock is not just comment, but specification of all input types.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Violation of BC policies, all the parameters should be optional (nullable)

{
$this->fileSourceFactory = $fileSourceFactory;
}


/**
* Adapter factory. Checks for availability, loads and create instance of import adapter object.
*
Expand All @@ -25,29 +38,12 @@ class Adapter
* @return AbstractSource
*
* @throws \Magento\Framework\Exception\LocalizedException
* @deprecated
* @see Adapter::createSourceByPath()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better to avoid mixing up Deprecated logic and Newly created one in one class.
Thus, we recommend to introduce new Factory entity and mark it with @ api.
Current class to mark as @ deprecated.

Inject newly created factory as DI injection to Adapter class.
And make current class to proxy calls to newly created API

*/
public static function factory($type, $directory, $source, $options = null)
{
if (!is_string($type) || !$type) {
throw new \Magento\Framework\Exception\LocalizedException(
__('The adapter type must be a non-empty string.')
);
}
$adapterClass = 'Magento\ImportExport\Model\Import\Source\\' . ucfirst(strtolower($type));

if (!class_exists($adapterClass)) {
throw new \Magento\Framework\Exception\LocalizedException(
__('\'%1\' file extension is not supported', $type)
);
}
$adapter = new $adapterClass($source, $directory, $options);

if (!$adapter instanceof AbstractSource) {
throw new \Magento\Framework\Exception\LocalizedException(
__('Adapter must be an instance of \Magento\ImportExport\Model\Import\AbstractSource')
);
}
return $adapter;
return self::createBackwardCompatibleInstance()->createSourceByPath($source, $options);
}

/**
Expand All @@ -58,9 +54,72 @@ public static function factory($type, $directory, $source, $options = null)
* @param mixed $options OPTIONAL Adapter constructor options
*
* @return AbstractSource
* @deprecated
* @see Adapter::createSourceByPath()
*/
public static function findAdapterFor($source, $directory, $options = null)
{
return self::factory(pathinfo($source, PATHINFO_EXTENSION), $directory, $source, $options);
return self::createBackwardCompatibleInstance()->createSourceByPath($source, $options);
}



/**
* Finds source for import by file path
*
* @param $path
* @param $options
*
* @return AbstractSource
*/
public function createSourceByPath($path, $options = [])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be a responsibility of new API entity

{
try {
$options = $this->mapBackwardCompatibleFileParserOption($options);
$parser = $this->fileSourceFactory->createFromFilePath($path, $options);
} catch (UnsupportedPathException $e) {
$this->throwUnsupportedFileException($path);
} catch (CorruptedFileException $e) {
$this->throwUnsupportedFileException($path);
}

return $parser;
}


private function extractFileExtension($path)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better to use PHP core function for these purposes than to implement one by yourself

$path_parts = pathinfo('/www/htdocs/inc/lib.inc.php');
echo $path_parts['extension'];

http://php.net/manual/en/function.pathinfo.php

{
$fileName = basename($path);

if (strpos($path, '.') === false) {
return $fileName;
}

$fileExtension = substr($fileName, strrpos($fileName, '.') + 1);
return $fileExtension;
}

private function throwUnsupportedFileException($path)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is no PHP Doc Block

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it required to have DocBlock on private methods that describe its purpose in the name?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's required for all methods and properties, no matter whether it private or public one

{
$fileExtension = $this->extractFileExtension($path);
throw new \Magento\Framework\Exception\LocalizedException(
__('\'%1\' file extension is not supported', $fileExtension)
);
}

private function mapBackwardCompatibleFileParserOption($options)
{
if (!is_array($options)) {
$options = ['delimiter' => $options ?? ','];
}
return $options;
}

/**
* @return self
*/
private static function createBackwardCompatibleInstance()
{
return ObjectManager::getInstance()->create(self::class);
}
}
2 changes: 2 additions & 0 deletions app/code/Magento/ImportExport/Model/Import/Source/Csv.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

/**
* CSV import adapter
*
* @deprecated
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reason of depreciation should be provided and @ see tag specifying the new, correct version of current API

*/
class Csv extends \Magento\ImportExport\Model\Import\AbstractSource
{
Expand Down
31 changes: 31 additions & 0 deletions app/code/Magento/ImportExport/Model/Import/Source/File.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/

namespace Magento\ImportExport\Model\Import\Source;

use Magento\ImportExport\Model\Import\AbstractSource;

class File extends AbstractSource
{
private $parser;

public function __construct(FileParser\ParserInterface $parser)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PHP Doc Block absent

{
$this->parser = $parser;
parent::__construct($this->parser->getColumnNames());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not correct to make computation in constructors, Magento encourages developers to use constructors just for assignments

}

protected function _getNextRow()
{
return $this->parser->fetchRow();
}

public function rewind()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc Blocks are absent

{
$this->parser->reset();
parent::rewind();
}
}
53 changes: 53 additions & 0 deletions app/code/Magento/ImportExport/Model/Import/Source/FileFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/

namespace Magento\ImportExport\Model\Import\Source;


use Magento\Framework\ObjectManagerInterface;
use Magento\ImportExport\Model\Import\AbstractSource;
use Magento\ImportExport\Model\Import\Source\FileParser\ParserFactoryInterface;
use Magento\ImportExport\Model\Import\Source\FileParser\ParserInterface;

class FileFactory
{
/** @var ParserFactoryInterface */
private $parserFactory;

/** @var ObjectManagerInterface */
private $objectManager;

public function __construct(ParserFactoryInterface $parserFactory, ObjectManagerInterface $objectManager)
{
$this->parserFactory = $parserFactory;
$this->objectManager = $objectManager;
}

/**
* Creates file source from file path
*
* @param string $path
* @param array $options
* @return AbstractSource
*/
public function createFromFilePath($path, array $options = [])
{
return $this->createFromFileParser(
$this->parserFactory->create($path, $options)
);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we have two different methods of creation in the same class?
even dependencies and input parameters are different.
It's better to segregate this code onto two different classes.

/**
* Creates file source from file parser
*
* @param ParserInterface $parser
* @return AbstractSource
*/
public function createFromFileParser(ParserInterface $parser)
{
return $this->objectManager->create(File::class, ['parser' => $parser]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/

namespace Magento\ImportExport\Model\Import\Source\FileParser;

/**
* File Parser Composite
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be marked as @ api , because used as an Extension Point

*
*/
class CompositeParserFactory implements ParserFactoryInterface
{
/** @var ParserFactoryInterface[] */
private $parserFactories;

public function __construct(array $parserFactories = [])
{
$this->parserFactories = [];

foreach ($parserFactories as $parserFactory) {
$this->addParserFactory($parserFactory);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there should not be logic in the constructors , just assignments

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addParserFactory validates type of the factory passed into the constructor. What would be your advice on doing it, unless variadic types are not supported by DI?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variadic types are not supported by DI and I doubt it should be supported.
What is correct that DI should make a type validation for arrays, and it probably would do that in future.

For now, there are two possibilities to make a type check :

  1. Use xsi:type="object" not xsi:type="string". But doing that way you can't implement lazy loading, because ObjectManager will create an entity of needed type for you

  2. Use fast type check in the constructor. It does not look well, but quite fast and adding comment that it's just for type check that would be easy to remove that in future when DI would be responsible for this operation

// Type check for array to make sure 
// that all parser factories implement ParserFactoryInterface
$this->parserFactories = (function(ParserFactoryInterface ...$parserFactories) {
	return $parserFactories;
})(...$parserFactories);

}

public function create($path, array $options = [])
{
foreach ($this->parserFactories as $parserFactory) {
$parser = $this->createParserIfSupported($parserFactory, $path, $options);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this does not look like a proper Object Oriented way of polymorphic object creation.

because we instantiate in advance all the factories and just looping through all of them trying to instantiate needed parser


if ($parser === false) {
continue;
}

return $parser;
}

$this->thereWasNoParserFound($path);
}

public function addParserFactory(ParserFactoryInterface $parserFactory)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this method should not be public, actually I doubt we need it at all , because all it does is just add item to the array

{
$this->parserFactories[] = $parserFactory;
}

private function thereWasNoParserFound($path)
{
throw new UnsupportedPathException($path);
}

private function createParserIfSupported(ParserFactoryInterface $parserFactory, $path, array $options)
{
try {
$parser = $parserFactory->create($path, $options);
} catch (UnsupportedPathException $e) {
return false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this does not look like a proper way of work with Exceptions

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate on this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The root cause of the problem described above

this does not look like a proper Object Oriented way of polymorphic object creation.

because we instantiate in advance all the factories and just looping through all of them trying to instantiate needed parser

Because we don't use proper Object Oriented design to choose correct factory to be used and instantiate needed parser. We just look over all possible factories and try to instantiate parser with the help of each next factory in the list. So, our Exception is not actually exception (because Exception represents unexpected behavior, but here we have expected false results) that's why we have a juggling of Exception -> FALSE
Which is bad practice working with Exceptions

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a valid case for actual CSV file parser factory or for ZIP file parser factory as if they are used directly by a client, returning false is a terrible thing. Adding if ($status) is worse practice as it increases cyclomatic complexity of client code and hidden bugs for unchecked conditions, exceptions look better in this case in my opinion.

Also, a CompositeFactory does throw the same exception if no other parser factories gave a valid parser instance, so unexpected path gets notified up the chain.

We cannot use here common for Magento 2 "file extension based" strategy pattern, as file path might be a stream or even a temporary file without the extension, so offloading logic to parser factory looks more clean and extensible. As well factory always throws UnsupportedPathException if the file cannot be read by any of the parsers.

If you have a better idea how to solve this issue, I would be happy to change the code to that structure.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idea is to have a decision point. The decision point in your case is your composite factory.
The decision point will detect which Factory should be used in each particular case and then just instantiate the only needed factory to process the document.
In this case, we will instantiate just one factory, instead all of the factories in advance (even if we need the only one). And will not try to process document with factories which does not fit for this document.

In this case, Exception would be a real exceptional state, not like it's now when Exception is Expected behavior.

returning false is a terrible thing.

Yes, that's why I am advising you to make your code more Object Oriented friendly to get rid of such code

$parser = $this->createParserIfSupported($parserFactory, $path, $options);
if ($parser === false) {
    continue;
 }

Problems you have with Exceptions and False handling is just a side-effect of wrong OO design with choosing right Factory to handle the precise business case

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you'd like I can move it to RegExp based detection on a path via factory selection strategy. But I wouldn't change actual CSV and ZIP factories for path validation, as they must throw an exception if the file is not in the expected format.
Also, I will extract RegExp detection into FactoryDetector strategy, so any other factory detection can be replaced (e.g. in future based on actual file first bytes).

}

return $parser;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/

namespace Magento\ImportExport\Model\Import\Source\FileParser;

class CorruptedFileException extends \RuntimeException
{
private $fileName;

public function __construct($fileName = '', $message = '')
{
$this->fileName = $fileName;
parent::__construct($message ?: sprintf('File "%s" is corrupted', $fileName));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

something wrong with this code, because it has two default parameters
$fileName = '', $message = ''
and creating an object with default parameters we got a message
'File "" is corrupted'

}


public function getFileName()
{
return $this->fileName;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need public getter?
This violates encapsulation in OOP

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file name can be of a help for client code that catches this specific exception. I can remove it if filename is redundant error information for parsing files.

}
Loading