From cd14a564fd64a73eb2b50f1d0d095f6c31082228 Mon Sep 17 00:00:00 2001 From: Aydin Hassan Date: Thu, 22 May 2014 15:50:16 +0100 Subject: [PATCH] OneToMany Reader - For Merging 2 readers --- README.md | 77 ++++ .../DataImport/Reader/OneToManyReader.php | 182 +++++++++ .../Tests/Reader/OneToManyReaderTest.php | 347 ++++++++++++++++++ 3 files changed, 606 insertions(+) create mode 100644 src/Ddeboer/DataImport/Reader/OneToManyReader.php create mode 100644 tests/Ddeboer/DataImport/Tests/Reader/OneToManyReaderTest.php diff --git a/README.md b/README.md index db8ede29..3dd0d18f 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Documentation - [DbalReader](#dbalreader) - [DoctrineReader](#doctrinereader) - [ExcelReader](#excelreader) + - [One To Many Reader](#onetomanyreader) - [Create a reader](#create-a-reader) * [Writers](#writers) - [ArrayWriter](#arraywriter) @@ -267,6 +268,82 @@ $file = new \SplFileObject('path/to/ecxel_file.xls'); $reader = new ExcelReader($file); ``` +###OneToManyReader + +Allows for merging of two data sources (using existing readers), for example you have one CSV with orders and another with order items. + +Imagine two CSV's like the following: + +``` +OrderId,Price +1,30 +2,15 +``` + +``` +OrderId,Name +1,"Super Cool Item 1" +1,"Super Cool Item 2" +2,"Super Cool Item 3" +``` + +You want to associate the items to the order. Using the OneToMany reader we can nest these rows in the order using a key +which you specify in the OneToManyReader. + +The code would look something like: + +```php +$orderFile = new \SplFileObject("orders.csv"); +$orderReader = new CsvReader($file, $orderFile); +$orderReader->setHeaderRowNumber(0); + +$orderItemFile = new \SplFileObject("order_items.csv"); +$orderItemReader = new CsvReader($file, $orderFile); +$orderItemReader->setHeaderRowNumber(0); + +$oneToManyReader = new OneToManyReader($orderReader, $orderItemReader, 'items', 'OrderId', 'OrderId'); +``` + +The third parameter is the key which the order item data will be nested under. This will be an array of order items. +The fourth and fifth parameters are "primary" and "foreign" keys of the data. The OneToMany reader will try to match the data using these keys. +Take for example the CSV's given above, you would expect that Order "1" has the first 2 Order Items associated to it due to their Order Id's also +being "1". + +Note: You can omit the last parameter, if both files have the same field. Eg if parameter 4 is 'OrderId' and you don't specify +paramater 5, the reader will look for the foreign key using 'OrderId' + +The resulting data will look like: + +```php +//Row 1 +array( + 'OrderId' => 1, + 'Price' => 30, + 'items' => array( + array( + 'OrderId' => 1, + 'Name' => 'Super Cool Item 1', + ), + array( + 'OrderId' => 1, + 'Name' => 'Super Cool Item 2', + ), + ), +); + +//Row2 +array( + 'OrderId' => 2, + 'Price' => 15, + 'items' => array( + array( + 'OrderId' => 2, + 'Name' => 'Super Cool Item 1', + ), + ) +); +``` + #### Create a reader You can create your own data reader by implementing the diff --git a/src/Ddeboer/DataImport/Reader/OneToManyReader.php b/src/Ddeboer/DataImport/Reader/OneToManyReader.php new file mode 100644 index 00000000..2a519ec0 --- /dev/null +++ b/src/Ddeboer/DataImport/Reader/OneToManyReader.php @@ -0,0 +1,182 @@ + + * @author Aydin Hassan + */ +class OneToManyReader implements ReaderInterface +{ + /** + * @var ReaderInterface + */ + protected $leftReader; + + /** + * @var ReaderInterface + */ + protected $rightReader; + + /** + * @var string Left Join Field + */ + protected $leftJoinField; + + /** + * @var string Right Join Field + */ + protected $rightJoinField; + + /** + * @var string Key to nest the rightRows under + */ + protected $nestKey; + + /** + * @param ReaderInterface $leftReader + * @param ReaderInterface $rightReader + * @param string $nestKey + * @param string $leftJoinField + * @param string $rightJoinField + */ + public function __construct( + ReaderInterface $leftReader, + ReaderInterface $rightReader, + $nestKey, + $leftJoinField, + $rightJoinField = null + ) { + $this->leftJoinField = $leftJoinField; + + if (!$rightJoinField) { + $this->rightJoinField = $this->leftJoinField; + } else { + $this->rightJoinField = $rightJoinField; + } + + $this->leftReader = $leftReader; + $this->rightReader = $rightReader; + $this->nestKey = $nestKey; + } + + /** + * Create an array of children in the leftRow, + * with the data returned from the right reader + * Where the ID fields Match + * + * @return array + * @throws ReaderException + */ + public function current() + { + $leftRow = $this->leftReader->current(); + + if (array_key_exists($this->nestKey, $leftRow)) { + throw new ReaderException( + sprintf( + 'Left Row: "%s" Reader already contains a field named "%s". Please choose a different nest key field', + $this->key(), + $this->nestKey + ) + ); + } + $leftRow[$this->nestKey] = array(); + + $leftId = $this->getRowId($leftRow, $this->leftJoinField); + $rightRow = $this->rightReader->current(); + $rightId = $this->getRowId($rightRow, $this->rightJoinField); + + while ($leftId == $rightId && $this->rightReader->valid()) { + + $leftRow[$this->nestKey][] = $rightRow; + $this->rightReader->next(); + + $rightRow = $this->rightReader->current(); + + if($this->rightReader->valid()) { + $rightId = $this->getRowId($rightRow, $this->rightJoinField); + } + } + + return $leftRow; + } + + /** + * @param array $row + * @param string $idField + * @return mixed + * @throws ReaderException + */ + protected function getRowId(array $row, $idField) + { + if (!array_key_exists($idField, $row)) { + throw new ReaderException( + sprintf( + 'Row: "%s" has no field named "%s"', + $this->key(), + $idField + ) + ); + } + + return $row[$idField]; + } + + /** + * @return void Any returned value is ignored. + */ + public function next() + { + $this->leftReader->next(); + //right reader is iterated in current() method. + } + + /** + * @return mixed scalar on success, or null on failure. + */ + public function key() + { + return $this->leftReader->key(); + } + + /** + * Checks if current position is valid + * Returns true on success or false on failure. + */ + public function valid() + { + return $this->leftReader->valid() && $this->rightReader->valid(); + } + + /** + * Rewind the Iterator to the first element + * @return void Any returned value is ignored. + */ + public function rewind() + { + $this->leftReader->rewind(); + $this->rightReader->rewind(); + } + + /** + * @return array + */ + public function getFields() + { + return array_merge($this->leftReader->getFields(), array($this->nestKey)); + } + + /** + * Count elements of an object + * The return value is cast to an integer. + */ + public function count() + { + return $this->leftReader->count(); + } +} \ No newline at end of file diff --git a/tests/Ddeboer/DataImport/Tests/Reader/OneToManyReaderTest.php b/tests/Ddeboer/DataImport/Tests/Reader/OneToManyReaderTest.php new file mode 100644 index 00000000..ccfdd887 --- /dev/null +++ b/tests/Ddeboer/DataImport/Tests/Reader/OneToManyReaderTest.php @@ -0,0 +1,347 @@ + + */ +class OneToManyReaderTest extends \PHPUnit_Framework_TestCase +{ + public function testReaderMergesOneToMany() + { + $leftData = array( + array( + 'OrderId' => 1, + 'Price' => 30, + ), + array( + 'OrderId' => 2, + 'Price' => 15, + ), + ); + + $rightData = array( + array( + 'OrderId' => 1, + 'Name' => 'Super Cool Item 1', + ), + array( + 'OrderId' => 1, + 'Name' => 'Super Cool Item 2', + ), + array( + 'OrderId' => 2, + 'Name' => 'Super Cool Item 1', + ), + ); + + $leftReader = new ArrayReader($leftData); + $rightReader = new ArrayReader($rightData); + $oneToManyReader = new OneToManyReader($leftReader, $rightReader, 'items', 'OrderId', 'OrderId'); + + $expected = array( + array( + 'OrderId' => 1, + 'Price' => 30, + 'items' => array( + array( + 'OrderId' => 1, + 'Name' => 'Super Cool Item 1', + ), + array( + 'OrderId' => 1, + 'Name' => 'Super Cool Item 2', + ), + ), + ), + array( + 'OrderId' => 2, + 'Price' => 15, + 'items' => array( + array( + 'OrderId' => 2, + 'Name' => 'Super Cool Item 1', + ), + ) + ), + ); + + $i = 0; + foreach($oneToManyReader as $row) { + $this->assertEquals($row, $expected[$i++]); + } + } + + public function testIfRightReaderIdFieldIsMissingLeftIsUsed() + { + $leftData = array( + array( + 'OrderId' => 1, + 'Price' => 30, + ), + array( + 'OrderId' => 2, + 'Price' => 15, + ), + ); + + $rightData = array( + array( + 'OrderId' => 1, + 'Name' => 'Super Cool Item 1', + ), + array( + 'OrderId' => 1, + 'Name' => 'Super Cool Item 2', + ), + array( + 'OrderId' => 2, + 'Name' => 'Super Cool Item 1', + ), + ); + + $leftReader = new ArrayReader($leftData); + $rightReader = new ArrayReader($rightData); + $oneToManyReader = new OneToManyReader($leftReader, $rightReader, 'items', 'OrderId'); + + $expected = array( + array( + 'OrderId' => 1, + 'Price' => 30, + 'items' => array( + array( + 'OrderId' => 1, + 'Name' => 'Super Cool Item 1', + ), + array( + 'OrderId' => 1, + 'Name' => 'Super Cool Item 2', + ), + ), + ), + array( + 'OrderId' => 2, + 'Price' => 15, + 'items' => array( + array( + 'OrderId' => 2, + 'Name' => 'Super Cool Item 1', + ), + ) + ), + ); + + $i = 0; + foreach($oneToManyReader as $row) { + $this->assertEquals($row, $expected[$i++]); + } + } + + public function testReaderThrowsExceptionIfNestKeyExistsInLeftReaderRow() + { + $leftData = array( + array( + 'OrderId' => 1, + 'Price' => 30, + 'items' => null, + ), + ); + + $rightData = array( + array( + 'OrderId' => 1, + 'Name' => 'Super Cool Item 1', + ), + ); + + $leftReader = new ArrayReader($leftData); + $rightReader = new ArrayReader($rightData); + $oneToManyReader = new OneToManyReader($leftReader, $rightReader, 'items', 'OrderId'); + + $this->setExpectedException('Ddeboer\DataImport\Exception\ReaderException', 'Left Row: "0" Reader already contains a field named "items". Please choose a different nest key field'); + + $oneToManyReader->rewind(); + $oneToManyReader->current(); + } + + public function testReaderThrowsExceptionIfIdFieldDoesNotExistInLeftRow() + { + $leftData = array( + array( + 'Price' => 30, + ), + ); + + $rightData = array( + array( + 'Name' => 'Super Cool Item 1', + ), + ); + + $leftReader = new ArrayReader($leftData); + $rightReader = new ArrayReader($rightData); + $oneToManyReader = new OneToManyReader($leftReader, $rightReader, 'items', 'OrderId'); + + $this->setExpectedException('Ddeboer\DataImport\Exception\ReaderException', 'Row: "0" has no field named "OrderId"'); + + $oneToManyReader->rewind(); + $oneToManyReader->current(); + } + + public function testReaderThrowsExceptionIfIdFieldDoesNotExistInRightRow() + { + $leftData = array( + array( + 'OrderId' => 1, + 'Price' => 30, + ), + ); + + $rightData = array( + array( + 'Name' => 'Super Cool Item 1', + ), + ); + + $leftReader = new ArrayReader($leftData); + $rightReader = new ArrayReader($rightData); + $oneToManyReader = new OneToManyReader($leftReader, $rightReader, 'items', 'OrderId'); + + $this->setExpectedException('Ddeboer\DataImport\Exception\ReaderException', 'Row: "0" has no field named "OrderId"'); + + $oneToManyReader->rewind(); + $oneToManyReader->current(); + } + + public function testGetKeysReturnsLeftReaderColumnsMergedWithNestKey() + { + $leftReader = new ArrayReader(array(array('col1' => 'data1', 'col2' => 'data2'), array('data3', 'data4'))); + $rightReader = new ArrayReader(array()); + $oneToManyReader = new OneToManyReader($leftReader, $rightReader, 'items', 'OrderId'); + + $this->assertSame(array('col1', 'col2', 'items'), $oneToManyReader->getFields()); + } + + public function testCountReturnsTheCountOfTheLeftReader() + { + $leftReader = new ArrayReader(array()); + $rightReader = new ArrayReader(array()); + + $oneToManyReader = new OneToManyReader($leftReader, $rightReader, 'items', 'OrderId'); + $this->assertEquals(0, $oneToManyReader->count()); + + $leftReader = new ArrayReader(array(array(), array(), array())); + $oneToManyReader = new OneToManyReader($leftReader, $rightReader, 'items', 'OrderId'); + $this->assertEquals(3, $oneToManyReader->count()); + } + + /** + * This is probably a limitation - but it's not need for current implementation + * @dataProvider outOfOrderRowProvider + */ + public function testOutOfOrderRowsInRightReaderAreNotNested($leftData, $rightData, $expected) + { + //var_dump($leftData); + //var_dump($rightData); + $leftReader = new ArrayReader($leftData); + $rightReader = new ArrayReader($rightData); + $oneToManyReader = new OneToManyReader($leftReader, $rightReader, 'items', 'OrderId'); + + $i = 0; + foreach($oneToManyReader as $row) { + $this->assertEquals($row, $expected[$i++]); + } + } + + public function outOfOrderRowProvider() + { + return array( + 'skip-first-right-row' => array( + 'left' => array( + array( + 'OrderId' => 3, + 'Price' => 30, + ), + array( + 'OrderId' => 2, + 'Price' => 15, + ), + ), + 'right' => array( + array( + 'OrderId' => 2, + 'Name' => 'Super Cool Item 1', + ), + array( + 'OrderId' => 1, + 'Name' => 'Super Cool Item 1', + ), + array( + 'OrderId' => 1, + 'Name' => 'Super Cool Item 2', + ), + ), + 'expected' => array( + array( + 'OrderId' => 3, + 'Price' => 30, + 'items' => array() + ), + array( + 'OrderId' => 2, + 'Price' => 15, + 'items' => array( + array( + 'OrderId' => 2, + 'Name' => 'Super Cool Item 1', + ), + ) + ), + ), + ), + 'skip-out-of-order' => array( + 'left' => array( + array( + 'OrderId' => 1, + 'Price' => 30, + ), + array( + 'OrderId' => 2, + 'Price' => 15, + ), + ), + 'right' => array( + array( + 'OrderId' => 0, + 'Name' => 'Super Cool Item 1', + ), + array( + 'OrderId' => 2, + 'Name' => 'Super Cool Item 2', + ), + array( + 'OrderId' => 1, + 'Name' => 'Super Cool Item 3', + ), + ), + 'expected' => array( + array( + 'OrderId' => 1, + 'Price' => 30, + 'items' => array() + ), + array( + 'OrderId' => 2, + 'Price' => 15, + 'items' => array(), + ), + ), + ), + ); + } +}