204 lines
6.0 KiB
PHP
204 lines
6.0 KiB
PHP
<?php
|
|
|
|
namespace KupShop\FeedGeneratorBundle;
|
|
|
|
use KupShop\FeedGeneratorBundle\Exception\XMLFeedReader\UnreadableXMLException;
|
|
use KupShop\FeedGeneratorBundle\Exception\XMLFeedReader\XMLFeedReaderException;
|
|
use KupShop\FeedGeneratorBundle\XSD\Parser;
|
|
|
|
class XMLFeedReader
|
|
{
|
|
private const INDEX_ATTRIBUTE_NAME = 'position';
|
|
private const SEARCH_ATTRIBUTE_NAME = 'search';
|
|
|
|
private array $basePath = ['feed', 'item'];
|
|
private array $typeCorrectionMap = [];
|
|
|
|
private \XMLReader $reader;
|
|
|
|
/**
|
|
* @throws UnreadableXMLException
|
|
*/
|
|
public function open(string $xmlContent): void
|
|
{
|
|
$reader = new \XMLReader();
|
|
if (!$reader->XML($xmlContent)) {
|
|
throw new UnreadableXMLException('Failed to read XML from string.');
|
|
} else {
|
|
$this->reader = $reader;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws UnreadableXMLException
|
|
*/
|
|
public function openFile(string $url): void
|
|
{
|
|
$reader = @\XMLReader::open($url);
|
|
if (!$reader || @filesize($url) === 0) {
|
|
throw new UnreadableXMLException('Failed to read XML.');
|
|
} else {
|
|
$this->reader = $reader;
|
|
}
|
|
}
|
|
|
|
private function goToStart(): bool
|
|
{
|
|
if (empty($this->basePath)) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($this->basePath as $pathPart) {
|
|
while ($this->reader->name !== $pathPart) {
|
|
$res = $this->reader->read();
|
|
if (!$res) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function goToNext(): bool
|
|
{
|
|
return $this->reader->next(end($this->basePath));
|
|
}
|
|
|
|
/**
|
|
* Supplies a \Generator which yields XML elements converted to associative arrays.
|
|
*
|
|
* @throws XMLFeedReaderException
|
|
*/
|
|
public function read(): \Generator
|
|
{
|
|
$hasNext = $this->goToStart();
|
|
if (!$hasNext) {
|
|
throw new XMLFeedReaderException('Could not find feed root element.');
|
|
}
|
|
|
|
while ($hasNext) {
|
|
$search = $this->reader->getAttribute(self::SEARCH_ATTRIBUTE_NAME);
|
|
$doc = $this->xmlToAssoc(new \SimpleXMLElement($this->reader->readOuterXml()));
|
|
|
|
$doc['__search'] = $search ?? '';
|
|
|
|
$hasNext = $this->goToNext();
|
|
yield $doc;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws XMLFeedReaderException
|
|
*/
|
|
public function findElementBySearchAttribute(string $value, string $searchAttributeName = self::SEARCH_ATTRIBUTE_NAME): array
|
|
{
|
|
$hasNext = $this->goToStart();
|
|
if (!$hasNext) {
|
|
throw new XMLFeedReaderException('Could not find feed root element.');
|
|
}
|
|
|
|
while ($hasNext) {
|
|
if ($this->reader->getAttribute($searchAttributeName) === $value) {
|
|
return $this->xmlToAssoc(new \SimpleXMLElement($this->reader->readOuterXml()));
|
|
}
|
|
|
|
$hasNext = $this->goToNext();
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private function xmlToAssoc(\SimpleXMLElement $xml, string $currentBaseFieldName = ''): array
|
|
{
|
|
$res = [];
|
|
|
|
foreach ($xml->children() as $child) {
|
|
$childName = $child->getName();
|
|
$fieldName = empty($currentBaseFieldName) ? $childName : "{$currentBaseFieldName}.{$childName}";
|
|
|
|
// if the current node is a value without children
|
|
if ($child->count() === 0) {
|
|
$corrected = $this->correctValueType($child, $fieldName);
|
|
$index = self::getElementAttribute($child, self::INDEX_ATTRIBUTE_NAME);
|
|
$isAlreadyDefined = isset($res[$childName]);
|
|
|
|
if ($isAlreadyDefined || $index !== null) {
|
|
$current = $isAlreadyDefined ? (array) $res[$childName] : [];
|
|
$index ??= $isAlreadyDefined ? count((array) $res[$childName]) : 0;
|
|
|
|
$res[$childName] = $current + [$index => $corrected];
|
|
continue;
|
|
}
|
|
|
|
$res[$childName] = $corrected;
|
|
continue;
|
|
}
|
|
|
|
// array in XML (to not override existing entry, but merge into one array)
|
|
if (isset($res[$childName])) {
|
|
$res[$childName] = array_merge(
|
|
(array) $res[$childName],
|
|
[$this->xmlToAssoc($child, $fieldName)],
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// only one occurrence in XML, but has maxOccurs=unbounded in XSD
|
|
if ($this->typeCorrectionMap[$fieldName] ?? '' === Parser::TYPE_ARRAY) {
|
|
$res[$childName] = [$this->xmlToAssoc($child, $fieldName)];
|
|
continue;
|
|
}
|
|
|
|
$res[$childName] = $this->xmlToAssoc($child, $fieldName);
|
|
}
|
|
|
|
return $res;
|
|
}
|
|
|
|
private function correctValueType(\SimpleXMLElement $element, string $fieldName)
|
|
{
|
|
switch ($this->typeCorrectionMap[$fieldName] ?? '') {
|
|
case Parser::TYPE_INT:
|
|
return (int) $element;
|
|
case Parser::TYPE_FLOAT:
|
|
return (float) $element;
|
|
case Parser::TYPE_BOOL:
|
|
// bool cast checks if $element is nullish -> has to be checked manually
|
|
// https://www.w3.org/TR/xmlschema-2/#boolean
|
|
$val = (string) $element;
|
|
|
|
return $val === 'true' || $val === '1';
|
|
default:
|
|
return (string) $element;
|
|
}
|
|
}
|
|
|
|
private static function getElementAttribute(\SimpleXMLElement $self, string $name, $default = null)
|
|
{
|
|
$attributes = $self->attributes();
|
|
|
|
if ($attributes === null) {
|
|
return $default;
|
|
}
|
|
|
|
foreach ($attributes as $attr) {
|
|
if ($attr->getName() === $name) {
|
|
return (string) $attr;
|
|
}
|
|
}
|
|
|
|
return $default;
|
|
}
|
|
|
|
public function setBasePath(array $basePath): void
|
|
{
|
|
$this->basePath = $basePath;
|
|
}
|
|
|
|
public function setTypeCorrectionMap(array $typeCorrectionMap): void
|
|
{
|
|
$this->typeCorrectionMap = $typeCorrectionMap;
|
|
}
|
|
}
|