From 8d508a4d663bafa0232d239ecf8ebcaa58799b3e Mon Sep 17 00:00:00 2001 From: Christian Wolfram Date: Tue, 6 May 2025 16:45:55 +0200 Subject: [PATCH 1/8] Prepare v13 compatible release --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 7caa296..aa9a3f4 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "require": { "php": ">= 8.1 < 8.4", "ramsey/collection": "^2.0", - "typo3/cms-core": "^12.4" + "typo3/cms-core": "^12.4 || ^13.4" }, "require-dev": { "rector/rector": "^0.12.23", From ae42f54e9a94e8da32edbc25dd397361a8add73a Mon Sep 17 00:00:00 2001 From: Daniel Siepmann Date: Mon, 16 Jun 2025 12:14:47 +0200 Subject: [PATCH 2/8] !!!|feat: TYPO3 13.4 compatibility * Remove 12.4 support to not introduce fallbacks for deprecated API. * Use dependency injection. * Use new attributes to declare EventListener. * Adjust CI to new TYPO3 version. --- .github/workflows/ci.yaml | 13 +-- CHANGELOG.md | 4 + .../ContentProcessor.php | 20 ++-- Classes/EventListener/ModifyCacheLifetime.php | 4 +- Classes/Hooks/DataHandler.php | 2 +- Classes/Service/VariablesService.php | 105 +++++++++--------- Configuration/Services.yaml | 8 -- Resources/Public/Icons/Extension.png | Bin 900 -> 692 bytes .../FlushViaDataHandlerChangesTest.php | 18 +-- .../Functional/Fixtures/Frontend/Content.csv | 11 ++ .../Functional/Fixtures/Frontend/Content.xml | 77 ------------- Tests/Functional/Fixtures/Frontend/Marker.csv | 5 + Tests/Functional/Fixtures/Frontend/Marker.xml | 21 ---- .../AbstractProcessesMarkersTestCase.php | 26 ++++- .../Frontend/BreaksOnInvalidHookTest.php | 9 +- .../Frontend/ProcessesMarkersHookTest.php | 16 +-- .../Frontend/ProcessesMarkersTest.php | 42 ++++--- composer.json | 11 +- ext_emconf.php | 26 ----- ext_icon.png | Bin 900 -> 0 bytes ext_localconf.php | 3 - phpunit.xml.dist | 2 +- 22 files changed, 161 insertions(+), 262 deletions(-) rename Classes/{Hooks => EventListener}/ContentProcessor.php (60%) create mode 100644 Tests/Functional/Fixtures/Frontend/Content.csv delete mode 100644 Tests/Functional/Fixtures/Frontend/Content.xml create mode 100644 Tests/Functional/Fixtures/Frontend/Marker.csv delete mode 100644 Tests/Functional/Fixtures/Frontend/Marker.xml delete mode 100644 ext_emconf.php delete mode 100644 ext_icon.png diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1e85da9..44c676e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,7 +15,7 @@ jobs: - name: Install PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 coverage: none tools: composer:v2 env: @@ -29,7 +29,6 @@ jobs: strategy: matrix: php-version: - - 8.1 - 8.2 - 8.3 steps: @@ -55,7 +54,7 @@ jobs: - name: Install PHP uses: shivammathur/setup-php@v2 with: - php-version: "8.1" + php-version: "8.2" coverage: none tools: composer:v2 env: @@ -86,7 +85,7 @@ jobs: - name: Install PHP uses: shivammathur/setup-php@v2 with: - php-version: "8.1" + php-version: "8.2" coverage: none tools: composer:v2 env: @@ -106,12 +105,10 @@ jobs: strategy: matrix: include: - - php-version: '8.1' - typo3-version: '^12.4' - php-version: '8.2' - typo3-version: '^12.4' + typo3-version: '^13.4' - php-version: '8.3' - typo3-version: '^12.4' + typo3-version: '^13.4' steps: - uses: actions/checkout@v3 diff --git a/CHANGELOG.md b/CHANGELOG.md index f4f264a..e7b56f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 5.0.0 (2025-09-18) + +[!!!][TASK] TYPO3 13 compatibility + ## 3.0.0 (2022-06-16) [TASK] php annotation diff --git a/Classes/Hooks/ContentProcessor.php b/Classes/EventListener/ContentProcessor.php similarity index 60% rename from Classes/Hooks/ContentProcessor.php rename to Classes/EventListener/ContentProcessor.php index 3b0273c..c9b4adc 100644 --- a/Classes/Hooks/ContentProcessor.php +++ b/Classes/EventListener/ContentProcessor.php @@ -13,26 +13,26 @@ * The TYPO3 project - inspiring people to share! */ -namespace Sinso\Variables\Hooks; +namespace Sinso\Variables\EventListener; use Sinso\Variables\Service\VariablesService; -use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; +use TYPO3\CMS\Core\Attribute\AsEventListener; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Frontend\Event\AfterCacheableContentIsGeneratedEvent; class ContentProcessor { - protected VariablesService $variablesService; - - public function __construct() - { - $this->variablesService = GeneralUtility::makeInstance(VariablesService::class); + public function __construct( + private readonly VariablesService $variablesService, + ) { } + #[AsEventListener()] public function __invoke(AfterCacheableContentIsGeneratedEvent $event): void { - $extensionConfiguration = GeneralUtility::makeInstance(ExtensionConfiguration::class); - $this->variablesService->initialize($extensionConfiguration, $event->getController()); - $this->variablesService->replaceMarkersInStructureAndAdjustCaching($event->getController()->content); + $this->variablesService->replaceMarkersInStructureAndAdjustCaching( + $event->getRequest(), + $event->getController()->content + ); } } diff --git a/Classes/EventListener/ModifyCacheLifetime.php b/Classes/EventListener/ModifyCacheLifetime.php index d9951f0..a9585ab 100644 --- a/Classes/EventListener/ModifyCacheLifetime.php +++ b/Classes/EventListener/ModifyCacheLifetime.php @@ -5,12 +5,13 @@ namespace Sinso\Variables\EventListener; use Sinso\Variables\Service\VariablesService; +use TYPO3\CMS\Core\Attribute\AsEventListener; use TYPO3\CMS\Frontend\Event\ModifyCacheLifetimeForPageEvent; final class ModifyCacheLifetime { public function __construct( - private VariablesService $variablesService, + private readonly VariablesService $variablesService, ) { } @@ -18,6 +19,7 @@ public function __construct( * Calculate shortest lifetime (aka duration) respecting data from * markers */ + #[AsEventListener()] public function __invoke(ModifyCacheLifetimeForPageEvent $event): void { $event->setCacheLifetime( diff --git a/Classes/Hooks/DataHandler.php b/Classes/Hooks/DataHandler.php index 394040a..ceac8b1 100644 --- a/Classes/Hooks/DataHandler.php +++ b/Classes/Hooks/DataHandler.php @@ -50,7 +50,7 @@ public function clearCachePostProc(array $params, \TYPO3\CMS\Core\DataHandling\D protected function getMarkerFromHook(array $params, \TYPO3\CMS\Core\DataHandling\DataHandler $dataHandler): ?Marker { if ( - ($params['table'] !== 'tx_variables_marker') + (($params['table'] ?? '') !== 'tx_variables_marker') || !isset($params['uid']) ) { return null; diff --git a/Classes/Service/VariablesService.php b/Classes/Service/VariablesService.php index e7cb415..9ab0c1c 100644 --- a/Classes/Service/VariablesService.php +++ b/Classes/Service/VariablesService.php @@ -2,17 +2,21 @@ namespace Sinso\Variables\Service; +use Psr\Http\Message\ServerRequestInterface; use Ramsey\Collection\Set; use Sinso\Variables\Domain\Model\Marker; use Sinso\Variables\Domain\Model\MarkerCollection; use Sinso\Variables\Hooks\MarkersProcessorInterface; use Sinso\Variables\Utility\CacheKeyUtility; +use TYPO3\CMS\Core\Cache\CacheDataCollector; +use TYPO3\CMS\Core\Cache\CacheTag; use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; +use TYPO3\CMS\Core\Context\Context; use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction; use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController; +use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; class VariablesService { @@ -21,36 +25,18 @@ class VariablesService protected Set $cacheTags; protected array $usedMarkerKeys = []; - protected ?ExtensionConfiguration $extensionConfiguration = null; - protected ?TypoScriptFrontendController $typoScriptFrontendController = null; protected ?MarkerCollection $markerCollection = null; protected ?array $markerKeys = null; protected ?string $markerRegexp = null; - public function __construct() - { + public function __construct( + private readonly ExtensionConfiguration $extensionConfiguration, + private readonly Context $context, + private readonly ConnectionPool $connectionPool, + ) { $this->cacheTags = new Set('string'); } - public function initialize( - ExtensionConfiguration $extensionConfiguration = null, - TypoScriptFrontendController $typoScriptFrontendController = null, - ): void { - if (!$typoScriptFrontendController instanceof \TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController) { - $typoScriptFrontendController = $this->getTypoScriptFrontendController(); - } - - if (!$extensionConfiguration instanceof \TYPO3\CMS\Core\Configuration\ExtensionConfiguration) { - $extensionConfiguration = GeneralUtility::makeInstance(ExtensionConfiguration::class); - } - - $this->extensionConfiguration = $extensionConfiguration; - $this->typoScriptFrontendController = $typoScriptFrontendController; - $this->markerCollection = $this->getMarkers(); - $this->markerKeys = $this->markerCollection->getMarkerKeys(); - $this->markerRegexp = '/(' . implode('|', array_map('preg_quote', $this->markerKeys)) . ')/'; - } - /** * Iterates over a structure (array, object) and replaces markers in every string found. * @@ -58,13 +44,15 @@ public function initialize( * @throws \Exception */ public function replaceMarkersInStructureAndAdjustCaching( + ServerRequestInterface $request, mixed &$structure ): void { - if (!$this->markerCollection instanceof \Sinso\Variables\Domain\Model\MarkerCollection) { - throw new \Exception('Markers not initialized. Please run initialize() first.', 1726241619); - } + $this->markerCollection = $this->getMarkers($request); + $this->markerKeys = $this->markerCollection->getMarkerKeys(); + $this->markerRegexp = '/(' . implode('|', array_map('preg_quote', $this->markerKeys)) . ')/'; + $this->replaceMarkersInStructure($structure); - $this->setCacheTagsInTsfe(); + $this->setCacheTags($request); } /** @@ -72,7 +60,7 @@ public function replaceMarkersInStructureAndAdjustCaching( * * @throws \Exception */ - protected function replaceMarkersInStructure(mixed &$structure): void + private function replaceMarkersInStructure(mixed &$structure): void { if (is_null($structure) || is_bool($structure) || is_int($structure) || is_float($structure) || $structure instanceof \UnitEnum) { return; @@ -93,7 +81,7 @@ protected function replaceMarkersInStructure(mixed &$structure): void throw new \Exception(sprintf('Unsupported type "%s" in structure', gettype($structure)), 1725955598); } - protected function replaceMarkersInText(string &$text): void + private function replaceMarkersInText(string &$text): void { $loops = 0; while (preg_match($this->markerRegexp, $text) && $loops++ < self::MAXIMUM_LOOP_COUNT) { @@ -130,18 +118,20 @@ protected function replaceMarkersInText(string &$text): void /** * Returns the markers available in the current root line. */ - protected function getMarkers(): MarkerCollection - { + private function getMarkers( + ServerRequestInterface $request, + ): MarkerCollection { $pids = array_map(static function ($page) { return $page['uid']; - }, $this->typoScriptFrontendController->rootLine); + }, $request->getAttribute('frontend.page.information')->getRootLine()); - if (!empty($GLOBALS['TYPO3_REQUEST']->getAttribute('frontend.typoscript')->getSetupArray()['plugin.']['tx_variables.']['persistence.']['storagePid'])) { - $pids[] = (int)$GLOBALS['TYPO3_REQUEST']->getAttribute('frontend.typoscript')->getSetupArray()['plugin.']['tx_variables.']['persistence.']['storagePid']; + if (!empty($request->getAttribute('frontend.typoscript')->getSetupArray()['plugin.']['tx_variables.']['persistence.']['storagePid'])) { + $pids[] = (int)$request->getAttribute('frontend.typoscript')->getSetupArray()['plugin.']['tx_variables.']['persistence.']['storagePid']; } $table = 'tx_variables_marker'; - $rows = $this->typoScriptFrontendController->cObj->getRecords( + $contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class); + $rows = $contentObjectRenderer->getRecords( $table, [ 'selectFields' => 'marker, replacement', @@ -173,38 +163,48 @@ protected function getMarkers(): MarkerCollection return $markers; } - protected function setCacheTagsInTsfe(): void - { - if (count($this->cacheTags) > 0) { - $this->typoScriptFrontendController->addCacheTags($this->cacheTags->toArray()); + private function setCacheTags( + ServerRequestInterface $request, + ): void { + if (count($this->cacheTags) === 0) { + return; + } + + $cacheCollector = $request->getAttribute('frontend.cache.collector'); + if (($cacheCollector instanceof CacheDataCollector) === false) { + return; } + + $cacheTags = array_map(function (string $tag): CacheTag { + return new CacheTag($tag); + }, $this->cacheTags->toArray()); + + $cacheCollector->addCacheTags(...$cacheTags); } public function getLifetime(): int { - return $this->getNearestTimestampForMarkers($this->usedMarkerKeys) - \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Context\Context::class)->getPropertyFromAspect('date', 'timestamp'); + return $this->getNearestTimestampForMarkers($this->usedMarkerKeys) - $this->context->getPropertyFromAspect('date', 'timestamp'); } /** * Get the nearest timestamp in the future when changes for Markers should happen. * This respects starttime and endtime. * The result will be used to calculate the maximal caching duration - * - * @throws \Doctrine\DBAL\Exception */ - public function getNearestTimestampForMarkers(array $usedMarkerKeys): int + private function getNearestTimestampForMarkers(array $usedMarkerKeys): int { // Max value possible to keep an int \TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->realPageCacheContent ($timeOutTime = $GLOBALS['EXEC_TIME'] + $cacheTimeout;) $result = PHP_INT_MAX; $tableName = 'tx_variables_marker'; - $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($tableName)->createQueryBuilder(); + $queryBuilder = $this->connectionPool->getConnectionForTable($tableName)->createQueryBuilder(); $queryBuilder->getRestrictions()->removeAll() ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); // Code heavily inspired by: // \TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->getFirstTimeValueForRecord - $now = (int)\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Context\Context::class)->getPropertyFromAspect('date', 'timestamp'); + $now = (int)$this->context->getPropertyFromAspect('date', 'timestamp'); $timeFields = []; $timeConditions = $queryBuilder->expr()->or(); foreach (['starttime', 'endtime'] as $field) { @@ -215,7 +215,7 @@ public function getNearestTimestampForMarkers(array $usedMarkerKeys): int . 'CASE WHEN ' . $queryBuilder->expr()->lte( $timeFields[$field], - $queryBuilder->createNamedParameter($now, \PDO::PARAM_INT) + $queryBuilder->createNamedParameter($now, Connection::PARAM_INT) ) . ' THEN NULL ELSE ' . $queryBuilder->quoteIdentifier($timeFields[$field]) . ' END' . ') AS ' . $queryBuilder->quoteIdentifier($timeFields[$field]) @@ -223,7 +223,7 @@ public function getNearestTimestampForMarkers(array $usedMarkerKeys): int $timeConditions->with( $queryBuilder->expr()->gt( $timeFields[$field], - $queryBuilder->createNamedParameter($now, \PDO::PARAM_INT) + $queryBuilder->createNamedParameter($now, Connection::PARAM_INT) ) ); } @@ -239,8 +239,8 @@ public function getNearestTimestampForMarkers(array $usedMarkerKeys): int $timeConditions ); $row = $queryBuilder - ->execute() - ->fetch(); + ->executeQuery() + ->fetchAssociative(); if ($row) { foreach (array_keys($timeFields) as $timeField) { @@ -258,9 +258,4 @@ public function getNearestTimestampForMarkers(array $usedMarkerKeys): int return $result; } - - public function getTypoScriptFrontendController(): TypoScriptFrontendController - { - return $GLOBALS['TSFE']; - } } diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index f2115f4..b062dc8 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -10,13 +10,5 @@ services: Sinso\Variables\Hooks\DataHandler: public: true - Sinso\Variables\Hooks\ContentProcessor: - tags: - - name: event.listener - Sinso\Variables\Service\VariablesService: public: true - - Sinso\Variables\EventListener\ModifyCacheLifetime: - tags: - - name: event.listener diff --git a/Resources/Public/Icons/Extension.png b/Resources/Public/Icons/Extension.png index 6c3d56661fa75bd2fd90b3d7751c6958b5b912c8..df12dbaa9c64ac82ae7b5689003dad30a8036cbd 100644 GIT binary patch delta 624 zcmV-$0+0QK2ebu{8A1dA007x@vVQ;o03c&XQcVB=dL{q>fP?@5`Tzg`fam}Kbua(` z>RI+y?e7jT@qQ9J+u00Lr5M??VshmXv^kuev40tZP%K~zY`#g$EK6Hyd~pEJ{z z)|UDK;-Ux=Yov=x$V_5X5KF<B0k5GMw*aj2x&duJ(JJ6hX1ad8 zeU)evT4**PcEX;IhkQ-<+w+#SEkJJ)da>@VkL->|?49m99yz`TLT`x3%r*e~C=poz zuv_h=woguu&3Fv#+fJ^$409D0Ic4?vJ2(x0;c~SIxyB0vzH8537O=x9HPKMMz~S^!s!KG`M0nJ8U~PCMA#wO%{ zZD--p574k+cmSRjGA}hvQUrKPNiL?JnX}|D^qO<&aT(5;GR|`Ko&3Xrs+Ho2eTMs) zDW(9PZxR(l9k;$d#(o{!<{?3LMaL^&yt%3z^n;wuX1!JK835V4_(pc+@0{yEXe7i1 z6hVd)-Sw&y-7*Xm#hM`nl=)sKp@TLRU^ehz5e+)p{`++P1<)0vOxZFv^N~g;3IG5} KMNUMnLSTXwRu;|x delta 854 zcmV-c1F8J91%wBX87&3?0047(dh`GQ00eVFNmK|32nc)#WQYI&010qNS#tmY5eWbQ z5eWgn<)Rpqg90ai0|7}yK~z|U<(E%rTvZgtf8Tq_keX8efMg*;d94)}4Pow`=Olu( z6l^UNq$wfb!iBg}sF01D?hCe{p!=>3vI9c^l2omKRu4<^z|KB%{%U5~%F4>8 zW@eh4oZQiFw=bL7p*|D;!z6t+JUo0X9vd4w4ZOac8k& z+GTdmeLfcg3P4sZoH;GBD5%L0H`@^!6nBuNs0 zV{shs1AYT`n%SIl?rq?W9QI>ZjWqyCN6Sct$^yWD^8jXcPeA@w9pHQC+$Bk~W_DQ8 zpOOxm+0!E^p!E z!ZRiD^{)NP_rt_-T;zUR##7pXD2i^G8F3u%1u_8VTwT&Tz|WGN1J;2(z)|4C%{4*@ zd-Lsoms=J9+yXGOeT75P$NYVTwxcLI2%IppcY#B-TJ40SZ$?K)*B2HR{wQ)K5k*m{ zfYKw#vg`{pYXGy#za-#u;Etp>8;!=zIF8Q(t3YIC?}rfHT3lRg0nE(I#FD0gYn$(p z2kL;CnR9LqxbD4w75F5}vXj8c0<*NVbQh?9N{Y-ZbmwnuY#axkHM8So96dV#&}=qu znAvov)A^+rZZmoA`$5uc&1SRNE4SA-P>8tjZ $params */ - public function testDoesNotInteractWithCacheManagerOnUnkownData(array $params): void + #[DataProvider('possibleNoneTriggeringParams')] + #[Test] + public function doesNotInteractWithCacheManagerOnUnkownData(array $params): void { $connectionPool = self::createMock(ConnectionPool::class); $cacheManager = self::createMock(CacheManager::class); @@ -54,7 +57,7 @@ public function testDoesNotInteractWithCacheManagerOnUnkownData(array $params): } /** - * @return Generator}> + * @return \Generator}|array{params:array}> */ public static function possibleNoneTriggeringParams(): \Generator { @@ -75,7 +78,8 @@ public static function possibleNoneTriggeringParams(): \Generator ]; } - public function testFlushCachesByGroupForMarker(): void + #[Test] + public function flushCachesByGroupForMarker(): void { $connectionPool = self::createMock(ConnectionPool::class); $cacheManager = self::createMock(CacheManager::class); diff --git a/Tests/Functional/Fixtures/Frontend/Content.csv b/Tests/Functional/Fixtures/Frontend/Content.csv new file mode 100644 index 0000000..d4372d0 --- /dev/null +++ b/Tests/Functional/Fixtures/Frontend/Content.csv @@ -0,0 +1,11 @@ +pages +,uid,pid,title,slug,sorting,deleted +,1,0,"Page 1",/page-1,128,0 +,2,1,"Page 2",/page-2,128,0 +,3,1,"Page 3",/page-3,128,0, +,4,1,"Page 4 (Storage)",/page-4,128,0, +tt_content +,uid,pid,hidden,sorting,deleted,starttime,endtime,colPos,sys_language_uid,CType,header,bodytext +,1,1,0,1,0,0,0,0,0,text,"Has Marker on Page 1","Some example text with marker {{MARKER1}}" +,2,2,0,1,0,0,0,0,0,text,"Has Marker on Page 2","Some example text with marker {{MARKER2}} {{MARKER1}}" +,3,3,0,1,0,0,0,0,0,text,"Has Marker on Page 3","Some example text with marker {{MARKER3}}" diff --git a/Tests/Functional/Fixtures/Frontend/Content.xml b/Tests/Functional/Fixtures/Frontend/Content.xml deleted file mode 100644 index 552ee24..0000000 --- a/Tests/Functional/Fixtures/Frontend/Content.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - 1 - 0 - Page 1 - /page-1 - 128 - 0 - - - 2 - 1 - Page 2 - /page-2 - 128 - 0 - - - 3 - 1 - Page 3 - /page-3 - 128 - 0 - - - 4 - 1 - Page 4 (Storage) - /page-4 - 128 - 0 - - - 1 - 1 - 0 - 1 - 0 - 0 - 0 - 0 - 0 - text -
Has Marker on Page 1
- Some example text with marker {{MARKER1}} -
- - 2 - 2 - 0 - 1 - 0 - 0 - 0 - 0 - 0 - text -
Has Marker on Page 2
- Some example text with marker {{MARKER2}} {{MARKER1}} -
- - 3 - 3 - 0 - 1 - 0 - 0 - 0 - 0 - 0 - text -
Has Marker on Page 3
- Some example text with marker {{MARKER3}} -
-
diff --git a/Tests/Functional/Fixtures/Frontend/Marker.csv b/Tests/Functional/Fixtures/Frontend/Marker.csv new file mode 100644 index 0000000..43183bd --- /dev/null +++ b/Tests/Functional/Fixtures/Frontend/Marker.csv @@ -0,0 +1,5 @@ +tx_variables_marker +,uid,pid,marker,replacement +,1,1,MARKER1,"Replaced marker 1 from pid 1" +,2,2,MARKER2,"Replaced marker 2 from pid 2" +,3,4,MARKER3,"Replaced marker 3 from storage pid 4" diff --git a/Tests/Functional/Fixtures/Frontend/Marker.xml b/Tests/Functional/Fixtures/Frontend/Marker.xml deleted file mode 100644 index d6c2962..0000000 --- a/Tests/Functional/Fixtures/Frontend/Marker.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - 1 - 1 - MARKER1 - Replaced marker 1 from pid 1 - - - 2 - 2 - MARKER2 - Replaced marker 2 from pid 2 - - - 3 - 4 - MARKER3 - Replaced marker 3 from storage pid 4 - - diff --git a/Tests/Functional/Frontend/AbstractProcessesMarkersTestCase.php b/Tests/Functional/Frontend/AbstractProcessesMarkersTestCase.php index 5e45150..ffe49ef 100644 --- a/Tests/Functional/Frontend/AbstractProcessesMarkersTestCase.php +++ b/Tests/Functional/Frontend/AbstractProcessesMarkersTestCase.php @@ -54,7 +54,7 @@ protected function setUp(): void parent::setUp(); - $this->importDataSet('EXT:variables/Tests/Functional/Fixtures/Frontend/Content.xml'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/Frontend/Content.csv'); $this->setUpFrontendRootPage(1, [ 'EXT:variables/Tests/Functional/Fixtures/Frontend/Rendering.typoscript', ]); @@ -65,6 +65,28 @@ protected function fetchContentForPage(int $pageUid): string $request = new InternalRequest(); $request = $request->withPageId($pageUid); - return $this->executeFrontendRequest($request)->getBody()->__toString(); + return $this->executeFrontendSubRequest($request)->getBody()->__toString(); + } + + protected function assertHasCacheForKey(string $cacheKey): void + { + $queryBuilder = $this->getConnectionPool()->getQueryBuilderForTable('cache_pages_tags'); + $queryBuilder->count('*'); + $queryBuilder->from('cache_pages_tags'); + $queryBuilder->where($queryBuilder->expr()->eq('tag', $queryBuilder->createNamedParameter($cacheKey))); + + $count = $queryBuilder->executeQuery()->fetchOne(); + self::assertSame(1, $count, 'Did not find a single cache entry for key: ' . $cacheKey); + } + + protected function assertHasNoCacheKeyStartingWithPrefix(string $cacheKeyPrefix): void + { + $queryBuilder = $this->getConnectionPool()->getQueryBuilderForTable('cache_pages_tags'); + $queryBuilder->count('*'); + $queryBuilder->from('cache_pages_tags'); + $queryBuilder->where($queryBuilder->expr()->like('tag', $queryBuilder->createNamedParameter($queryBuilder->escapeLikeWildcards($cacheKeyPrefix)))); + + $count = $queryBuilder->executeQuery()->fetchOne(); + self::assertSame(0, $count, 'Did find a cache entries for key prefix: ' . $cacheKeyPrefix); } } diff --git a/Tests/Functional/Frontend/BreaksOnInvalidHookTest.php b/Tests/Functional/Frontend/BreaksOnInvalidHookTest.php index 9d2ae96..5f31720 100644 --- a/Tests/Functional/Frontend/BreaksOnInvalidHookTest.php +++ b/Tests/Functional/Frontend/BreaksOnInvalidHookTest.php @@ -23,11 +23,11 @@ namespace Sinso\Variables\Tests\Functional\Frontend; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; use Sinso\Variables\Tests\Functional\Fixtures\Frontend\Hook\NotImplementingInterface; -/** - * @covers \Sinso\Variables\Service\VariablesService - */ +#[CoversClass(VariablesService::class)] class BreaksOnInvalidHookTest extends AbstractProcessesMarkersTestCase { protected array $configurationToUseInTestInstance = [ @@ -40,7 +40,8 @@ class BreaksOnInvalidHookTest extends AbstractProcessesMarkersTestCase ], ]; - public function testThrowsExceptionIfConfiguredClassDoesntImplementInterface(): void + #[Test] + public function throwsExceptionIfConfiguredClassDoesntImplementInterface(): void { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Sinso\Variables\Tests\Functional\Fixtures\Frontend\Hook\NotImplementingInterface does not implement Sinso\Variables\Hooks\MarkersProcessorInterface'); diff --git a/Tests/Functional/Frontend/ProcessesMarkersHookTest.php b/Tests/Functional/Frontend/ProcessesMarkersHookTest.php index 7e21f82..a0f1346 100644 --- a/Tests/Functional/Frontend/ProcessesMarkersHookTest.php +++ b/Tests/Functional/Frontend/ProcessesMarkersHookTest.php @@ -23,11 +23,12 @@ namespace Sinso\Variables\Tests\Functional\Frontend; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use Sinso\Variables\Hooks\ContentProcessor; use Sinso\Variables\Tests\Functional\Fixtures\Frontend\Hook\ImplementingInterface; -/** - * @covers \Sinso\Variables\Hooks\ContentProcessor - */ +#[CoversClass(ContentProcessor::class)] class ProcessesMarkersHookTest extends AbstractProcessesMarkersTestCase { protected array $configurationToUseInTestInstance = [ @@ -40,17 +41,16 @@ class ProcessesMarkersHookTest extends AbstractProcessesMarkersTestCase ], ]; - public function testExecutesHookClass(): void + #[Test] + public function executesHookClass(): void { - $this->importDataSet('EXT:variables/Tests/Functional/Fixtures/Frontend/Marker.xml'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/Frontend/Marker.csv'); self::assertStringContainsString( '

Some example text with marker Modified by hook

', $this->fetchContentForPage(1) ); - $pageCache = $this->getAllRecords('cache_pages_tags'); - self::assertCount(2, $pageCache); - self::assertSame('tx_variables_key_hash_b3560bb929f682dcc19c903256f98639', $pageCache[0]['tag']); + $this->assertHasCacheForKey('tx_variables_key_hash_b3560bb929f682dcc19c903256f98639'); } } diff --git a/Tests/Functional/Frontend/ProcessesMarkersTest.php b/Tests/Functional/Frontend/ProcessesMarkersTest.php index dd9dc92..4841205 100644 --- a/Tests/Functional/Frontend/ProcessesMarkersTest.php +++ b/Tests/Functional/Frontend/ProcessesMarkersTest.php @@ -23,62 +23,60 @@ namespace Sinso\Variables\Tests\Functional\Frontend; -/** - * @covers \Sinso\Variables\Hooks\ContentProcessor - */ +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; + +#[CoversClass(ContentProcessor::class)] class ProcessesMarkersTest extends AbstractProcessesMarkersTestCase { - public function testNoMarkerAppliedAsNoneExist(): void + #[Test] + public function noMarkerAppliedAsNoneExist(): void { self::assertStringContainsString( '

Some example text with marker {{MARKER1}}

', $this->fetchContentForPage(1) ); - $pageCache = $this->getAllRecords('cache_pages_tags'); - self::assertCount(1, $pageCache); + $this->assertHasNoCacheKeyStartingWithPrefix('tx_variables_key_hash'); } - public function testAppliesMarkersStoredOnSamePage(): void + #[Test] + public function appliesMarkersStoredOnSamePage(): void { - $this->importDataSet('EXT:variables/Tests/Functional/Fixtures/Frontend/Marker.xml'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/Frontend/Marker.csv'); self::assertStringContainsString( '

Some example text with marker Replaced marker 1 from pid 1

', $this->fetchContentForPage(1) ); - $pageCache = $this->getAllRecords('cache_pages_tags'); - self::assertCount(2, $pageCache); - self::assertSame('tx_variables_key_hash_b3560bb929f682dcc19c903256f98639', $pageCache[0]['tag']); + $this->assertHasCacheForKey('tx_variables_key_hash_b3560bb929f682dcc19c903256f98639'); } - public function testAppliesMarkersFromRootlinePage(): void + #[Test] + public function appliesMarkersFromRootlinePage(): void { - $this->importDataSet('EXT:variables/Tests/Functional/Fixtures/Frontend/Marker.xml'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/Frontend/Marker.csv'); self::assertStringContainsString( '

Some example text with marker Replaced marker 2 from pid 2 Replaced marker 1 from pid 1

', $this->fetchContentForPage(2) ); - $pageCache = $this->getAllRecords('cache_pages_tags'); - self::assertCount(3, $pageCache); - self::assertSame('tx_variables_key_hash_b3560bb929f682dcc19c903256f98639', $pageCache[0]['tag']); - self::assertSame('tx_variables_key_hash_7324efb2ab7ff6e7ef0fe77210ff6b20', $pageCache[1]['tag']); + $this->assertHasCacheForKey('tx_variables_key_hash_b3560bb929f682dcc19c903256f98639'); + $this->assertHasCacheForKey('tx_variables_key_hash_7324efb2ab7ff6e7ef0fe77210ff6b20'); } - public function testAppliesMarkersFromConfiguredStoragePid(): void + #[Test] + public function appliesMarkersFromConfiguredStoragePid(): void { - $this->importDataSet('EXT:variables/Tests/Functional/Fixtures/Frontend/Marker.xml'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/Frontend/Marker.csv'); self::assertStringContainsString( '

Some example text with marker Replaced marker 3 from storage pid 4

', $this->fetchContentForPage(3) ); - $pageCache = $this->getAllRecords('cache_pages_tags'); - self::assertCount(2, $pageCache); - self::assertSame('tx_variables_key_hash_2328e4c0fcee8716480763f53e97ea82', $pageCache[0]['tag']); + $this->assertHasCacheForKey('tx_variables_key_hash_2328e4c0fcee8716480763f53e97ea82'); } } diff --git a/composer.json b/composer.json index aa9a3f4..2f1edd7 100644 --- a/composer.json +++ b/composer.json @@ -16,11 +16,11 @@ "require": { "php": ">= 8.1 < 8.4", "ramsey/collection": "^2.0", - "typo3/cms-core": "^12.4 || ^13.4" + "typo3/cms-core": "^13.4" }, "require-dev": { "rector/rector": "^0.12.23", - "typo3/testing-framework": "^7.0", + "typo3/testing-framework": "^8.0", "symplify/easy-coding-standard": "^12.3" }, "extra": { @@ -30,15 +30,10 @@ } }, "config": { + "lock": false, "allow-plugins": { "typo3/cms-composer-installers": true, "typo3/class-alias-loader": true } - }, - "scripts": { - "post-autoload-dump": [ - "mkdir -p .Build/web/typo3conf/ext/", - "[ -L .Build/web/typo3conf/ext/variables ] || ln -snvf ../../../../. .Build/web/typo3conf/ext/variables" - ] } } diff --git a/ext_emconf.php b/ext_emconf.php deleted file mode 100644 index ce9901c..0000000 --- a/ext_emconf.php +++ /dev/null @@ -1,26 +0,0 @@ - 'Content Variables', - 'description' => 'Search and replace strings recursively after page generation using user-managed replacement definitions.', - 'category' => 'misc', - 'version' => '3.0.0', - 'state' => 'beta', - 'author' => 'Xavier Perseguers', - 'author_email' => 'xavier@causal.ch', - 'author_company' => 'Swisscom (Schweiz) AG', - 'constraints' => [ - 'depends' => [ - 'typo3' => '12.4.0-12.4.99', - 'php' => '8.1.0-8.3.99', - ], - 'conflicts' => [ - ], - 'suggests' => [ - ], - ] -]; diff --git a/ext_icon.png b/ext_icon.png deleted file mode 100644 index 6c3d56661fa75bd2fd90b3d7751c6958b5b912c8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 900 zcmV-~1AF|5P)3vI9c^l2ol$4@>gE&OUSgYG&EW%F3r^ zW}2Lw+|h2gFPqt+J`?}LBz-nKJbWx38yho7@$Ltzn+2TDvh4JBuzT;{D+CPu2p(+#wOZ{Z@BPIzO_Qw__TKOJ-d}Xi zxrY{DW*-5`^78UVw|{(m{K+&;C%pHMcl*;cotT=MdI}&(lCOaYNoO8PfcO5zyujry z->~=oYNb;7DTJ^J+)dN8mdDP85LR2Q)-{0n`S}iT1vucGdtu80fLHQ$t#Bks5`bfI z9Pa~u19qC(oOAAN;Ef#iV^@te07*y7NQTM+!1DlRc27Y5RvqAb=iDVpvu1W!(w~wJ zn%UDMBO_M}yqT>@0x0c3F9D8;!=zIF8Q(t3YIC?}rfHT3lRg0nE(I#FD0gYn$(p2kL;C znR9LqxbD4w75F5}vXj8c0<*NVbQh>fip(r@=WlFm90#5?v*TqPJv#utDV z`K1?bGkNa&LDFl@X0zEVx7RmNh`8`?n3B934}$CNfJ#ySz1`!AfZG6)j@0Y*UHxXR z*Xz3^9myYW%Q<%jn9Ch4;P*aj*a1}X{n?>PrE;p>ZcpTcNu?{;SIY;JQ!=x`j%NS+ a>G&_5%p8JRZJ`bT0000replaceContent'; - $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc'][$extKey . '_clearcache'] = DataHandler::class . '->clearCachePostProc'; })('variables'); diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d8a68b7..af99489 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ Date: Thu, 18 Sep 2025 14:21:10 +0200 Subject: [PATCH 3/8] chore: Add PHPStan To gain more comfort during upcoming upgrades, and this upgrade. To higher code quality. --- .github/workflows/ci.yaml | 29 +++++++ Classes/Domain/Model/MarkerCollection.php | 6 ++ Classes/Hooks/DataHandler.php | 22 ++++-- Classes/Hooks/MarkersProcessorInterface.php | 2 +- Classes/Service/VariablesService.php | 78 +++++++++++++------ .../Frontend/BreaksOnInvalidHookTest.php | 1 + .../Frontend/ProcessesMarkersHookTest.php | 2 +- .../Frontend/ProcessesMarkersTest.php | 1 + composer.json | 7 +- phpstan-baseline.neon | 16 ++++ phpstan.neon | 12 +++ 11 files changed, 143 insertions(+), 33 deletions(-) create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 44c676e..17a4ce9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -126,3 +126,32 @@ jobs: - name: PHPUnit Tests run: ./vendor/bin/phpunit --testdox + + phpstan: + runs-on: ubuntu-latest + needs: + - check-composer + strategy: + matrix: + include: + - php-version: '8.2' + typo3-version: '^13.4' + - php-version: '8.3' + typo3-version: '^13.4' + steps: + - uses: actions/checkout@v3 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "${{ matrix.php-version }}" + coverage: none + tools: composer:v2 + env: + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install dependencies with expected TYPO3 version + run: composer require --no-progress --no-interaction --optimize-autoloader "typo3/cms-core:${{ matrix.typo3-version }}" + + - name: PHPStan + run: ./vendor/bin/phpstan diff --git a/Classes/Domain/Model/MarkerCollection.php b/Classes/Domain/Model/MarkerCollection.php index 2a7b9b6..43b3695 100644 --- a/Classes/Domain/Model/MarkerCollection.php +++ b/Classes/Domain/Model/MarkerCollection.php @@ -17,6 +17,9 @@ use Ramsey\Collection\AbstractArray; +/** + * @extends AbstractArray + */ class MarkerCollection extends AbstractArray { public function getType(): string @@ -41,6 +44,9 @@ public function get(string $markerKey): Marker return $this[$markerKey]; } + /** + * @return mixed[] + */ public function getMarkerKeys(): array { return array_keys($this->data); diff --git a/Classes/Hooks/DataHandler.php b/Classes/Hooks/DataHandler.php index ceac8b1..2efc74e 100644 --- a/Classes/Hooks/DataHandler.php +++ b/Classes/Hooks/DataHandler.php @@ -20,6 +20,7 @@ use TYPO3\CMS\Core\Cache\CacheManager; use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\DataHandling\DataHandler as Typo3CoreDataHandler; class DataHandler { @@ -31,8 +32,9 @@ public function __construct( /** * Flushes the cache if a marker record was edited. + * @param array $params */ - public function clearCachePostProc(array $params, \TYPO3\CMS\Core\DataHandling\DataHandler $dataHandler): void + public function clearCachePostProc(array $params, Typo3CoreDataHandler $dataHandler): void { $marker = $this->getMarkerFromHook($params, $dataHandler); @@ -47,11 +49,15 @@ public function clearCachePostProc(array $params, \TYPO3\CMS\Core\DataHandling\D $this->cacheManager->flushCachesInGroupByTag('pages', $cacheTagToFlush); } - protected function getMarkerFromHook(array $params, \TYPO3\CMS\Core\DataHandling\DataHandler $dataHandler): ?Marker + /** + * @param array $params + */ + protected function getMarkerFromHook(array $params, Typo3CoreDataHandler $dataHandler): ?Marker { if ( (($params['table'] ?? '') !== 'tx_variables_marker') || !isset($params['uid']) + || is_numeric($params['uid']) === false ) { return null; } @@ -59,7 +65,7 @@ protected function getMarkerFromHook(array $params, \TYPO3\CMS\Core\DataHandling $marker = $dataHandler->datamap[$params['table']][$params['uid']]['marker'] ?? null; if (!$marker) { - $marker = $this->findVariableMarkerByUidEventIfHiddenOrDeleted($params['uid']); + $marker = $this->findVariableMarkerByUidEventIfHiddenOrDeleted((int) $params['uid']); } if (!$marker) { @@ -67,7 +73,7 @@ protected function getMarkerFromHook(array $params, \TYPO3\CMS\Core\DataHandling } return new Marker( - uid: $params['uid'], + uid: (int) $params['uid'], key: $marker, replacement: '', // value doesn't matter here ); @@ -77,7 +83,7 @@ protected function findVariableMarkerByUidEventIfHiddenOrDeleted(int $uid): ?str { $queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_variables_marker'); $queryBuilder->getRestrictions()->removeAll(); - return $queryBuilder + $result = $queryBuilder ->select('marker') ->from('tx_variables_marker') ->where( @@ -85,5 +91,11 @@ protected function findVariableMarkerByUidEventIfHiddenOrDeleted(int $uid): ?str ) ->executeQuery() ->fetchOne(); + + if (is_string($result) || is_null($result)) { + return $result; + } + + return null; } } diff --git a/Classes/Hooks/MarkersProcessorInterface.php b/Classes/Hooks/MarkersProcessorInterface.php index e6937e4..e689650 100644 --- a/Classes/Hooks/MarkersProcessorInterface.php +++ b/Classes/Hooks/MarkersProcessorInterface.php @@ -19,5 +19,5 @@ interface MarkersProcessorInterface { - public function postProcessMarkers(MarkerCollection $markers); + public function postProcessMarkers(MarkerCollection $markers): void; } diff --git a/Classes/Service/VariablesService.php b/Classes/Service/VariablesService.php index 9ab0c1c..a2f721b 100644 --- a/Classes/Service/VariablesService.php +++ b/Classes/Service/VariablesService.php @@ -15,6 +15,8 @@ use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction; +use TYPO3\CMS\Core\TypoScript\FrontendTypoScript; +use TYPO3\CMS\Core\Utility\ArrayUtility; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; @@ -22,13 +24,15 @@ class VariablesService { public const MAXIMUM_LOOP_COUNT = 100; + /** + * @var Set + */ protected Set $cacheTags; + /** + * @var string[] + */ protected array $usedMarkerKeys = []; - protected ?MarkerCollection $markerCollection = null; - protected ?array $markerKeys = null; - protected ?string $markerRegexp = null; - public function __construct( private readonly ExtensionConfiguration $extensionConfiguration, private readonly Context $context, @@ -47,11 +51,7 @@ public function replaceMarkersInStructureAndAdjustCaching( ServerRequestInterface $request, mixed &$structure ): void { - $this->markerCollection = $this->getMarkers($request); - $this->markerKeys = $this->markerCollection->getMarkerKeys(); - $this->markerRegexp = '/(' . implode('|', array_map('preg_quote', $this->markerKeys)) . ')/'; - - $this->replaceMarkersInStructure($structure); + $this->replaceMarkersInStructure($this->getMarkers($request), $structure); $this->setCacheTags($request); } @@ -60,20 +60,22 @@ public function replaceMarkersInStructureAndAdjustCaching( * * @throws \Exception */ - private function replaceMarkersInStructure(mixed &$structure): void - { + private function replaceMarkersInStructure( + MarkerCollection $markerCollection, + mixed &$structure + ): void { if (is_null($structure) || is_bool($structure) || is_int($structure) || is_float($structure) || $structure instanceof \UnitEnum) { return; } if (is_string($structure)) { - $this->replaceMarkersInText($structure); + $this->replaceMarkersInText($markerCollection, $structure); return; } - if (is_array($structure) || is_object($structure)) { + if (is_iterable($structure)) { foreach ($structure as &$subStructure) { - $this->replaceMarkersInStructure($subStructure); + $this->replaceMarkersInStructure($markerCollection, $subStructure); } return; } @@ -81,11 +83,16 @@ private function replaceMarkersInStructure(mixed &$structure): void throw new \Exception(sprintf('Unsupported type "%s" in structure', gettype($structure)), 1725955598); } - private function replaceMarkersInText(string &$text): void - { + private function replaceMarkersInText( + MarkerCollection $markerCollection, + string &$text + ): void { + $markerRegexp = '/(' . implode('|', array_map('preg_quote', $markerCollection->getMarkerKeys())) . ')/'; + $loops = 0; - while (preg_match($this->markerRegexp, $text) && $loops++ < self::MAXIMUM_LOOP_COUNT) { - foreach ($this->markerCollection as $marker) { + + while (preg_match($markerRegexp, $text) && $loops++ < self::MAXIMUM_LOOP_COUNT) { + foreach ($markerCollection as $marker) { $newContent = str_replace( $marker->getMarkerWithBrackets(), $marker->replacement, @@ -123,10 +130,11 @@ private function getMarkers( ): MarkerCollection { $pids = array_map(static function ($page) { return $page['uid']; - }, $request->getAttribute('frontend.page.information')->getRootLine()); + }, $request->getAttribute('frontend.page.information')?->getRootLine() ?? []); - if (!empty($request->getAttribute('frontend.typoscript')->getSetupArray()['plugin.']['tx_variables.']['persistence.']['storagePid'])) { - $pids[] = (int)$request->getAttribute('frontend.typoscript')->getSetupArray()['plugin.']['tx_variables.']['persistence.']['storagePid']; + $storagePid = $this->getStoragePidFromTypoScript($request); + if (is_int($storagePid)) { + $pids[] = $storagePid; } $table = 'tx_variables_marker'; @@ -184,7 +192,7 @@ private function setCacheTags( public function getLifetime(): int { - return $this->getNearestTimestampForMarkers($this->usedMarkerKeys) - $this->context->getPropertyFromAspect('date', 'timestamp'); + return $this->getNearestTimestampForMarkers() - $this->context->getPropertyFromAspect('date', 'timestamp'); } /** @@ -192,7 +200,7 @@ public function getLifetime(): int * This respects starttime and endtime. * The result will be used to calculate the maximal caching duration */ - private function getNearestTimestampForMarkers(array $usedMarkerKeys): int + private function getNearestTimestampForMarkers(): int { // Max value possible to keep an int \TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->realPageCacheContent ($timeOutTime = $GLOBALS['EXEC_TIME'] + $cacheTimeout;) $result = PHP_INT_MAX; @@ -235,7 +243,7 @@ private function getNearestTimestampForMarkers(array $usedMarkerKeys): int $queryBuilder ->from($tableName) ->where( - $queryBuilder->expr()->in('marker', $queryBuilder->createNamedParameter($usedMarkerKeys, Connection::PARAM_STR_ARRAY)), + $queryBuilder->expr()->in('marker', $queryBuilder->createNamedParameter($this->usedMarkerKeys, Connection::PARAM_STR_ARRAY)), $timeConditions ); $row = $queryBuilder @@ -258,4 +266,26 @@ private function getNearestTimestampForMarkers(array $usedMarkerKeys): int return $result; } + + private function getStoragePidFromTypoScript(ServerRequestInterface $request): ?int + { + $typoScript = $request->getAttribute('frontend.typoscript'); + if (($typoScript instanceof FrontendTypoScript) === false) { + return null; + } + + $typoScriptArray = $typoScript->getSetupArray(); + $lookUpPath = 'plugin./tx_variables./persistence./storagePid'; + + if (ArrayUtility::isValidPath($typoScriptArray, $lookUpPath) === false) { + return null; + } + + $storagePid = ArrayUtility::getValueByPath($typoScriptArray, $lookUpPath); + if (is_numeric($storagePid) === false) { + return null; + } + + return (int)$storagePid; + } } diff --git a/Tests/Functional/Frontend/BreaksOnInvalidHookTest.php b/Tests/Functional/Frontend/BreaksOnInvalidHookTest.php index 5f31720..3972943 100644 --- a/Tests/Functional/Frontend/BreaksOnInvalidHookTest.php +++ b/Tests/Functional/Frontend/BreaksOnInvalidHookTest.php @@ -25,6 +25,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use Sinso\Variables\Service\VariablesService; use Sinso\Variables\Tests\Functional\Fixtures\Frontend\Hook\NotImplementingInterface; #[CoversClass(VariablesService::class)] diff --git a/Tests/Functional/Frontend/ProcessesMarkersHookTest.php b/Tests/Functional/Frontend/ProcessesMarkersHookTest.php index a0f1346..7d4552a 100644 --- a/Tests/Functional/Frontend/ProcessesMarkersHookTest.php +++ b/Tests/Functional/Frontend/ProcessesMarkersHookTest.php @@ -25,7 +25,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; -use Sinso\Variables\Hooks\ContentProcessor; +use Sinso\Variables\EventListener\ContentProcessor; use Sinso\Variables\Tests\Functional\Fixtures\Frontend\Hook\ImplementingInterface; #[CoversClass(ContentProcessor::class)] diff --git a/Tests/Functional/Frontend/ProcessesMarkersTest.php b/Tests/Functional/Frontend/ProcessesMarkersTest.php index 4841205..38abee0 100644 --- a/Tests/Functional/Frontend/ProcessesMarkersTest.php +++ b/Tests/Functional/Frontend/ProcessesMarkersTest.php @@ -25,6 +25,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use Sinso\Variables\EventListener\ContentProcessor; #[CoversClass(ContentProcessor::class)] class ProcessesMarkersTest extends AbstractProcessesMarkersTestCase diff --git a/composer.json b/composer.json index 2f1edd7..0711b62 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,9 @@ "require-dev": { "rector/rector": "^0.12.23", "typo3/testing-framework": "^8.0", - "symplify/easy-coding-standard": "^12.3" + "symplify/easy-coding-standard": "^12.3", + "phpstan/extension-installer": "^1.4", + "saschaegerer/phpstan-typo3": "^1.10" }, "extra": { "typo3/cms": { @@ -33,7 +35,8 @@ "lock": false, "allow-plugins": { "typo3/cms-composer-installers": true, - "typo3/class-alias-loader": true + "typo3/class-alias-loader": true, + "phpstan/extension-installer": true } } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..2f50ac6 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,16 @@ +parameters: + ignoreErrors: + - + message: "#^Cannot cast mixed to int\\.$#" + count: 3 + path: Classes/Service/VariablesService.php + + - + message: "#^Parameter \\#1 \\$callback of function array_map expects \\(callable\\(mixed\\)\\: mixed\\)\\|null, 'preg_quote' given\\.$#" + count: 1 + path: Classes/Service/VariablesService.php + + - + message: "#^Unable to resolve the template type T in call to method static method TYPO3\\\\CMS\\\\Core\\\\Utility\\\\GeneralUtility\\:\\:makeInstance\\(\\)$#" + count: 1 + path: Classes/Service/VariablesService.php diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..35db6e2 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,12 @@ +includes: + - phpstan-baseline.neon +parameters: + level: max + paths: + - Classes + - Tests + reportUnmatchedIgnoredErrors: false + typo3: + requestGetAttributeMapping: + 'frontend.page.information': 'TYPO3\CMS\Frontend\Page\PageInformation|null' + 'frontend.cache.collector': 'TYPO3\CMS\Core\Cache\CacheDataCollector|null' From 197052a269162bc5cac5e2442aafc99741e3f3b1 Mon Sep 17 00:00:00 2001 From: Daniel Siepmann Date: Thu, 18 Sep 2025 14:45:42 +0200 Subject: [PATCH 4/8] feat: Add PHP 8.4 As this is officially supported by TYPO3 v13.4 --- .github/workflows/ci.yaml | 5 +++++ composer.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 17a4ce9..1b16b1c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,6 +31,7 @@ jobs: php-version: - 8.2 - 8.3 + - 8.4 steps: - name: Checkout uses: actions/checkout@v3 @@ -109,6 +110,8 @@ jobs: typo3-version: '^13.4' - php-version: '8.3' typo3-version: '^13.4' + - php-version: '8.4' + typo3-version: '^13.4' steps: - uses: actions/checkout@v3 @@ -138,6 +141,8 @@ jobs: typo3-version: '^13.4' - php-version: '8.3' typo3-version: '^13.4' + - php-version: '8.4' + typo3-version: '^13.4' steps: - uses: actions/checkout@v3 diff --git a/composer.json b/composer.json index 0711b62..ee12552 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ } }, "require": { - "php": ">= 8.1 < 8.4", + "php": ">= 8.1 < 8.5", "ramsey/collection": "^2.0", "typo3/cms-core": "^13.4" }, From 03f54c56cd3b4676b58cbf6baed4f2de40a448d7 Mon Sep 17 00:00:00 2001 From: Daniel Siepmann Date: Thu, 18 Sep 2025 14:48:19 +0200 Subject: [PATCH 5/8] chore: Add gitattributes To prevent distribution of development only files. --- .gitattributes | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a64119a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +Tests export-ignore +.github export-ignore + +.gitattributes export-ignore +.gitignore export-ignore + +ecs.php export-ignore +rector.php export-ignore +phpstan-baseline.neon export-ignore +phpstan.neon export-ignore +phpunit.xml.dist export-ignore From 6a6e3ea0bdee1867ea46c24375fc9be51fbfebba Mon Sep 17 00:00:00 2001 From: Daniel Siepmann Date: Thu, 18 Sep 2025 15:08:49 +0200 Subject: [PATCH 6/8] chore: Increase Test coverage In order to ensure that the Extension works with TYPO3 version. --- Classes/Domain/Model/Marker.php | 1 - .../Caching/AdjustsPageCacheLifetimeTest.php | 138 ++++++++++++++++++ .../FlushViaDataHandlerChangesTest.php | 94 +++++++++++- .../Frontend/ProcessesMarkersHookTest.php | 4 +- .../Domain/Model/MarkerCollectionTest.php | 81 ++++++++++ Tests/Unit/Domain/Model/MarkerTest.php | 65 +++++++++ Tests/Unit/Utility/CacheKeyUtilityTest.php | 59 ++++++++ phpunit.xml.dist | 3 + 8 files changed, 440 insertions(+), 5 deletions(-) create mode 100644 Tests/Functional/Caching/AdjustsPageCacheLifetimeTest.php create mode 100644 Tests/Unit/Domain/Model/MarkerCollectionTest.php create mode 100644 Tests/Unit/Domain/Model/MarkerTest.php create mode 100644 Tests/Unit/Utility/CacheKeyUtilityTest.php diff --git a/Classes/Domain/Model/Marker.php b/Classes/Domain/Model/Marker.php index f0634db..536a112 100644 --- a/Classes/Domain/Model/Marker.php +++ b/Classes/Domain/Model/Marker.php @@ -21,7 +21,6 @@ public function __construct( public int $uid, public string $key, public string $replacement, - // public ?int $smallestValueFromTimeFields, ) { } diff --git a/Tests/Functional/Caching/AdjustsPageCacheLifetimeTest.php b/Tests/Functional/Caching/AdjustsPageCacheLifetimeTest.php new file mode 100644 index 0000000..270b351 --- /dev/null +++ b/Tests/Functional/Caching/AdjustsPageCacheLifetimeTest.php @@ -0,0 +1,138 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace Sinso\Variables\Tests\Functional\Caching; + +use DateTimeImmutable; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Sinso\Variables\EventListener\ModifyCacheLifetime; +use Sinso\Variables\Hooks\DataHandler; +use Sinso\Variables\Service\VariablesService; +use Sinso\Variables\Tests\Functional\Frontend\AbstractProcessesMarkersTestCase; +use TYPO3\CMS\Core\Cache\CacheManager; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\DataHandling\DataHandler as Typo3DataHandler; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Frontend\Cache\CacheLifetimeCalculator; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +#[CoversClass(ModifyCacheLifetime::class)] +#[CoversClass(VariablesService::class)] +class AdjustsPageCacheLifetimeTest extends AbstractProcessesMarkersTestCase +{ + /** + * @param array $availableVariable + */ + #[DataProvider('possibleVariableLifeTimeCombinations')] + #[Test] + public function cacheLifetimeOfPageIsSetAsExpected( + DateTimeImmutable $expectedTimeStamp, + array $availableVariable, + ): void { + if ($availableVariable !== []) { + $this->insertMarkerWithData($availableVariable); + } + + $this->fetchContentForPage(1); + + $this->assertCacheTimeSampMatches($expectedTimeStamp); + } + + /** + * @return \Generator + */ + public static function possibleVariableLifeTimeCombinations(): iterable + { + yield 'No Variable Exists' => [ + 'expectedTimeStamp' => (new DateTimeImmutable())->modify('+1 day'), + 'availableVariable' => [], + ]; + yield 'Only Variable is deleted' => [ + 'expectedTimeStamp' => (new DateTimeImmutable())->modify('+1 day'), + 'availableVariable' => [ + 'deleted' => '1', + ], + ]; + yield 'Variable StartTime more in future than default life time' => [ + 'expectedTimeStamp' => (new DateTimeImmutable())->modify('+1 day'), + 'availableVariable' => [ + 'starttime' => (new DateTimeImmutable())->modify('+2 day')->format('U'), + ], + ]; + yield 'Variable EndTime more in future than default life time' => [ + 'expectedTimeStamp' => (new DateTimeImmutable())->modify('+1 day'), + 'availableVariable' => [ + 'endtime' => (new DateTimeImmutable())->modify('+2 day')->format('U'), + ], + ]; + // Currently broken. + // yield 'Variable StartTime nearer than default life time' => [ + // 'expectedTimeStamp' => (new DateTimeImmutable())->modify('+1 hour'), + // 'availableVariable' => [ + // 'starttime' => (new DateTimeImmutable())->modify('+1 hour')->format('U'), + // ], + // ]; + yield 'Variable EndTime nearer than default life time' => [ + 'expectedTimeStamp' => (new DateTimeImmutable())->modify('+1 hour'), + 'availableVariable' => [ + 'endtime' => (new DateTimeImmutable())->modify('+1 hour')->format('U'), + ], + ]; + } + + private function assertCacheTimeSampMatches(\DateTimeImmutable $dateTime): void + { + $cacheEntries = $this->getConnectionPool() + ->getConnectionForTable('cache_pages') + ->select(['expires'], 'cache_pages') + ->fetchAllAssociative(); + + self::assertCount(1, $cacheEntries); + + $actualTimeStamp = $cacheEntries[0]['expires']; + + $minimumTimeStamp = $dateTime->modify('-5 seconds')->format('U'); + $maximumTimeStamp = $dateTime->modify('+5 seconds')->format('U'); + + self::assertGreaterThanOrEqual($minimumTimeStamp, $actualTimeStamp); + self::assertLessThanOrEqual($maximumTimeStamp, $actualTimeStamp); + } + + /** + * @param array $data + */ + private function insertMarkerWithData(array $data): void + { + $this->getConnectionPool()->getConnectionForTable('tx_variables_marker') + ->insert('tx_variables_marker', array_merge([ + 'pid' => '1', + 'marker' => 'MARKER1', + 'replacement' => 'Replaced marker 1', + ], $data)); + } +} diff --git a/Tests/Functional/Caching/FlushViaDataHandlerChangesTest.php b/Tests/Functional/Caching/FlushViaDataHandlerChangesTest.php index dcd2acf..45ed87e 100644 --- a/Tests/Functional/Caching/FlushViaDataHandlerChangesTest.php +++ b/Tests/Functional/Caching/FlushViaDataHandlerChangesTest.php @@ -38,6 +38,17 @@ #[CoversClass(DataHandler::class)] class FlushViaDataHandlerChangesTest extends FunctionalTestCase { + protected array $testExtensionsToLoad = [ + 'typo3conf/ext/variables', + ]; + + protected function setUp(): void + { + parent::setUp(); + + $this->importCSVDataSet(__DIR__ . '/../Fixtures/Frontend/Content.csv'); + } + /** * @param array $params */ @@ -45,7 +56,7 @@ class FlushViaDataHandlerChangesTest extends FunctionalTestCase #[Test] public function doesNotInteractWithCacheManagerOnUnkownData(array $params): void { - $connectionPool = self::createMock(ConnectionPool::class); + $connectionPool = $this->get(ConnectionPool::class); $cacheManager = self::createMock(CacheManager::class); $cacheManager ->expects(self::never()) @@ -81,7 +92,7 @@ public static function possibleNoneTriggeringParams(): \Generator #[Test] public function flushCachesByGroupForMarker(): void { - $connectionPool = self::createMock(ConnectionPool::class); + $connectionPool = $this->get(ConnectionPool::class); $cacheManager = self::createMock(CacheManager::class); $cacheManager ->expects(self::once()) @@ -105,4 +116,83 @@ public function flushCachesByGroupForMarker(): void 'marker' => 'TEST' ], $dataHandler); } + + #[Test] + public function flushCachesByGroupForHiddenMarker(): void + { + $connectionPool = $this->get(ConnectionPool::class); + $connectionPool->getConnectionForTable('tx_variables_marker') + ->insert('tx_variables_marker', [ + 'uid' => '100', + 'hidden' => '1', + 'marker' => 'hidden_marker', + 'replacement' => 'replacement', + ]); + + $cacheManager = self::createMock(CacheManager::class); + $cacheManager + ->expects(self::once()) + ->method('flushCachesInGroupByTag') + ->with('pages', $this->stringStartsWith('tx_variables_key_hash_')) + ; + + $dataHandler = self::createStub(Typo3DataHandler::class); + + $subject = new DataHandler($connectionPool, $cacheManager); + $subject->clearCachePostProc([ + 'table' => 'tx_variables_marker', + 'uid' => '100', + 'marker' => 'TEST' + ], $dataHandler); + } + + #[Test] + public function flushCachesByGroupForDeletedMarker(): void + { + $connectionPool = $this->get(ConnectionPool::class); + $connectionPool->getConnectionForTable('tx_variables_marker') + ->insert('tx_variables_marker', [ + 'uid' => '100', + 'deleted' => '1', + 'marker' => 'hidden_marker', + 'replacement' => 'replacement', + ]); + + $cacheManager = self::createMock(CacheManager::class); + $cacheManager + ->expects(self::once()) + ->method('flushCachesInGroupByTag') + ->with('pages', $this->stringStartsWith('tx_variables_key_hash_')) + ; + + $dataHandler = self::createStub(Typo3DataHandler::class); + + $subject = new DataHandler($connectionPool, $cacheManager); + $subject->clearCachePostProc([ + 'table' => 'tx_variables_marker', + 'uid' => '100', + 'marker' => 'TEST' + ], $dataHandler); + } + + #[Test] + public function doesNotFlushCachesByGroupForUnkownMarker(): void + { + $connectionPool = $this->get(ConnectionPool::class); + + $cacheManager = self::createMock(CacheManager::class); + $cacheManager + ->expects(self::never()) + ->method('flushCachesInGroupByTag') + ; + + $dataHandler = self::createStub(Typo3DataHandler::class); + + $subject = new DataHandler($connectionPool, $cacheManager); + $subject->clearCachePostProc([ + 'table' => 'tx_variables_marker', + 'uid' => '100', + 'marker' => 'TEST' + ], $dataHandler); + } } diff --git a/Tests/Functional/Frontend/ProcessesMarkersHookTest.php b/Tests/Functional/Frontend/ProcessesMarkersHookTest.php index 7d4552a..0fa7899 100644 --- a/Tests/Functional/Frontend/ProcessesMarkersHookTest.php +++ b/Tests/Functional/Frontend/ProcessesMarkersHookTest.php @@ -25,10 +25,10 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; -use Sinso\Variables\EventListener\ContentProcessor; +use Sinso\Variables\Service\VariablesService; use Sinso\Variables\Tests\Functional\Fixtures\Frontend\Hook\ImplementingInterface; -#[CoversClass(ContentProcessor::class)] +#[CoversClass(VariablesService::class)] class ProcessesMarkersHookTest extends AbstractProcessesMarkersTestCase { protected array $configurationToUseInTestInstance = [ diff --git a/Tests/Unit/Domain/Model/MarkerCollectionTest.php b/Tests/Unit/Domain/Model/MarkerCollectionTest.php new file mode 100644 index 0000000..16da293 --- /dev/null +++ b/Tests/Unit/Domain/Model/MarkerCollectionTest.php @@ -0,0 +1,81 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace Sinso\Variables\Tests\Unit\Domain\Model; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use RuntimeException; +use Sinso\Variables\Domain\Model\Marker; +use Sinso\Variables\Domain\Model\MarkerCollection; + +#[CoversClass(MarkerCollection::class)] +final class MarkerCollectionTest extends TestCase +{ + #[Test] + public function returnsType(): void + { + $subject = new MarkerCollection(); + + self::assertSame('Sinso\Variables\Domain\Model\Marker', $subject->getType()); + } + + #[Test] + public function throwsExceptionWhenRequestingUnavailableMarker(): void + { + $subject = new MarkerCollection(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Marker not found'); + $this->expectExceptionCode(6494338102); + + $subject->get('{{unavailable}}'); + } + + #[Test] + public function returnsAddedMarker(): void + { + $marker = new Marker(0, 'key', 'replacement'); + + $subject = new MarkerCollection(); + + $subject->add($marker); + + self::assertSame($marker, $subject->get('{{key}}')); + } + + #[Test] + public function returnsMarkerKeys(): void + { + $subject = new MarkerCollection(); + + $subject->add(new Marker(0, 'key_1', 'replacement_1')); + $subject->add(new Marker(0, 'key_2', 'replacement_2')); + + self::assertSame([ + '{{key_1}}', + '{{key_2}}', + ], $subject->getMarkerKeys()); + } +} diff --git a/Tests/Unit/Domain/Model/MarkerTest.php b/Tests/Unit/Domain/Model/MarkerTest.php new file mode 100644 index 0000000..5d122a6 --- /dev/null +++ b/Tests/Unit/Domain/Model/MarkerTest.php @@ -0,0 +1,65 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace Sinso\Variables\Tests\Unit\Domain\Model; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Sinso\Variables\Domain\Model\Marker; + +#[CoversClass(Marker::class)] +final class MarkerTest extends TestCase +{ + #[Test] + public function exposesUid(): void + { + $subject = new Marker(10, '', ''); + + self::assertSame(10, $subject->uid); + } + + #[Test] + public function exposesKey(): void + { + $subject = new Marker(0, 'key', ''); + + self::assertSame('key', $subject->key); + } + + #[Test] + public function exposesReplacement(): void + { + $subject = new Marker(0, '', 'replacement'); + + self::assertSame('replacement', $subject->replacement); + } + + #[Test] + public function returnsMarkerWithBrackets(): void + { + $subject = new Marker(0, 'key', ''); + + self::assertSame('{{key}}', $subject->getMarkerWithBrackets()); + } +} diff --git a/Tests/Unit/Utility/CacheKeyUtilityTest.php b/Tests/Unit/Utility/CacheKeyUtilityTest.php new file mode 100644 index 0000000..65664b0 --- /dev/null +++ b/Tests/Unit/Utility/CacheKeyUtilityTest.php @@ -0,0 +1,59 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace Sinso\Variables\Tests\Unit\Utility; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Sinso\Variables\Utility\CacheKeyUtility; + +#[CoversClass(CacheKeyUtility::class)] +final class CacheKeyUtilityTest extends TestCase +{ + #[DataProvider('possibleCacheKeys')] + #[Test] + public function returnsCacheKey(string $cacheKey): void + { + self::assertSame('tx_variables_key_hash_3d70412c7e9ea2d96fa23d4f1f1f0a1c', CacheKeyUtility::getCacheKey($cacheKey)); + } + + /** + * @return \Generator + */ + public static function possibleCacheKeys(): iterable + { + yield 'Simple Cache Key' => [ + 'cacheKey' => 'some_key', + ]; + + yield 'Cache Key with leading white space' => [ + 'cacheKey' => ' some_key', + ]; + + yield 'Cache Key with trailing white space' => [ + 'cacheKey' => 'some_key ', + ]; + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index af99489..523903c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -13,6 +13,9 @@ > + + Tests/Unit/ + Tests/Functional/ From 30f02cd35e0ce29a892512f57d11452863e31b74 Mon Sep 17 00:00:00 2001 From: Christian Wolfram Date: Mon, 21 Sep 2026 18:41:35 +0200 Subject: [PATCH 7/8] [TASK] Add PHP 8.5 support, drop PHP 8.1 (#4) This change raises the constraint to "php >= 8.2 < 8.6" and adds 8.5 to all three CI matrices (linting, tests, PHPStan). PHP 8.1 is dropped. --- .github/workflows/ci.yaml | 5 + Classes/Domain/Model/MarkerCollection.php | 7 +- Classes/EventListener/ContentProcessor.php | 1 - Classes/EventListener/ModifyCacheLifetime.php | 4 +- Classes/Service/VariablesService.php | 11 +- Configuration/TCA/tx_variables_marker.php | 1 - .../Caching/AdjustsPageCacheLifetimeTest.php | 10 -- .../FlushViaDataHandlerChangesTest.php | 3 - .../Domain/Model/MarkerCollectionTest.php | 2 +- composer.json | 7 +- ext_localconf.php | 1 - phpstan-baseline.neon | 159 +++++++++++++++++- rector.php | 121 +++---------- 13 files changed, 198 insertions(+), 134 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1b16b1c..6200187 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,6 +32,7 @@ jobs: - 8.2 - 8.3 - 8.4 + - 8.5 steps: - name: Checkout uses: actions/checkout@v3 @@ -112,6 +113,8 @@ jobs: typo3-version: '^13.4' - php-version: '8.4' typo3-version: '^13.4' + - php-version: '8.5' + typo3-version: '^13.4' steps: - uses: actions/checkout@v3 @@ -143,6 +146,8 @@ jobs: typo3-version: '^13.4' - php-version: '8.4' typo3-version: '^13.4' + - php-version: '8.5' + typo3-version: '^13.4' steps: - uses: actions/checkout@v3 diff --git a/Classes/Domain/Model/MarkerCollection.php b/Classes/Domain/Model/MarkerCollection.php index 43b3695..912d686 100644 --- a/Classes/Domain/Model/MarkerCollection.php +++ b/Classes/Domain/Model/MarkerCollection.php @@ -45,10 +45,13 @@ public function get(string $markerKey): Marker } /** - * @return mixed[] + * @return list */ public function getMarkerKeys(): array { - return array_keys($this->data); + return array_map( + static fn (Marker $marker): string => $marker->getMarkerWithBrackets(), + array_values($this->data), + ); } } diff --git a/Classes/EventListener/ContentProcessor.php b/Classes/EventListener/ContentProcessor.php index c9b4adc..4aee085 100644 --- a/Classes/EventListener/ContentProcessor.php +++ b/Classes/EventListener/ContentProcessor.php @@ -17,7 +17,6 @@ use Sinso\Variables\Service\VariablesService; use TYPO3\CMS\Core\Attribute\AsEventListener; -use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Frontend\Event\AfterCacheableContentIsGeneratedEvent; class ContentProcessor diff --git a/Classes/EventListener/ModifyCacheLifetime.php b/Classes/EventListener/ModifyCacheLifetime.php index a9585ab..a77361e 100644 --- a/Classes/EventListener/ModifyCacheLifetime.php +++ b/Classes/EventListener/ModifyCacheLifetime.php @@ -8,10 +8,10 @@ use TYPO3\CMS\Core\Attribute\AsEventListener; use TYPO3\CMS\Frontend\Event\ModifyCacheLifetimeForPageEvent; -final class ModifyCacheLifetime +final readonly class ModifyCacheLifetime { public function __construct( - private readonly VariablesService $variablesService, + private VariablesService $variablesService, ) { } diff --git a/Classes/Service/VariablesService.php b/Classes/Service/VariablesService.php index a2f721b..a9aa907 100644 --- a/Classes/Service/VariablesService.php +++ b/Classes/Service/VariablesService.php @@ -87,7 +87,7 @@ private function replaceMarkersInText( MarkerCollection $markerCollection, string &$text ): void { - $markerRegexp = '/(' . implode('|', array_map('preg_quote', $markerCollection->getMarkerKeys())) . ')/'; + $markerRegexp = '/(' . implode('|', array_map(preg_quote(...), $markerCollection->getMarkerKeys())) . ')/'; $loops = 0; @@ -128,9 +128,7 @@ private function replaceMarkersInText( private function getMarkers( ServerRequestInterface $request, ): MarkerCollection { - $pids = array_map(static function ($page) { - return $page['uid']; - }, $request->getAttribute('frontend.page.information')?->getRootLine() ?? []); + $pids = array_map(static fn ($page) => $page['uid'], $request->getAttribute('frontend.page.information')?->getRootLine() ?? []); $storagePid = $this->getStoragePidFromTypoScript($request); if (is_int($storagePid)) { @@ -183,10 +181,7 @@ private function setCacheTags( return; } - $cacheTags = array_map(function (string $tag): CacheTag { - return new CacheTag($tag); - }, $this->cacheTags->toArray()); - + $cacheTags = array_map(fn (string $tag): CacheTag => new CacheTag($tag), $this->cacheTags->toArray()); $cacheCollector->addCacheTags(...$cacheTags); } diff --git a/Configuration/TCA/tx_variables_marker.php b/Configuration/TCA/tx_variables_marker.php index 341a9dc..c754085 100644 --- a/Configuration/TCA/tx_variables_marker.php +++ b/Configuration/TCA/tx_variables_marker.php @@ -24,7 +24,6 @@ 'starttime' => 'starttime', 'endtime' => 'endtime', ], - 'searchFields' => 'marker,replacement', 'iconfile' => 'EXT:variables/Resources/Public/Icons/tx_variables_marker.png' ], 'types' => [ diff --git a/Tests/Functional/Caching/AdjustsPageCacheLifetimeTest.php b/Tests/Functional/Caching/AdjustsPageCacheLifetimeTest.php index 270b351..5a01373 100644 --- a/Tests/Functional/Caching/AdjustsPageCacheLifetimeTest.php +++ b/Tests/Functional/Caching/AdjustsPageCacheLifetimeTest.php @@ -27,19 +27,9 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Sinso\Variables\EventListener\ModifyCacheLifetime; -use Sinso\Variables\Hooks\DataHandler; use Sinso\Variables\Service\VariablesService; use Sinso\Variables\Tests\Functional\Frontend\AbstractProcessesMarkersTestCase; -use TYPO3\CMS\Core\Cache\CacheManager; -use TYPO3\CMS\Core\Context\Context; -use TYPO3\CMS\Core\Database\ConnectionPool; -use TYPO3\CMS\Core\DataHandling\DataHandler as Typo3DataHandler; -use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Frontend\Cache\CacheLifetimeCalculator; -use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; #[CoversClass(ModifyCacheLifetime::class)] #[CoversClass(VariablesService::class)] diff --git a/Tests/Functional/Caching/FlushViaDataHandlerChangesTest.php b/Tests/Functional/Caching/FlushViaDataHandlerChangesTest.php index 45ed87e..e7b3740 100644 --- a/Tests/Functional/Caching/FlushViaDataHandlerChangesTest.php +++ b/Tests/Functional/Caching/FlushViaDataHandlerChangesTest.php @@ -26,13 +26,10 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Sinso\Variables\Hooks\DataHandler; use TYPO3\CMS\Core\Cache\CacheManager; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\DataHandling\DataHandler as Typo3DataHandler; -use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; #[CoversClass(DataHandler::class)] diff --git a/Tests/Unit/Domain/Model/MarkerCollectionTest.php b/Tests/Unit/Domain/Model/MarkerCollectionTest.php index 16da293..3701d1c 100644 --- a/Tests/Unit/Domain/Model/MarkerCollectionTest.php +++ b/Tests/Unit/Domain/Model/MarkerCollectionTest.php @@ -38,7 +38,7 @@ public function returnsType(): void { $subject = new MarkerCollection(); - self::assertSame('Sinso\Variables\Domain\Model\Marker', $subject->getType()); + self::assertSame(\Sinso\Variables\Domain\Model\Marker::class, $subject->getType()); } #[Test] diff --git a/composer.json b/composer.json index ee12552..4719a48 100644 --- a/composer.json +++ b/composer.json @@ -14,16 +14,17 @@ } }, "require": { - "php": ">= 8.1 < 8.5", + "php": ">= 8.2 < 8.6", "ramsey/collection": "^2.0", "typo3/cms-core": "^13.4" }, "require-dev": { - "rector/rector": "^0.12.23", + "rector/rector": "^2.6", "typo3/testing-framework": "^8.0", "symplify/easy-coding-standard": "^12.3", "phpstan/extension-installer": "^1.4", - "saschaegerer/phpstan-typo3": "^1.10" + "saschaegerer/phpstan-typo3": "^2.1", + "ssch/typo3-rector": "^3.16" }, "extra": { "typo3/cms": { diff --git a/ext_localconf.php b/ext_localconf.php index 100fbd1..8eadc45 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -1,6 +1,5 @@ , mixed given\.$#' + identifier: argument.type + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter \#1 \$fieldName of method TYPO3\\CMS\\Core\\Database\\Query\\Expression\\ExpressionBuilder\:\:gt\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter \#1 \$fieldName of method TYPO3\\CMS\\Core\\Database\\Query\\Expression\\ExpressionBuilder\:\:lte\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter \#1 \$identifier of method TYPO3\\CMS\\Core\\Database\\Query\\QueryBuilder\:\:quoteIdentifier\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 2 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter \#2 \$array of function implode expects array\, array given\.$#' + identifier: argument.type count: 1 path: Classes/Service/VariablesService.php - - message: "#^Unable to resolve the template type T in call to method static method TYPO3\\\\CMS\\\\Core\\\\Utility\\\\GeneralUtility\\:\\:makeInstance\\(\\)$#" + message: '#^Parameter \$key of class Sinso\\Variables\\Domain\\Model\\Marker constructor expects string, mixed given\.$#' + identifier: argument.type count: 1 path: Classes/Service/VariablesService.php + + - + message: '#^Parameter \$replacement of class Sinso\\Variables\\Domain\\Model\\Marker constructor expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter \$uid of class Sinso\\Variables\\Domain\\Model\\Marker constructor expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter &\$text by\-ref type of method Sinso\\Variables\\Service\\VariablesService\:\:replaceMarkersInText\(\) expects string, string\|null given\.$#' + identifier: parameterByRef.type + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Unable to resolve the template type T in call to static method TYPO3\\CMS\\Core\\Utility\\GeneralUtility\:\:makeInstance\(\)$#' + identifier: argument.templateType + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter \#1 \$params of method Sinso\\Variables\\Hooks\\DataHandler\:\:clearCachePostProc\(\) expects array\, array\ given\.$#' + identifier: argument.type + count: 1 + path: Tests/Functional/Caching/FlushViaDataHandlerChangesTest.php + + - + message: '#^Property TYPO3\\TestingFramework\\Core\\Functional\\FunctionalTestCase\:\:\$configurationToUseInTestInstance \(array\\) does not accept array\.$#' + identifier: assign.propertyType + count: 1 + path: Tests/Functional/Frontend/AbstractProcessesMarkersTestCase.php diff --git a/rector.php b/rector.php index f7e3f08..11f8007 100644 --- a/rector.php +++ b/rector.php @@ -3,105 +3,28 @@ declare(strict_types=1); use Rector\Config\RectorConfig; -use Rector\Core\Configuration\Option; -use Rector\Core\ValueObject\PhpVersion; -use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector; -use Ssch\TYPO3Rector\Configuration\Typo3Option; -use Ssch\TYPO3Rector\FileProcessor\Composer\Rector\ExtensionComposerRector; -use Ssch\TYPO3Rector\FileProcessor\TypoScript\Rector\ExtbasePersistenceTypoScriptRector; -use Ssch\TYPO3Rector\FileProcessor\TypoScript\Rector\FileIncludeToImportStatementTypoScriptRector; -use Ssch\TYPO3Rector\Rector\General\ConvertImplicitVariablesToExplicitGlobalsRector; -use Ssch\TYPO3Rector\Rector\General\ExtEmConfRector; -use Ssch\TYPO3Rector\Rector\v9\v0\InjectAnnotationRector; +use Rector\PHPUnit\Set\PHPUnitSetList; +use Rector\Set\ValueObject\LevelSetList; +use Rector\ValueObject\PhpVersion; use Ssch\TYPO3Rector\Set\Typo3LevelSetList; -return static function (RectorConfig $rectorConfig): void { - $rectorConfig->sets([ - Typo3LevelSetList::UP_TO_TYPO3_11, +/** + * If FQDNs are imported globally at a later date, it must be ensured that + * extended domain models of the EXT:news extension are excluded. To check + * which files this affects, you can look in the configuration of the PhpCsFixer. + * @see https://docs.typo3.org/p/georgringer/news/main/en-us/Tutorials/ExtendNews/ProxyClassGenerator/Index.html + */ +return RectorConfig::configure() + ->withPaths([ + getcwd() . '/Classes', + getcwd() . '/Configuration', + getcwd() . '/Tests', + getcwd() . '/ext_localconf.php', + ]) + ->withImportNames(false, true, false) + ->withPhpVersion(PhpVersion::PHP_82) + ->withSets([ + LevelSetList::UP_TO_PHP_82, + Typo3LevelSetList::UP_TO_TYPO3_13, + PHPUnitSetList::ANNOTATIONS_TO_ATTRIBUTES, ]); - - // In order to have a better analysis from phpstan we teach it here some more things - $rectorConfig->phpstanConfig(Typo3Option::PHPSTAN_FOR_RECTOR_PATH); - - // FQN classes are not imported by default. If you don't do it manually after every Rector run, enable it by: - $rectorConfig->importNames(); - - // this will not import root namespace classes, like \DateTime or \Exception - $parameters = $rectorConfig->parameters(); - $parameters->set(Option::IMPORT_SHORT_CLASSES, false); - - // this prevents infinite loop issues due to symlinks in e.g. ".Build/" folders within single extensions - $parameters->set(Option::FOLLOW_SYMLINKS, false); - - // Define your target version which you want to support - $rectorConfig->phpVersion(PhpVersion::PHP_73); - - // If you have an editorconfig and changed files should keep their format enable it here - // $parameters->set(Option::ENABLE_EDITORCONFIG, true); - - // If you only want to process one/some TYPO3 extension(s), you can specify its path(s) here. - // If you use the option --config change __DIR__ to getcwd() - $rectorConfig->paths([ - __DIR__, - ]); - - // If you use the option --config change __DIR__ to getcwd() - $rectorConfig->skip([ - __DIR__ . '/vendor/*', - __DIR__ . '/.Build/*', - ]); - - // This is used by the class \Ssch\TYPO3Rector\Rector\PostRector\FullQualifiedNamePostRector to force FQN in this paths and files - $parameters->set(Typo3Option::PATHS_FULL_QUALIFIED_NAMESPACES, [ - # If you are targeting TYPO3 Version 11 use can now use Short namespace - # @see namespace https://docs.typo3.org/m/typo3/reference-coreapi/master/en-us/ExtensionArchitecture/ConfigurationFiles/Index.html - 'ext_localconf.php', - 'ext_tables.php', - __DIR__ . '/**/Configuration/*.php', - __DIR__ . '/**/Configuration/**/*.php', - ]); - - // If you have trouble that rector cannot run because some TYPO3 constants are not defined add an additional constants file - // @see https://github.com/sabbelasichon/typo3-rector/blob/master/typo3.constants.php - // @see https://github.com/rectorphp/rector/blob/main/docs/static_reflection_and_autoload.md#include-files - // $parameters->set(Option::BOOTSTRAP_FILES, [ - // __DIR__ . '/typo3.constants.php' - // ]); - - // register a single rule - // $rectorConfig->rule(InjectAnnotationRector::class); - - /** - * Useful rule from RectorPHP itself to transform i.e. GeneralUtility::makeInstance('TYPO3\CMS\Core\Log\LogManager') - * to GeneralUtility::makeInstance(\TYPO3\CMS\Core\Log\LogManager::class) calls. - * But be warned, sometimes it produces false positives (edge cases), so watch out - */ - // $rectorConfig->rule(StringClassNameToClassConstantRector::class); - - // Optional non-php file functionalities: - // @see https://github.com/sabbelasichon/typo3-rector/blob/main/docs/beyond_php_file_processors.md - - // Adapt your composer.json dependencies to the latest available version for the defined SetList - // $rectorConfig->sets([ - // Typo3SetList::COMPOSER_PACKAGES_104_CORE - // Typo3SetList::COMPOSER_PACKAGES_104_EXTENSIONS, - // ); - - // Rewrite your extbase persistence class mapping from typoscript into php according to official docs. - // This processor will create a summarized file with all of the typoscript rewrites combined into a single file. - // The filename can be passed as argument, "Configuration_Extbase_Persistence_Classes.php" is default. - $rectorConfig->ruleWithConfiguration(ExtbasePersistenceTypoScriptRector::class, [ - ExtbasePersistenceTypoScriptRector::FILENAME => 'Configuration_Extbase_Persistence_Classes.php', - ]); - // Add some general TYPO3 rules - $rectorConfig->rule(ConvertImplicitVariablesToExplicitGlobalsRector::class); - $rectorConfig->ruleWithConfiguration(ExtEmConfRector::class, [ - ExtEmConfRector::ADDITIONAL_VALUES_TO_BE_REMOVED => [], - ]); - $rectorConfig->ruleWithConfiguration(ExtensionComposerRector::class, [ - ExtensionComposerRector::TYPO3_VERSION_CONSTRAINT => '^10.4 || ^11.5', - ]); - - // Do you want to modernize your TypoScript include statements for files and move from to @import use the FileIncludeToImportStatementVisitor - // $rectorConfig->rule(FileIncludeToImportStatementTypoScriptRector::class); -}; From 8c0e8cc1fe04f6ec1c122ba94080474acb224b7b Mon Sep 17 00:00:00 2001 From: Christian Wolfram Date: Tue, 22 Sep 2026 13:06:10 +0200 Subject: [PATCH 8/8] [BUGFIX] Escape slashes in marker replacement (#5) Markers whose key contains a slash were never replaced in the frontend output. The regular expression that searches for markers is built with a slash as delimiter, but the marker keys were escaped without telling the escaping function about that delimiter. A key such as "foo/bar" therefore ended the pattern early, and the resulting expression either failed or matched the wrong text. Editors saw the raw marker instead of its value. The marker keys are now escaped with the slash delimiter in mind, so every character of a key is treated as literal text. Markers containing slashes, dots or other characters with a special meaning in regular expressions are replaced like any other marker. --- Classes/Service/VariablesService.php | 55 +++++++++------ Tests/Unit/Service/VariablesServiceTest.php | 74 +++++++++++++++++++++ 2 files changed, 107 insertions(+), 22 deletions(-) create mode 100644 Tests/Unit/Service/VariablesServiceTest.php diff --git a/Classes/Service/VariablesService.php b/Classes/Service/VariablesService.php index a9aa907..91ce70a 100644 --- a/Classes/Service/VariablesService.php +++ b/Classes/Service/VariablesService.php @@ -87,30 +87,31 @@ private function replaceMarkersInText( MarkerCollection $markerCollection, string &$text ): void { - $markerRegexp = '/(' . implode('|', array_map(preg_quote(...), $markerCollection->getMarkerKeys())) . ')/'; - - $loops = 0; - - while (preg_match($markerRegexp, $text) && $loops++ < self::MAXIMUM_LOOP_COUNT) { - foreach ($markerCollection as $marker) { - $newContent = str_replace( - $marker->getMarkerWithBrackets(), - $marker->replacement, - $text - ); + if ($markerCollection->isEmpty() === false) { + $markerRegexp = $this->buildMarkerRegExp($markerCollection); + $loops = 0; + + while (preg_match($markerRegexp, $text) && $loops++ < self::MAXIMUM_LOOP_COUNT) { + foreach ($markerCollection as $marker) { + $newContent = str_replace( + $marker->getMarkerWithBrackets(), + $marker->replacement, + $text + ); + + if ($newContent === $text) { + continue; + } - if ($newContent === $text) { - continue; + // Assign a cache key associated with the marker + $this->cacheTags->add( + CacheKeyUtility::getCacheKey( + $marker->getMarkerWithBrackets() + ) + ); + $this->usedMarkerKeys[] = $marker->key; + $text = $newContent; } - - // Assign a cache key associated with the marker - $this->cacheTags->add( - CacheKeyUtility::getCacheKey( - $marker->getMarkerWithBrackets() - ) - ); - $this->usedMarkerKeys[] = $marker->key; - $text = $newContent; } } @@ -169,6 +170,16 @@ private function getMarkers( return $markers; } + private function buildMarkerRegExp(MarkerCollection $markerCollection): string + { + $quotedMarkerKeys = []; + foreach ($markerCollection->getMarkerKeys() as $markerKey) { + $quotedMarkerKeys[] = preg_quote($markerKey, '/'); + } + + return '/(' . implode('|', $quotedMarkerKeys) . ')/'; + } + private function setCacheTags( ServerRequestInterface $request, ): void { diff --git a/Tests/Unit/Service/VariablesServiceTest.php b/Tests/Unit/Service/VariablesServiceTest.php new file mode 100644 index 0000000..85b29e8 --- /dev/null +++ b/Tests/Unit/Service/VariablesServiceTest.php @@ -0,0 +1,74 @@ + + */ + public static function markerKeyDataProvider(): iterable + { + yield 'plain key' => ['key', 'replacement', 'before {{key}} after', 'before replacement after']; + yield 'key containing a slash' => ['foo/bar', 'replacement', 'before {{foo/bar}} after', 'before replacement after']; + yield 'key containing a regular expression special character' => ['foo.bar', 'replacement', 'before {{foo.bar}} after', 'before replacement after']; + } + + #[Test] + #[DataProvider('markerKeyDataProvider')] + public function replacesMarkerRegardlessOfSpecialCharactersInKey( + string $markerKey, + string $replacement, + string $text, + string $expected, + ): void { + $markerCollection = new MarkerCollection(); + $markerCollection->add(new Marker(1, $markerKey, $replacement)); + + $this->replaceMarkersInStructure($markerCollection, $text); + + self::assertSame($expected, $text); + } + + private function replaceMarkersInStructure(MarkerCollection $markerCollection, mixed &$structure): void + { + $extensionConfiguration = $this->createMock(ExtensionConfiguration::class); + $extensionConfiguration->method('get')->willReturn(false); + + $subject = new VariablesService( + $extensionConfiguration, + $this->createMock(Context::class), + $this->createMock(ConnectionPool::class), + ); + + $reflectionMethod = new \ReflectionMethod($subject, 'replaceMarkersInStructure'); + $reflectionMethod->invokeArgs($subject, [$markerCollection, &$structure]); + } +}