Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 28 additions & 11 deletions src/XMLParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ class XMLParser implements \Iterator

private string $currentTagContent = '';

private bool $streamFinished = false;

/**
* The stack of the XML node names as go deeper into the tree.
*
Expand Down Expand Up @@ -51,6 +53,7 @@ public function setUp(): void
$this->currentTagName = null;
$this->currentTagAttributes = [];
$this->nodeNamesStack = [];
$this->streamFinished = false;

$this->parser = \xml_parser_create();

Expand Down Expand Up @@ -179,25 +182,39 @@ public function rewind(): void
*
* Callbacks are called, nodes are pushed to the stack and iterator can go over them.
*
* Loops until at least one node is queued or the stream is exhausted.
* This handles compressed or network streams that may return empty reads mid-stream
* (e.g. zlib flush markers or TCP packet boundaries) without signalling EOF.
*
* @return void
* @throws ParsingError
*/
private function parseNextChunk(): void
{
// the nodes stack has been iterated over, consume and parse the next piece of the XML stream
$data = stream_get_contents($this->stream, length: self::BATCH_READ_SIZE);
$isFinal = ($data === false);
if ($this->streamFinished) {
return;
}

$res = xml_parse($this->parser, $data, $isFinal);
while (empty($this->nodesQueue)) {
$data = stream_get_contents($this->stream, length: self::BATCH_READ_SIZE);
$isFinal = ($data === false || feof($this->stream));

if ($res === 0 /* returns 0 on failure */) {
// take more details from the parser instance and throw an exception
throw ParsingError::fromParserInstance($this->parser, is_string($data) ? $data : null);
}
if ($data === false) {
$data = '';
}

$res = xml_parse($this->parser, $data, $isFinal);

// we're done with reading and parsing the stream, close the XML parser instance
if ($isFinal) {
$this->close();
if ($res === 0 /* returns 0 on failure */) {
// take more details from the parser instance and throw an exception
throw ParsingError::fromParserInstance($this->parser, $data !== '' ? $data : null);
}

if ($isFinal) {
$this->streamFinished = true;
$this->close();
return;
}
}
}

Expand Down
139 changes: 139 additions & 0 deletions tests/XMLParserNestedElementsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

use Elecena\XmlIterator\Nodes\XMLNodeContent;
use Elecena\XmlIterator\XMLParser;

/**
* A PHP stream wrapper that inserts empty reads at regular intervals to simulate
* compressed or network streams (e.g. zlib flush markers, HTTP chunked boundaries).
*
* @see https://www.php.net/manual/en/stream.streamwrapper.example-1.php
*/
class IntermittentReadStreamWrapper
{
public $context;

private static string $content = '';
private static int $emptyEveryN = 3;
private int $position = 0;
private int $readCount = 0;

public static function open(string $content, int $emptyEveryN = 3): string
{
self::$content = $content;
self::$emptyEveryN = $emptyEveryN;

if (in_array('intermittent', stream_get_wrappers())) {
stream_wrapper_unregister('intermittent');
}

stream_wrapper_register('intermittent', self::class);

return 'intermittent://stream';
}

public static function unregister(): void
{
if (in_array('intermittent', stream_get_wrappers())) {
stream_wrapper_unregister('intermittent');
}
}

public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
{
$this->position = 0;
$this->readCount = 0;
return true;
}

public function stream_read(int $count): string
{
$this->readCount++;

// Return empty string every N reads unless already at EOF
if ($this->readCount % self::$emptyEveryN === 0 && !$this->stream_eof()) {
return '';
}

$chunk = substr(self::$content, $this->position, $count);
$this->position += strlen($chunk);
return $chunk;
}

public function stream_eof(): bool
{
return $this->position >= strlen(self::$content);
}

public function stream_stat(): array
{
return [];
}
}

/**
* Tests XML parsing of nested elements that share the same tag name,
* as seen in the Discogs data dump (label > sublabels > label).
*
* @see https://github.com/elecena/xml-iterator/issues/13
*/
class XMLParserNestedElementsTest extends XMLParserTestCase
{
private static string $xml = '';

public static function setUpBeforeClass(): void
{
$xml = '<?xml version="1.0" encoding="UTF-8"?><labels>';
for ($i = 0; $i < 300; $i++) {
$xml .= sprintf(
'<label id="%d"><name>Label %d</name><sublabels><label>Sub %d</label></sublabels></label>',
$i,
$i,
$i
);
}
$xml .= '</labels>';
self::$xml = $xml;
}

protected function tearDown(): void
{
IntermittentReadStreamWrapper::unregister();
}

protected function getParserStream()
{
return self::streamFromString(self::$xml);
}

public function testParsesNestedSameNameElements(): void
{
$labelCount = 0;

foreach ($this->getParser() as $node) {
if ($node instanceof XMLNodeContent && $node->name === 'label') {
$labelCount++;
}
}

// 300 outer labels + 300 inner labels inside sublabels
$this->assertSame(600, $labelCount);
}

public function testParsesStreamWithIntermittentEmptyReads(): void
{
$url = IntermittentReadStreamWrapper::open(self::$xml, emptyEveryN: 3);
$parser = new XMLParser(stream: fopen($url, 'r'));

$labelCount = 0;

foreach ($parser as $node) {
if ($node instanceof XMLNodeContent && $node->name === 'label') {
$labelCount++;
}
}

// 300 outer labels + 300 inner labels inside sublabels
$this->assertSame(600, $labelCount);
}
}
Loading