diff --git a/README.md b/README.md index e744961..331a0ac 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,12 @@ The parser distinguishes the internal project format from the detected eXeLearni Use `getFormatVersion()` for the ODE format version, `getApplicationVersion()` for the declared eXeLearning version, and `getVersionInfo()` when the distinction between declared and inferred versions matters. +## Performance characteristics + +Parsed projects are indexed by page, block and iDevice ID for constant-time lookup. Aggregate collections and diagnostics are cached because parser instances are immutable after construction. Asset-reference resolution also caches normalized archive lookups. + +The upstream compatibility corpus records per-project timings, total elapsed time, peak memory and the five slowest projects. These measurements are informational and do not impose brittle timing thresholds in CI. + ## Compatibility regression testing The regular test suite includes a deterministic corpus for malformed XML, encoded and Unicode asset paths, malformed iDevice state and cyclic page hierarchies. diff --git a/src/Asset/AssetReferenceExtractor.php b/src/Asset/AssetReferenceExtractor.php index 4068bef..45b0294 100644 --- a/src/Asset/AssetReferenceExtractor.php +++ b/src/Asset/AssetReferenceExtractor.php @@ -32,6 +32,9 @@ class AssetReferenceExtractor /** @var array */ private array $archiveLookup = []; + /** @var array */ + private array $resolutionCache = []; + /** * @param array $archiveEntries Archive entry names. */ @@ -69,7 +72,10 @@ public function extract(array $pages): array } $this->collectStringValues($idevice['jsonProperties'] ?? [], $sources); - $this->collectStringValues($idevice['data'] ?? [], $sources); + + if (($idevice['storagePattern'] ?? '') !== 'standard-json') { + $this->collectStringValues($idevice['data'] ?? [], $sources); + } foreach ($sources as $source) { foreach ($this->extractPathsFromString($source) as $path) { @@ -137,6 +143,10 @@ public function findBrokenReferences(array $pages): array $this->collectStringValues($idevice['jsonProperties'] ?? [], $sources); + if (($idevice['storagePattern'] ?? '') !== 'standard-json') { + $this->collectStringValues($idevice['data'] ?? [], $sources); + } + foreach ($sources as $source) { foreach ($this->extractCandidatesFromString($source) as $candidate) { if ($this->resolveArchivePath($candidate) !== null) { @@ -271,6 +281,25 @@ private function collectStringValues(mixed $value, array &$strings): void * @return string|null */ private function resolveArchivePath(string $candidate): ?string + { + if (array_key_exists($candidate, $this->resolutionCache)) { + return $this->resolutionCache[$candidate]; + } + + $resolved = $this->resolveArchivePathUncached($candidate); + $this->resolutionCache[$candidate] = $resolved; + + return $resolved; + } + + /** + * Resolve an uncached asset reference against archive entries. + * + * @param string $candidate Raw asset reference. + * + * @return string|null + */ + private function resolveArchivePathUncached(string $candidate): ?string { $candidate = trim($candidate, " \t\n\r\0\x0B\"'"); $candidate = str_replace('{{context_path}}/', '', $candidate); diff --git a/src/ELPParser.php b/src/ELPParser.php index c1c5c57..bb74a84 100644 --- a/src/ELPParser.php +++ b/src/ELPParser.php @@ -102,6 +102,45 @@ class ELPParser implements JsonSerializable private InternalReferenceExtractor $internalReferenceExtractor; private ?string $ownedTemporaryFile = null; + /** @var array> */ + private array $pagesById = []; + + /** @var array> */ + private array $blocksById = []; + + /** @var array> */ + private array $idevicesById = []; + + /** @var array>|null */ + private ?array $blocksCache = null; + + /** @var array>|null */ + private ?array $idevicesCache = null; + + /** @var array>|null */ + private ?array $pageTreeCache = null; + + /** @var array|null */ + private ?array $orphanAssetsCache = null; + + /** @var array>|null */ + private ?array $brokenReferencesCache = null; + + /** @var array>|null */ + private ?array $internalLinksCache = null; + + /** @var array>|null */ + private ?array $brokenInternalLinksCache = null; + + /** @var array|null */ + private ?array $usedIdeviceTypesCache = null; + + /** @var array|null */ + private ?array $availableIdeviceTypesCache = null; + + /** @var array|null */ + private ?array $packageManifestCache = null; + /** * Create a new parser instance. * @@ -329,6 +368,61 @@ protected function parse(): void $this->assetsDetailed ); sort($this->assets); + $this->buildIndexes(); + } + + /** + * Build immutable lookup indexes and aggregate caches. + * + * @return void + */ + private function buildIndexes(): void + { + $blocks = []; + $idevices = []; + + foreach ($this->pages as $page) { + $pageId = (string) ($page['id'] ?? ''); + if ($pageId !== '') { + $this->pagesById[$pageId] = $page; + } + + foreach (($page['blocks'] ?? []) as $block) { + if (!is_array($block)) { + continue; + } + + $normalizedBlock = $block + [ + 'pageTitle' => $page['title'] ?? '', + ]; + $blocks[] = $normalizedBlock; + + $blockId = (string) ($block['id'] ?? ''); + if ($blockId !== '') { + $this->blocksById[$blockId] = $normalizedBlock; + } + } + + foreach (($page['idevices'] ?? []) as $idevice) { + if (!is_array($idevice)) { + continue; + } + + $normalizedIdevice = $idevice + [ + 'pageId' => $page['id'] ?? '', + 'pageTitle' => $page['title'] ?? '', + ]; + $idevices[] = $normalizedIdevice; + + $ideviceId = (string) ($idevice['id'] ?? ''); + if ($ideviceId !== '') { + $this->idevicesById[$ideviceId] = $normalizedIdevice; + } + } + } + + $this->blocksCache = $blocks; + $this->idevicesCache = $idevices; } /** @@ -712,13 +806,7 @@ public function getPages(): array */ public function getPageById(string $pageId): ?array { - foreach ($this->pages as $page) { - if (($page['id'] ?? '') === $pageId) { - return $page; - } - } - - return null; + return $this->pagesById[$pageId] ?? null; } /** @@ -728,34 +816,38 @@ public function getPageById(string $pageId): ?array */ public function getPageTree(): array { - $pagesById = []; - $childrenByParent = []; + if ($this->pageTreeCache !== null) { + return $this->pageTreeCache; + } - foreach ($this->pages as $page) { - $id = (string) ($page['id'] ?? ''); - if ($id === '') { - continue; - } + $childrenByParent = []; - $pagesById[$id] = $page; + foreach ($this->pagesById as $id => $page) { $parentId = (string) ($page['parentId'] ?? ''); $childrenByParent[$parentId][] = $id; } $rootIds = []; - foreach ($pagesById as $id => $page) { + foreach ($this->pagesById as $id => $page) { $parentId = (string) ($page['parentId'] ?? ''); - if ($parentId === '' || !isset($pagesById[$parentId])) { + if ($parentId === '' || !isset($this->pagesById[$parentId])) { $rootIds[] = $id; } } $tree = []; foreach ($rootIds as $rootId) { - $tree[] = $this->buildPageTreeNode($rootId, $pagesById, $childrenByParent, []); + $tree[] = $this->buildPageTreeNode( + $rootId, + $this->pagesById, + $childrenByParent, + [] + ); } - return $tree; + $this->pageTreeCache = $tree; + + return $this->pageTreeCache; } /** @@ -780,19 +872,7 @@ public function getVisiblePages(): array */ public function getBlocks(): array { - $blocks = []; - - foreach ($this->pages as $page) { - foreach (($page['blocks'] ?? []) as $block) { - if (!is_array($block)) { - continue; - } - - $blocks[] = $block + ['pageTitle' => $page['title'] ?? '']; - } - } - - return $blocks; + return $this->blocksCache ?? []; } /** @@ -804,13 +884,7 @@ public function getBlocks(): array */ public function getBlockById(string $blockId): ?array { - foreach ($this->getBlocks() as $block) { - if (($block['id'] ?? '') === $blockId) { - return $block; - } - } - - return null; + return $this->blocksById[$blockId] ?? null; } /** @@ -820,22 +894,7 @@ public function getBlockById(string $blockId): ?array */ public function getIdevices(): array { - $idevices = []; - - foreach ($this->pages as $page) { - foreach (($page['idevices'] ?? []) as $idevice) { - if (!is_array($idevice)) { - continue; - } - - $idevices[] = $idevice + [ - 'pageId' => $page['id'] ?? '', - 'pageTitle' => $page['title'] ?? '', - ]; - } - } - - return $idevices; + return $this->idevicesCache ?? []; } /** @@ -847,13 +906,7 @@ public function getIdevices(): array */ public function getIdeviceById(string $ideviceId): ?array { - foreach ($this->getIdevices() as $idevice) { - if (($idevice['id'] ?? '') === $ideviceId) { - return $idevice; - } - } - - return null; + return $this->idevicesById[$ideviceId] ?? null; } /** @@ -1022,6 +1075,10 @@ public function getDocuments(): array */ public function getOrphanAssets(): array { + if ($this->orphanAssetsCache !== null) { + return $this->orphanAssetsCache; + } + $referenced = array_fill_keys($this->assets, true); $orphans = []; @@ -1041,8 +1098,9 @@ public function getOrphanAssets(): array } sort($orphans); + $this->orphanAssetsCache = $orphans; - return $orphans; + return $this->orphanAssetsCache; } /** @@ -1052,7 +1110,13 @@ public function getOrphanAssets(): array */ public function getBrokenReferences(): array { - return $this->assetExtractor->findBrokenReferences($this->pages); + if ($this->brokenReferencesCache === null) { + $this->brokenReferencesCache = $this->assetExtractor->findBrokenReferences( + $this->pages + ); + } + + return $this->brokenReferencesCache; } /** @@ -1112,7 +1176,13 @@ public function getArchiveEntries(): array */ public function getInternalLinks(): array { - return $this->internalReferenceExtractor->extract($this->pages); + if ($this->internalLinksCache === null) { + $this->internalLinksCache = $this->internalReferenceExtractor->extract( + $this->pages + ); + } + + return $this->internalLinksCache; } /** @@ -1122,23 +1192,20 @@ public function getInternalLinks(): array */ public function getBrokenInternalLinks(): array { - $pageIds = []; - - foreach ($this->pages as $page) { - $id = (string) ($page['id'] ?? ''); - if ($id !== '') { - $pageIds[$id] = true; - } + if ($this->brokenInternalLinksCache !== null) { + return $this->brokenInternalLinksCache; } - return array_values( + $this->brokenInternalLinksCache = array_values( array_filter( $this->getInternalLinks(), - static fn(array $link): bool => !isset( - $pageIds[(string) ($link['targetPageId'] ?? '')] + fn(array $link): bool => !isset( + $this->pagesById[(string) ($link['targetPageId'] ?? '')] ) ) ); + + return $this->brokenInternalLinksCache; } /** @@ -1148,6 +1215,10 @@ public function getBrokenInternalLinks(): array */ public function getUsedIdeviceTypes(): array { + if ($this->usedIdeviceTypesCache !== null) { + return $this->usedIdeviceTypesCache; + } + $types = []; foreach ($this->getIdevices() as $idevice) { @@ -1159,8 +1230,9 @@ public function getUsedIdeviceTypes(): array $types = array_keys($types); sort($types); + $this->usedIdeviceTypesCache = $types; - return $types; + return $this->usedIdeviceTypesCache; } /** @@ -1170,6 +1242,10 @@ public function getUsedIdeviceTypes(): array */ public function getAvailableIdeviceTypes(): array { + if ($this->availableIdeviceTypesCache !== null) { + return $this->availableIdeviceTypesCache; + } + $types = []; foreach ($this->archiveEntries as $entry) { @@ -1180,8 +1256,9 @@ public function getAvailableIdeviceTypes(): array $types = array_keys($types); sort($types); + $this->availableIdeviceTypesCache = $types; - return $types; + return $this->availableIdeviceTypesCache; } /** @@ -1206,6 +1283,10 @@ public function getMissingIdeviceRuntimes(): array */ public function getPackageManifest(): array { + if ($this->packageManifestCache !== null) { + return $this->packageManifestCache; + } + $manifest = [ 'rootFiles' => [], 'themeFiles' => [], @@ -1255,11 +1336,13 @@ public function getPackageManifest(): array ksort($manifest['ideviceFiles']); - return $manifest + [ + $this->packageManifestCache = $manifest + [ 'usedIdeviceTypes' => $this->getUsedIdeviceTypes(), 'availableIdeviceTypes' => $this->getAvailableIdeviceTypes(), 'missingIdeviceRuntimes' => $this->getMissingIdeviceRuntimes(), ]; + + return $this->packageManifestCache; } /** diff --git a/tests/Unit/IndexedParserTest.php b/tests/Unit/IndexedParserTest.php new file mode 100644 index 0000000..aeb8918 --- /dev/null +++ b/tests/Unit/IndexedParserTest.php @@ -0,0 +1,75 @@ + + * @license MIT https://opensource.org/licenses/MIT + * @link https://github.com/exelearning/elp-parser + */ + +namespace Exelearning\ElpParser\Tests\Unit; + +use Exelearning\Asset\AssetReferenceExtractor; +use Exelearning\ELPParser; + +it( + 'keeps repeated indexed lookups behaviorally stable', + function () { + $parser = ELPParser::fromFile(__DIR__ . '/../Fixtures/propiedades.elpx'); + $pageId = (string) $parser->getPages()[0]['id']; + $blockId = (string) $parser->getBlocks()[0]['id']; + $ideviceId = (string) $parser->getIdevices()[0]['id']; + + for ($iteration = 0; $iteration < 100; $iteration++) { + expect($parser->getPageById($pageId)['id'])->toBe($pageId); + expect($parser->getBlockById($blockId)['id'])->toBe($blockId); + expect($parser->getIdeviceById($ideviceId)['id'])->toBe($ideviceId); + } + + expect($parser->getBlocks())->toBe($parser->getBlocks()); + expect($parser->getIdevices())->toBe($parser->getIdevices()); + expect($parser->getPageTree())->toBe($parser->getPageTree()); + expect($parser->getPackageManifest())->toBe($parser->getPackageManifest()); + expect($parser->getBrokenReferences())->toBe($parser->getBrokenReferences()); + expect($parser->getInternalLinks())->toBe($parser->getInternalLinks()); + } +); + +it( + 'does not double count standard json state after normalization', + function () { + $extractor = new AssetReferenceExtractor( + ['content/resources/image.jpg'] + ); + + $assets = $extractor->extract( + [ + [ + 'id' => 'PAGE', + 'title' => 'Page', + 'idevices' => [ + [ + 'id' => 'TEXT', + 'type' => 'text', + 'html' => '', + 'storagePattern' => 'standard-json', + 'jsonProperties' => [ + 'image' => '{{context_path}}/image.jpg', + ], + 'data' => [ + 'image' => '{{context_path}}/image.jpg', + ], + ], + ], + ], + ] + ); + + expect($assets)->toHaveCount(1); + expect($assets[0]['path'])->toBe('content/resources/image.jpg'); + expect($assets[0]['occurrences'])->toBe(1); + } +); diff --git a/tests/upstream-compat.php b/tests/upstream-compat.php index 75da2a2..5945532 100644 --- a/tests/upstream-compat.php +++ b/tests/upstream-compat.php @@ -42,6 +42,8 @@ $parsed = 0; $skipped = 0; $failures = []; +$timings = []; +$totalStartedAt = hrtime(true); foreach ($candidates as $path) { $zip = new ZipArchive(); @@ -60,8 +62,10 @@ } try { + $startedAt = hrtime(true); $inspection = ELPParser::inspect($path); $parser = ELPParser::fromFile($path); + $elapsedMs = (hrtime(true) - $startedAt) / 1000000; if (($inspection['title'] ?? '') !== $parser->getTitle()) { throw new RuntimeException( @@ -70,12 +74,22 @@ } $parsed++; + $relativePath = substr( + $path, + strlen(rtrim($root, DIRECTORY_SEPARATOR)) + 1 + ); + $timings[] = [ + 'path' => $relativePath, + 'milliseconds' => $elapsedMs, + ]; + fwrite( STDOUT, sprintf( - "PASS %s [%s]\n", - substr($path, strlen(rtrim($root, DIRECTORY_SEPARATOR)) + 1), - $parser->getPackageProfile() + "PASS %s [%s] %.1f ms\n", + $relativePath, + $parser->getPackageProfile(), + $elapsedMs ) ); } catch (Throwable $exception) { @@ -97,14 +111,46 @@ } } +$totalMs = (hrtime(true) - $totalStartedAt) / 1000000; + +usort( + $timings, + static fn(array $left, array $right): int => $right['milliseconds'] + <=> $left['milliseconds'] +); + fwrite( STDOUT, sprintf( - "\nUpstream corpus: %d candidates, %d parsed, %d skipped, %d failures.\n", + "\nUpstream corpus: %d candidates, %d parsed, %d skipped, %d failures, %.1f ms total.\n", count($candidates), $parsed, $skipped, - count($failures) + count($failures), + $totalMs + ) +); + +if ($timings !== []) { + fwrite(STDOUT, "Slowest parsed projects:\n"); + + foreach (array_slice($timings, 0, 5) as $timing) { + fwrite( + STDOUT, + sprintf( + " %.1f ms %s\n", + $timing['milliseconds'], + $timing['path'] + ) + ); + } +} + +fwrite( + STDOUT, + sprintf( + "Peak memory: %.1f MiB\n", + memory_get_peak_usage(true) / 1048576 ) );