diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7ee277f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Exclude from release archives +/.github export-ignore +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +Makefile export-ignore +phpstan.neon export-ignore +phpstan-baseline.neon export-ignore +phpunit.xml export-ignore +rector.php export-ignore +grumphp.yml export-ignore +fractor.php export-ignore diff --git a/.github/workflows/tasks.yml b/.github/workflows/tasks.yml new file mode 100644 index 0000000..0bf9e1c --- /dev/null +++ b/.github/workflows/tasks.yml @@ -0,0 +1,87 @@ +name: PHP Composer + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + build: + name: "php: ${{ matrix.php }} TYPO3: ${{ matrix.typo3 }}" + runs-on: ubuntu-latest + strategy: + fail-fast: true + max-parallel: 4 + matrix: + php: [ '82', '83', '84', '85' ] + typo3: [ '14' ] + outputs: + result: ${{ steps.set-result.outputs.result }} + php: ${{ matrix.php }} + typo3: ${{ matrix.typo3 }} + container: + image: ghcr.io/typo3/core-testing-php${{ matrix.php }}:latest + steps: + - name: Install dependencies (Alpine) + run: apk add --no-cache nodejs npm gnupg + - uses: actions/checkout@v4 + - name: Get composer project name + id: composer-name + run: echo "name=$(php -r "echo str_replace('/', '-', json_decode(file_get_contents('composer.json'))->name);")" >> $GITHUB_OUTPUT + - uses: actions/cache@v4 + with: + path: | + ~/.composer/cache/files + vendor + node_modules + key: ${{ matrix.typo3 }}-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + restore-keys: | + ${{ matrix.typo3 }}-${{ matrix.php }}-composer- + - run: git config --global --add safe.directory $GITHUB_WORKSPACE + - run: composer switchto${{ matrix.typo3 }} --ignore-platform-req=php+ + - run: ./vendor/bin/grumphp run --ansi --no-interaction + - run: composer test + - name: Generate coverage report + if: matrix.php == '83' && matrix.typo3 == '14' + run: ./vendor/bin/phpunit --coverage-clover coverage.xml + env: + XDEBUG_MODE: coverage + - name: Upload coverage to Codecov + if: matrix.php == '83' && matrix.typo3 == '14' + uses: codecov/codecov-action@v5.5.3 + continue-on-error: true + with: + files: coverage.xml + fail_ci_if_error: false + verbose: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + - name: Save result + if: always() + run: | + mkdir -p summary + echo "PHP=${{ matrix.php }} TYPO3=${{ matrix.typo3 }} RESULT=${{ job.status }}" \ + >> summary/results.txt + - uses: actions/upload-artifact@v4 + if: always() + with: + name: ${{ steps.composer-name.outputs.name }}-${{ matrix.typo3 }}-${{ matrix.php }} + path: summary/results.txt + summary: + runs-on: ubuntu-latest + needs: build + if: always() + steps: + - uses: actions/checkout@v4 + - name: Get composer project name + id: composer-name + run: echo "name=$(python3 -c "import json; d=json.load(open('composer.json')); print(d['name'].replace('/', '-'))")" >> $GITHUB_OUTPUT + - uses: actions/download-artifact@v4 + with: + path: summary + pattern: ${{ steps.composer-name.outputs.name }}-* + merge-multiple: false + - name: Show results + run: | + echo "### Matrix results" + find summary -name results.txt | sort | xargs cat diff --git a/.gitignore b/.gitignore index 055e47e..7d7af69 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ var vendor .phpunit.result.cache .idea/ +composer.lock diff --git a/Classes/Cache/Backend/LuceneCacheBackend.php b/Classes/Cache/Backend/LuceneCacheBackend.php index 78e5f8a..27528ca 100644 --- a/Classes/Cache/Backend/LuceneCacheBackend.php +++ b/Classes/Cache/Backend/LuceneCacheBackend.php @@ -30,18 +30,26 @@ use Zend_Search_Lucene_Search_QueryParser; use Zend_Search_Lucene_Search_QueryParserException; -use function trigger_deprecation; - class LuceneCacheBackend extends SimpleFileBackend implements TaggableBackendInterface { - protected Zend_Search_Lucene_Interface $index; - protected string $directory; protected bool $compression = false; protected int $compressionLevel = -1; + /** + * Compression algorithm: 'zstd', 'gzdeflate', 'gzcompress' + * Will auto-detect best available if not set. + */ + protected string $compressionAlgorithm = ''; + + /** + * Minimum data size in bytes to apply compression. + * Compressing very small data adds overhead without benefit. + */ + protected int $compressionMinSize = 256; + protected int $maxBufferedDocs = 1000; /** @var array */ @@ -55,26 +63,22 @@ class LuceneCacheBackend extends SimpleFileBackend implements TaggableBackendInt * @param array $options * @throws AspectNotFoundException */ - public function __construct(string $context, array $options = []) + public function __construct(array $options = []) { - parent::__construct($context, $options); + parent::__construct($options); Zend_Search_Lucene::setTermsPerQueryLimit(PHP_INT_MAX); - Zend_Search_Lucene_Analysis_Analyzer::setDefault(new SingleSpaceTokenizer()); $this->execTime = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); register_shutdown_function([$this, 'shutdown']); } /** * @inheritdoc + * @param array $tags * @throws Exception */ - public function set($entryIdentifier, $data, array $tags = [], $lifetime = null): void + public function set(string $entryIdentifier, string $data, array $tags = [], ?int $lifetime = null): void { - if (!is_string($data)) { - throw new Exception('lucene-cache only accepts string'); - } - // Lifetime of this cache entry in seconds. If NULL is specified, the default lifetime is used. "0" means unlimited lifetime. if ($lifetime === null) { $lifetime = $this->defaultLifetime; @@ -109,53 +113,49 @@ public function set($entryIdentifier, $data, array $tags = [], $lifetime = null) */ protected function commit(): void { - if (!$this->buffer) { + if ($this->buffer === []) { return; } - $identifiers = array_keys($this->buffer); - $index = $this->getIndex(); - $maxBufferedDocs = $index->getMaxBufferedDocs(); - $index->setMaxBufferedDocs(count($identifiers) + 10); - - // delete the current entry, lucene cant replace - foreach ($identifiers as $identifier) { - $query = new Zend_Search_Lucene_Search_Query_Term( - new Zend_Search_Lucene_Index_Term($identifier, 'identifier') - ); - $hits = $index->find($query); - foreach ($hits as $hit) { - $index->delete($hit->id); - } - } - - foreach ($this->buffer as $entryIdentifier => $item) { - $data = $item['content']; - assert(is_string($data)); - $expires = $item['lifetime']; - $tags = implode(' ', $item['tags']); - - if ($this->compression) { - /** @noinspection PhpComposerExtensionStubsInspection */ - $data = gzcompress($data, $this->compressionLevel); - if (false === $data) { - throw new RuntimeException('Could not compress data'); + $this->withAnalyzer(function (): void { + $identifiers = array_keys($this->buffer); + $index = $this->getIndex(); + $maxBufferedDocs = $index->getMaxBufferedDocs(); + $index->setMaxBufferedDocs(count($identifiers) + 10); + + // delete the current entry, lucene cant replace + foreach ($identifiers as $identifier) { + $query = new Zend_Search_Lucene_Search_Query_Term( + new Zend_Search_Lucene_Index_Term($identifier, 'identifier') + ); + $hits = $index->find($query); + foreach ($hits as $hit) { + $index->delete($hit->id); } } - $doc = new Document(); - $doc->addField(Field::keyword('identifier', $entryIdentifier)); - $doc->addField(Field::binary('content', $data)); - $doc->addField(Field::unStored('tags', $tags)); - $doc->addField(Field::keyword('lifetime', $expires)); - $index->addDocument($doc); - } + foreach ($this->buffer as $entryIdentifier => $item) { + $data = $item['content']; + assert(is_string($data)); + $expires = $item['lifetime']; + $tags = implode(' ', $item['tags']); - $index->commit(); + $data = $this->compress($data); - $index->setMaxBufferedDocs($maxBufferedDocs); + $doc = new Document(); + $doc->addField(Field::keyword('identifier', $entryIdentifier)); + $doc->addField(Field::binary('content', $data)); + $doc->addField(Field::unStored('tags', $tags)); + $doc->addField(Field::keyword('lifetime', $expires)); + $index->addDocument($doc); + } - $this->buffer = []; + $index->commit(); + + $index->setMaxBufferedDocs($maxBufferedDocs); + + $this->buffer = []; + }); } /** @@ -181,6 +181,24 @@ private function getIndex(): Zend_Search_Lucene_Proxy return $proxy; } + /** + * Execute a callback with the SingleSpaceTokenizer, restoring the previous analyzer afterwards. + * + * @template T + * @param callable(): T $callback + * @return T + */ + private function withAnalyzer(callable $callback): mixed + { + $previousAnalyzer = Zend_Search_Lucene_Analysis_Analyzer::getDefault(); + Zend_Search_Lucene_Analysis_Analyzer::setDefault(new SingleSpaceTokenizer()); + try { + return $callback(); + } finally { + Zend_Search_Lucene_Analysis_Analyzer::setDefault($previousAnalyzer); + } + } + public function setMaxBufferedDocs(int $maxBufferedDocs): void { $this->maxBufferedDocs = abs($maxBufferedDocs); @@ -191,7 +209,7 @@ public function setMaxBufferedDocs(int $maxBufferedDocs): void * @throws Zend_Search_Lucene_Exception * @throws Exception */ - public function get($entryIdentifier): mixed + public function get(string $entryIdentifier): false|string { if (isset($this->buffer[$entryIdentifier]) && $this->buffer[$entryIdentifier]['lifetime'] > $this->execTime) { return $this->buffer[$entryIdentifier]['content']; @@ -211,12 +229,7 @@ public function get($entryIdentifier): mixed return $data; } - if ($this->compression) { - /** @noinspection PhpComposerExtensionStubsInspection */ - return gzuncompress($data); - } - - return $data; + return $this->decompress($data); } /** @@ -224,7 +237,7 @@ public function get($entryIdentifier): mixed * @throws Zend_Search_Exception * @throws Zend_Search_Lucene_Exception */ - public function has($entryIdentifier): bool + public function has(string $entryIdentifier): bool { if (isset($this->buffer[$entryIdentifier]) && $this->buffer[$entryIdentifier]['lifetime'] > $this->execTime) { return true; @@ -244,7 +257,7 @@ public function has($entryIdentifier): bool * @throws Zend_Search_Exception * @throws Zend_Search_Lucene_Exception */ - public function remove($entryIdentifier): bool + public function remove(string $entryIdentifier): bool { if (isset($this->buffer[$entryIdentifier])) { unset($this->buffer[$entryIdentifier]); @@ -281,6 +294,8 @@ public function flush(): void foreach ($hits as $hit) { $index->delete($hit->id); } + + $index->commit(); } /** @@ -289,21 +304,23 @@ public function flush(): void * @throws Zend_Search_Lucene_Exception * @throws Zend_Search_Lucene_Search_QueryParserException */ - public function flushByTag($tag): void + public function flushByTag(string $tag): void { $this->commit(); - $query = new Zend_Search_Lucene_Search_Query_Term( - new Zend_Search_Lucene_Index_Term($tag, 'tags') - ); + $this->withAnalyzer(function () use ($tag): void { + $query = new Zend_Search_Lucene_Search_Query_Term( + new Zend_Search_Lucene_Index_Term($tag, 'tags') + ); - $index = $this->getIndex(); - $hits = $index->find($query); + $index = $this->getIndex(); + $hits = $index->find($query); - foreach ($hits as $hit) { - $index->delete($hit); - } + foreach ($hits as $hit) { + $index->delete($hit); + } - $index->commit(); + $index->commit(); + }); } /** @@ -315,43 +332,46 @@ public function flushByTag($tag): void public function flushByTags(array $tags): void { $this->commit(); - $escapedTags = array_map(static fn($tag): string => '"' . addslashes($tag) . '"', $tags); - $queryStr = 'tags:(' . implode(' OR ', $escapedTags) . ')'; + $this->withAnalyzer(function () use ($tags): void { + $escapedTags = array_map(static fn(string $tag): string => '"' . addslashes($tag) . '"', $tags); + $queryStr = 'tags:(' . implode(' OR ', $escapedTags) . ')'; - $query = Zend_Search_Lucene_Search_QueryParser::parse($queryStr); - $index = $this->getIndex(); - $hits = $index->find($query); + $query = Zend_Search_Lucene_Search_QueryParser::parse($queryStr); + $index = $this->getIndex(); + $hits = $index->find($query); - foreach ($hits as $hit) { - $index->delete($hit); - } + foreach ($hits as $hit) { + $index->delete($hit); + } - $index->commit(); + $index->commit(); + }); } /** * @inheritdoc - * @param string $tag * @return array * @throws Zend_Search_Exception */ - public function findIdentifiersByTag($tag): array + public function findIdentifiersByTag(string $tag): array { $this->commit(); - $query = new Zend_Search_Lucene_Search_Query_Term( - new Zend_Search_Lucene_Index_Term($tag, 'tags') - ); + return $this->withAnalyzer(function () use ($tag): array { + $query = new Zend_Search_Lucene_Search_Query_Term( + new Zend_Search_Lucene_Index_Term($tag, 'tags') + ); - $index = $this->getIndex(); - $hits = $index->find($query); - $hits = $this->filterByLifetime($index, $hits); + $index = $this->getIndex(); + $hits = $index->find($query); + $hits = $this->filterByLifetime($index, $hits); - $identifiers = []; - foreach ($hits as $hit) { - $identifiers[] = $hit->getDocument()->getFieldValue('identifier'); - } + $identifiers = []; + foreach ($hits as $hit) { + $identifiers[] = $hit->getDocument()->getFieldValue('identifier'); + } - return $identifiers; + return $identifiers; + }); } /** @@ -381,17 +401,129 @@ public function collectGarbage(): void public function setCompression(bool $compression): void { - if ($compression && !function_exists('gzcompress')) { - throw new RuntimeException('Compression is not supported'); + $this->compression = $compression; + + if ($compression && $this->compressionAlgorithm === '') { + // Auto-detect best available algorithm + $this->compressionAlgorithm = $this->detectBestCompressionAlgorithm(); + } + } + + /** + * Set the compression algorithm: 'zstd', 'gzdeflate', 'gzcompress' + */ + public function setCompressionAlgorithm(string $algorithm): void + { + $supported = ['zstd', 'gzdeflate', 'gzcompress']; + if (!in_array($algorithm, $supported, true)) { + throw new RuntimeException(sprintf( + 'Unsupported compression algorithm "%s". Supported: %s', + $algorithm, + implode(', ', $supported) + ), 3064094861); } - $this->compression = $compression; + if ($algorithm === 'zstd' && !function_exists('zstd_compress')) { + throw new RuntimeException('zstd compression requires ext-zstd', 1888340496); + } + + $this->compressionAlgorithm = $algorithm; + } + + /** + * Set minimum data size for compression (in bytes). + * Data smaller than this will not be compressed. + */ + public function setCompressionMinSize(int $minSize): void + { + $this->compressionMinSize = max(0, $minSize); + } + + /** + * Detect the best available compression algorithm. + */ + private function detectBestCompressionAlgorithm(): string + { + // zstd is fastest with excellent ratio + if (function_exists('zstd_compress')) { + return 'zstd'; + } + + // gzdeflate is slightly faster than gzcompress (no header) + if (function_exists('gzdeflate')) { + return 'gzdeflate'; + } + + if (function_exists('gzcompress')) { + return 'gzcompress'; + } + + throw new RuntimeException('No compression algorithm available', 6634054114); + } + + /** + * Compress data using the configured algorithm. + * Returns original data if compression is disabled or data is too small. + */ + private function compress(string $data): string + { + if (!$this->compression) { + return $data; + } + + // Skip compression for small data - overhead not worth it + if (strlen($data) < $this->compressionMinSize) { + return $data; + } + + $compressed = match ($this->compressionAlgorithm) { + 'zstd' => zstd_compress($data, $this->compressionLevel > 0 ? $this->compressionLevel : 3), + 'gzdeflate' => gzdeflate($data, $this->compressionLevel), + 'gzcompress' => gzcompress($data, $this->compressionLevel), + default => throw new RuntimeException('No compression algorithm configured', 6585967389), + }; + + if ($compressed === false) { + throw new RuntimeException('Compression failed', 3000545271); + } + + // Prefix with algorithm identifier for decompression + return match ($this->compressionAlgorithm) { + 'zstd' => "\x00Z" . $compressed, + 'gzdeflate' => "\x00D" . $compressed, + 'gzcompress' => "\x00C" . $compressed, + default => $compressed, + }; } - public function setOptimize(bool $optimize): void + /** + * Decompress data, auto-detecting the algorithm from the prefix. + */ + private function decompress(string $data): string|false { - trigger_deprecation('weakbit/lucene-cache', '2.0.3', 'Optimization flag is deprecated, remove it from your cache configuration'); - $this->optimize = $optimize; + // Check for compression prefix + if (strlen($data) < 2 || $data[0] !== "\x00") { + // No prefix - check if it looks like legacy gzcompress data + // gzcompress starts with 0x78 (zlib header) + if ($data !== '' && ord($data[0]) === 0x78) { + $result = @gzuncompress($data); + return $result !== false ? $result : $data; + } + + return $data; + } + + $algorithm = $data[1]; + $compressedData = substr($data, 2); + + return match ($algorithm) { + 'Z' => function_exists('zstd_uncompress') + ? zstd_uncompress($compressedData) + : throw new RuntimeException('zstd decompression requires ext-zstd', 7341513500), + 'D' => gzinflate($compressedData), + 'C' => gzuncompress($compressedData), + default => $data, // Unknown format, return as-is + }; } /** diff --git a/Classes/Cache/Frontend/VariableFrontend.php b/Classes/Cache/Frontend/VariableFrontend.php deleted file mode 100644 index 3eacdfb..0000000 --- a/Classes/Cache/Frontend/VariableFrontend.php +++ /dev/null @@ -1,18 +0,0 @@ -setDescription('Optimize Lucene cache indices that are flagged for optimization'); - $this->setHelp('This command checks all Lucene cache backends for optimization flags and optimizes them if needed.'); - } - /** * @throws NoSuchCacheException * @throws Zend_Search_Exception @@ -37,7 +34,6 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $cacheManager = GeneralUtility::makeInstance(CacheManager::class); - assert($cacheManager instanceof CacheManager); $optimizedCaches = 0; $totalCaches = 0; diff --git a/Classes/Event/CacheOptimizationRequestedEvent.php b/Classes/Event/CacheOptimizationRequestedEvent.php index 662811b..62e4cab 100644 --- a/Classes/Event/CacheOptimizationRequestedEvent.php +++ b/Classes/Event/CacheOptimizationRequestedEvent.php @@ -4,8 +4,6 @@ namespace Weakbit\LuceneCache\Event; -use Weakbit\LuceneCache\Cache\Backend\LuceneCacheBackend; - /** * Event dispatched when a Lucene cache backend needs optimization */ diff --git a/Classes/EventListener/AbstractExtensionConfigListener.php b/Classes/EventListener/AbstractExtensionConfigListener.php index 3c9208d..3b8fa1a 100644 --- a/Classes/EventListener/AbstractExtensionConfigListener.php +++ b/Classes/EventListener/AbstractExtensionConfigListener.php @@ -6,18 +6,20 @@ use Exception; use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; -use TYPO3\CMS\Core\Utility\GeneralUtility; abstract class AbstractExtensionConfigListener { + public function __construct(private readonly ExtensionConfiguration $extensionConfiguration) + { + } + /** * @return array */ protected function getExtensionConfiguration(): array { try { - $extensionConfiguration = GeneralUtility::makeInstance(ExtensionConfiguration::class); - return $extensionConfiguration->get('lucene_cache') ?? []; + return $this->extensionConfiguration->get('lucene_cache') ?? []; } catch (Exception) { return []; } diff --git a/Classes/EventListener/EnableOptimizationListener.php b/Classes/EventListener/EnableOptimizationListener.php index fb88968..3308bca 100644 --- a/Classes/EventListener/EnableOptimizationListener.php +++ b/Classes/EventListener/EnableOptimizationListener.php @@ -4,11 +4,13 @@ namespace Weakbit\LuceneCache\EventListener; +use TYPO3\CMS\Core\Attribute\AsEventListener; use Weakbit\LuceneCache\Event\CacheOptimizationRequestedEvent; /** * Listens for CacheOptimizationRequestedEvent and disables optimization if not enabled in config */ +#[AsEventListener('lucene-cache-enable-optimization')] class EnableOptimizationListener extends AbstractExtensionConfigListener { public function __invoke(CacheOptimizationRequestedEvent $event): void diff --git a/Classes/EventListener/SystemLoadOptimizationListener.php b/Classes/EventListener/SystemLoadOptimizationListener.php index 569e3ce..c9de251 100644 --- a/Classes/EventListener/SystemLoadOptimizationListener.php +++ b/Classes/EventListener/SystemLoadOptimizationListener.php @@ -4,11 +4,13 @@ namespace Weakbit\LuceneCache\EventListener; +use TYPO3\CMS\Core\Attribute\AsEventListener; use Weakbit\LuceneCache\Event\CacheOptimizationRequestedEvent; /** * Listens for CacheOptimizationRequestedEvent and disables optimization if system load is too high */ +#[AsEventListener('lucene-cache-systemload-optimization')] class SystemLoadOptimizationListener extends AbstractExtensionConfigListener { public function __invoke(CacheOptimizationRequestedEvent $event): void @@ -40,7 +42,7 @@ private function getSystemLoad(): ?array } $load = sys_getloadavg(); - if ($load === false || !is_array($load) || count($load) < 3) { + if ($load === false) { return null; } diff --git a/Classes/Tokenizer/SingleSpaceTokenizer.php b/Classes/Tokenizer/SingleSpaceTokenizer.php index 2fdbb49..5d018e7 100644 --- a/Classes/Tokenizer/SingleSpaceTokenizer.php +++ b/Classes/Tokenizer/SingleSpaceTokenizer.php @@ -26,7 +26,7 @@ public function reset(): void foreach ($terms as $position => $term) { $term = trim($term); - if ($term != '') { + if ($term !== '') { $this->tokens[] = new Zend_Search_Lucene_Analysis_Token( $term, $position, diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index eac6949..f2dc27a 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -4,25 +4,5 @@ services: autoconfigure: true public: false - Weakbit\LuceneCache\Cache\Backend\LuceneCacheBackend: - tags: - - name: 'cache.backend' - identifier: 'lucene_cache_backend' - - Weakbit\LuceneCache\Command\OptimizeLuceneCacheCommand: - tags: - - name: 'console.command' - command: 'lucene:cache:optimize' - description: 'Optimize Lucene cache indices' - - Weakbit\LuceneCache\EventListener\EnableOptimizationListener: - tags: - - name: event.listener - identifier: 'lucene-cache-enable-optimization' - event: Weakbit\LuceneCache\Event\CacheOptimizationRequestedEvent - - Weakbit\LuceneCache\EventListener\SystemLoadOptimizationListener: - tags: - - name: event.listener - identifier: 'lucene-cache-systemload-optimization' - event: Weakbit\LuceneCache\Event\CacheOptimizationRequestedEvent + Weakbit\LuceneCache\: + resource: '../Classes/*' diff --git a/Dockerfile.83 b/Dockerfile.83 deleted file mode 100644 index 8c758b8..0000000 --- a/Dockerfile.83 +++ /dev/null @@ -1,6 +0,0 @@ -FROM pluswerk/php-dev:nginx-8.3-alpine as base - -ADD . /app -WORKDIR /app - - diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9ea025a --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +ACT_IMAGE ?= efrecon/act:v0.2.80 +WORKDIR ?= /work +UID_GID := $(shell id -u):$(shell id -g) +DOCKER_GID := $(shell stat -c '%g' /var/run/docker.sock) + +PLATFORM ?= -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-24.04 + +# Secrets-File in ENV format +SECRETS ?= --secret-file .secrets + +ACT_ARGS ?= \ + --artifact-server-path /home/act/.cache/artifacts + +define DOCKER_RUN +docker run --rm -it \ + -u $(UID_GID) \ + --group-add $(DOCKER_GID) \ + -e HOME=/home/act \ + -e XDG_CACHE_HOME=/home/act/.cache \ + -e ACT_CACHE_DIR=/home/act/.cache/actcache \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v $(PWD):$(WORKDIR) -w $(WORKDIR) \ + -v $$HOME/.cache:/home/act/.cache \ + $(ACT_IMAGE) +endef + +# ---- Targets ---- +.PHONY: all ci clean + +all: ci clean + +ci: ## Standard-Event "push" + $(DOCKER_RUN) $(PLATFORM) $(SECRETS) $(ACT_ARGS) + + +clean: + docker rm -f $$(docker ps -aq --filter "name=act-") 2>/dev/null || true + diff --git a/README.md b/README.md index f02d911..7e4f302 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ # Use Lucene as Cache Backend for your TYPO3 projects + +[![CI](https://github.com/andersundsehr/lucene-cache/actions/workflows/tasks.yml/badge.svg)](https://github.com/andersundsehr/lucene-cache/actions/workflows/tasks.yml) +[![codecov](https://codecov.io/gh/andersundsehr/lucene-cache/graph/badge.svg)](https://codecov.io/gh/andersundsehr/lucene-cache) + Provides a cache backend for TYPO3 that stores all cache information in Lucene index. ## Key Features of lucene-cache for TYPO3 @@ -25,8 +29,9 @@ $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['pages'] = 'options' => [ 'defaultLifetime' => 604800, 'maxBufferedDocs' => 1000, - 'optimize' => false, - 'compress' => true, + 'compression' => true, + 'compressionAlgorithm' => 'zstd', // optional: auto-detects if not set + 'compressionMinSize' => 256, // optional: skip compression for small data ], 'groups' => [ 'pages', @@ -38,38 +43,45 @@ The Option "indexName" must not contain other than the following chars: *a-zA-Z0 ### Performance -The issue to develop that cache was a usage of very many cache Tags. +The issue to develop that cache was a usage of very many cache tags. -maxBufferedDocs is set to 1000 here, that means that up to 1000 documents are buffered before the writeout, that is good for large imports if you have some spare ram. Meanwhite that caching information is not available to other PHP processes. +maxBufferedDocs is set to 1000 here, that means that up to 1000 documents are buffered before to write, that is good for large imports if you have some spare ram. Meanwhile, that caching information is not available to other PHP processes. Keep in mind that some operations (flushes and garbage collection) will always commit the buffer first to have a full index to search in. +### compression -#### optimize - -The optimize setting sounds like a always good idea, but it is a very ressource intensive job so it is disabled by default. - -### compress +Enable compression to reduce disk space usage. The extension supports multiple compression algorithms and will auto-detect the best available one. -Filters the data through gzcompress (must be compiled into your PHP installation) to reduce data - -### Keep in mind +```php +'options' => [ + 'compression' => true, + 'compressionAlgorithm' => 'zstd', // 'zstd', 'gzdeflate', or 'gzcompress' + 'compressionLevel' => 3, // -1 to 9 (default: -1 = auto) + 'compressionMinSize' => 256, // minimum bytes to compress (default: 256) +], +``` -This extenion relies on using the SingleSpaceTokenizer with the lucene package, so if you already use lucene in your project, your tokenizer is overwritten which could lead into problems. -*This is a todo we work on* +**Available algorithms (in order of preference):** -Use the proper cache technology for your needs, lucene may fit well for a page cache with lots of entries and tags, while others with more write/read operations go better with database/redis or even apcu if you do not have concurrency problems. +| Algorithm | Speed | Ratio | Requirement | +|--------------|--------|-----------|-------------| +| `zstd` | ⚡ Fast | Excellent | ext-zstd | +| `gzdeflate` | Medium | Good | ext-zlib | +| `gzcompress` | Medium | Good | ext-zlib | -# Additional Resources +- **Auto-detection**: If `compressionAlgorithm` is not set, the best available algorithm is used automatically. +- **Minimum size**: Data smaller than `compressionMinSize` bytes is not compressed (overhead not worth it). +- **Backward compatible**: Existing caches using legacy `gzcompress` will still decompress correctly. For more detailed information, refer to the following resources: [Lucene-cache GitHub Repository](https://github.com/andersundsehr/lucene-cache) -[TYPO3 Documentation on Caching Framework](https://docs.typo3.org/m/typo3/reference-coreapi/12.4/en-us/ApiOverview/CachingFramework/Index.html****) +[TYPO3 Documentation on Caching Framework](https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/ApiOverview/CachingFramework/Index.html#caching-framework) These resources provide comprehensive documentation and examples to help you get started with the lucene-cache backend for TYPO3. # Credits -This extension is inspired by Benni Mack's [https://github.com/bmack/local-caches](bmack/local-caches) +This extension is inspired by Benni Mack's [bmack/local-caches](https://github.com/bmack/local-caches) diff --git a/Tests/Unit/Cache/Backend/LuceneCacheBackendTest.php b/Tests/Unit/Cache/Backend/LuceneCacheBackendTest.php index e987d86..97f186c 100644 --- a/Tests/Unit/Cache/Backend/LuceneCacheBackendTest.php +++ b/Tests/Unit/Cache/Backend/LuceneCacheBackendTest.php @@ -4,6 +4,9 @@ namespace Weakbit\LuceneCache\Tests\Unit\Cache\Backend; +use stdClass; +use RuntimeException; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; use ReflectionException; use TYPO3\CMS\Core\Cache\CacheManager; @@ -68,12 +71,6 @@ protected function setUp(): void $this->subject = $luceneCacheBackend; } - protected function tearDown(): void - { - unset($this->subject); - parent::tearDown(); - } - /** * Test if the backend can set and retrieve cache entries. * @@ -87,7 +84,7 @@ public function testSetAndGetCacheEntries(): void $lifetime = 3600; $this->subject->set($entryIdentifier, $data, $tags, $lifetime); - static::assertSame($data, $this->subject->get($entryIdentifier)); + self::assertSame($data, $this->subject->get($entryIdentifier)); } /** @@ -101,10 +98,10 @@ public function testRemoveCacheEntries(): void $data = 'dataToCache'; $this->subject->set($entryIdentifier, $data, [], 3600); - static::assertTrue($this->subject->has($entryIdentifier)); + self::assertTrue($this->subject->has($entryIdentifier)); $this->subject->remove($entryIdentifier); - static::assertFalse($this->subject->has($entryIdentifier)); + self::assertFalse($this->subject->has($entryIdentifier)); } /** @@ -120,18 +117,18 @@ public function testTagRemovalAlsoRemovesAssociatedData(): void $lifetime = 3600; $this->subject->set($entryIdentifier, $data, $tags, $lifetime); - static::assertSame($data, $this->subject->get($entryIdentifier)); + self::assertSame($data, $this->subject->get($entryIdentifier)); $entryIdentifier = 'taggedDataIdentifier2'; $tags = ['importantTag2']; $this->subject->set($entryIdentifier, $data, $tags, $lifetime); - static::assertSame($data, $this->subject->get($entryIdentifier)); + self::assertSame($data, $this->subject->get($entryIdentifier)); $this->subject->flushByTag('importantTag'); - static::assertFalse($this->subject->has('taggedDataIdentifier'), 'The data should not be found after removing the tag.'); - static::assertTrue($this->subject->has('taggedDataIdentifier2'), 'The data should be found after removing the tag.'); + self::assertFalse($this->subject->has('taggedDataIdentifier'), 'The data should not be found after removing the tag.'); + self::assertTrue($this->subject->has('taggedDataIdentifier2'), 'The data should be found after removing the tag.'); } /** @@ -161,14 +158,14 @@ public function testFlushRemovesAllData(): void */ public function testTagsRemovalAlsoRemovesAssociatedData(): void { - $this->subject->set('identifier1', 'whatever data', ['tag1', 'tag2'], 3600); - $this->subject->set('identifier2', 'whatever data', ['tag2', 'tag3'], 3600); - $this->subject->set('identifier3', 'whatever data', ['tag4', 'tag5'], 3600); - - $this->subject->flushByTags(['tag1', 'tag2']); - static::assertFalse($this->subject->has('identifier1'), 'The data should not be found after removing the tag.'); - static::assertFalse($this->subject->has('identifier2'), 'The data should not be found after removing the tag.'); - static::assertTrue($this->subject->has('identifier3'), 'The data should be found after removing the tag.'); + $this->subject->set('identifier1', 'whatever data', ['tag1', 'tag2', 'tag-6', 'tag_7'], 3600); + $this->subject->set('identifier2', 'whatever data', ['tag2', 'tag3', 'tag-8', 'tag_9'], 3600); + $this->subject->set('identifier3', 'whatever data', ['tag4', 'tag5', 'tag-10', 'tag_11'], 3600); + + $this->subject->flushByTags(['tag1', 'tag2', 'tag-6', 'tag_7']); + self::assertFalse($this->subject->has('identifier1'), 'The data should not be found after removing the tag.'); + self::assertFalse($this->subject->has('identifier2'), 'The data should not be found after removing the tag.'); + self::assertTrue($this->subject->has('identifier3'), 'The data should be found after removing the tag.'); } /** @@ -185,14 +182,14 @@ public function testCommitingTwiceFindsOneActualDocument(): void $this->subject->set('twicecheck', 'whatever data'); // flush forces a commit from the ram buffer $this->subject->flushByTag('sometagthatdoesnotexist'); - static::assertFalse($noData); + self::assertFalse($noData); $this->subject->set('twicecheck', 'some other data'); $this->subject->flushByTag('sometagthatdoesnotexist'); $data = $this->subject->get('twicecheck'); - static::assertNotFalse($data); - static::assertSame('some other data', $data); + self::assertNotFalse($data); + self::assertSame('some other data', $data); } /** @@ -215,8 +212,8 @@ public function testGarbageCollectionRemovesExpiredEntries(): void $this->subject->collectGarbage(); - static::assertFalse($this->subject->has('pastData'), 'Past data should be removed by garbage collection.'); - static::assertTrue($this->subject->has($entryIdentifierFuture), 'Future data should remain after garbage collection.'); + self::assertFalse($this->subject->has('pastData'), 'Past data should be removed by garbage collection.'); + self::assertTrue($this->subject->has($entryIdentifierFuture), 'Future data should remain after garbage collection.'); } /** @@ -233,10 +230,10 @@ public function testSetGetCacheEntriesWithLifetime(): void $lifetime = 3600; $this->subject->set($entryIdentifier, $data, $tags, $lifetime); - static::assertSame($data, $this->subject->get($entryIdentifier), 'Data should be available before it expires.'); + self::assertSame($data, $this->subject->get($entryIdentifier), 'Data should be available before it expires.'); $this->addTimeToSubject(3605); - static::assertFalse($this->subject->get($entryIdentifier), 'Data should be expired after the lifetime has passed.'); + self::assertFalse($this->subject->get($entryIdentifier), 'Data should be expired after the lifetime has passed.'); } /** @@ -246,10 +243,420 @@ private function addTimeToSubject(int $int): void { $reflection = new ReflectionClass($this->subject); $execTimeProperty = $reflection->getProperty('execTime'); - /** @noinspection PhpExpressionResultUnusedInspection */ - $execTimeProperty->setAccessible(true); $previousValue = $execTimeProperty->getValue($this->subject); $execTimeProperty->setValue($this->subject, $previousValue + $int); } + + /** + * @return array + */ + public static function compressionAlgorithmProvider(): array + { + return [ + 'zstd' => [ + 'algorithm' => 'zstd', + 'requiredFunction' => 'zstd_compress', + ], + 'gzdeflate' => [ + 'algorithm' => 'gzdeflate', + 'requiredFunction' => 'gzdeflate', + ], + 'gzcompress' => [ + 'algorithm' => 'gzcompress', + 'requiredFunction' => 'gzcompress', + ], + ]; + } + + /** + * @throws NoSuchCacheException + * @throws Exception + * @throws InvalidDataException + */ + #[DataProvider('compressionAlgorithmProvider')] + public function testCompressionAlgorithms(string $algorithm, string $requiredFunction): void + { + if (!function_exists($requiredFunction)) { + self::markTestSkipped(sprintf( + 'Compression algorithm "%s" requires function "%s" which is not available.', + $algorithm, + $requiredFunction + )); + } + + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + $cacheManager->setCacheConfigurations([ + 'lucene_cache_compression_test_' . $algorithm => [ + 'backend' => LuceneCacheBackend::class, + 'options' => [ + 'maxBufferedDocs' => 0, + 'compression' => true, + 'compressionAlgorithm' => $algorithm, + 'compressionMinSize' => 1, // Compress even small data for testing + ], + ], + ]); + + $cache = $cacheManager->getCache('lucene_cache_compression_test_' . $algorithm); + $backend = $cache->getBackend(); + assert($backend instanceof LuceneCacheBackend); + + $entryIdentifier = 'compression_test_' . $algorithm; + // Use data larger than default minSize to ensure compression is applied + $data = str_repeat('This is test data for compression algorithm testing. ', 20); + $tags = ['compressionTest']; + $lifetime = 3600; + + $backend->set($entryIdentifier, $data, $tags, $lifetime); + $retrievedData = $backend->get($entryIdentifier); + + self::assertSame($data, $retrievedData, sprintf( + 'Data should be correctly stored and retrieved using "%s" compression.', + $algorithm + )); + + // Clean up + $backend->flush(); + } + + /** + * Test that compression is skipped for data smaller than compressionMinSize. + * + * @throws NoSuchCacheException + * @throws ReflectionException + */ + public function testCompressionMinSizeRespected(): void + { + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + $cacheManager->setCacheConfigurations([ + 'lucene_cache_minsize_test' => [ + 'backend' => LuceneCacheBackend::class, + 'options' => [ + 'maxBufferedDocs' => 0, + 'compression' => true, + 'compressionMinSize' => 1000, // Only compress data > 1000 bytes + ], + ], + ]); + + $cache = $cacheManager->getCache('lucene_cache_minsize_test'); + $backend = $cache->getBackend(); + assert($backend instanceof LuceneCacheBackend); + + // Use reflection to access the private compress method + $reflection = new ReflectionClass($backend); + $compressMethod = $reflection->getMethod('compress'); + + // Small data (should NOT be compressed - returned as-is) + $smallData = 'Small data'; + $compressedSmall = $compressMethod->invoke($backend, $smallData); + self::assertSame( + $smallData, + $compressedSmall, + 'Small data should not be compressed (returned unchanged).' + ); + + // Large data (should be compressed - will have prefix) + $largeData = str_repeat('Large data for compression testing. ', 100); + $compressedLarge = $compressMethod->invoke($backend, $largeData); + + // Compressed data should have a prefix: \x00 followed by algorithm identifier (Z, D, or C) + self::assertNotSame( + $largeData, + $compressedLarge, + 'Large data should be compressed (different from original).' + ); + self::assertStringStartsWith( + "\x00", + $compressedLarge, + 'Compressed data should start with the compression prefix.' + ); + self::assertContains( + $compressedLarge[1], + ['Z', 'D', 'C'], + 'Compressed data should have a valid algorithm identifier (Z=zstd, D=gzdeflate, C=gzcompress).' + ); + + // Verify the compressed data is actually smaller (for highly compressible data) + self::assertLessThan( + strlen($largeData), + strlen((string) $compressedLarge), + 'Compressed data should be smaller than the original for repetitive data.' + ); + + // Clean up + $backend->flush(); + } + + /** + * Test compression with various data types and edge cases. + * + * @throws NoSuchCacheException + * @throws Exception + * @throws InvalidDataException + */ + public function testCompressionWithVariousDataTypes(): void + { + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + $cacheManager->setCacheConfigurations([ + 'lucene_cache_data_types_test' => [ + 'backend' => LuceneCacheBackend::class, + 'options' => [ + 'maxBufferedDocs' => 0, + 'compression' => true, + 'compressionMinSize' => 1, + ], + ], + ]); + + $cache = $cacheManager->getCache('lucene_cache_data_types_test'); + $backend = $cache->getBackend(); + assert($backend instanceof LuceneCacheBackend); + + $testCases = [ + 'empty_string' => '', + 'binary_data' => "\x00\x01\x02\x03\x04\x05", + 'unicode_data' => 'Héllo Wörld! 你好世界 🎉', + 'json_data' => json_encode(['key' => 'value', 'nested' => ['array' => [1, 2, 3]]]), + 'serialized_object' => serialize(['object' => new stdClass()]), + 'highly_compressible' => str_repeat('AAAAAAAAAA', 500), + 'random_looking' => base64_encode(random_bytes(500)), + ]; + + foreach ($testCases as $identifier => $data) { + assert(is_string($data)); + $backend->set($identifier, $data, [], 3600); + self::assertSame( + $data, + $backend->get($identifier), + sprintf('Data type "%s" should be stored and retrieved correctly.', $identifier) + ); + } + + // Clean up + $backend->flush(); + } + + /** + * Test findIdentifiersByTag returns correct identifiers. + * + * @throws Exception + * @throws InvalidDataException + * @throws Zend_Search_Exception + * @throws Zend_Search_Lucene_Exception + */ + public function testFindIdentifiersByTag(): void + { + $this->subject->set('entry1', 'data1', ['sharedTag', 'uniqueTag1'], 3600); + $this->subject->set('entry2', 'data2', ['sharedTag', 'uniqueTag2'], 3600); + $this->subject->set('entry3', 'data3', ['otherTag'], 3600); + + $identifiers = $this->subject->findIdentifiersByTag('sharedTag'); + sort($identifiers); + + self::assertCount(2, $identifiers, 'Should find exactly 2 entries with sharedTag.'); + self::assertSame(['entry1', 'entry2'], $identifiers, 'Should return correct identifiers.'); + + $uniqueIdentifiers = $this->subject->findIdentifiersByTag('uniqueTag1'); + self::assertCount(1, $uniqueIdentifiers, 'Should find exactly 1 entry with uniqueTag1.'); + self::assertSame(['entry1'], $uniqueIdentifiers); + + $noIdentifiers = $this->subject->findIdentifiersByTag('nonExistentTag'); + self::assertCount(0, $noIdentifiers, 'Should return empty array for non-existent tag.'); + } + + /** + * Test setCompressionAlgorithm throws exception for invalid algorithm. + */ + public function testSetCompressionAlgorithmThrowsExceptionForInvalidAlgorithm(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unsupported compression algorithm "invalid"'); + + $this->subject->setCompressionAlgorithm('invalid'); + } + + /** + * Test setCompressionAlgorithm throws exception when zstd is not available. + */ + public function testSetCompressionAlgorithmThrowsExceptionForMissingZstd(): void + { + if (function_exists('zstd_compress')) { + self::markTestSkipped('This test requires zstd extension to NOT be installed.'); + } + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('zstd compression requires ext-zstd'); + + $this->subject->setCompressionAlgorithm('zstd'); + } + + /** + * Test setCompressionLevel respects boundaries. + * + * @throws ReflectionException + */ + public function testSetCompressionLevelBoundaries(): void + { + $reflection = new ReflectionClass($this->subject); + $compressionLevelProperty = $reflection->getProperty('compressionLevel'); + + // Test valid levels + $this->subject->setCompressionLevel(5); + self::assertSame(5, $compressionLevelProperty->getValue($this->subject), 'Level 5 should be accepted.'); + + $this->subject->setCompressionLevel(-1); + self::assertSame(-1, $compressionLevelProperty->getValue($this->subject), 'Level -1 (default) should be accepted.'); + + $this->subject->setCompressionLevel(0); + self::assertSame(0, $compressionLevelProperty->getValue($this->subject), 'Level 0 should be accepted.'); + + $this->subject->setCompressionLevel(9); + self::assertSame(9, $compressionLevelProperty->getValue($this->subject), 'Level 9 should be accepted.'); + + // Test invalid levels (should be ignored) + $this->subject->setCompressionLevel(-2); + self::assertSame(9, $compressionLevelProperty->getValue($this->subject), 'Level -2 should be ignored (value unchanged).'); + + $this->subject->setCompressionLevel(10); + self::assertSame(9, $compressionLevelProperty->getValue($this->subject), 'Level 10 should be ignored (value unchanged).'); + } + + /** + * Test get() retrieves data from buffer before commit. + * + * @throws NoSuchCacheException + * @throws Exception + * @throws InvalidDataException + */ + public function testGetRetrievesFromBuffer(): void + { + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + $cacheManager->setCacheConfigurations([ + 'lucene_cache_buffer_test' => [ + 'backend' => LuceneCacheBackend::class, + 'options' => [ + // High buffer limit - data stays in buffer + 'maxBufferedDocs' => 1000, + 'compression' => false, + ], + ], + ]); + + $cache = $cacheManager->getCache('lucene_cache_buffer_test'); + $backend = $cache->getBackend(); + assert($backend instanceof LuceneCacheBackend); + + $backend->set('buffered_entry', 'buffered_data', [], 3600); + + // Data should be retrievable from buffer (not committed yet) + self::assertSame('buffered_data', $backend->get('buffered_entry'), 'Should retrieve data from buffer.'); + + $backend->flush(); + } + + /** + * Test has() checks buffer before commit. + * + * @throws NoSuchCacheException + * @throws Exception + * @throws InvalidDataException + */ + public function testHasChecksBuffer(): void + { + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + $cacheManager->setCacheConfigurations([ + 'lucene_cache_has_buffer_test' => [ + 'backend' => LuceneCacheBackend::class, + 'options' => [ + 'maxBufferedDocs' => 1000, + 'compression' => false, + ], + ], + ]); + + $cache = $cacheManager->getCache('lucene_cache_has_buffer_test'); + $backend = $cache->getBackend(); + assert($backend instanceof LuceneCacheBackend); + + self::assertFalse($backend->has('buffered_has_entry'), 'Entry should not exist initially.'); + + $backend->set('buffered_has_entry', 'some_data', [], 3600); + + // has() should find the entry in buffer + self::assertTrue($backend->has('buffered_has_entry'), 'Should find entry in buffer.'); + + $backend->flush(); + } + + /** + * Test decompression of legacy gzcompress data (without prefix). + * + * @throws ReflectionException + */ + public function testDecompressLegacyData(): void + { + $reflection = new ReflectionClass($this->subject); + $decompressMethod = $reflection->getMethod('decompress'); + + // Simulate legacy compressed data (gzcompress without prefix) + $originalData = 'This is legacy compressed data that uses gzcompress without the new prefix format.'; + /** @noinspection PhpComposerExtensionStubsInspection */ + $legacyCompressed = gzcompress($originalData); + self::assertIsString($legacyCompressed, 'gzcompress should return a string.'); + + // Verify it starts with 0x78 (zlib header) and has no prefix + self::assertSame(0x78, ord($legacyCompressed[0]), 'Legacy gzcompress data should start with 0x78.'); + self::assertNotSame("\x00", $legacyCompressed[0], 'Legacy data should not have the new prefix.'); + + // Decompress should handle legacy format + $decompressed = $decompressMethod->invoke($this->subject, $legacyCompressed); + self::assertSame($originalData, $decompressed, 'Legacy gzcompress data should be decompressed correctly.'); + } + + /** + * Test that expired data in buffer returns false. + * + * @throws NoSuchCacheException + * @throws ReflectionException + * @throws Exception + * @throws InvalidDataException + */ + public function testExpiredDataInBufferReturnsFalse(): void + { + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + $cacheManager->setCacheConfigurations([ + 'lucene_cache_expired_buffer_test' => [ + 'backend' => LuceneCacheBackend::class, + 'options' => [ + 'maxBufferedDocs' => 1000, + 'compression' => false, + ], + ], + ]); + + $cache = $cacheManager->getCache('lucene_cache_expired_buffer_test'); + $backend = $cache->getBackend(); + assert($backend instanceof LuceneCacheBackend); + + // Set entry with short lifetime + $backend->set('expiring_buffer_entry', 'will_expire', [], 10); + + // Should be available initially + self::assertSame('will_expire', $backend->get('expiring_buffer_entry'), 'Data should be available before expiry.'); + self::assertTrue($backend->has('expiring_buffer_entry'), 'has() should return true before expiry.'); + + // Simulate time passing using reflection + $reflection = new ReflectionClass($backend); + $execTimeProperty = $reflection->getProperty('execTime'); + $currentTime = $execTimeProperty->getValue($backend); + $execTimeProperty->setValue($backend, $currentTime + 20); + + // Now the buffered data should be considered expired + self::assertFalse($backend->get('expiring_buffer_entry'), 'Expired buffered data should return false.'); + self::assertFalse($backend->has('expiring_buffer_entry'), 'has() should return false for expired buffered data.'); + + $backend->flush(); + } } diff --git a/composer.json b/composer.json index 26741b1..0be1983 100644 --- a/composer.json +++ b/composer.json @@ -5,27 +5,22 @@ "GPL-2.0-or-later" ], "type": "typo3-cms-extension", - "authors": [ - { - "name": "Stefan Lamm", - "email": "lucene-cache@webentwicklung-lamm.de" - } - ], "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", - "typo3/cms-core": "~11.5.0 || ~12.4.0 || ~13.4.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "typo3/cms-core": "~14.1.0", "zf1s/zend-search-lucene": "^1.15.5" }, "require-dev": { "phpstan/extension-installer": "^1.1", - "pluswerk/grumphp-config": "^6.8.0", + "pluswerk/grumphp-config": "^10.2.6 || ^11.0.0", "saschaegerer/phpstan-typo3": "*", - "ssch/typo3-rector": "^1.3.5", - "typo3/minimal": "^13", - "typo3/testing-framework": "^8.2" + "ssch/typo3-rector": "*", + "typo3/minimal": "^14.0", + "typo3/testing-framework": "^8.2 || ^9.4" }, "suggest": { - "ext-zlib": "Possibility to save disk space" + "ext-zlib": "Possibility to save disk space", + "ext-zstd": "Faster compression with better ratio (recommended)" }, "minimum-stability": "dev", "prefer-stable": true, @@ -41,6 +36,7 @@ }, "config": { "allow-plugins": { + "a9f/fractor-extension-installer": true, "ergebnis/composer-normalize": true, "phpro/grumphp": true, "phpstan/extension-installer": true, @@ -55,6 +51,10 @@ } }, "scripts": { - "test": "@php ./vendor/bin/phpunit" + "switchto14": [ + "rm -rf composer.lock vendor/composer/installed.json", + "@composer req typo3/minimal:^14.0.0 --dev --no-interaction --no-progress" + ], + "test": "@php ./vendor/bin/phpunit --testdox --stderr --display-deprecations --display-notices --display-warnings" } } diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 40235eb..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,5 +0,0 @@ -services: - web: - build: - context: . - dockerfile: Dockerfile.83 diff --git a/fractor.php b/fractor.php new file mode 100644 index 0000000..063ed5b --- /dev/null +++ b/fractor.php @@ -0,0 +1,16 @@ +withPaths(array_filter(explode("\n", (string)shell_exec("git ls-files | xargs ls -d 2>/dev/null")))) + ->withSets([ + ...FractorSettings::sets(true), + ]) + ->withRules([ + ...FractorSettings::rules(), + ]) + ->withOptions([ + ...FractorSettings::options(), + ]); diff --git a/grumphp.yml b/grumphp.yml index f682bd0..f4699d5 100644 --- a/grumphp.yml +++ b/grumphp.yml @@ -14,3 +14,7 @@ parameters: convention.rector_config: rector.php convention.rector_clear-cache: false convention.phpstan_level: null + convention.fractor_ignore_pattern: { } + convention.fractor_enable: true + convention.fractor_config: fractor.php + convention.fractor_clear-cache: false diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 44eb968..e69de29 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,11 +0,0 @@ -parameters: - ignoreErrors: - - - message: "#^Method Weakbit\\\\LuceneCache\\\\Cache\\\\Backend\\\\LuceneCacheBackend\\:\\:set\\(\\) has parameter \\$tags with no value type specified in iterable type array\\.$#" - count: 1 - path: Classes/Cache/Backend/LuceneCacheBackend.php - - - - message: "#^Method TYPO3\\\\CMS\\\\Core\\\\Domain\\\\Page\\:\\:toArray\\(\\) invoked with 1 parameter, 0 required\\.$#" - count: 1 - path: Classes/Transformer/PageTransformer.php diff --git a/phpunit.xml b/phpunit.xml index 6185cd9..2f2b108 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,14 +1,14 @@ - - - - Tests - - + + + + Tests + + + + + Classes + + diff --git a/rector.php b/rector.php index a59bbd8..2f8ee6e 100644 --- a/rector.php +++ b/rector.php @@ -2,17 +2,14 @@ declare(strict_types=1); -use PHPStan\Type\Php\PregMatchParameterOutTypeExtension; use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPrivateMethodRector; use Rector\Php71\Rector\FuncCall\RemoveExtraParametersRector; use Rector\Privatization\Rector\Property\PrivatizeFinalClassPropertyRector; use Rector\Privatization\Rector\ClassMethod\PrivatizeFinalClassMethodRector; -use Rector\Privatization\Rector\Class_\FinalizeClassesWithoutChildrenRector; -use Ssch\TYPO3Rector\Rector\v11\v0\DateTimeAspectInsteadOfGlobalsExecTimeRector; use PLUS\GrumPHPConfig\RectorSettings; use Rector\Config\RectorConfig; use Rector\Caching\ValueObject\Storage\FileCacheStorage; -use Rector\Php81\Rector\ClassConst\FinalizePublicClassConstantRector; +use Ssch\TYPO3Rector\TYPO311\v0\DateTimeAspectInsteadOfGlobalsExecTimeRector; return static function (RectorConfig $rectorConfig): void { $rectorConfig->parallel(); @@ -29,7 +26,6 @@ $rectorConfig->sets( [ ...RectorSettings::sets(true), - ...RectorSettings::setsTypo3(false), ] ); @@ -38,11 +34,8 @@ $rectorConfig->skip( [ ...RectorSettings::skip(), - ...RectorSettings::skipTypo3(), - FinalizePublicClassConstantRector::class, PrivatizeFinalClassPropertyRector::class, PrivatizeFinalClassMethodRector::class, - FinalizeClassesWithoutChildrenRector::class, DateTimeAspectInsteadOfGlobalsExecTimeRector::class, RemoveExtraParametersRector::class, RemoveUnusedPrivateMethodRector::class, diff --git a/runTest.sh b/runTest.sh deleted file mode 100644 index e462c86..0000000 --- a/runTest.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh -rm composer.lock -docker run -u1000 -v ./:/app -w /app ghcr.io/pluswerk/php-dev:nginx-8.1-alpine sh -c 'git config --global --add safe.directory /app && composer require typo3/testing-framework typo3/minimal="^11" -q -n -W --dev && composer test && vendor/bin/phpstan analyse' -docker run -u1000 -v ./:/app -w /app ghcr.io/pluswerk/php-dev:nginx-8.1-alpine sh -c 'git config --global --add safe.directory /app && composer require typo3/testing-framework typo3/minimal="^12" -q -n -W --dev && composer test && vendor/bin/phpstan analyse' -docker run -u1000 -v ./:/app -w /app ghcr.io/pluswerk/php-dev:nginx-8.2-alpine sh -c 'git config --global --add safe.directory /app && composer require typo3/testing-framework typo3/minimal="^11" -q -n -W --dev && composer test && vendor/bin/phpstan analyse' -docker run -u1000 -v ./:/app -w /app ghcr.io/pluswerk/php-dev:nginx-8.2-alpine sh -c 'git config --global --add safe.directory /app && composer require typo3/testing-framework typo3/minimal="^12" -q -n -W --dev && composer test && vendor/bin/phpstan analyse' -docker run -u1000 -v ./:/app -w /app ghcr.io/pluswerk/php-dev:nginx-8.3-alpine sh -c 'git config --global --add safe.directory /app && composer require typo3/testing-framework typo3/minimal="^11" -q -n -W --dev && composer test && vendor/bin/phpstan analyse' -docker run -u1000 -v ./:/app -w /app ghcr.io/pluswerk/php-dev:nginx-8.3-alpine sh -c 'git config --global --add safe.directory /app && composer require typo3/testing-framework typo3/minimal="^12" -q -n -W --dev && composer test && vendor/bin/phpstan analyse' -docker run -u1000 -v ./:/app -w /app ghcr.io/pluswerk/php-dev:nginx-8.3-alpine sh -c 'git config --global --add safe.directory /app && composer require typo3/testing-framework typo3/minimal="^13" -q -n -W --dev && composer test && vendor/bin/phpstan analyse'