Files
kupshop/bundles/KupShop/FeedGeneratorBundle/XMLFeedReader.php
2025-08-02 16:30:27 +02:00

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;
}
}