├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── code-analysis.yml │ └── tests.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── TESTING.md ├── composer.json ├── composer.lock ├── config └── smart-cache.php ├── docs ├── googlea41a9f57385f6f00.html ├── index.html ├── robots.txt └── sitemap.xml ├── phpunit.xml ├── src ├── Collections │ └── LazyChunkedCollection.php ├── Console │ └── Commands │ │ ├── ClearCommand.php │ │ └── StatusCommand.php ├── Contracts │ ├── OptimizationStrategy.php │ └── SmartCache.php ├── Drivers │ └── MemoizedCacheDriver.php ├── Events │ ├── CacheHit.php │ ├── CacheMissed.php │ ├── KeyForgotten.php │ ├── KeyWritten.php │ └── OptimizationApplied.php ├── Facades │ └── SmartCache.php ├── Observers │ └── CacheInvalidationObserver.php ├── Providers │ └── SmartCacheServiceProvider.php ├── Services │ ├── CacheInvalidationService.php │ └── SmartChunkSizeCalculator.php ├── SmartCache.php ├── Strategies │ ├── AdaptiveCompressionStrategy.php │ ├── ChunkingStrategy.php │ ├── CompressionStrategy.php │ └── SmartSerializationStrategy.php ├── Traits │ ├── CacheInvalidation.php │ ├── DispatchesCacheEvents.php │ └── HasLocks.php └── helpers.php └── tests ├── Feature ├── AdvancedInvalidationIntegrationTest.php ├── EnhancedSmartCacheControllerTest.php └── SmartCacheIntegrationTest.php ├── TestCase.php ├── Unit ├── BatchOperationsTest.php ├── Collections │ └── LazyChunkedCollectionTest.php ├── Console │ ├── ClearCommandTest.php │ └── StatusCommandTest.php ├── DependencyTrackingTest.php ├── Drivers │ └── MemoizationTest.php ├── Events │ └── CacheEventsTest.php ├── Http │ └── HttpCommandExecutionTest.php ├── Laravel12 │ └── Laravel12SWRTest.php ├── Locks │ └── AtomicLocksTest.php ├── Monitoring │ └── PerformanceMonitoringTest.php ├── PatternBasedInvalidationTest.php ├── Providers │ └── SmartCacheServiceProviderTest.php ├── Services │ ├── CacheInvalidationServiceTest.php │ └── SmartChunkSizeCalculatorTest.php ├── SmartCacheTest.php ├── Strategies │ ├── AdaptiveCompressionStrategyTest.php │ ├── ChunkingStrategyTest.php │ ├── CompressionStrategyTest.php │ └── SmartSerializationStrategyTest.php ├── StrategyOrderingTest.php ├── TagBasedInvalidationTest.php └── Traits │ └── ModelIntegrationTest.php └── bootstrap.php /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Pull Request 2 | 3 | ## Description 4 | Please include a summary of the changes and the related issue. Please also include relevant motivation and context. 5 | 6 | Fixes # (issue) 7 | 8 | ## Type of change 9 | - [ ] Bug fix (non-breaking change which fixes an issue) 10 | - [ ] New feature (non-breaking change which adds functionality) 11 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 12 | - [ ] This change requires a documentation update 13 | 14 | ## How Has This Been Tested? 15 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. 16 | 17 | - [ ] Test A 18 | - [ ] Test B 19 | 20 | **Test Configuration**: 21 | * PHP version: 22 | * Laravel version: 23 | 24 | ## Checklist: 25 | - [ ] My code follows the style guidelines of this project 26 | - [ ] I have performed a self-review of my own code 27 | - [ ] I have commented my code, particularly in hard-to-understand areas 28 | - [ ] I have made corresponding changes to the documentation 29 | - [ ] My changes generate no new warnings 30 | - [ ] I have added tests that prove my fix is effective or that my feature works 31 | - [ ] New and existing unit tests pass locally with my changes 32 | - [ ] Any dependent changes have been merged and published in downstream modules 33 | -------------------------------------------------------------------------------- /.github/workflows/code-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Coverage 2 | 3 | on: 4 | push: 5 | branches: [ main, master ] 6 | pull_request: 7 | branches: [ main, master ] 8 | 9 | jobs: 10 | coverage: 11 | runs-on: ubuntu-latest 12 | name: Coverage Report (PHP 8.3) 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup PHP 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: 8.3 22 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo 23 | coverage: xdebug 24 | 25 | - name: Install dependencies 26 | run: | 27 | composer update --prefer-stable --prefer-dist --no-interaction 28 | 29 | - name: Execute tests with coverage 30 | run: vendor/bin/phpunit --coverage-text --testdox 31 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ main, master ] 6 | pull_request: 7 | branches: [ main, master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | fail-fast: true 15 | matrix: 16 | php: [8.1, 8.2, 8.3] 17 | laravel: [8.*, 9.*, 10.*, 11.*, 12.*] 18 | stability: [prefer-stable] 19 | include: 20 | - laravel: 8.* 21 | testbench: ^6.0 22 | - laravel: 9.* 23 | testbench: ^7.0 24 | - laravel: 10.* 25 | testbench: ^8.0 26 | - laravel: 11.* 27 | testbench: ^9.0 28 | - laravel: 12.* 29 | testbench: ^10.0 30 | exclude: 31 | - laravel: 11.* 32 | php: 8.1 33 | - laravel: 12.* 34 | php: 8.1 35 | 36 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} 37 | 38 | steps: 39 | - name: Checkout code 40 | uses: actions/checkout@v4 41 | 42 | - name: Setup PHP 43 | uses: shivammathur/setup-php@v2 44 | with: 45 | php-version: ${{ matrix.php }} 46 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo 47 | coverage: none 48 | 49 | - name: Setup problem matchers 50 | run: | 51 | echo "::add-matcher::${{ runner.tool_cache }}/php.json" 52 | echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 53 | 54 | - name: Install dependencies 55 | run: | 56 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 57 | composer update --${{ matrix.stability }} --prefer-dist --no-interaction 58 | 59 | - name: List Installed Dependencies 60 | run: composer show -D 61 | 62 | - name: Execute tests 63 | run: vendor/bin/phpunit --testdox 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .phpunit.cache/ 3 | coverage/ 4 | .DS_Store 5 | tests/storage/framework/cache/ 6 | tests/storage/framework/sessions/ 7 | tests/storage/framework/views/ 8 | tests/storage/logs/ 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at eazaran@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | I love your input! I want to make contributing to this project as easy and transparent as possible, whether it's: 3 | 4 | - Reporting a bug 5 | - Discussing the current state of the code 6 | - Submitting a fix 7 | - Proposing new features 8 | - Becoming a maintainer 9 | 10 | ## All Code Changes Happen Through Pull Requests 11 | Pull requests are the best way to propose changes to the codebase. I actively welcome your pull requests: 12 | 13 | 1. Fork the repo and create your branch from `main`. 14 | 2. If you've added code that should be tested, add tests. 15 | 3. If you've changed APIs or functions, update the documentation. 16 | 4. Make sure your code lints. 17 | 5. Issue that pull request! 18 | 19 | ## Any contributions you make will be under the MIT Software License 20 | In short, when you submit code changes, your submissions are understood to be under the same. 21 | 22 | ## License 23 | By contributing, you agree that your contributions will be licensed under its MIT License. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ismael Azaran 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "iazaran/smart-cache", 3 | "description": "Smart Cache is a caching optimization package designed to enhance the way your Laravel application handles data caching. It intelligently manages large data sets by compressing, chunking, or applying other optimization strategies to keep your application performant and efficient.", 4 | "keywords": [ 5 | "smart-cache", 6 | "smart cache laravel", 7 | "laravel package", 8 | "laravel caching", 9 | "cache optimization", 10 | "data compression", 11 | "cache chunking", 12 | "laravel performance", 13 | "redis cache", 14 | "file cache", 15 | "cache management", 16 | "laravel cache driver", 17 | "cache serialization", 18 | "cache strategies", 19 | "php caching", 20 | "laravel optimization" 21 | ], 22 | "homepage": "https://github.com/iazaran/smart-cache", 23 | "type": "library", 24 | "license": "MIT", 25 | "support": { 26 | "issues": "https://github.com/iazaran/smart-cache/issues", 27 | "source": "https://github.com/iazaran/smart-cache" 28 | }, 29 | "authors": [ 30 | { 31 | "name": "Ismael Azaran", 32 | "email": "eazaran@gmail.com" 33 | } 34 | ], 35 | "require": { 36 | "php": "^8.1", 37 | "illuminate/support": "^8.0||^9.0||^10.0||^11.0||^12.0", 38 | "illuminate/cache": "^8.0||^9.0||^10.0||^11.0||^12.0", 39 | "illuminate/console": "^8.0||^9.0||^10.0||^11.0||^12.0", 40 | "illuminate/contracts": "^8.0||^9.0||^10.0||^11.0||^12.0" 41 | }, 42 | "require-dev": { 43 | "phpunit/phpunit": "^9.0|^10.0|^11.0", 44 | "orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0", 45 | "mockery/mockery": "^1.5", 46 | "symfony/var-dumper": "^5.4|^6.0|^7.0" 47 | }, 48 | "autoload": { 49 | "psr-4": { 50 | "SmartCache\\": "src/" 51 | }, 52 | "files": [ 53 | "src/helpers.php" 54 | ] 55 | }, 56 | "autoload-dev": { 57 | "psr-4": { 58 | "SmartCache\\Tests\\": "tests/" 59 | } 60 | }, 61 | "scripts": { 62 | "test": "vendor/bin/phpunit --testdox", 63 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage --testdox" 64 | }, 65 | "extra": { 66 | "laravel": { 67 | "providers": [ 68 | "SmartCache\\Providers\\SmartCacheServiceProvider" 69 | ], 70 | "aliases": { 71 | "SmartCache": "SmartCache\\Facades\\SmartCache" 72 | } 73 | } 74 | }, 75 | "minimum-stability": "dev", 76 | "prefer-stable": true 77 | } 78 | -------------------------------------------------------------------------------- /config/smart-cache.php: -------------------------------------------------------------------------------- 1 | [ 22 | 'compression' => 1024 * 50, // 50KB 23 | 'chunking' => 1024 * 100, // 100KB 24 | ], 25 | 26 | /* 27 | |-------------------------------------------------------------------------- 28 | | Strategies 29 | |-------------------------------------------------------------------------- 30 | | 31 | | Configure which optimization strategies are enabled and their options. 32 | | 33 | */ 34 | 'strategies' => [ 35 | 'compression' => [ 36 | 'enabled' => true, 37 | 'mode' => 'fixed', // 'fixed' or 'adaptive' 38 | 'level' => 6, // 0-9 (higher = better compression but slower) - used in fixed mode 39 | 'adaptive' => [ 40 | 'sample_size' => 1024, // Bytes to sample for compressibility test 41 | 'high_compression_threshold' => 0.5, // Ratio below which to use level 9 42 | 'low_compression_threshold' => 0.7, // Ratio above which to use level 3 43 | 'frequency_threshold' => 100, // Access count for speed priority 44 | ], 45 | ], 46 | 'chunking' => [ 47 | 'enabled' => true, 48 | 'chunk_size' => 1000, // Items per chunk for arrays/collections 49 | 'lazy_loading' => false, // Enable lazy loading for chunks 50 | 'smart_sizing' => false, // Enable smart chunk size calculation 51 | ], 52 | ], 53 | 54 | /* 55 | |-------------------------------------------------------------------------- 56 | | Fallback 57 | |-------------------------------------------------------------------------- 58 | | 59 | | Configure fallback behavior if optimizations fail or are incompatible. 60 | | 61 | */ 62 | 'fallback' => [ 63 | 'enabled' => true, 64 | 'log_errors' => true, 65 | ], 66 | 67 | /* 68 | |-------------------------------------------------------------------------- 69 | | Performance Monitoring 70 | |-------------------------------------------------------------------------- 71 | | 72 | | Enable performance monitoring to track cache hit/miss ratios, 73 | | optimization impact, and operation durations. 74 | | 75 | */ 76 | 'monitoring' => [ 77 | 'enabled' => true, 78 | 'metrics_ttl' => 3600, // How long to keep metrics in cache (seconds) 79 | 'recent_entries_limit' => 100, // Number of recent operations to track per type 80 | ], 81 | 82 | /* 83 | |-------------------------------------------------------------------------- 84 | | Performance Warnings 85 | |-------------------------------------------------------------------------- 86 | | 87 | | Configure thresholds for performance warnings and recommendations. 88 | | 89 | */ 90 | 'warnings' => [ 91 | 'hit_ratio_threshold' => 70, // Percentage below which to warn about low hit ratio 92 | 'optimization_ratio_threshold' => 20, // Percentage below which to warn about low optimization usage 93 | 'slow_write_threshold' => 0.1, // Seconds above which to warn about slow writes 94 | ], 95 | 96 | /* 97 | |-------------------------------------------------------------------------- 98 | | Cache Drivers 99 | |-------------------------------------------------------------------------- 100 | | 101 | | Configure which cache drivers should use which optimization strategies. 102 | | Set to null to use the global strategies configuration. 103 | | 104 | */ 105 | 'drivers' => [ 106 | 'redis' => null, // Use global settings 107 | 'file' => [ 108 | 'compression' => true, 109 | 'chunking' => true, 110 | ], 111 | 'memcached' => [ 112 | 'compression' => false, // Memcached has its own compression 113 | 'chunking' => true, 114 | ], 115 | ], 116 | 117 | /* 118 | |-------------------------------------------------------------------------- 119 | | Cache Events 120 | |-------------------------------------------------------------------------- 121 | | 122 | | Enable cache events to track cache operations and optimization effectiveness. 123 | | Events can be used for monitoring, logging, and debugging. 124 | | 125 | */ 126 | 'events' => [ 127 | 'enabled' => false, // Disabled by default for backward compatibility 128 | 'dispatch' => [ 129 | 'cache_hit' => true, 130 | 'cache_missed' => true, 131 | 'key_written' => true, 132 | 'key_forgotten' => true, 133 | 'optimization_applied' => true, 134 | ], 135 | ], 136 | ]; -------------------------------------------------------------------------------- /docs/googlea41a9f57385f6f00.html: -------------------------------------------------------------------------------- 1 | google-site-verification: googlea41a9f57385f6f00.html -------------------------------------------------------------------------------- /docs/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | Sitemap: https://iazaran.github.io/smart-cache/sitemap.xml -------------------------------------------------------------------------------- /docs/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://iazaran.github.io/smart-cache/ 5 | 2025-06-04 6 | weekly 7 | 1.0 8 | 9 | 10 | https://github.com/iazaran/smart-cache 11 | 2025-06-04 12 | weekly 13 | 0.9 14 | 15 | 16 | https://packagist.org/packages/iazaran/smart-cache 17 | 2025-06-04 18 | monthly 19 | 0.8 20 | 21 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests/Unit 14 | 15 | 16 | ./tests/Feature 17 | 18 | 19 | 20 | 21 | ./src 22 | 23 | 24 | ./src/Console 25 | ./src/helpers.php 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/Collections/LazyChunkedCollection.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 83 | $this->chunkKeys = $chunkKeys; 84 | $this->chunkSize = $chunkSize; 85 | $this->totalItems = $totalItems; 86 | $this->isCollection = $isCollection; 87 | } 88 | 89 | /** 90 | * Get the current element. 91 | * 92 | * @return mixed 93 | */ 94 | public function current(): mixed 95 | { 96 | $this->ensureChunkLoaded($this->position); 97 | 98 | $indexInChunk = $this->position % $this->chunkSize; 99 | 100 | if ($this->currentChunk === null || !isset($this->currentChunk[$indexInChunk])) { 101 | return null; 102 | } 103 | 104 | return $this->currentChunk[$indexInChunk]; 105 | } 106 | 107 | /** 108 | * Get the current key. 109 | * 110 | * @return int 111 | */ 112 | public function key(): int 113 | { 114 | return $this->position; 115 | } 116 | 117 | /** 118 | * Move to the next element. 119 | * 120 | * @return void 121 | */ 122 | public function next(): void 123 | { 124 | $this->position++; 125 | } 126 | 127 | /** 128 | * Rewind to the first element. 129 | * 130 | * @return void 131 | */ 132 | public function rewind(): void 133 | { 134 | $this->position = 0; 135 | } 136 | 137 | /** 138 | * Check if the current position is valid. 139 | * 140 | * @return bool 141 | */ 142 | public function valid(): bool 143 | { 144 | return $this->position < $this->totalItems; 145 | } 146 | 147 | /** 148 | * Count the total number of items. 149 | * 150 | * @return int 151 | */ 152 | public function count(): int 153 | { 154 | return $this->totalItems; 155 | } 156 | 157 | /** 158 | * Check if an offset exists. 159 | * 160 | * @param mixed $offset 161 | * @return bool 162 | */ 163 | public function offsetExists(mixed $offset): bool 164 | { 165 | return is_int($offset) && $offset >= 0 && $offset < $this->totalItems; 166 | } 167 | 168 | /** 169 | * Get an item at a given offset. 170 | * 171 | * @param mixed $offset 172 | * @return mixed 173 | */ 174 | public function offsetGet(mixed $offset): mixed 175 | { 176 | if (!$this->offsetExists($offset)) { 177 | return null; 178 | } 179 | 180 | $this->ensureChunkLoaded($offset); 181 | 182 | $indexInChunk = $offset % $this->chunkSize; 183 | 184 | return $this->currentChunk[$indexInChunk] ?? null; 185 | } 186 | 187 | /** 188 | * Set an item at a given offset. 189 | * 190 | * @param mixed $offset 191 | * @param mixed $value 192 | * @return void 193 | * @throws \RuntimeException 194 | */ 195 | public function offsetSet(mixed $offset, mixed $value): void 196 | { 197 | throw new \RuntimeException('LazyChunkedCollection is read-only'); 198 | } 199 | 200 | /** 201 | * Unset an item at a given offset. 202 | * 203 | * @param mixed $offset 204 | * @return void 205 | * @throws \RuntimeException 206 | */ 207 | public function offsetUnset(mixed $offset): void 208 | { 209 | throw new \RuntimeException('LazyChunkedCollection is read-only'); 210 | } 211 | 212 | /** 213 | * Convert to array (loads all chunks). 214 | * 215 | * @return array 216 | */ 217 | public function toArray(): array 218 | { 219 | $result = []; 220 | 221 | foreach ($this->chunkKeys as $chunkKey) { 222 | $chunk = $this->cache->get($chunkKey, []); 223 | $result = array_merge($result, $chunk); 224 | } 225 | 226 | return $result; 227 | } 228 | 229 | /** 230 | * Convert to Laravel Collection (loads all chunks). 231 | * 232 | * @return Collection 233 | */ 234 | public function toCollection(): Collection 235 | { 236 | return new Collection($this->toArray()); 237 | } 238 | 239 | /** 240 | * Get a slice of the collection without loading all chunks. 241 | * 242 | * @param int $offset 243 | * @param int|null $length 244 | * @return array 245 | */ 246 | public function slice(int $offset, ?int $length = null): array 247 | { 248 | $result = []; 249 | $end = $length !== null ? $offset + $length : $this->totalItems; 250 | $end = min($end, $this->totalItems); 251 | 252 | for ($i = $offset; $i < $end; $i++) { 253 | $result[] = $this->offsetGet($i); 254 | } 255 | 256 | return $result; 257 | } 258 | 259 | /** 260 | * Apply a callback to each item without loading all chunks at once. 261 | * 262 | * @param callable $callback 263 | * @return void 264 | */ 265 | public function each(callable $callback): void 266 | { 267 | foreach ($this as $index => $item) { 268 | $callback($item, $index); 269 | } 270 | } 271 | 272 | /** 273 | * Filter items without loading all chunks at once. 274 | * 275 | * @param callable $callback 276 | * @return array 277 | */ 278 | public function filter(callable $callback): array 279 | { 280 | $result = []; 281 | 282 | foreach ($this as $index => $item) { 283 | if ($callback($item, $index)) { 284 | $result[] = $item; 285 | } 286 | } 287 | 288 | return $result; 289 | } 290 | 291 | /** 292 | * Map items without loading all chunks at once. 293 | * 294 | * @param callable $callback 295 | * @return array 296 | */ 297 | public function map(callable $callback): array 298 | { 299 | $result = []; 300 | 301 | foreach ($this as $index => $item) { 302 | $result[] = $callback($item, $index); 303 | } 304 | 305 | return $result; 306 | } 307 | 308 | /** 309 | * Ensure the chunk containing the given position is loaded. 310 | * 311 | * @param int $position 312 | * @return void 313 | */ 314 | protected function ensureChunkLoaded(int $position): void 315 | { 316 | $chunkIndex = (int) floor($position / $this->chunkSize); 317 | 318 | // Already loaded 319 | if ($chunkIndex === $this->currentChunkIndex) { 320 | return; 321 | } 322 | 323 | // Check if chunk is in loaded chunks cache 324 | if (isset($this->loadedChunks[$chunkIndex])) { 325 | $this->currentChunk = $this->loadedChunks[$chunkIndex]; 326 | $this->currentChunkIndex = $chunkIndex; 327 | return; 328 | } 329 | 330 | // Load the chunk 331 | if (isset($this->chunkKeys[$chunkIndex])) { 332 | $this->currentChunk = $this->cache->get($this->chunkKeys[$chunkIndex], []); 333 | $this->currentChunkIndex = $chunkIndex; 334 | 335 | // Cache the loaded chunk 336 | $this->loadedChunks[$chunkIndex] = $this->currentChunk; 337 | 338 | // Limit memory usage by removing old chunks 339 | if (count($this->loadedChunks) > $this->maxLoadedChunks) { 340 | // Remove the oldest chunk (first key) 341 | $oldestKey = array_key_first($this->loadedChunks); 342 | unset($this->loadedChunks[$oldestKey]); 343 | } 344 | } else { 345 | $this->currentChunk = null; 346 | $this->currentChunkIndex = -1; 347 | } 348 | } 349 | 350 | /** 351 | * Get memory usage statistics. 352 | * 353 | * @return array 354 | */ 355 | public function getMemoryStats(): array 356 | { 357 | return [ 358 | 'loaded_chunks' => count($this->loadedChunks), 359 | 'total_chunks' => count($this->chunkKeys), 360 | 'memory_usage' => strlen(serialize($this->loadedChunks)), 361 | 'total_items' => $this->totalItems, 362 | 'chunk_size' => $this->chunkSize, 363 | ]; 364 | } 365 | } 366 | 367 | -------------------------------------------------------------------------------- /src/Console/Commands/ClearCommand.php: -------------------------------------------------------------------------------- 1 | argument('key'); 30 | 31 | if ($specificKey) { 32 | return $this->clearSpecificKey($cache, $specificKey); 33 | } 34 | 35 | return $this->clearAllKeys($cache); 36 | } 37 | 38 | /** 39 | * Clear a specific cache key. 40 | */ 41 | protected function clearSpecificKey(SmartCache $cache, string $key): int 42 | { 43 | $managedKeys = $cache->getManagedKeys(); 44 | $isManaged = in_array($key, $managedKeys); 45 | $force = $this->option('force'); 46 | 47 | // Check if key exists in cache (either managed or regular Laravel cache) 48 | $keyExists = $cache->has($key); 49 | 50 | if (!$isManaged && !$force) { 51 | if ($keyExists) { 52 | $this->error("Cache key '{$key}' exists but is not managed by SmartCache. Use --force to clear it anyway."); 53 | } else { 54 | $this->error("Cache key '{$key}' is not managed by SmartCache or does not exist."); 55 | } 56 | return 1; 57 | } 58 | 59 | if (!$keyExists) { 60 | $this->error("Cache key '{$key}' does not exist."); 61 | return 1; 62 | } 63 | 64 | if ($isManaged) { 65 | $this->info("Clearing SmartCache managed item with key '{$key}'..."); 66 | $success = $cache->forget($key); 67 | } else { 68 | $this->info("Clearing cache item with key '{$key}' (not managed by SmartCache)..."); 69 | // Use the underlying Laravel cache to clear non-managed keys 70 | $success = $cache->store()->forget($key); 71 | } 72 | 73 | if ($success) { 74 | $this->info("Cache key '{$key}' has been cleared successfully."); 75 | return 0; 76 | } else { 77 | $this->error("Failed to clear cache key '{$key}'."); 78 | return 1; 79 | } 80 | } 81 | 82 | /** 83 | * Clear all SmartCache managed keys. 84 | */ 85 | protected function clearAllKeys(SmartCache $cache): int 86 | { 87 | $keys = $cache->getManagedKeys(); 88 | $count = count($keys); 89 | $force = $this->option('force'); 90 | 91 | if ($count === 0) { 92 | if ($force) { 93 | $this->info('No SmartCache managed items found. Checking for non-managed cache keys...'); 94 | return $this->clearOrphanedKeys($cache); 95 | } else { 96 | $this->info('No SmartCache managed items found.'); 97 | return 0; 98 | } 99 | } 100 | 101 | $this->info("Clearing {$count} SmartCache managed items..."); 102 | 103 | // Clean up expired keys first and report 104 | $expiredCleaned = $cache->cleanupExpiredManagedKeys(); 105 | if ($expiredCleaned > 0) { 106 | $this->info("Cleaned up {$expiredCleaned} expired keys from tracking list."); 107 | } 108 | 109 | // Get updated key count after cleanup 110 | $keys = $cache->getManagedKeys(); 111 | $actualCount = count($keys); 112 | 113 | if ($actualCount === 0) { 114 | $this->info('All managed keys were expired and have been cleaned up.'); 115 | return 0; 116 | } 117 | 118 | $this->info("Clearing {$actualCount} active SmartCache managed items..."); 119 | 120 | $success = $cache->clear(); 121 | 122 | if ($success) { 123 | $this->info('All SmartCache managed items have been cleared successfully.'); 124 | 125 | if ($force) { 126 | $this->info('Checking for non-managed cache keys...'); 127 | $this->clearOrphanedKeys($cache); 128 | } 129 | 130 | return 0; 131 | } else { 132 | $this->error('Some SmartCache items could not be cleared.'); 133 | $this->comment('This may be due to cache driver limitations or permission issues.'); 134 | return 1; 135 | } 136 | } 137 | 138 | /** 139 | * Clear orphaned keys (with --force, clears ALL non-managed keys). 140 | */ 141 | protected function clearOrphanedKeys(SmartCache $cache): int 142 | { 143 | $repository = $cache->store(); 144 | $store = $repository->getStore(); 145 | $cleared = 0; 146 | $managedKeys = $cache->getManagedKeys(); 147 | 148 | // Try to get all cache keys (this varies by cache driver) 149 | try { 150 | $allKeys = $this->getAllCacheKeys($store); 151 | 152 | foreach ($allKeys as $key) { 153 | // With --force, clear ALL keys that are not currently managed by SmartCache 154 | // but exclude SmartCache internal keys 155 | if (!in_array($key, $managedKeys) && !$this->isSmartCacheInternalKey($key)) { 156 | if ($repository->forget($key)) { 157 | $cleared++; 158 | $this->line("Cleared key: {$key}"); 159 | } 160 | } 161 | } 162 | 163 | if ($cleared > 0) { 164 | $this->info("Cleared {$cleared} non-managed cache keys."); 165 | } else { 166 | $this->info('No non-managed cache keys found.'); 167 | } 168 | 169 | return 0; 170 | } catch (\Exception $e) { 171 | $this->warn('Could not scan for non-managed keys with this cache driver. Only managed keys were cleared.'); 172 | return 0; 173 | } 174 | } 175 | 176 | /** 177 | * Get all cache keys (driver-dependent). 178 | */ 179 | protected function getAllCacheKeys($store): array 180 | { 181 | $storeClass = get_class($store); 182 | 183 | // Redis store 184 | if (str_contains($storeClass, 'Redis')) { 185 | return $store->connection()->keys('*'); 186 | } 187 | 188 | // Array store (used in testing) 189 | if (str_contains($storeClass, 'ArrayStore')) { 190 | return $this->getArrayStoreKeys($store); 191 | } 192 | 193 | // For other drivers, we can't easily get all keys 194 | // This is a limitation of Laravel's cache abstraction 195 | throw new \Exception('Cannot enumerate keys for this cache driver'); 196 | } 197 | 198 | /** 199 | * Get all keys from ArrayStore (Laravel version compatible). 200 | */ 201 | protected function getArrayStoreKeys($store): array 202 | { 203 | // Try the newer method first (Laravel 10+) 204 | if (method_exists($store, 'all')) { 205 | return array_keys($store->all(false)); // false to avoid unserializing values 206 | } 207 | 208 | // Fall back to reflection for older versions or when all() doesn't exist 209 | try { 210 | $reflection = new \ReflectionClass($store); 211 | $storageProperty = $reflection->getProperty('storage'); 212 | $storageProperty->setAccessible(true); 213 | $storage = $storageProperty->getValue($store); 214 | 215 | return array_keys($storage ?? []); 216 | } catch (\ReflectionException $e) { 217 | // If reflection fails, return empty array 218 | return []; 219 | } 220 | } 221 | 222 | /** 223 | * Check if a key is a SmartCache internal key. 224 | */ 225 | protected function isSmartCacheInternalKey(string $key): bool 226 | { 227 | // Look for SmartCache-specific patterns (internal keys that shouldn't be cleared as "non-managed") 228 | return str_contains($key, '_sc_') || 229 | str_contains($key, '_sc_meta') || 230 | str_contains($key, '_sc_chunk_') || 231 | $key === '_sc_managed_keys'; 232 | } 233 | 234 | } -------------------------------------------------------------------------------- /src/Console/Commands/StatusCommand.php: -------------------------------------------------------------------------------- 1 | getManagedKeys(); 32 | $count = count($keys); 33 | $force = $this->option('force'); 34 | 35 | $this->info('SmartCache Status'); 36 | $this->line('----------------'); 37 | $this->line(''); 38 | 39 | // Display driver information 40 | $this->line('Cache Driver: ' . Cache::getDefaultDriver()); 41 | $this->line(''); 42 | 43 | // Display managed keys count 44 | $this->line("Managed Keys: {$count}"); 45 | 46 | // If there are keys, show some examples 47 | if ($count > 0) { 48 | $this->line(''); 49 | $this->line('Examples:'); 50 | 51 | $sampleKeys = array_slice($keys, 0, min(5, $count)); 52 | 53 | foreach ($sampleKeys as $key) { 54 | $this->line(" - {$key}"); 55 | } 56 | 57 | if ($count > 5) { 58 | $this->line(" - ... and " . ($count - 5) . " more"); 59 | } 60 | } 61 | 62 | // Force option: Scan for orphaned SmartCache keys 63 | if ($force) { 64 | $this->line(''); 65 | $this->line('Laravel Cache Analysis (--force):'); 66 | $this->line('-----------------------------------'); 67 | 68 | $nonManagedKeys = $this->findAllNonManagedKeys($cache); 69 | 70 | if (!empty($nonManagedKeys)) { 71 | $this->line(''); 72 | $this->warn("Found " . count($nonManagedKeys) . " non-managed Laravel cache keys:"); 73 | 74 | $sampleNonManaged = array_slice($nonManagedKeys, 0, min(10, count($nonManagedKeys))); 75 | foreach ($sampleNonManaged as $key) { 76 | $this->line(" ! {$key}"); 77 | } 78 | 79 | if (count($nonManagedKeys) > 10) { 80 | $this->line(" ! ... and " . (count($nonManagedKeys) - 10) . " more"); 81 | } 82 | 83 | $this->line(''); 84 | $this->comment('These keys are stored in Laravel cache but not managed by SmartCache.'); 85 | $this->comment('Consider running: php artisan smart-cache:clear --force'); 86 | } else { 87 | $this->line(''); 88 | $this->info('✓ No non-managed cache keys found.'); 89 | } 90 | 91 | // Check if managed keys actually exist in cache 92 | $missingKeys = []; 93 | foreach ($keys as $key) { 94 | if (!$cache->has($key)) { 95 | $missingKeys[] = $key; 96 | } 97 | } 98 | 99 | if (!empty($missingKeys)) { 100 | $this->line(''); 101 | $this->warn("Found " . count($missingKeys) . " managed keys that no longer exist in cache:"); 102 | 103 | $sampleMissing = array_slice($missingKeys, 0, min(5, count($missingKeys))); 104 | foreach ($sampleMissing as $key) { 105 | $this->line(" ? {$key}"); 106 | } 107 | 108 | if (count($missingKeys) > 5) { 109 | $this->line(" ? ... and " . (count($missingKeys) - 5) . " more"); 110 | } 111 | 112 | $this->line(''); 113 | $this->comment('These managed keys no longer exist in the cache store.'); 114 | } else if ($count > 0) { 115 | $this->line(''); 116 | $this->info('✓ All managed keys exist in cache.'); 117 | } 118 | } 119 | 120 | // Display configuration 121 | $configData = $config->get('smart-cache'); 122 | $this->line(''); 123 | $this->line('Configuration:'); 124 | $this->line(' - Compression: ' . ($configData['strategies']['compression']['enabled'] ? 'Enabled' : 'Disabled')); 125 | $this->line(' * Threshold: ' . number_format($configData['thresholds']['compression'] / 1024, 2) . ' KB'); 126 | $this->line(' * Level: ' . $configData['strategies']['compression']['level']); 127 | $this->line(' - Chunking: ' . ($configData['strategies']['chunking']['enabled'] ? 'Enabled' : 'Disabled')); 128 | $this->line(' * Threshold: ' . number_format($configData['thresholds']['chunking'] / 1024, 2) . ' KB'); 129 | $this->line(' * Chunk Size: ' . $configData['strategies']['chunking']['chunk_size'] . ' items'); 130 | 131 | return 0; 132 | } 133 | 134 | /** 135 | * Find all non-managed keys in the cache. 136 | */ 137 | protected function findAllNonManagedKeys(SmartCache $cache): array 138 | { 139 | $repository = $cache->store(); 140 | $store = $repository->getStore(); 141 | $managedKeys = $cache->getManagedKeys(); 142 | $nonManagedKeys = []; 143 | 144 | try { 145 | $allKeys = $this->getAllCacheKeys($store); 146 | 147 | foreach ($allKeys as $key) { 148 | // Check if key is not managed by SmartCache and not a SmartCache internal key 149 | if (!in_array($key, $managedKeys) && !$this->isSmartCacheInternalKey($key)) { 150 | $nonManagedKeys[] = $key; 151 | } 152 | } 153 | 154 | return $nonManagedKeys; 155 | } catch (\Exception $e) { 156 | // If we can't scan keys, return empty array 157 | return []; 158 | } 159 | } 160 | 161 | /** 162 | * Get all cache keys (driver-dependent). 163 | */ 164 | protected function getAllCacheKeys($store): array 165 | { 166 | $storeClass = get_class($store); 167 | 168 | // Redis store 169 | if (str_contains($storeClass, 'Redis')) { 170 | return $store->connection()->keys('*'); 171 | } 172 | 173 | // Array store (used in testing) 174 | if (str_contains($storeClass, 'ArrayStore')) { 175 | return $this->getArrayStoreKeys($store); 176 | } 177 | 178 | // For other drivers, we can't easily get all keys 179 | // This is a limitation of Laravel's cache abstraction 180 | throw new \Exception('Cannot enumerate keys for this cache driver'); 181 | } 182 | 183 | /** 184 | * Get all keys from ArrayStore (Laravel version compatible). 185 | */ 186 | protected function getArrayStoreKeys($store): array 187 | { 188 | // Try the newer method first (Laravel 10+) 189 | if (method_exists($store, 'all')) { 190 | return array_keys($store->all(false)); // false to avoid unserializing values 191 | } 192 | 193 | // Fall back to reflection for older versions or when all() doesn't exist 194 | try { 195 | $reflection = new \ReflectionClass($store); 196 | $storageProperty = $reflection->getProperty('storage'); 197 | $storageProperty->setAccessible(true); 198 | $storage = $storageProperty->getValue($store); 199 | 200 | return array_keys($storage ?? []); 201 | } catch (\ReflectionException $e) { 202 | // If reflection fails, return empty array 203 | return []; 204 | } 205 | } 206 | 207 | /** 208 | * Check if a key is a SmartCache internal key. 209 | */ 210 | protected function isSmartCacheInternalKey(string $key): bool 211 | { 212 | // Look for SmartCache-specific patterns (internal keys that shouldn't be shown as "non-managed") 213 | return str_contains($key, '_sc_') || 214 | str_contains($key, '_sc_meta') || 215 | str_contains($key, '_sc_chunk_') || 216 | $key === '_sc_managed_keys'; 217 | } 218 | 219 | } -------------------------------------------------------------------------------- /src/Contracts/OptimizationStrategy.php: -------------------------------------------------------------------------------- 1 | key = $key; 38 | $this->value = $value; 39 | $this->tags = $tags; 40 | } 41 | } 42 | 43 | -------------------------------------------------------------------------------- /src/Events/CacheMissed.php: -------------------------------------------------------------------------------- 1 | key = $key; 30 | $this->tags = $tags; 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/Events/KeyForgotten.php: -------------------------------------------------------------------------------- 1 | key = $key; 30 | $this->tags = $tags; 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/Events/KeyWritten.php: -------------------------------------------------------------------------------- 1 | key = $key; 46 | $this->value = $value; 47 | $this->seconds = $seconds; 48 | $this->tags = $tags; 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /src/Events/OptimizationApplied.php: -------------------------------------------------------------------------------- 1 | key = $key; 53 | $this->strategy = $strategy; 54 | $this->originalSize = $originalSize; 55 | $this->optimizedSize = $optimizedSize; 56 | $this->ratio = $optimizedSize > 0 ? $optimizedSize / $originalSize : 0; 57 | } 58 | } 59 | 60 | -------------------------------------------------------------------------------- /src/Facades/SmartCache.php: -------------------------------------------------------------------------------- 1 | invalidateCache($model); 19 | } 20 | 21 | /** 22 | * Handle the model "updated" event. 23 | * 24 | * @param Model $model 25 | * @return void 26 | */ 27 | public function updated(Model $model): void 28 | { 29 | $this->invalidateCache($model); 30 | } 31 | 32 | /** 33 | * Handle the model "deleted" event. 34 | * 35 | * @param Model $model 36 | * @return void 37 | */ 38 | public function deleted(Model $model): void 39 | { 40 | $this->invalidateCache($model); 41 | } 42 | 43 | /** 44 | * Handle the model "restored" event. 45 | * 46 | * @param Model $model 47 | * @return void 48 | */ 49 | public function restored(Model $model): void 50 | { 51 | $this->invalidateCache($model); 52 | } 53 | 54 | /** 55 | * Perform cache invalidation if the model uses the CacheInvalidation trait. 56 | * 57 | * @param Model $model 58 | * @return void 59 | */ 60 | protected function invalidateCache(Model $model): void 61 | { 62 | if ($this->usesCacheInvalidationTrait($model)) { 63 | $model->performCacheInvalidation(); 64 | } 65 | } 66 | 67 | /** 68 | * Check if the model uses the CacheInvalidation trait. 69 | * 70 | * @param Model $model 71 | * @return bool 72 | */ 73 | protected function usesCacheInvalidationTrait(Model $model): bool 74 | { 75 | return in_array(CacheInvalidation::class, class_uses_recursive($model)); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Providers/SmartCacheServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom( 23 | __DIR__ . '/../../config/smart-cache.php', 'smart-cache' 24 | ); 25 | 26 | // Register the service 27 | $this->app->singleton(SmartCacheContract::class, function ($app) { 28 | $cacheManager = $app['cache']; 29 | $config = $app['config']; 30 | 31 | // Create strategies based on config 32 | // Order matters: more specific strategies (chunking) should be tried first 33 | $strategies = []; 34 | 35 | if ($config->get('smart-cache.strategies.chunking.enabled', true)) { 36 | $strategies[] = new ChunkingStrategy( 37 | $config->get('smart-cache.thresholds.chunking', 102400), 38 | $config->get('smart-cache.strategies.chunking.chunk_size', 1000), 39 | $config->get('smart-cache.strategies.chunking.lazy_loading', false), 40 | $config->get('smart-cache.strategies.chunking.smart_sizing', false) 41 | ); 42 | } 43 | 44 | if ($config->get('smart-cache.strategies.compression.enabled', true)) { 45 | $compressionMode = $config->get('smart-cache.strategies.compression.mode', 'fixed'); 46 | 47 | if ($compressionMode === 'adaptive') { 48 | $strategies[] = new AdaptiveCompressionStrategy( 49 | $config->get('smart-cache.thresholds.compression', 51200), 50 | $config->get('smart-cache.strategies.compression.level', 6), 51 | $config->get('smart-cache.strategies.compression.adaptive.sample_size', 1024), 52 | $config->get('smart-cache.strategies.compression.adaptive.high_compression_threshold', 0.5), 53 | $config->get('smart-cache.strategies.compression.adaptive.low_compression_threshold', 0.7), 54 | $config->get('smart-cache.strategies.compression.adaptive.frequency_threshold', 100) 55 | ); 56 | } else { 57 | $strategies[] = new CompressionStrategy( 58 | $config->get('smart-cache.thresholds.compression', 51200), 59 | $config->get('smart-cache.strategies.compression.level', 6) 60 | ); 61 | } 62 | } 63 | 64 | return new SmartCache($cacheManager->store(), $cacheManager, $config, $strategies); 65 | }); 66 | 67 | // Register alias for facade 68 | $this->app->alias(SmartCacheContract::class, 'smart-cache'); 69 | } 70 | 71 | /** 72 | * Bootstrap services. 73 | */ 74 | public function boot(): void 75 | { 76 | // Publish config 77 | $this->publishes([ 78 | __DIR__ . '/../../config/smart-cache.php' => $this->app->configPath('smart-cache.php'), 79 | ], 'smart-cache-config'); 80 | 81 | // Register commands 82 | if ($this->app->runningInConsole()) { 83 | $this->commands([ 84 | ClearCommand::class, 85 | StatusCommand::class, 86 | ]); 87 | } 88 | 89 | // Register command metadata for HTTP context 90 | $this->app->singleton('smart-cache.commands', function ($app) { 91 | return [ 92 | 'smart-cache:clear' => [ 93 | 'class' => ClearCommand::class, 94 | 'description' => 'Clear SmartCache managed items', 95 | 'signature' => 'smart-cache:clear {key? : The specific cache key to clear} {--force : Force clear keys even if not managed by SmartCache}' 96 | ], 97 | 'smart-cache:status' => [ 98 | 'class' => StatusCommand::class, 99 | 'description' => 'Display information about SmartCache usage and configuration', 100 | 'signature' => 'smart-cache:status {--force : Include Laravel cache analysis and orphaned SmartCache keys}' 101 | ] 102 | ]; 103 | }); 104 | } 105 | } -------------------------------------------------------------------------------- /src/Services/CacheInvalidationService.php: -------------------------------------------------------------------------------- 1 | smartCache = $smartCache; 15 | } 16 | 17 | /** 18 | * Flush cache by multiple patterns with advanced matching. 19 | * 20 | * @param array $patterns 21 | * @return int Number of keys invalidated 22 | */ 23 | public function flushPatterns(array $patterns): int 24 | { 25 | $invalidated = 0; 26 | $managedKeys = $this->smartCache->getManagedKeys(); 27 | 28 | foreach ($patterns as $pattern) { 29 | foreach ($managedKeys as $key) { 30 | if ($this->matchesAdvancedPattern($key, $pattern)) { 31 | $result = $this->smartCache->forget($key); 32 | if ($result) { 33 | $invalidated++; 34 | } 35 | } 36 | } 37 | } 38 | 39 | return $invalidated; 40 | } 41 | 42 | /** 43 | * Invalidate cache based on model relationships. 44 | * 45 | * @param string $modelClass 46 | * @param mixed $modelId 47 | * @param array $relationships 48 | * @return int Number of keys invalidated 49 | */ 50 | public function invalidateModelRelations(string $modelClass, mixed $modelId, array $relationships = []): int 51 | { 52 | $invalidated = 0; 53 | $basePatterns = [ 54 | $modelClass . '_' . $modelId . '_*', 55 | $modelClass . '_*_' . $modelId, 56 | strtolower(class_basename($modelClass)) . '_' . $modelId . '_*', 57 | ]; 58 | 59 | // Add relationship-based patterns 60 | foreach ($relationships as $relation) { 61 | $basePatterns[] = $relation . '_*_' . $modelClass . '_' . $modelId; 62 | $basePatterns[] = $modelClass . '_' . $modelId . '_' . $relation . '_*'; 63 | } 64 | 65 | $invalidated += $this->flushPatterns($basePatterns); 66 | 67 | return $invalidated; 68 | } 69 | 70 | /** 71 | * Set up cache warming for frequently accessed keys. 72 | * 73 | * @param array $warmingRules 74 | * @return void 75 | */ 76 | public function setupCacheWarming(array $warmingRules): void 77 | { 78 | foreach ($warmingRules as $rule) { 79 | if (isset($rule['key'], $rule['callback'], $rule['ttl'])) { 80 | // Warm the cache if it doesn't exist or is about to expire 81 | if (!$this->smartCache->has($rule['key'])) { 82 | $value = $rule['callback'](); 83 | $this->smartCache->put($rule['key'], $value, $rule['ttl']); 84 | } 85 | } 86 | } 87 | } 88 | 89 | /** 90 | * Create cache hierarchies for organized invalidation. 91 | * 92 | * @param string $parentKey 93 | * @param array $childKeys 94 | * @return void 95 | */ 96 | public function createCacheHierarchy(string $parentKey, array $childKeys): void 97 | { 98 | foreach ($childKeys as $childKey) { 99 | $this->smartCache->dependsOn($childKey, $parentKey); 100 | } 101 | } 102 | 103 | /** 104 | * Advanced pattern matching with regex and wildcard support. 105 | * 106 | * @param string $key 107 | * @param string $pattern 108 | * @return bool 109 | */ 110 | protected function matchesAdvancedPattern(string $key, string $pattern): bool 111 | { 112 | try { 113 | // Handle regex patterns (starting with /) 114 | if (str_starts_with($pattern, '/') && str_ends_with($pattern, '/')) { 115 | $result = preg_match($pattern, $key); 116 | return $result === 1; 117 | } 118 | 119 | // Handle glob patterns (* and ?) 120 | if (str_contains($pattern, '*') || str_contains($pattern, '?')) { 121 | // Convert glob pattern to regex pattern 122 | $regexPattern = preg_quote($pattern, '/'); 123 | $regexPattern = str_replace(['\*', '\?'], ['.*', '.'], $regexPattern); 124 | $result = preg_match("/^{$regexPattern}$/", $key); 125 | return $result === 1; 126 | } 127 | 128 | // Exact match 129 | return $key === $pattern; 130 | } catch (\Exception $e) { 131 | // If pattern matching fails, return false to prevent errors 132 | return false; 133 | } 134 | } 135 | 136 | /** 137 | * Get cache statistics and analytics. 138 | * 139 | * @return array 140 | */ 141 | public function getCacheStatistics(): array 142 | { 143 | $managedKeys = $this->smartCache->getManagedKeys(); 144 | $stats = [ 145 | 'managed_keys_count' => count($managedKeys), 146 | 'tag_usage' => [], 147 | 'dependency_chains' => [], 148 | 'optimization_stats' => [ 149 | 'compressed' => 0, 150 | 'chunked' => 0, 151 | 'unoptimized' => 0, 152 | ], 153 | ]; 154 | 155 | // Analyze optimization usage 156 | foreach ($managedKeys as $key) { 157 | $value = $this->smartCache->get($key); 158 | if (is_array($value)) { 159 | if (isset($value['_sc_compressed'])) { 160 | $stats['optimization_stats']['compressed']++; 161 | } elseif (isset($value['_sc_chunked'])) { 162 | $stats['optimization_stats']['chunked']++; 163 | } else { 164 | $stats['optimization_stats']['unoptimized']++; 165 | } 166 | } else { 167 | $stats['optimization_stats']['unoptimized']++; 168 | } 169 | } 170 | 171 | return $stats; 172 | } 173 | 174 | /** 175 | * Perform cache health check and cleanup. 176 | * 177 | * @return array 178 | */ 179 | public function healthCheckAndCleanup(): array 180 | { 181 | $results = [ 182 | 'orphaned_chunks_cleaned' => 0, 183 | 'broken_dependencies_fixed' => 0, 184 | 'invalid_tags_removed' => 0, 185 | 'expired_keys_cleaned' => 0, 186 | 'total_keys_checked' => 0, 187 | ]; 188 | 189 | $managedKeys = $this->smartCache->getManagedKeys(); 190 | $results['total_keys_checked'] = count($managedKeys); 191 | 192 | // Clean up expired managed keys first 193 | $results['expired_keys_cleaned'] = $this->smartCache->cleanupExpiredManagedKeys(); 194 | 195 | // Check for orphaned chunks 196 | foreach ($managedKeys as $key) { 197 | $value = $this->smartCache->get($key); 198 | if (is_array($value) && isset($value['_sc_chunked'])) { 199 | $missingChunks = 0; 200 | foreach ($value['chunk_keys'] as $chunkKey) { 201 | if (!$this->smartCache->has($chunkKey)) { 202 | $missingChunks++; 203 | } 204 | } 205 | 206 | if ($missingChunks > 0) { 207 | // Key has missing chunks, remove it 208 | $this->smartCache->forget($key); 209 | $results['orphaned_chunks_cleaned']++; 210 | } 211 | } 212 | } 213 | 214 | return $results; 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/Services/SmartChunkSizeCalculator.php: -------------------------------------------------------------------------------- 1 | 512 * 1024 * 1024, // 512MB 22 | 'memcached' => 1024 * 1024, // 1MB 23 | 'file' => PHP_INT_MAX, // No practical limit 24 | 'database' => 16 * 1024 * 1024, // 16MB (typical BLOB limit) 25 | 'dynamodb' => 400 * 1024, // 400KB 26 | 'array' => PHP_INT_MAX, // No limit 27 | 'default' => 1024 * 1024, // 1MB default 28 | ]; 29 | 30 | /** 31 | * Calculate optimal chunk size for the given data and driver. 32 | * 33 | * @param array $data 34 | * @param string|null $driver 35 | * @param int $defaultChunkSize 36 | * @return int 37 | */ 38 | public function calculateOptimalSize(array $data, ?string $driver = null, int $defaultChunkSize = 1000): int 39 | { 40 | // Get driver limit 41 | $maxValueSize = $this->getDriverLimit($driver); 42 | 43 | // Analyze data characteristics 44 | $totalItems = count($data); 45 | $avgItemSize = $this->calculateAverageItemSize($data); 46 | $totalSize = $totalItems * $avgItemSize; 47 | 48 | // If data is small, don't chunk 49 | if ($totalSize < 100 * 1024) { // < 100KB 50 | return $totalItems; // Return all items in one chunk 51 | } 52 | 53 | // Calculate chunk size based on driver limit 54 | // Use 80% of max size to leave safety margin 55 | $safeMaxSize = (int) ($maxValueSize * 0.8); 56 | $optimalChunkSize = $avgItemSize > 0 57 | ? (int) floor($safeMaxSize / $avgItemSize) 58 | : $defaultChunkSize; 59 | 60 | // Ensure minimum chunk size 61 | $optimalChunkSize = max($optimalChunkSize, 100); 62 | 63 | // Adjust based on total data size 64 | if ($totalSize > 10 * 1024 * 1024) { // > 10MB 65 | // Use smaller chunks for very large datasets 66 | $optimalChunkSize = min($optimalChunkSize, 500); 67 | } elseif ($totalSize < 1024 * 1024) { // < 1MB 68 | // Use larger chunks for smaller datasets 69 | $optimalChunkSize = min($optimalChunkSize, 5000); 70 | } 71 | 72 | // Ensure we don't exceed total items 73 | $optimalChunkSize = min($optimalChunkSize, $totalItems); 74 | 75 | return $optimalChunkSize; 76 | } 77 | 78 | /** 79 | * Get the maximum value size for a driver. 80 | * 81 | * @param string|null $driver 82 | * @return int 83 | */ 84 | public function getDriverLimit(?string $driver): int 85 | { 86 | if (!$driver) { 87 | return $this->driverLimits['default']; 88 | } 89 | 90 | return $this->driverLimits[$driver] ?? $this->driverLimits['default']; 91 | } 92 | 93 | /** 94 | * Calculate the average item size in the array. 95 | * 96 | * @param array $data 97 | * @param int $sampleSize 98 | * @return int 99 | */ 100 | protected function calculateAverageItemSize(array $data, int $sampleSize = 100): int 101 | { 102 | if (empty($data)) { 103 | return 0; 104 | } 105 | 106 | $totalItems = count($data); 107 | $samplesToTake = min($sampleSize, $totalItems); 108 | 109 | // Sample random items 110 | $samples = array_rand($data, $samplesToTake); 111 | if (!is_array($samples)) { 112 | $samples = [$samples]; 113 | } 114 | 115 | $totalSize = 0; 116 | foreach ($samples as $key) { 117 | $totalSize += strlen(serialize($data[$key])); 118 | } 119 | 120 | return (int) ceil($totalSize / $samplesToTake); 121 | } 122 | 123 | /** 124 | * Calculate the number of chunks needed. 125 | * 126 | * @param int $totalItems 127 | * @param int $chunkSize 128 | * @return int 129 | */ 130 | public function calculateChunkCount(int $totalItems, int $chunkSize): int 131 | { 132 | return (int) ceil($totalItems / $chunkSize); 133 | } 134 | 135 | /** 136 | * Get chunking recommendations for the given data. 137 | * 138 | * @param array $data 139 | * @param string|null $driver 140 | * @return array 141 | */ 142 | public function getRecommendations(array $data, ?string $driver = null): array 143 | { 144 | $totalItems = count($data); 145 | $avgItemSize = $this->calculateAverageItemSize($data); 146 | $totalSize = $totalItems * $avgItemSize; 147 | $optimalChunkSize = $this->calculateOptimalSize($data, $driver); 148 | $chunkCount = $this->calculateChunkCount($totalItems, $optimalChunkSize); 149 | 150 | return [ 151 | 'total_items' => $totalItems, 152 | 'total_size' => $totalSize, 153 | 'avg_item_size' => $avgItemSize, 154 | 'optimal_chunk_size' => $optimalChunkSize, 155 | 'chunk_count' => $chunkCount, 156 | 'driver' => $driver ?? 'default', 157 | 'driver_limit' => $this->getDriverLimit($driver), 158 | 'should_chunk' => $totalSize > 100 * 1024, // Recommend chunking for > 100KB 159 | 'estimated_chunk_size' => $optimalChunkSize * $avgItemSize, 160 | ]; 161 | } 162 | 163 | /** 164 | * Validate that chunk size is safe for the driver. 165 | * 166 | * @param int $chunkSize 167 | * @param int $avgItemSize 168 | * @param string|null $driver 169 | * @return bool 170 | */ 171 | public function isChunkSizeSafe(int $chunkSize, int $avgItemSize, ?string $driver = null): bool 172 | { 173 | $estimatedChunkBytes = $chunkSize * $avgItemSize; 174 | $driverLimit = $this->getDriverLimit($driver); 175 | 176 | // Use 80% of limit as safety margin 177 | return $estimatedChunkBytes <= ($driverLimit * 0.8); 178 | } 179 | 180 | /** 181 | * Set custom driver limit. 182 | * 183 | * @param string $driver 184 | * @param int $limit 185 | * @return void 186 | */ 187 | public function setDriverLimit(string $driver, int $limit): void 188 | { 189 | $this->driverLimits[$driver] = $limit; 190 | } 191 | 192 | /** 193 | * Get all driver limits. 194 | * 195 | * @return array 196 | */ 197 | public function getDriverLimits(): array 198 | { 199 | return $this->driverLimits; 200 | } 201 | } 202 | 203 | -------------------------------------------------------------------------------- /src/Strategies/AdaptiveCompressionStrategy.php: -------------------------------------------------------------------------------- 1 | threshold = $threshold; 67 | $this->defaultLevel = $defaultLevel; 68 | $this->sampleSize = $sampleSize; 69 | $this->highCompressionThreshold = $highCompressionThreshold; 70 | $this->lowCompressionThreshold = $lowCompressionThreshold; 71 | $this->frequencyThreshold = $frequencyThreshold; 72 | } 73 | 74 | /** 75 | * {@inheritdoc} 76 | */ 77 | public function shouldApply(mixed $value, array $context = []): bool 78 | { 79 | // Check if driver supports compression 80 | if (isset($context['driver']) && 81 | isset($context['config']['drivers'][$context['driver']]['compression']) && 82 | $context['config']['drivers'][$context['driver']]['compression'] === false) { 83 | return false; 84 | } 85 | 86 | // Only compress strings and serializable objects/arrays 87 | if (!is_string($value) && !is_array($value) && !is_object($value)) { 88 | return false; 89 | } 90 | 91 | // Convert to string to measure size 92 | $serialized = is_string($value) ? $value : serialize($value); 93 | 94 | return strlen($serialized) > $this->threshold; 95 | } 96 | 97 | /** 98 | * {@inheritdoc} 99 | */ 100 | public function optimize(mixed $value, array $context = []): mixed 101 | { 102 | $isString = is_string($value); 103 | $data = $isString ? $value : serialize($value); 104 | 105 | // Select optimal compression level 106 | $level = $this->selectCompressionLevel($data, $context); 107 | 108 | $compressed = gzencode($data, $level); 109 | 110 | return [ 111 | '_sc_compressed' => true, 112 | '_sc_adaptive' => true, 113 | 'data' => base64_encode($compressed), 114 | 'is_string' => $isString, 115 | 'level' => $level, 116 | 'original_size' => strlen($data), 117 | 'compressed_size' => strlen($compressed), 118 | ]; 119 | } 120 | 121 | /** 122 | * {@inheritdoc} 123 | */ 124 | public function restore(mixed $value, array $context = []): mixed 125 | { 126 | if (!is_array($value) || !isset($value['_sc_compressed']) || $value['_sc_compressed'] !== true) { 127 | return $value; 128 | } 129 | 130 | $decompressed = gzdecode(base64_decode($value['data'])); 131 | 132 | if ($value['is_string']) { 133 | return $decompressed; 134 | } 135 | 136 | return unserialize($decompressed); 137 | } 138 | 139 | /** 140 | * {@inheritdoc} 141 | */ 142 | public function getIdentifier(): string 143 | { 144 | return 'adaptive_compression'; 145 | } 146 | 147 | /** 148 | * Select the optimal compression level based on data characteristics. 149 | * 150 | * @param string $data 151 | * @param array $context 152 | * @return int 153 | */ 154 | protected function selectCompressionLevel(string $data, array $context): int 155 | { 156 | $dataSize = strlen($data); 157 | 158 | // For very small data, use fast compression 159 | if ($dataSize < $this->threshold * 2) { 160 | return 3; 161 | } 162 | 163 | // Test compressibility with a sample 164 | $sampleSize = min($this->sampleSize, $dataSize); 165 | $sample = substr($data, 0, $sampleSize); 166 | $testCompressed = gzcompress($sample, $this->defaultLevel); 167 | $compressionRatio = strlen($testCompressed) / strlen($sample); 168 | 169 | // Get access frequency from context 170 | $accessFrequency = $this->getAccessFrequency($context['key'] ?? null); 171 | 172 | // Determine level based on compressibility and access frequency 173 | $level = $this->defaultLevel; 174 | 175 | // If data compresses very well, use higher compression 176 | if ($compressionRatio < $this->highCompressionThreshold) { 177 | $level = 9; 178 | } 179 | // If data doesn't compress well, use lower compression 180 | elseif ($compressionRatio > $this->lowCompressionThreshold) { 181 | $level = 3; 182 | } 183 | // Medium compressibility, use default 184 | else { 185 | $level = $this->defaultLevel; 186 | } 187 | 188 | // For frequently accessed data, prioritize speed over compression ratio 189 | if ($accessFrequency > $this->frequencyThreshold) { 190 | $level = min($level, 3); 191 | } 192 | 193 | // For very large data, consider using higher compression to save space 194 | if ($dataSize > 1024 * 1024 * 10 && $level < 9) { // > 10MB 195 | $level = min($level + 2, 9); 196 | } 197 | 198 | return $level; 199 | } 200 | 201 | /** 202 | * Get the access frequency for a key. 203 | * 204 | * @param string|null $key 205 | * @return int 206 | */ 207 | protected function getAccessFrequency(?string $key): int 208 | { 209 | if (!$key) { 210 | return 0; 211 | } 212 | 213 | $frequencyKey = "_sc_access_freq_{$key}"; 214 | 215 | try { 216 | return (int) Cache::get($frequencyKey, 0); 217 | } catch (\Exception $e) { 218 | return 0; 219 | } 220 | } 221 | 222 | /** 223 | * Track access frequency for a key. 224 | * 225 | * @param string $key 226 | * @return void 227 | */ 228 | public function trackAccess(string $key): void 229 | { 230 | $frequencyKey = "_sc_access_freq_{$key}"; 231 | 232 | try { 233 | Cache::increment($frequencyKey); 234 | 235 | // Set TTL if not already set (24 hours) 236 | if (!Cache::has($frequencyKey . '_ttl')) { 237 | Cache::put($frequencyKey . '_ttl', true, 86400); 238 | } 239 | } catch (\Exception $e) { 240 | // Silently fail if cache doesn't support increment 241 | } 242 | } 243 | 244 | /** 245 | * Get compression statistics for monitoring. 246 | * 247 | * @param mixed $optimizedValue 248 | * @return array 249 | */ 250 | public function getCompressionStats(mixed $optimizedValue): array 251 | { 252 | if (!is_array($optimizedValue) || !isset($optimizedValue['_sc_adaptive'])) { 253 | return []; 254 | } 255 | 256 | return [ 257 | 'level' => $optimizedValue['level'] ?? null, 258 | 'original_size' => $optimizedValue['original_size'] ?? 0, 259 | 'compressed_size' => $optimizedValue['compressed_size'] ?? 0, 260 | 'ratio' => $optimizedValue['compressed_size'] > 0 261 | ? $optimizedValue['compressed_size'] / $optimizedValue['original_size'] 262 | : 0, 263 | 'savings_bytes' => ($optimizedValue['original_size'] ?? 0) - ($optimizedValue['compressed_size'] ?? 0), 264 | 'savings_percent' => $optimizedValue['original_size'] > 0 265 | ? (1 - ($optimizedValue['compressed_size'] / $optimizedValue['original_size'])) * 100 266 | : 0, 267 | ]; 268 | } 269 | } 270 | 271 | -------------------------------------------------------------------------------- /src/Strategies/ChunkingStrategy.php: -------------------------------------------------------------------------------- 1 | threshold = $threshold; 51 | $this->chunkSize = $chunkSize; 52 | $this->lazyLoading = $lazyLoading; 53 | $this->smartSizing = $smartSizing; 54 | 55 | if ($smartSizing) { 56 | $this->sizeCalculator = new SmartChunkSizeCalculator(); 57 | } 58 | } 59 | 60 | /** 61 | * {@inheritdoc} 62 | */ 63 | public function shouldApply(mixed $value, array $context = []): bool 64 | { 65 | // Check if driver supports chunking 66 | if (isset($context['driver']) && 67 | isset($context['config']['drivers'][$context['driver']]['chunking']) && 68 | $context['config']['drivers'][$context['driver']]['chunking'] === false) { 69 | return false; 70 | } 71 | 72 | // Only chunk arrays and array-like objects 73 | if (!is_array($value) && !($value instanceof \Traversable)) { 74 | return false; 75 | } 76 | 77 | // For Laravel collections, get the underlying array 78 | if (class_exists('\Illuminate\Support\Collection') && $value instanceof \Illuminate\Support\Collection) { 79 | $value = $value->all(); 80 | } 81 | 82 | $serialized = serialize($value); 83 | 84 | // Check if size exceeds threshold and the array is large enough to benefit from chunking 85 | return strlen($serialized) > $this->threshold && (is_array($value) && count($value) > $this->chunkSize); 86 | } 87 | 88 | /** 89 | * {@inheritdoc} 90 | */ 91 | public function optimize(mixed $value, array $context = []): mixed 92 | { 93 | // Convert to array if it's a collection 94 | if (class_exists('\Illuminate\Support\Collection') && $value instanceof \Illuminate\Support\Collection) { 95 | $isCollection = true; 96 | $value = $value->all(); 97 | } else { 98 | $isCollection = false; 99 | } 100 | 101 | // Get cache instance from context 102 | $cache = $context['cache'] ?? null; 103 | $prefix = $context['key'] ?? uniqid('chunk_'); 104 | $ttl = $context['ttl'] ?? null; 105 | $driver = $context['driver'] ?? null; 106 | 107 | // Calculate optimal chunk size if smart sizing is enabled 108 | $chunkSize = $this->chunkSize; 109 | if ($this->smartSizing && $this->sizeCalculator) { 110 | $chunkSize = $this->sizeCalculator->calculateOptimalSize($value, $driver, $this->chunkSize); 111 | } 112 | 113 | $chunks = array_chunk($value, $chunkSize, true); 114 | $chunkKeys = []; 115 | 116 | // Store each chunk separately 117 | foreach ($chunks as $index => $chunk) { 118 | $chunkKey = "_sc_chunk_{$prefix}_{$index}"; 119 | $chunkKeys[] = $chunkKey; 120 | 121 | if ($cache) { 122 | $cache->put($chunkKey, $chunk, $ttl); 123 | } 124 | } 125 | 126 | return [ 127 | '_sc_chunked' => true, 128 | 'chunk_keys' => $chunkKeys, 129 | 'total_items' => count($value), 130 | 'is_collection' => $isCollection, 131 | 'original_key' => $prefix, 132 | 'driver' => $driver, 133 | 'lazy_loading' => $this->lazyLoading, 134 | 'chunk_size' => $chunkSize, // Use actual chunk size (may be different if smart sizing is enabled) 135 | ]; 136 | } 137 | 138 | /** 139 | * {@inheritdoc} 140 | */ 141 | public function restore(mixed $value, array $context = []): mixed 142 | { 143 | if (!is_array($value) || !isset($value['_sc_chunked']) || $value['_sc_chunked'] !== true) { 144 | return $value; 145 | } 146 | 147 | $cache = $context['cache'] ?? null; 148 | if (!$cache) { 149 | throw new \RuntimeException('Cache repository is required to restore chunked data'); 150 | } 151 | 152 | // Check if lazy loading is enabled 153 | $lazyLoading = $value['lazy_loading'] ?? $this->lazyLoading; 154 | 155 | // Get the chunk size that was used (may be different if smart sizing was enabled) 156 | $chunkSize = $value['chunk_size'] ?? $this->chunkSize; 157 | 158 | if ($lazyLoading) { 159 | // Return lazy collection 160 | return new LazyChunkedCollection( 161 | $cache, 162 | $value['chunk_keys'], 163 | $chunkSize, 164 | $value['total_items'], 165 | $value['is_collection'] ?? false 166 | ); 167 | } 168 | 169 | $result = []; 170 | 171 | // Retrieve and merge all chunks (eager loading) 172 | foreach ($value['chunk_keys'] as $chunkKey) { 173 | $chunk = $cache->get($chunkKey); 174 | if ($chunk === null) { 175 | // If any chunk is missing, return null indicating cache miss 176 | return null; 177 | } 178 | 179 | $result = array_merge($result, $chunk); 180 | } 181 | 182 | // Convert back to collection if needed 183 | if ($value['is_collection'] && class_exists('\Illuminate\Support\Collection')) { 184 | return new \Illuminate\Support\Collection($result); 185 | } 186 | 187 | return $result; 188 | } 189 | 190 | /** 191 | * {@inheritdoc} 192 | */ 193 | public function getIdentifier(): string 194 | { 195 | return 'chunking'; 196 | } 197 | } -------------------------------------------------------------------------------- /src/Strategies/CompressionStrategy.php: -------------------------------------------------------------------------------- 1 | threshold = $threshold; 28 | $this->level = $level; 29 | } 30 | 31 | /** 32 | * {@inheritdoc} 33 | */ 34 | public function shouldApply(mixed $value, array $context = []): bool 35 | { 36 | // Check if driver supports compression 37 | if (isset($context['driver']) && 38 | isset($context['config']['drivers'][$context['driver']]['compression']) && 39 | $context['config']['drivers'][$context['driver']]['compression'] === false) { 40 | return false; 41 | } 42 | 43 | // Only compress strings and serializable objects/arrays 44 | if (!is_string($value) && !is_array($value) && !is_object($value)) { 45 | return false; 46 | } 47 | 48 | // Convert to string to measure size 49 | $serialized = is_string($value) ? $value : serialize($value); 50 | 51 | return strlen($serialized) > $this->threshold; 52 | } 53 | 54 | /** 55 | * {@inheritdoc} 56 | */ 57 | public function optimize(mixed $value, array $context = []): mixed 58 | { 59 | $isString = is_string($value); 60 | $data = $isString ? $value : serialize($value); 61 | 62 | $compressed = gzencode($data, $this->level); 63 | 64 | return [ 65 | '_sc_compressed' => true, 66 | 'data' => base64_encode($compressed), 67 | 'is_string' => $isString, 68 | ]; 69 | } 70 | 71 | /** 72 | * {@inheritdoc} 73 | */ 74 | public function restore(mixed $value, array $context = []): mixed 75 | { 76 | if (!is_array($value) || !isset($value['_sc_compressed']) || $value['_sc_compressed'] !== true) { 77 | return $value; 78 | } 79 | 80 | $decompressed = gzdecode(base64_decode($value['data'])); 81 | 82 | if ($value['is_string']) { 83 | return $decompressed; 84 | } 85 | 86 | return unserialize($decompressed); 87 | } 88 | 89 | /** 90 | * {@inheritdoc} 91 | */ 92 | public function getIdentifier(): string 93 | { 94 | return 'compression'; 95 | } 96 | } -------------------------------------------------------------------------------- /src/Strategies/SmartSerializationStrategy.php: -------------------------------------------------------------------------------- 1 | preferredMethod = $preferredMethod; 36 | $this->autoDetect = $autoDetect; 37 | } 38 | 39 | /** 40 | * {@inheritdoc} 41 | */ 42 | public function shouldApply(mixed $value, array $context = []): bool 43 | { 44 | // This strategy can be applied to any value 45 | // It's more about choosing the right serialization method 46 | return true; 47 | } 48 | 49 | /** 50 | * {@inheritdoc} 51 | */ 52 | public function optimize(mixed $value, array $context = []): mixed 53 | { 54 | $method = $this->selectSerializationMethod($value); 55 | 56 | return [ 57 | '_sc_serialized' => true, 58 | 'method' => $method, 59 | 'data' => $this->serialize($value, $method), 60 | ]; 61 | } 62 | 63 | /** 64 | * {@inheritdoc} 65 | */ 66 | public function restore(mixed $value, array $context = []): mixed 67 | { 68 | if (!is_array($value) || !isset($value['_sc_serialized']) || $value['_sc_serialized'] !== true) { 69 | return $value; 70 | } 71 | 72 | return $this->unserialize($value['data'], $value['method']); 73 | } 74 | 75 | /** 76 | * {@inheritdoc} 77 | */ 78 | public function getIdentifier(): string 79 | { 80 | return 'smart_serialization'; 81 | } 82 | 83 | /** 84 | * Select the best serialization method for the given value. 85 | * 86 | * @param mixed $value 87 | * @return string 88 | */ 89 | protected function selectSerializationMethod(mixed $value): string 90 | { 91 | // If not auto-detecting, use preferred method 92 | if (!$this->autoDetect) { 93 | return $this->getAvailableMethod($this->preferredMethod); 94 | } 95 | 96 | // Auto-detect best method 97 | 98 | // Try JSON first for simple data (fastest and most compact) 99 | if ($this->isJsonSafe($value)) { 100 | return 'json'; 101 | } 102 | 103 | // Use igbinary if available (more compact than PHP serialize) 104 | if (function_exists('igbinary_serialize')) { 105 | return 'igbinary'; 106 | } 107 | 108 | // Fallback to PHP serialize 109 | return 'php'; 110 | } 111 | 112 | /** 113 | * Check if a value can be safely serialized with JSON. 114 | * 115 | * @param mixed $value 116 | * @return bool 117 | */ 118 | protected function isJsonSafe(mixed $value): bool 119 | { 120 | // JSON can't handle objects (except stdClass), resources, or closures 121 | if (is_object($value) && !($value instanceof \stdClass)) { 122 | return false; 123 | } 124 | 125 | if (is_resource($value) || $value instanceof \Closure) { 126 | return false; 127 | } 128 | 129 | // For arrays, check recursively 130 | if (is_array($value)) { 131 | foreach ($value as $item) { 132 | if (!$this->isJsonSafe($item)) { 133 | return false; 134 | } 135 | } 136 | } 137 | 138 | // Try encoding to verify 139 | $encoded = json_encode($value); 140 | if ($encoded === false || json_last_error() !== JSON_ERROR_NONE) { 141 | return false; 142 | } 143 | 144 | return true; 145 | } 146 | 147 | /** 148 | * Get an available serialization method. 149 | * 150 | * @param string $method 151 | * @return string 152 | */ 153 | protected function getAvailableMethod(string $method): string 154 | { 155 | if ($method === 'auto') { 156 | return 'php'; 157 | } 158 | 159 | if ($method === 'igbinary' && !function_exists('igbinary_serialize')) { 160 | return 'php'; 161 | } 162 | 163 | return $method; 164 | } 165 | 166 | /** 167 | * Serialize a value using the specified method. 168 | * 169 | * @param mixed $value 170 | * @param string $method 171 | * @return string 172 | */ 173 | protected function serialize(mixed $value, string $method): string 174 | { 175 | return match ($method) { 176 | 'json' => json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 177 | 'igbinary' => base64_encode(igbinary_serialize($value)), 178 | 'php' => base64_encode(serialize($value)), 179 | default => base64_encode(serialize($value)), 180 | }; 181 | } 182 | 183 | /** 184 | * Unserialize a value using the specified method. 185 | * 186 | * @param string $data 187 | * @param string $method 188 | * @return mixed 189 | */ 190 | protected function unserialize(string $data, string $method): mixed 191 | { 192 | return match ($method) { 193 | 'json' => json_decode($data, true), 194 | 'igbinary' => igbinary_unserialize(base64_decode($data)), 195 | 'php' => unserialize(base64_decode($data)), 196 | default => unserialize(base64_decode($data)), 197 | }; 198 | } 199 | 200 | /** 201 | * Get serialization statistics for monitoring. 202 | * 203 | * @param mixed $value 204 | * @return array 205 | */ 206 | public function getSerializationStats(mixed $value): array 207 | { 208 | $stats = []; 209 | 210 | // Test JSON 211 | if ($this->isJsonSafe($value)) { 212 | $jsonData = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); 213 | $stats['json'] = [ 214 | 'size' => strlen($jsonData), 215 | 'available' => true, 216 | ]; 217 | } else { 218 | $stats['json'] = [ 219 | 'size' => null, 220 | 'available' => false, 221 | ]; 222 | } 223 | 224 | // Test igbinary 225 | if (function_exists('igbinary_serialize')) { 226 | $igbinaryData = igbinary_serialize($value); 227 | $stats['igbinary'] = [ 228 | 'size' => strlen($igbinaryData), 229 | 'available' => true, 230 | ]; 231 | } else { 232 | $stats['igbinary'] = [ 233 | 'size' => null, 234 | 'available' => false, 235 | ]; 236 | } 237 | 238 | // Test PHP serialize 239 | $phpData = serialize($value); 240 | $stats['php'] = [ 241 | 'size' => strlen($phpData), 242 | 'available' => true, 243 | ]; 244 | 245 | // Determine best method 246 | $bestMethod = 'php'; 247 | $bestSize = $stats['php']['size']; 248 | 249 | if ($stats['json']['available'] && $stats['json']['size'] < $bestSize) { 250 | $bestMethod = 'json'; 251 | $bestSize = $stats['json']['size']; 252 | } 253 | 254 | if ($stats['igbinary']['available'] && $stats['igbinary']['size'] < $bestSize) { 255 | $bestMethod = 'igbinary'; 256 | $bestSize = $stats['igbinary']['size']; 257 | } 258 | 259 | $stats['recommended'] = $bestMethod; 260 | $stats['best_size'] = $bestSize; 261 | 262 | return $stats; 263 | } 264 | } 265 | 266 | -------------------------------------------------------------------------------- /src/Traits/CacheInvalidation.php: -------------------------------------------------------------------------------- 1 | [], 17 | 'tags' => [], 18 | 'patterns' => [], 19 | 'dependencies' => [], 20 | ]; 21 | 22 | /** 23 | * Boot the cache invalidation trait. 24 | */ 25 | protected static function bootCacheInvalidation(): void 26 | { 27 | static::observe(CacheInvalidationObserver::class); 28 | } 29 | 30 | /** 31 | * Define cache keys to invalidate when this model changes. 32 | * 33 | * @param array $keys 34 | * @return static 35 | */ 36 | public function invalidatesKeys(array $keys): static 37 | { 38 | $this->cacheInvalidation['keys'] = array_merge($this->cacheInvalidation['keys'], $keys); 39 | return $this; 40 | } 41 | 42 | /** 43 | * Define cache tags to flush when this model changes. 44 | * 45 | * @param array $tags 46 | * @return static 47 | */ 48 | public function invalidatesTags(array $tags): static 49 | { 50 | $this->cacheInvalidation['tags'] = array_merge($this->cacheInvalidation['tags'], $tags); 51 | return $this; 52 | } 53 | 54 | /** 55 | * Define cache key patterns to invalidate when this model changes. 56 | * 57 | * @param array $patterns 58 | * @return static 59 | */ 60 | public function invalidatesPatterns(array $patterns): static 61 | { 62 | $this->cacheInvalidation['patterns'] = array_merge($this->cacheInvalidation['patterns'], $patterns); 63 | return $this; 64 | } 65 | 66 | /** 67 | * Define cache dependencies when this model changes. 68 | * 69 | * @param array $dependencies 70 | * @return static 71 | */ 72 | public function invalidatesDependencies(array $dependencies): static 73 | { 74 | $this->cacheInvalidation['dependencies'] = array_merge($this->cacheInvalidation['dependencies'], $dependencies); 75 | return $this; 76 | } 77 | 78 | /** 79 | * Get cache invalidation configuration. 80 | * 81 | * @return array 82 | */ 83 | public function getCacheInvalidationConfig(): array 84 | { 85 | return $this->cacheInvalidation; 86 | } 87 | 88 | /** 89 | * Get cache keys to invalidate for this model instance. 90 | * Override this method to define dynamic cache keys. 91 | * 92 | * @return array 93 | */ 94 | public function getCacheKeysToInvalidate(): array 95 | { 96 | $keys = $this->cacheInvalidation['keys']; 97 | 98 | // Add dynamic keys based on model attributes 99 | $dynamicKeys = []; 100 | foreach ($keys as $key) { 101 | // Replace placeholders like {id}, {slug}, etc. 102 | $dynamicKey = preg_replace_callback('/\{(\w+)\}/', function ($matches) { 103 | $attribute = $matches[1]; 104 | return $this->getAttribute($attribute) ?? $matches[0]; 105 | }, $key); 106 | $dynamicKeys[] = $dynamicKey; 107 | } 108 | 109 | return array_merge($keys, $dynamicKeys); 110 | } 111 | 112 | /** 113 | * Get cache tags to flush for this model instance. 114 | * Override this method to define dynamic cache tags. 115 | * 116 | * @return array 117 | */ 118 | public function getCacheTagsToFlush(): array 119 | { 120 | $tags = $this->cacheInvalidation['tags']; 121 | 122 | // Add dynamic tags based on model attributes 123 | $dynamicTags = []; 124 | foreach ($tags as $tag) { 125 | // Replace placeholders like {id}, {category_id}, etc. 126 | $dynamicTag = preg_replace_callback('/\{(\w+)\}/', function ($matches) { 127 | $attribute = $matches[1]; 128 | return $this->getAttribute($attribute) ?? $matches[0]; 129 | }, $tag); 130 | $dynamicTags[] = $dynamicTag; 131 | } 132 | 133 | return array_merge($tags, $dynamicTags); 134 | } 135 | 136 | /** 137 | * Perform cache invalidation. 138 | * 139 | * @return void 140 | */ 141 | public function performCacheInvalidation(): void 142 | { 143 | // Invalidate specific keys 144 | foreach ($this->getCacheKeysToInvalidate() as $key) { 145 | SmartCache::forget($key); 146 | } 147 | 148 | // Flush tags 149 | $tagsToFlush = $this->getCacheTagsToFlush(); 150 | if (!empty($tagsToFlush)) { 151 | SmartCache::flushTags($tagsToFlush); 152 | } 153 | 154 | // Handle patterns (basic wildcard support) 155 | foreach ($this->cacheInvalidation['patterns'] as $pattern) { 156 | $this->invalidatePattern($pattern); 157 | } 158 | 159 | // Handle dependencies 160 | foreach ($this->cacheInvalidation['dependencies'] as $dependency) { 161 | SmartCache::invalidate($dependency); 162 | } 163 | } 164 | 165 | /** 166 | * Invalidate cache keys matching a pattern. 167 | * 168 | * @param string $pattern 169 | * @return void 170 | */ 171 | protected function invalidatePattern(string $pattern): void 172 | { 173 | // Replace model placeholders 174 | $pattern = preg_replace_callback('/\{(\w+)\}/', function ($matches) { 175 | $attribute = $matches[1]; 176 | return $this->getAttribute($attribute) ?? $matches[0]; 177 | }, $pattern); 178 | 179 | // This is a simplified pattern matching 180 | // In a real implementation, you might want to use the cache store's 181 | // native pattern matching capabilities (like Redis KEYS command) 182 | $managedKeys = SmartCache::getManagedKeys(); 183 | 184 | foreach ($managedKeys as $key) { 185 | if ($this->matchesPattern($key, $pattern)) { 186 | SmartCache::forget($key); 187 | } 188 | } 189 | } 190 | 191 | /** 192 | * Check if a key matches a pattern with basic wildcard support. 193 | * 194 | * @param string $key 195 | * @param string $pattern 196 | * @return bool 197 | */ 198 | protected function matchesPattern(string $key, string $pattern): bool 199 | { 200 | // Convert simple wildcard pattern to regex 201 | $regexPattern = str_replace(['*', '?'], ['.*', '.'], preg_quote($pattern, '/')); 202 | return (bool) preg_match("/^{$regexPattern}$/", $key); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/Traits/DispatchesCacheEvents.php: -------------------------------------------------------------------------------- 1 | shouldDispatchEvent('cache_hit')) { 24 | Event::dispatch(new CacheHit($key, $value, $this->activeTags)); 25 | } 26 | } 27 | 28 | /** 29 | * Dispatch a cache missed event. 30 | * 31 | * @param string $key 32 | * @return void 33 | */ 34 | protected function dispatchCacheMissed(string $key): void 35 | { 36 | if ($this->shouldDispatchEvent('cache_missed')) { 37 | Event::dispatch(new CacheMissed($key, $this->activeTags)); 38 | } 39 | } 40 | 41 | /** 42 | * Dispatch a key written event. 43 | * 44 | * @param string $key 45 | * @param mixed $value 46 | * @param int|null $seconds 47 | * @return void 48 | */ 49 | protected function dispatchKeyWritten(string $key, mixed $value, ?int $seconds = null): void 50 | { 51 | if ($this->shouldDispatchEvent('key_written')) { 52 | Event::dispatch(new KeyWritten($key, $value, $seconds, $this->activeTags)); 53 | } 54 | } 55 | 56 | /** 57 | * Dispatch a key forgotten event. 58 | * 59 | * @param string $key 60 | * @return void 61 | */ 62 | protected function dispatchKeyForgotten(string $key): void 63 | { 64 | if ($this->shouldDispatchEvent('key_forgotten')) { 65 | Event::dispatch(new KeyForgotten($key, $this->activeTags)); 66 | } 67 | } 68 | 69 | /** 70 | * Dispatch an optimization applied event. 71 | * 72 | * @param string $key 73 | * @param string $strategy 74 | * @param int $originalSize 75 | * @param int $optimizedSize 76 | * @return void 77 | */ 78 | protected function dispatchOptimizationApplied(string $key, string $strategy, int $originalSize, int $optimizedSize): void 79 | { 80 | if ($this->shouldDispatchEvent('optimization_applied')) { 81 | Event::dispatch(new OptimizationApplied($key, $strategy, $originalSize, $optimizedSize)); 82 | } 83 | } 84 | 85 | /** 86 | * Determine if an event should be dispatched. 87 | * 88 | * @param string $eventType 89 | * @return bool 90 | */ 91 | protected function shouldDispatchEvent(string $eventType): bool 92 | { 93 | if (!$this->config->get('smart-cache.events.enabled', false)) { 94 | return false; 95 | } 96 | 97 | return $this->config->get("smart-cache.events.dispatch.{$eventType}", true); 98 | } 99 | } 100 | 101 | -------------------------------------------------------------------------------- /src/Traits/HasLocks.php: -------------------------------------------------------------------------------- 1 | cache->getStore(); 21 | 22 | if (!$store instanceof LockProvider) { 23 | throw new \RuntimeException( 24 | "Cache store [" . get_class($store) . "] does not support atomic locks. " . 25 | "Please use a cache driver that implements LockProvider (redis, memcached, dynamodb, database, file, array)." 26 | ); 27 | } 28 | 29 | return $store->lock($name, $seconds, $owner); 30 | } 31 | 32 | /** 33 | * Restore a lock instance using the owner identifier. 34 | * 35 | * @param string $name 36 | * @param string $owner 37 | * @return Lock 38 | */ 39 | public function restoreLock(string $name, string $owner): Lock 40 | { 41 | $store = $this->cache->getStore(); 42 | 43 | if (!$store instanceof LockProvider) { 44 | throw new \RuntimeException( 45 | "Cache store [" . get_class($store) . "] does not support atomic locks." 46 | ); 47 | } 48 | 49 | return $store->restoreLock($name, $owner); 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | make('smart-cache'); 22 | } 23 | 24 | if (is_string($arguments[0])) { 25 | return SmartCache::get(...$arguments); 26 | } 27 | 28 | if (!is_array($arguments[0])) { 29 | throw new InvalidArgumentException( 30 | 'When using smart_cache(), the first argument must be an array of key / value pairs or a string.' 31 | ); 32 | } 33 | 34 | return SmartCache::put(key($arguments[0]), reset($arguments[0]), $arguments[1] ?? null); 35 | } 36 | } -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | loadPackageConfiguration(); 24 | } 25 | 26 | /** 27 | * Get package providers. 28 | * 29 | * @param \Illuminate\Foundation\Application $app 30 | * @return array 31 | */ 32 | protected function getPackageProviders($app): array 33 | { 34 | return [ 35 | SmartCacheServiceProvider::class, 36 | ]; 37 | } 38 | 39 | /** 40 | * Define aliases. 41 | * 42 | * @param \Illuminate\Foundation\Application $app 43 | * @return array 44 | */ 45 | protected function getPackageAliases($app): array 46 | { 47 | return [ 48 | 'SmartCache' => \SmartCache\Facades\SmartCache::class, 49 | ]; 50 | } 51 | 52 | /** 53 | * Define environment setup. 54 | * 55 | * @param \Illuminate\Foundation\Application $app 56 | */ 57 | protected function defineEnvironment($app): void 58 | { 59 | // Setup the application environment 60 | $app['config']->set('app.key', 'base64:' . base64_encode(random_bytes(32))); 61 | $app['config']->set('app.debug', true); 62 | 63 | // Setup cache configuration 64 | $app['config']->set('cache.default', 'array'); 65 | $app['config']->set('cache.stores.array', [ 66 | 'driver' => 'array', 67 | 'serialize' => false, 68 | ]); 69 | 70 | $app['config']->set('cache.stores.file', [ 71 | 'driver' => 'file', 72 | 'path' => __DIR__ . '/storage/framework/cache', 73 | ]); 74 | 75 | // Setup SmartCache configuration 76 | $app['config']->set('smart-cache', [ 77 | 'strategies' => [ 78 | 'compression' => [ 79 | 'enabled' => true, 80 | 'level' => 6, 81 | ], 82 | 'chunking' => [ 83 | 'enabled' => true, 84 | 'chunk_size' => 1000, 85 | ], 86 | ], 87 | 'thresholds' => [ 88 | 'compression' => 1024, // 1KB for testing (lower than default) 89 | 'chunking' => 2048, // 2KB for testing (lower than default) 90 | ], 91 | 'fallback' => [ 92 | 'enabled' => true, 93 | 'log_errors' => false, // Disable logging in tests 94 | ], 95 | ]); 96 | } 97 | 98 | /** 99 | * Load package configuration for testing. 100 | */ 101 | protected function loadPackageConfiguration(): void 102 | { 103 | // This method can be overridden in individual test classes 104 | // to provide specific configuration for different test scenarios 105 | } 106 | 107 | /** 108 | * Get a cache manager instance for testing. 109 | */ 110 | protected function getCacheManager(): CacheManager 111 | { 112 | return $this->app['cache']; 113 | } 114 | 115 | /** 116 | * Get a specific cache store for testing. 117 | */ 118 | protected function getCacheStore(string|null $store = null) 119 | { 120 | return $this->getCacheManager()->store($store); 121 | } 122 | 123 | /** 124 | * Create a large test dataset for optimization testing. 125 | */ 126 | protected function createLargeTestData(int $size = 2000): array 127 | { 128 | $data = []; 129 | for ($i = 0; $i < $size; $i++) { 130 | $data[] = [ 131 | 'id' => $i, 132 | 'name' => 'Test Item ' . $i, 133 | 'description' => str_repeat('Lorem ipsum dolor sit amet, consectetur adipiscing elit. ', 10), 134 | 'metadata' => [ 135 | 'created_at' => now()->toISOString(), 136 | 'tags' => ['tag1', 'tag2', 'tag3'], 137 | 'nested' => [ 138 | 'level1' => [ 139 | 'level2' => [ 140 | 'data' => str_repeat('nested data ', 20) 141 | ] 142 | ] 143 | ] 144 | ] 145 | ]; 146 | } 147 | 148 | return $data; 149 | } 150 | 151 | /** 152 | * Create test data that should trigger compression. 153 | */ 154 | protected function createCompressibleData(): string 155 | { 156 | // Create repetitive string that compresses well 157 | return str_repeat('This is test data that should compress well. ', 100); 158 | } 159 | 160 | /** 161 | * Create test data that should trigger chunking. 162 | */ 163 | protected function createChunkableData(): array 164 | { 165 | return $this->createLargeTestData(1200); // Creates much larger data to ensure chunking threshold is met 166 | } 167 | 168 | /** 169 | * Assert that a value is optimized (not the original value). 170 | */ 171 | protected function assertValueIsOptimized($original, $cached): void 172 | { 173 | $this->assertNotEquals($original, $cached, 'Value should be optimized and different from original'); 174 | } 175 | 176 | /** 177 | * Assert that a value has compression metadata. 178 | */ 179 | protected function assertValueIsCompressed($value): void 180 | { 181 | $this->assertIsArray($value, 'Compressed value should be an array'); 182 | $this->assertArrayHasKey('_sc_compressed', $value, 'Compressed value should have compression marker'); 183 | $this->assertTrue($value['_sc_compressed'], 'Compression marker should be true'); 184 | $this->assertArrayHasKey('data', $value, 'Compressed value should have data key'); 185 | } 186 | 187 | /** 188 | * Assert that a value has chunking metadata. 189 | */ 190 | protected function assertValueIsChunked($value): void 191 | { 192 | $this->assertIsArray($value, 'Chunked value should be an array'); 193 | $this->assertArrayHasKey('_sc_chunked', $value, 'Chunked value should have chunking marker'); 194 | $this->assertTrue($value['_sc_chunked'], 'Chunking marker should be true'); 195 | $this->assertArrayHasKey('chunk_keys', $value, 'Chunked value should have chunk_keys'); 196 | $this->assertArrayHasKey('total_items', $value, 'Chunked value should have total_items'); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /tests/Unit/BatchOperationsTest.php: -------------------------------------------------------------------------------- 1 | assertEquals([ 19 | 'key1' => 'value1', 20 | 'key2' => 'value2', 21 | 'key3' => 'value3', 22 | ], $values); 23 | } 24 | 25 | public function test_many_handles_missing_keys() 26 | { 27 | SmartCache::put('key1', 'value1', 60); 28 | 29 | $values = SmartCache::many(['key1', 'key2', 'key3']); 30 | 31 | $this->assertEquals('value1', $values['key1']); 32 | $this->assertNull($values['key2']); 33 | $this->assertNull($values['key3']); 34 | } 35 | 36 | public function test_put_many_stores_multiple_keys() 37 | { 38 | $result = SmartCache::putMany([ 39 | 'key1' => 'value1', 40 | 'key2' => 'value2', 41 | 'key3' => 'value3', 42 | ], 60); 43 | 44 | $this->assertTrue($result); 45 | $this->assertEquals('value1', SmartCache::get('key1')); 46 | $this->assertEquals('value2', SmartCache::get('key2')); 47 | $this->assertEquals('value3', SmartCache::get('key3')); 48 | } 49 | 50 | public function test_put_many_with_large_data() 51 | { 52 | $data = []; 53 | for ($i = 0; $i < 100; $i++) { 54 | $data["key_{$i}"] = array_fill(0, 1000, "value_{$i}"); 55 | } 56 | 57 | $result = SmartCache::putMany($data, 60); 58 | 59 | $this->assertTrue($result); 60 | 61 | // Verify a few keys 62 | $this->assertCount(1000, SmartCache::get('key_0')); 63 | $this->assertCount(1000, SmartCache::get('key_50')); 64 | $this->assertCount(1000, SmartCache::get('key_99')); 65 | } 66 | 67 | public function test_delete_multiple_removes_multiple_keys() 68 | { 69 | SmartCache::put('key1', 'value1', 60); 70 | SmartCache::put('key2', 'value2', 60); 71 | SmartCache::put('key3', 'value3', 60); 72 | 73 | $result = SmartCache::deleteMultiple(['key1', 'key2']); 74 | 75 | $this->assertTrue($result); 76 | $this->assertNull(SmartCache::get('key1')); 77 | $this->assertNull(SmartCache::get('key2')); 78 | $this->assertEquals('value3', SmartCache::get('key3')); 79 | } 80 | 81 | public function test_delete_multiple_handles_nonexistent_keys() 82 | { 83 | SmartCache::put('key1', 'value1', 60); 84 | 85 | $result = SmartCache::deleteMultiple(['key1', 'key2', 'key3']); 86 | 87 | // Returns false if any key doesn't exist 88 | $this->assertFalse($result); 89 | // But key1 should still be deleted 90 | $this->assertNull(SmartCache::get('key1')); 91 | } 92 | 93 | public function test_many_with_optimized_data() 94 | { 95 | // Store large data that will be optimized 96 | $largeData1 = array_fill(0, 10000, 'data1'); 97 | $largeData2 = array_fill(0, 10000, 'data2'); 98 | $largeData3 = array_fill(0, 10000, 'data3'); 99 | 100 | SmartCache::put('large1', $largeData1, 60); 101 | SmartCache::put('large2', $largeData2, 60); 102 | SmartCache::put('large3', $largeData3, 60); 103 | 104 | $values = SmartCache::many(['large1', 'large2', 'large3']); 105 | 106 | $this->assertCount(10000, $values['large1']); 107 | $this->assertCount(10000, $values['large2']); 108 | $this->assertCount(10000, $values['large3']); 109 | } 110 | 111 | public function test_put_many_with_different_ttls() 112 | { 113 | SmartCache::putMany([ 114 | 'key1' => 'value1', 115 | 'key2' => 'value2', 116 | ], 60); 117 | 118 | SmartCache::putMany([ 119 | 'key3' => 'value3', 120 | 'key4' => 'value4', 121 | ], 120); 122 | 123 | $this->assertEquals('value1', SmartCache::get('key1')); 124 | $this->assertEquals('value2', SmartCache::get('key2')); 125 | $this->assertEquals('value3', SmartCache::get('key3')); 126 | $this->assertEquals('value4', SmartCache::get('key4')); 127 | } 128 | 129 | public function test_many_operations_performance() 130 | { 131 | // Prepare data 132 | $keys = []; 133 | $data = []; 134 | for ($i = 0; $i < 100; $i++) { 135 | $key = "key_{$i}"; 136 | $keys[] = $key; 137 | $data[$key] = "value_{$i}"; 138 | } 139 | 140 | // Store data 141 | SmartCache::putMany($data, 60); 142 | 143 | // Measure many() performance 144 | $start = microtime(true); 145 | $values = SmartCache::many($keys); 146 | $duration = microtime(true) - $start; 147 | 148 | $this->assertCount(100, $values); 149 | // Should be reasonably fast (< 100ms) 150 | $this->assertLessThan(0.1, $duration); 151 | } 152 | 153 | public function test_batch_operations_with_tags() 154 | { 155 | SmartCache::tags(['users'])->putMany([ 156 | 'user1' => 'John', 157 | 'user2' => 'Jane', 158 | 'user3' => 'Bob', 159 | ], 60); 160 | 161 | $values = SmartCache::tags(['users'])->many(['user1', 'user2', 'user3']); 162 | 163 | $this->assertEquals([ 164 | 'user1' => 'John', 165 | 'user2' => 'Jane', 166 | 'user3' => 'Bob', 167 | ], $values); 168 | 169 | // Flush by tag 170 | SmartCache::tags(['users'])->flush(); 171 | 172 | $values = SmartCache::many(['user1', 'user2', 'user3']); 173 | $this->assertNull($values['user1']); 174 | $this->assertNull($values['user2']); 175 | $this->assertNull($values['user3']); 176 | } 177 | } 178 | 179 | -------------------------------------------------------------------------------- /tests/Unit/Collections/LazyChunkedCollectionTest.php: -------------------------------------------------------------------------------- 1 | app['cache']->store(); 14 | 15 | // Create chunks 16 | $cache->put('chunk_0', range(0, 99), 60); 17 | $cache->put('chunk_1', range(100, 199), 60); 18 | $cache->put('chunk_2', range(200, 299), 60); 19 | 20 | $collection = new LazyChunkedCollection( 21 | $cache, 22 | ['chunk_0', 'chunk_1', 'chunk_2'], 23 | 100, 24 | 300 25 | ); 26 | 27 | $this->assertCount(300, $collection); 28 | } 29 | 30 | public function test_lazy_collection_iteration() 31 | { 32 | $cache = $this->app['cache']->store(); 33 | 34 | $cache->put('chunk_0', ['a', 'b', 'c'], 60); 35 | $cache->put('chunk_1', ['d', 'e', 'f'], 60); 36 | 37 | $collection = new LazyChunkedCollection( 38 | $cache, 39 | ['chunk_0', 'chunk_1'], 40 | 3, 41 | 6 42 | ); 43 | 44 | $items = []; 45 | foreach ($collection as $item) { 46 | $items[] = $item; 47 | } 48 | 49 | $this->assertEquals(['a', 'b', 'c', 'd', 'e', 'f'], $items); 50 | } 51 | 52 | public function test_lazy_collection_array_access() 53 | { 54 | $cache = $this->app['cache']->store(); 55 | 56 | $cache->put('chunk_0', range(0, 99), 60); 57 | $cache->put('chunk_1', range(100, 199), 60); 58 | 59 | $collection = new LazyChunkedCollection( 60 | $cache, 61 | ['chunk_0', 'chunk_1'], 62 | 100, 63 | 200 64 | ); 65 | 66 | $this->assertEquals(0, $collection[0]); 67 | $this->assertEquals(50, $collection[50]); 68 | $this->assertEquals(100, $collection[100]); 69 | $this->assertEquals(150, $collection[150]); 70 | } 71 | 72 | public function test_lazy_collection_to_array() 73 | { 74 | $cache = $this->app['cache']->store(); 75 | 76 | $cache->put('chunk_0', ['a', 'b'], 60); 77 | $cache->put('chunk_1', ['c', 'd'], 60); 78 | 79 | $collection = new LazyChunkedCollection( 80 | $cache, 81 | ['chunk_0', 'chunk_1'], 82 | 2, 83 | 4 84 | ); 85 | 86 | $array = $collection->toArray(); 87 | 88 | $this->assertEquals(['a', 'b', 'c', 'd'], $array); 89 | } 90 | 91 | public function test_lazy_collection_slice() 92 | { 93 | $cache = $this->app['cache']->store(); 94 | 95 | $cache->put('chunk_0', range(0, 99), 60); 96 | $cache->put('chunk_1', range(100, 199), 60); 97 | $cache->put('chunk_2', range(200, 299), 60); 98 | 99 | $collection = new LazyChunkedCollection( 100 | $cache, 101 | ['chunk_0', 'chunk_1', 'chunk_2'], 102 | 100, 103 | 300 104 | ); 105 | 106 | $slice = $collection->slice(50, 10); 107 | 108 | $this->assertEquals(range(50, 59), $slice); 109 | } 110 | 111 | public function test_lazy_collection_each() 112 | { 113 | $cache = $this->app['cache']->store(); 114 | 115 | $cache->put('chunk_0', [1, 2, 3], 60); 116 | $cache->put('chunk_1', [4, 5, 6], 60); 117 | 118 | $collection = new LazyChunkedCollection( 119 | $cache, 120 | ['chunk_0', 'chunk_1'], 121 | 3, 122 | 6 123 | ); 124 | 125 | $sum = 0; 126 | $collection->each(function($item) use (&$sum) { 127 | $sum += $item; 128 | }); 129 | 130 | $this->assertEquals(21, $sum); // 1+2+3+4+5+6 131 | } 132 | 133 | public function test_lazy_collection_filter() 134 | { 135 | $cache = $this->app['cache']->store(); 136 | 137 | $cache->put('chunk_0', range(1, 10), 60); 138 | $cache->put('chunk_1', range(11, 20), 60); 139 | 140 | $collection = new LazyChunkedCollection( 141 | $cache, 142 | ['chunk_0', 'chunk_1'], 143 | 10, 144 | 20 145 | ); 146 | 147 | $filtered = $collection->filter(function($item) { 148 | return $item % 2 === 0; // Even numbers only 149 | }); 150 | 151 | $this->assertEquals([2, 4, 6, 8, 10, 12, 14, 16, 18, 20], array_values($filtered)); 152 | } 153 | 154 | public function test_lazy_collection_map() 155 | { 156 | $cache = $this->app['cache']->store(); 157 | 158 | $cache->put('chunk_0', [1, 2, 3], 60); 159 | $cache->put('chunk_1', [4, 5, 6], 60); 160 | 161 | $collection = new LazyChunkedCollection( 162 | $cache, 163 | ['chunk_0', 'chunk_1'], 164 | 3, 165 | 6 166 | ); 167 | 168 | $mapped = $collection->map(function($item) { 169 | return $item * 2; 170 | }); 171 | 172 | $this->assertEquals([2, 4, 6, 8, 10, 12], $mapped); 173 | } 174 | 175 | public function test_lazy_collection_memory_efficiency() 176 | { 177 | $cache = $this->app['cache']->store(); 178 | 179 | // Create many large chunks 180 | for ($i = 0; $i < 10; $i++) { 181 | $cache->put("chunk_{$i}", array_fill(0, 1000, "data_{$i}"), 60); 182 | } 183 | 184 | $chunkKeys = array_map(fn($i) => "chunk_{$i}", range(0, 9)); 185 | 186 | $collection = new LazyChunkedCollection( 187 | $cache, 188 | $chunkKeys, 189 | 1000, 190 | 10000 191 | ); 192 | 193 | // Access only a few items 194 | $item1 = $collection[0]; 195 | $item2 = $collection[5000]; 196 | 197 | // Get memory stats 198 | $stats = $collection->getMemoryStats(); 199 | 200 | // Should have loaded only a few chunks, not all 10 201 | $this->assertLessThan(10, $stats['loaded_chunks']); 202 | $this->assertEquals(10, $stats['total_chunks']); 203 | } 204 | 205 | public function test_lazy_collection_with_smart_cache() 206 | { 207 | // This test requires lazy loading to be enabled at service provider level 208 | // For now, we'll test the LazyChunkedCollection directly 209 | $cache = $this->app['cache']->store(); 210 | 211 | // Create chunks manually 212 | $largeData = array_fill(0, 10000, 'test_data'); 213 | $chunks = array_chunk($largeData, 1000); 214 | $chunkKeys = []; 215 | 216 | foreach ($chunks as $index => $chunk) { 217 | $key = "large_key_chunk_{$index}"; 218 | $cache->put($key, $chunk, 60); 219 | $chunkKeys[] = $key; 220 | } 221 | 222 | // Create lazy collection 223 | $retrieved = new LazyChunkedCollection($cache, $chunkKeys, 1000, 10000); 224 | 225 | // Should be able to access items 226 | $this->assertEquals('test_data', $retrieved[0]); 227 | $this->assertEquals('test_data', $retrieved[5000]); 228 | $this->assertCount(10000, $retrieved); 229 | } 230 | 231 | public function test_lazy_collection_to_collection() 232 | { 233 | $cache = $this->app['cache']->store(); 234 | 235 | $cache->put('chunk_0', ['a', 'b'], 60); 236 | $cache->put('chunk_1', ['c', 'd'], 60); 237 | 238 | $lazyCollection = new LazyChunkedCollection( 239 | $cache, 240 | ['chunk_0', 'chunk_1'], 241 | 2, 242 | 4, 243 | true // is_collection 244 | ); 245 | 246 | $collection = $lazyCollection->toCollection(); 247 | 248 | $this->assertInstanceOf(\Illuminate\Support\Collection::class, $collection); 249 | $this->assertEquals(['a', 'b', 'c', 'd'], $collection->toArray()); 250 | } 251 | 252 | public function test_lazy_collection_chunk_limit() 253 | { 254 | $cache = $this->app['cache']->store(); 255 | 256 | // Create 10 chunks 257 | for ($i = 0; $i < 10; $i++) { 258 | $cache->put("chunk_{$i}", range($i * 100, ($i + 1) * 100 - 1), 60); 259 | } 260 | 261 | $chunkKeys = array_map(fn($i) => "chunk_{$i}", range(0, 9)); 262 | 263 | $collection = new LazyChunkedCollection( 264 | $cache, 265 | $chunkKeys, 266 | 100, 267 | 1000, 268 | false, 269 | 3 // Max 3 chunks in memory 270 | ); 271 | 272 | // Access items from different chunks 273 | $collection[0]; // Chunk 0 274 | $collection[250]; // Chunk 2 275 | $collection[500]; // Chunk 5 276 | $collection[750]; // Chunk 7 277 | 278 | $stats = $collection->getMemoryStats(); 279 | 280 | // Should keep only 3 chunks in memory 281 | $this->assertLessThanOrEqual(3, $stats['loaded_chunks']); 282 | } 283 | } 284 | 285 | -------------------------------------------------------------------------------- /tests/Unit/Drivers/MemoizationTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(\SmartCache\SmartCache::class, $memo); 15 | } 16 | 17 | public function test_memo_caches_in_memory() 18 | { 19 | $memo = SmartCache::memo(); 20 | 21 | // First call hits cache 22 | $memo->put('test_key', 'test_value', 60); 23 | 24 | // Second call should be from memory (instant) 25 | $value1 = $memo->get('test_key'); 26 | $value2 = $memo->get('test_key'); 27 | $value3 = $memo->get('test_key'); 28 | 29 | $this->assertEquals('test_value', $value1); 30 | $this->assertEquals('test_value', $value2); 31 | $this->assertEquals('test_value', $value3); 32 | } 33 | 34 | public function test_memo_with_large_data() 35 | { 36 | $memo = SmartCache::memo(); 37 | 38 | // Create large dataset 39 | $largeData = array_fill(0, 10000, 'test_data'); 40 | 41 | $memo->put('large_key', $largeData, 60); 42 | 43 | // Multiple accesses should be instant (from memory) 44 | $start = microtime(true); 45 | for ($i = 0; $i < 100; $i++) { 46 | $data = $memo->get('large_key'); 47 | } 48 | $duration = microtime(true) - $start; 49 | 50 | // Should be very fast (< 100ms for 100 accesses - lenient for CI) 51 | $this->assertLessThan(0.1, $duration); 52 | $this->assertCount(10000, $data); 53 | } 54 | 55 | public function test_memo_handles_cache_misses() 56 | { 57 | $memo = SmartCache::memo(); 58 | 59 | // First miss 60 | $value1 = $memo->get('nonexistent_key', 'default'); 61 | $this->assertEquals('default', $value1); 62 | 63 | // Second miss (should be memoized) 64 | $value2 = $memo->get('nonexistent_key', 'default'); 65 | $this->assertEquals('default', $value2); 66 | } 67 | 68 | public function test_memo_clears_on_put() 69 | { 70 | $memo = SmartCache::memo(); 71 | 72 | $memo->put('test_key', 'value1', 60); 73 | $this->assertEquals('value1', $memo->get('test_key')); 74 | 75 | // Update value 76 | $memo->put('test_key', 'value2', 60); 77 | $this->assertEquals('value2', $memo->get('test_key')); 78 | } 79 | 80 | public function test_memo_clears_on_forget() 81 | { 82 | $memo = SmartCache::memo(); 83 | 84 | $memo->put('test_key', 'test_value', 60); 85 | $this->assertEquals('test_value', $memo->get('test_key')); 86 | 87 | $memo->forget('test_key'); 88 | $this->assertNull($memo->get('test_key')); 89 | } 90 | 91 | public function test_memo_with_remember() 92 | { 93 | $memo = SmartCache::memo(); 94 | 95 | $callCount = 0; 96 | 97 | // First call executes callback 98 | $value1 = $memo->remember('test_key', 60, function() use (&$callCount) { 99 | $callCount++; 100 | return 'computed_value'; 101 | }); 102 | 103 | // Subsequent calls use memoized value 104 | $value2 = $memo->remember('test_key', 60, function() use (&$callCount) { 105 | $callCount++; 106 | return 'computed_value'; 107 | }); 108 | 109 | $value3 = $memo->remember('test_key', 60, function() use (&$callCount) { 110 | $callCount++; 111 | return 'computed_value'; 112 | }); 113 | 114 | $this->assertEquals('computed_value', $value1); 115 | $this->assertEquals('computed_value', $value2); 116 | $this->assertEquals('computed_value', $value3); 117 | $this->assertEquals(1, $callCount); // Callback only called once 118 | } 119 | 120 | public function test_memo_with_different_stores() 121 | { 122 | // Test that memoization works with different cache stores 123 | // Use stores that don't require external services (file, array) 124 | 125 | // Test with file store 126 | $memoFile = SmartCache::memo('file'); 127 | $this->assertInstanceOf(\SmartCache\SmartCache::class, $memoFile); 128 | 129 | $memoFile->put('file_test', 'file_value', 60); 130 | $this->assertEquals('file_value', $memoFile->get('file_test')); 131 | 132 | // Test with array store 133 | $memoArray = SmartCache::memo('array'); 134 | $this->assertInstanceOf(\SmartCache\SmartCache::class, $memoArray); 135 | 136 | $memoArray->put('array_test', 'array_value', 60); 137 | $this->assertEquals('array_value', $memoArray->get('array_test')); 138 | } 139 | 140 | public function test_memo_performance_improvement() 141 | { 142 | // Test without memoization 143 | $start = microtime(true); 144 | for ($i = 0; $i < 100; $i++) { 145 | SmartCache::get('test_key'); 146 | } 147 | $durationWithoutMemo = microtime(true) - $start; 148 | 149 | // Test with memoization 150 | $memo = SmartCache::memo(); 151 | $memo->put('test_key', 'test_value', 60); 152 | 153 | $start = microtime(true); 154 | for ($i = 0; $i < 100; $i++) { 155 | $memo->get('test_key'); 156 | } 157 | $durationWithMemo = microtime(true) - $start; 158 | 159 | // Memoization should be significantly faster 160 | $this->assertLessThan($durationWithoutMemo, $durationWithMemo); 161 | } 162 | 163 | public function test_memo_with_many_operations() 164 | { 165 | $memo = SmartCache::memo(); 166 | 167 | $memo->putMany([ 168 | 'key1' => 'value1', 169 | 'key2' => 'value2', 170 | 'key3' => 'value3', 171 | ], 60); 172 | 173 | $values = $memo->many(['key1', 'key2', 'key3']); 174 | 175 | $this->assertEquals([ 176 | 'key1' => 'value1', 177 | 'key2' => 'value2', 178 | 'key3' => 'value3', 179 | ], $values); 180 | } 181 | 182 | public function test_memo_with_increment_decrement() 183 | { 184 | $memo = SmartCache::memo(); 185 | 186 | $memo->put('counter', 0, 60); 187 | 188 | $memo->increment('counter'); 189 | $this->assertEquals(1, $memo->get('counter')); 190 | 191 | $memo->increment('counter', 5); 192 | $this->assertEquals(6, $memo->get('counter')); 193 | 194 | $memo->decrement('counter', 2); 195 | $this->assertEquals(4, $memo->get('counter')); 196 | } 197 | 198 | public function test_memoization_stats() 199 | { 200 | $memo = SmartCache::memo(); 201 | 202 | // Put some data 203 | $memo->put('key1', 'value1', 60); 204 | $memo->put('key2', 'value2', 60); 205 | 206 | // Access them - this will memoize them 207 | $val1 = $memo->get('key1'); 208 | $val2 = $memo->get('key2'); 209 | 210 | // Verify they were retrieved correctly 211 | $this->assertEquals('value1', $val1); 212 | $this->assertEquals('value2', $val2); 213 | 214 | // Access nonexistent key 215 | $val3 = $memo->get('nonexistent'); 216 | $this->assertNull($val3); 217 | 218 | // Get stats directly from memoized driver 219 | $stats = $memo->getMemoizationStats(); 220 | 221 | $this->assertArrayHasKey('memoized_count', $stats); 222 | $this->assertArrayHasKey('missing_count', $stats); 223 | $this->assertArrayHasKey('total_memory', $stats); 224 | 225 | // Should have some memoized data 226 | $this->assertGreaterThanOrEqual(0, $stats['memoized_count']); 227 | $this->assertGreaterThanOrEqual(0, $stats['missing_count']); 228 | $this->assertGreaterThan(0, $stats['total_memory']); 229 | } 230 | } 231 | 232 | -------------------------------------------------------------------------------- /tests/Unit/Events/CacheEventsTest.php: -------------------------------------------------------------------------------- 1 | true]); 21 | } 22 | 23 | public function test_cache_hit_event_is_dispatched() 24 | { 25 | Event::fake(); 26 | 27 | SmartCache::put('test_key', 'test_value', 60); 28 | SmartCache::get('test_key'); 29 | 30 | Event::assertDispatched(CacheHit::class, function ($event) { 31 | return $event->key === 'test_key' && $event->value === 'test_value'; 32 | }); 33 | } 34 | 35 | public function test_cache_missed_event_is_dispatched() 36 | { 37 | Event::fake(); 38 | 39 | SmartCache::get('nonexistent_key'); 40 | 41 | Event::assertDispatched(CacheMissed::class, function ($event) { 42 | return $event->key === 'nonexistent_key'; 43 | }); 44 | } 45 | 46 | public function test_key_written_event_is_dispatched() 47 | { 48 | Event::fake(); 49 | 50 | SmartCache::put('test_key', 'test_value', 60); 51 | 52 | Event::assertDispatched(KeyWritten::class, function ($event) { 53 | return $event->key === 'test_key' 54 | && $event->value === 'test_value' 55 | && $event->seconds === 60; 56 | }); 57 | } 58 | 59 | public function test_key_forgotten_event_is_dispatched() 60 | { 61 | Event::fake(); 62 | 63 | SmartCache::put('test_key', 'test_value', 60); 64 | SmartCache::forget('test_key'); 65 | 66 | Event::assertDispatched(KeyForgotten::class, function ($event) { 67 | return $event->key === 'test_key'; 68 | }); 69 | } 70 | 71 | public function test_events_include_tags() 72 | { 73 | Event::fake(); 74 | 75 | SmartCache::tags(['users', 'posts'])->put('test_key', 'test_value', 60); 76 | SmartCache::tags(['users', 'posts'])->get('test_key'); 77 | 78 | Event::assertDispatched(CacheHit::class, function ($event) { 79 | return $event->key === 'test_key' 80 | && in_array('users', $event->tags) 81 | && in_array('posts', $event->tags); 82 | }); 83 | } 84 | 85 | public function test_events_can_be_disabled() 86 | { 87 | config(['smart-cache.events.enabled' => false]); 88 | 89 | Event::fake(); 90 | 91 | SmartCache::put('test_key', 'test_value', 60); 92 | SmartCache::get('test_key'); 93 | 94 | Event::assertNotDispatched(CacheHit::class); 95 | Event::assertNotDispatched(KeyWritten::class); 96 | } 97 | 98 | public function test_specific_events_can_be_disabled() 99 | { 100 | config(['smart-cache.events.dispatch.cache_hit' => false]); 101 | 102 | Event::fake(); 103 | 104 | SmartCache::put('test_key', 'test_value', 60); 105 | SmartCache::get('test_key'); 106 | 107 | Event::assertNotDispatched(CacheHit::class); 108 | Event::assertDispatched(KeyWritten::class); 109 | } 110 | 111 | public function test_events_with_large_data() 112 | { 113 | Event::fake(); 114 | 115 | $largeData = array_fill(0, 10000, 'test_data'); 116 | 117 | SmartCache::put('large_key', $largeData, 60); 118 | SmartCache::get('large_key'); 119 | 120 | Event::assertDispatched(KeyWritten::class, function ($event) use ($largeData) { 121 | return $event->key === 'large_key' && $event->value === $largeData; 122 | }); 123 | 124 | Event::assertDispatched(CacheHit::class, function ($event) use ($largeData) { 125 | return $event->key === 'large_key' && $event->value === $largeData; 126 | }); 127 | } 128 | 129 | public function test_can_listen_to_events() 130 | { 131 | $hitCount = 0; 132 | $missCount = 0; 133 | 134 | Event::listen(CacheHit::class, function ($event) use (&$hitCount) { 135 | $hitCount++; 136 | }); 137 | 138 | Event::listen(CacheMissed::class, function ($event) use (&$missCount) { 139 | $missCount++; 140 | }); 141 | 142 | SmartCache::put('test_key', 'test_value', 60); 143 | SmartCache::get('test_key'); // Hit 144 | SmartCache::get('nonexistent'); // Miss 145 | SmartCache::get('test_key'); // Hit 146 | 147 | $this->assertEquals(2, $hitCount); 148 | $this->assertEquals(1, $missCount); 149 | } 150 | 151 | public function test_events_with_remember() 152 | { 153 | Event::fake(); 154 | 155 | SmartCache::remember('test_key', 60, function() { 156 | return 'computed_value'; 157 | }); 158 | 159 | // Should dispatch KeyWritten (first time) 160 | Event::assertDispatched(KeyWritten::class); 161 | 162 | Event::fake(); // Reset 163 | 164 | SmartCache::remember('test_key', 60, function() { 165 | return 'computed_value'; 166 | }); 167 | 168 | // Should dispatch CacheHit (second time) 169 | Event::assertDispatched(CacheHit::class); 170 | } 171 | 172 | public function test_events_with_many_operations() 173 | { 174 | Event::fake(); 175 | 176 | SmartCache::putMany([ 177 | 'key1' => 'value1', 178 | 'key2' => 'value2', 179 | 'key3' => 'value3', 180 | ], 60); 181 | 182 | // Should dispatch KeyWritten for each key 183 | Event::assertDispatched(KeyWritten::class, 3); 184 | } 185 | 186 | public function test_events_with_delete_multiple() 187 | { 188 | Event::fake(); 189 | 190 | SmartCache::put('key1', 'value1', 60); 191 | SmartCache::put('key2', 'value2', 60); 192 | SmartCache::put('key3', 'value3', 60); 193 | 194 | Event::fake(); // Reset 195 | 196 | SmartCache::deleteMultiple(['key1', 'key2', 'key3']); 197 | 198 | // Should dispatch KeyForgotten for each key 199 | Event::assertDispatched(KeyForgotten::class, 3); 200 | } 201 | } 202 | 203 | -------------------------------------------------------------------------------- /tests/Unit/Http/HttpCommandExecutionTest.php: -------------------------------------------------------------------------------- 1 | cache = $this->app->make(SmartCacheContract::class); 17 | } 18 | 19 | public function test_get_available_commands_returns_command_metadata(): void 20 | { 21 | $commands = $this->cache->getAvailableCommands(); 22 | 23 | $this->assertIsArray($commands); 24 | $this->assertArrayHasKey('smart-cache:clear', $commands); 25 | $this->assertArrayHasKey('smart-cache:status', $commands); 26 | 27 | // Check clear command metadata 28 | $clearCommand = $commands['smart-cache:clear']; 29 | $this->assertArrayHasKey('class', $clearCommand); 30 | $this->assertArrayHasKey('description', $clearCommand); 31 | $this->assertArrayHasKey('signature', $clearCommand); 32 | $this->assertEquals('SmartCache\Console\Commands\ClearCommand', $clearCommand['class']); 33 | 34 | // Check status command metadata 35 | $statusCommand = $commands['smart-cache:status']; 36 | $this->assertArrayHasKey('class', $statusCommand); 37 | $this->assertArrayHasKey('description', $statusCommand); 38 | $this->assertArrayHasKey('signature', $statusCommand); 39 | $this->assertEquals('SmartCache\Console\Commands\StatusCommand', $statusCommand['class']); 40 | } 41 | 42 | public function test_execute_clear_command_without_parameters(): void 43 | { 44 | // Setup: Add some test data 45 | $this->cache->put('test_key_1', 'value1', 3600); 46 | $this->cache->put('test_key_2', 'value2', 3600); 47 | 48 | $initialCount = count($this->cache->getManagedKeys()); 49 | $this->assertGreaterThan(0, $initialCount); 50 | 51 | // Execute clear command 52 | $result = $this->cache->executeCommand('clear'); 53 | 54 | $this->assertIsArray($result); 55 | $this->assertTrue($result['success']); 56 | $this->assertArrayHasKey('message', $result); 57 | $this->assertArrayHasKey('cleared_count', $result); 58 | $this->assertEquals($initialCount, $result['cleared_count']); 59 | 60 | // Verify keys were cleared 61 | $this->assertEquals(0, count($this->cache->getManagedKeys())); 62 | } 63 | 64 | public function test_execute_clear_command_with_specific_key(): void 65 | { 66 | // Setup: Add test data 67 | $testKey = 'specific_key_test_' . time(); 68 | $this->cache->put($testKey, 'test_value', 3600); 69 | 70 | $this->assertTrue($this->cache->has($testKey)); 71 | 72 | // Execute clear command for specific key 73 | $result = $this->cache->executeCommand('clear', ['key' => $testKey]); 74 | 75 | $this->assertIsArray($result); 76 | $this->assertTrue($result['success']); 77 | $this->assertEquals(1, $result['cleared_count']); 78 | $this->assertEquals($testKey, $result['key']); 79 | $this->assertTrue($result['was_managed']); 80 | 81 | // Verify key was cleared 82 | $this->assertFalse($this->cache->has($testKey)); 83 | } 84 | 85 | public function test_execute_clear_command_with_non_existent_key(): void 86 | { 87 | $nonExistentKey = 'non_existent_key_' . time(); 88 | 89 | $result = $this->cache->executeCommand('clear', ['key' => $nonExistentKey]); 90 | 91 | $this->assertIsArray($result); 92 | $this->assertFalse($result['success']); 93 | $this->assertStringContainsString('not managed by SmartCache', $result['message']); 94 | $this->assertEquals(0, $result['cleared_count']); 95 | } 96 | 97 | public function test_execute_clear_command_with_non_managed_key_without_force(): void 98 | { 99 | // Add a key directly to Laravel cache (not managed by SmartCache) 100 | $nonManagedKey = 'non_managed_key_' . time(); 101 | $this->app['cache']->put($nonManagedKey, 'value', 3600); 102 | 103 | $result = $this->cache->executeCommand('clear', ['key' => $nonManagedKey]); 104 | 105 | $this->assertIsArray($result); 106 | $this->assertFalse($result['success']); 107 | $this->assertStringContainsString('not managed by SmartCache', $result['message']); 108 | $this->assertEquals(0, $result['cleared_count']); 109 | } 110 | 111 | public function test_execute_clear_command_with_non_managed_key_with_force(): void 112 | { 113 | // Add a key directly to Laravel cache (not managed by SmartCache) 114 | $nonManagedKey = 'non_managed_key_force_' . time(); 115 | $this->app['cache']->put($nonManagedKey, 'value', 3600); 116 | 117 | $result = $this->cache->executeCommand('clear', [ 118 | 'key' => $nonManagedKey, 119 | 'force' => true 120 | ]); 121 | 122 | $this->assertIsArray($result); 123 | $this->assertTrue($result['success']); 124 | $this->assertEquals(1, $result['cleared_count']); 125 | $this->assertEquals($nonManagedKey, $result['key']); 126 | $this->assertFalse($result['was_managed']); 127 | } 128 | 129 | public function test_execute_status_command(): void 130 | { 131 | // Setup: Add some test data 132 | $this->cache->put('status_test_key_1', 'value1', 3600); 133 | $this->cache->put('status_test_key_2', 'value2', 3600); 134 | 135 | $result = $this->cache->executeCommand('status'); 136 | 137 | $this->assertIsArray($result); 138 | $this->assertTrue($result['success']); 139 | $this->assertArrayHasKey('cache_driver', $result); 140 | $this->assertArrayHasKey('managed_keys_count', $result); 141 | $this->assertArrayHasKey('sample_keys', $result); 142 | $this->assertArrayHasKey('configuration', $result); 143 | $this->assertArrayHasKey('statistics', $result); 144 | $this->assertArrayHasKey('health_check', $result); 145 | 146 | $this->assertGreaterThanOrEqual(2, $result['managed_keys_count']); 147 | $this->assertIsArray($result['sample_keys']); 148 | $this->assertIsArray($result['configuration']); 149 | } 150 | 151 | public function test_execute_status_command_with_force(): void 152 | { 153 | // Setup: Add test data and create a scenario with missing keys 154 | $this->cache->put('status_force_test', 'value', 3600); 155 | 156 | $result = $this->cache->executeCommand('status', ['force' => true]); 157 | 158 | $this->assertIsArray($result); 159 | $this->assertTrue($result['success']); 160 | $this->assertArrayHasKey('analysis', $result); 161 | $this->assertArrayHasKey('managed_keys_missing_from_cache', $result['analysis']); 162 | $this->assertArrayHasKey('missing_keys_count', $result['analysis']); 163 | 164 | $this->assertIsArray($result['analysis']['managed_keys_missing_from_cache']); 165 | $this->assertIsInt($result['analysis']['missing_keys_count']); 166 | } 167 | 168 | public function test_execute_unknown_command(): void 169 | { 170 | $result = $this->cache->executeCommand('unknown-command'); 171 | 172 | $this->assertIsArray($result); 173 | $this->assertFalse($result['success']); 174 | $this->assertStringContainsString('Unknown command', $result['message']); 175 | $this->assertArrayHasKey('available_commands', $result); 176 | $this->assertIsArray($result['available_commands']); 177 | } 178 | 179 | public function test_execute_command_handles_exceptions(): void 180 | { 181 | // Mock a scenario that would cause an exception 182 | $result = $this->cache->executeCommand('clear', ['key' => null]); 183 | 184 | $this->assertIsArray($result); 185 | // Should handle gracefully even with invalid parameters 186 | $this->assertArrayHasKey('success', $result); 187 | $this->assertArrayHasKey('message', $result); 188 | } 189 | 190 | public function test_facade_execute_command(): void 191 | { 192 | \SmartCache\Facades\SmartCache::put('facade_command_test', 'value', 3600); 193 | 194 | $result = \SmartCache\Facades\SmartCache::executeCommand('status'); 195 | 196 | $this->assertIsArray($result); 197 | $this->assertTrue($result['success']); 198 | $this->assertGreaterThan(0, $result['managed_keys_count']); 199 | } 200 | 201 | public function test_facade_get_available_commands(): void 202 | { 203 | $commands = \SmartCache\Facades\SmartCache::getAvailableCommands(); 204 | 205 | $this->assertIsArray($commands); 206 | $this->assertArrayHasKey('smart-cache:clear', $commands); 207 | $this->assertArrayHasKey('smart-cache:status', $commands); 208 | } 209 | 210 | public function test_command_shortcuts(): void 211 | { 212 | // Test that both long and short command names work 213 | $this->cache->put('shortcut_test', 'value', 3600); 214 | 215 | $resultLong = $this->cache->executeCommand('smart-cache:status'); 216 | $resultShort = $this->cache->executeCommand('status'); 217 | 218 | $this->assertTrue($resultLong['success']); 219 | $this->assertTrue($resultShort['success']); 220 | $this->assertEquals($resultLong['managed_keys_count'], $resultShort['managed_keys_count']); 221 | 222 | // Test clear shortcuts 223 | $clearLong = $this->cache->executeCommand('smart-cache:clear'); 224 | 225 | // Reset data for second test 226 | $this->cache->put('shortcut_test_2', 'value', 3600); 227 | $clearShort = $this->cache->executeCommand('clear'); 228 | 229 | $this->assertTrue($clearLong['success']); 230 | $this->assertTrue($clearShort['success']); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /tests/Unit/Laravel12/Laravel12SWRTest.php: -------------------------------------------------------------------------------- 1 | cache = $this->app->make(SmartCacheContract::class); 17 | } 18 | 19 | public function test_swr_method_exists(): void 20 | { 21 | $this->assertTrue(method_exists($this->cache, 'swr')); 22 | } 23 | 24 | public function test_stale_method_exists(): void 25 | { 26 | $this->assertTrue(method_exists($this->cache, 'stale')); 27 | } 28 | 29 | public function test_refresh_ahead_method_exists(): void 30 | { 31 | $this->assertTrue(method_exists($this->cache, 'refreshAhead')); 32 | } 33 | 34 | public function test_swr_caching_pattern(): void 35 | { 36 | $key = 'swr_test_' . time(); 37 | $callCount = 0; 38 | 39 | $callback = function () use (&$callCount) { 40 | $callCount++; 41 | return [ 42 | 'data' => 'test_value', 43 | 'timestamp' => now()->toDateTimeString(), 44 | 'call_count' => $callCount 45 | ]; 46 | }; 47 | 48 | // First call should execute callback 49 | $result1 = $this->cache->swr($key, $callback, 2, 10); // 2 second fresh, 10 second stale 50 | $this->assertEquals(1, $callCount); 51 | $this->assertEquals('test_value', $result1['data']); 52 | $this->assertEquals(1, $result1['call_count']); 53 | 54 | // Immediate second call should use cache 55 | $result2 = $this->cache->swr($key, $callback, 2, 10); 56 | $this->assertEquals(1, $callCount); // Callback not called again 57 | $this->assertEquals($result1, $result2); 58 | 59 | // Wait for data to become stale but not expired 60 | sleep(3); // Fresh TTL exceeded but within stale TTL 61 | 62 | // This should return stale data immediately and trigger background refresh 63 | $result3 = $this->cache->swr($key, $callback, 2, 10); 64 | $this->assertEquals('test_value', $result3['data']); 65 | 66 | // The callback should have been called for background refresh 67 | $this->assertGreaterThanOrEqual(2, $callCount); 68 | } 69 | 70 | public function test_stale_caching_pattern(): void 71 | { 72 | $key = 'stale_test_' . time(); 73 | $callCount = 0; 74 | 75 | $callback = function () use (&$callCount) { 76 | $callCount++; 77 | return [ 78 | 'data' => 'stale_test_value', 79 | 'timestamp' => now()->toDateTimeString(), 80 | 'call_count' => $callCount 81 | ]; 82 | }; 83 | 84 | // First call should execute callback 85 | $result1 = $this->cache->stale($key, $callback, 1, 5); // 1 second fresh, 5 second stale 86 | $this->assertEquals(1, $callCount); 87 | $this->assertEquals('stale_test_value', $result1['data']); 88 | 89 | // Immediate call should use cache 90 | $result2 = $this->cache->stale($key, $callback, 1, 5); 91 | $this->assertEquals(1, $callCount); 92 | $this->assertEquals($result1, $result2); 93 | 94 | // Wait for fresh period to expire 95 | sleep(2); 96 | 97 | // Should serve stale data 98 | $result3 = $this->cache->stale($key, $callback, 1, 5); 99 | $this->assertEquals('stale_test_value', $result3['data']); 100 | } 101 | 102 | public function test_refresh_ahead_caching_pattern(): void 103 | { 104 | $key = 'refresh_ahead_test_' . time(); 105 | $callCount = 0; 106 | 107 | $callback = function () use (&$callCount) { 108 | $callCount++; 109 | return [ 110 | 'data' => 'refresh_ahead_value', 111 | 'timestamp' => now()->toDateTimeString(), 112 | 'call_count' => $callCount 113 | ]; 114 | }; 115 | 116 | // First call should execute callback 117 | $result1 = $this->cache->refreshAhead($key, $callback, 5, 2); // 5 second TTL, 2 second refresh window 118 | $this->assertEquals(1, $callCount); 119 | $this->assertEquals('refresh_ahead_value', $result1['data']); 120 | 121 | // Call within fresh period should use cache 122 | $result2 = $this->cache->refreshAhead($key, $callback, 5, 2); 123 | $this->assertEquals(1, $callCount); 124 | $this->assertEquals($result1, $result2); 125 | 126 | // Wait to enter refresh window 127 | sleep(4); // Should trigger refresh-ahead behavior 128 | 129 | $result3 = $this->cache->refreshAhead($key, $callback, 5, 2); 130 | $this->assertEquals('refresh_ahead_value', $result3['data']); 131 | 132 | // Should have triggered background refresh 133 | $this->assertGreaterThanOrEqual(2, $callCount); 134 | } 135 | 136 | public function test_flexible_method_with_custom_durations(): void 137 | { 138 | $key = 'flexible_test_' . time(); 139 | $callCount = 0; 140 | 141 | $callback = function () use (&$callCount) { 142 | $callCount++; 143 | return [ 144 | 'flexible' => true, 145 | 'call_count' => $callCount 146 | ]; 147 | }; 148 | 149 | // Test with custom durations 150 | $result1 = $this->cache->flexible($key, [3, 8], $callback); // 3 second fresh, 8 second total 151 | $this->assertEquals(1, $callCount); 152 | $this->assertTrue($result1['flexible']); 153 | 154 | // Should use cache within fresh period 155 | $result2 = $this->cache->flexible($key, [3, 8], $callback); 156 | $this->assertEquals(1, $callCount); 157 | $this->assertEquals($result1, $result2); 158 | } 159 | 160 | public function test_swr_with_facade(): void 161 | { 162 | $key = 'facade_swr_test_' . time(); 163 | $callCount = 0; 164 | 165 | $callback = function () use (&$callCount) { 166 | $callCount++; 167 | return ['facade_swr' => true, 'count' => $callCount]; 168 | }; 169 | 170 | // Test facade method 171 | $result = \SmartCache\Facades\SmartCache::swr($key, $callback); 172 | $this->assertEquals(1, $callCount); 173 | $this->assertTrue($result['facade_swr']); 174 | $this->assertEquals(1, $result['count']); 175 | 176 | // Second call should use cache 177 | $result2 = \SmartCache\Facades\SmartCache::swr($key, $callback); 178 | $this->assertEquals(1, $callCount); // No additional callback execution 179 | $this->assertEquals($result, $result2); 180 | } 181 | 182 | public function test_stale_with_facade(): void 183 | { 184 | $key = 'facade_stale_test_' . time(); 185 | $callCount = 0; 186 | 187 | $callback = function () use (&$callCount) { 188 | $callCount++; 189 | return ['facade_stale' => true, 'count' => $callCount]; 190 | }; 191 | 192 | $result = \SmartCache\Facades\SmartCache::stale($key, $callback); 193 | $this->assertEquals(1, $callCount); 194 | $this->assertTrue($result['facade_stale']); 195 | } 196 | 197 | public function test_refresh_ahead_with_facade(): void 198 | { 199 | $key = 'facade_refresh_ahead_test_' . time(); 200 | $callCount = 0; 201 | 202 | $callback = function () use (&$callCount) { 203 | $callCount++; 204 | return ['facade_refresh_ahead' => true, 'count' => $callCount]; 205 | }; 206 | 207 | $result = \SmartCache\Facades\SmartCache::refreshAhead($key, $callback); 208 | $this->assertEquals(1, $callCount); 209 | $this->assertTrue($result['facade_refresh_ahead']); 210 | } 211 | 212 | public function test_swr_methods_use_optimization(): void 213 | { 214 | $key = 'optimization_swr_test_' . time(); 215 | 216 | // Create large data that should trigger compression 217 | $largeData = array_fill(0, 2000, 'large_data_item_for_compression_testing'); 218 | 219 | $callback = function () use ($largeData) { 220 | return $largeData; 221 | }; 222 | 223 | $result = $this->cache->swr($key, $callback); 224 | $this->assertEquals($largeData, $result); 225 | 226 | // Verify the key was tracked (indicating optimization was applied) 227 | $managedKeys = $this->cache->getManagedKeys(); 228 | $this->assertContains($key, $managedKeys); 229 | } 230 | 231 | public function test_all_swr_methods_handle_exceptions_gracefully(): void 232 | { 233 | $key = 'exception_test_' . time(); 234 | 235 | $callback = function () { 236 | throw new \Exception('Test exception'); 237 | }; 238 | 239 | // All methods should handle exceptions gracefully when fallback is enabled 240 | $this->expectException(\Exception::class); 241 | $this->cache->swr($key, $callback); 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /tests/Unit/Locks/AtomicLocksTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(\Illuminate\Contracts\Cache\Lock::class, $lock); 16 | 17 | $acquired = $lock->get(); 18 | $this->assertTrue($acquired); 19 | 20 | $lock->release(); 21 | } 22 | 23 | public function test_lock_prevents_concurrent_access() 24 | { 25 | $lock1 = SmartCache::lock('test_lock', 10); 26 | $acquired1 = $lock1->get(); 27 | 28 | $this->assertTrue($acquired1); 29 | 30 | // Try to acquire the same lock 31 | $lock2 = SmartCache::lock('test_lock', 10); 32 | $acquired2 = $lock2->get(); 33 | 34 | $this->assertFalse($acquired2); 35 | 36 | $lock1->release(); 37 | } 38 | 39 | public function test_lock_with_callback() 40 | { 41 | $executed = false; 42 | 43 | SmartCache::lock('test_lock', 10)->get(function() use (&$executed) { 44 | $executed = true; 45 | }); 46 | 47 | $this->assertTrue($executed); 48 | } 49 | 50 | public function test_lock_with_callback_returns_value() 51 | { 52 | $result = SmartCache::lock('test_lock', 10)->get(function() { 53 | return 'test_value'; 54 | }); 55 | 56 | $this->assertEquals('test_value', $result); 57 | } 58 | 59 | public function test_can_restore_lock_with_owner() 60 | { 61 | $lock = SmartCache::lock('test_lock', 10); 62 | $lock->get(); 63 | 64 | $owner = $lock->owner(); 65 | $this->assertNotNull($owner); 66 | 67 | $restoredLock = SmartCache::restoreLock('test_lock', $owner); 68 | $this->assertInstanceOf(\Illuminate\Contracts\Cache\Lock::class, $restoredLock); 69 | 70 | $restoredLock->release(); 71 | } 72 | 73 | public function test_lock_prevents_cache_stampede() 74 | { 75 | $callCount = 0; 76 | 77 | // First lock acquisition should succeed 78 | $lock1 = SmartCache::lock('expensive_operation', 10); 79 | $this->assertTrue($lock1->get()); 80 | $callCount++; 81 | SmartCache::put('expensive_data', 'value', 60); 82 | 83 | // Subsequent attempts should fail while lock is held 84 | for ($i = 0; $i < 4; $i++) { 85 | $lock = SmartCache::lock('expensive_operation', 10); 86 | if ($lock->get()) { 87 | $callCount++; 88 | $lock->release(); 89 | } 90 | } 91 | 92 | // Only one process should have executed the expensive operation 93 | $this->assertEquals(1, $callCount); 94 | 95 | // Clean up 96 | $lock1->release(); 97 | } 98 | 99 | public function test_lock_with_block_waits_for_lock() 100 | { 101 | // Skip this test as it's timing-dependent and may fail in CI 102 | $this->markTestSkipped('Timing-dependent test - may fail in CI environments'); 103 | } 104 | 105 | public function test_lock_with_large_data_regeneration() 106 | { 107 | $key = 'large_dataset'; 108 | 109 | // Clear any existing data 110 | SmartCache::forget($key); 111 | 112 | $lock = SmartCache::lock("regenerate_{$key}", 30); 113 | 114 | if ($lock->get()) { 115 | // Generate large dataset 116 | $largeData = array_fill(0, 10000, 'test_data'); 117 | SmartCache::put($key, $largeData, 3600); 118 | $lock->release(); 119 | } 120 | 121 | $this->assertTrue(SmartCache::has($key)); 122 | $retrieved = SmartCache::get($key); 123 | $this->assertCount(10000, $retrieved); 124 | } 125 | 126 | public function test_lock_throws_exception_for_unsupported_driver() 127 | { 128 | // This test is driver-dependent 129 | // Most drivers support locks, so we just verify the method exists 130 | 131 | try { 132 | $lock = SmartCache::lock('test', 10); 133 | $this->assertInstanceOf(\Illuminate\Contracts\Cache\Lock::class, $lock); 134 | } catch (\RuntimeException $e) { 135 | $this->assertStringContainsString('does not support atomic locks', $e->getMessage()); 136 | } 137 | } 138 | } 139 | 140 | -------------------------------------------------------------------------------- /tests/Unit/Services/SmartChunkSizeCalculatorTest.php: -------------------------------------------------------------------------------- 1 | calculateOptimalSize($smallData); 16 | 17 | // Small data should not be chunked 18 | $this->assertEquals(100, $optimalSize); 19 | } 20 | 21 | public function test_calculates_optimal_size_for_large_data() 22 | { 23 | $calculator = new SmartChunkSizeCalculator(); 24 | 25 | $largeData = array_fill(0, 100000, str_repeat('test', 100)); 26 | $optimalSize = $calculator->calculateOptimalSize($largeData); 27 | 28 | // Should return a reasonable chunk size 29 | $this->assertGreaterThan(100, $optimalSize); 30 | $this->assertLessThan(100000, $optimalSize); 31 | } 32 | 33 | public function test_respects_driver_limits() 34 | { 35 | $calculator = new SmartChunkSizeCalculator(); 36 | 37 | $data = array_fill(0, 10000, str_repeat('x', 1000)); // ~10MB total 38 | 39 | // Memcached has 1MB limit 40 | $memcachedSize = $calculator->calculateOptimalSize($data, 'memcached'); 41 | 42 | // Redis has 512MB limit 43 | $redisSize = $calculator->calculateOptimalSize($data, 'redis'); 44 | 45 | // Redis should allow larger chunks 46 | $this->assertGreaterThan($memcachedSize, $redisSize); 47 | } 48 | 49 | public function test_get_driver_limit() 50 | { 51 | $calculator = new SmartChunkSizeCalculator(); 52 | 53 | $this->assertEquals(512 * 1024 * 1024, $calculator->getDriverLimit('redis')); 54 | $this->assertEquals(1024 * 1024, $calculator->getDriverLimit('memcached')); 55 | $this->assertEquals(16 * 1024 * 1024, $calculator->getDriverLimit('database')); 56 | $this->assertEquals(400 * 1024, $calculator->getDriverLimit('dynamodb')); 57 | } 58 | 59 | public function test_get_driver_limit_returns_default_for_unknown() 60 | { 61 | $calculator = new SmartChunkSizeCalculator(); 62 | 63 | $limit = $calculator->getDriverLimit('unknown_driver'); 64 | 65 | $this->assertEquals(1024 * 1024, $limit); // 1MB default 66 | } 67 | 68 | public function test_calculate_chunk_count() 69 | { 70 | $calculator = new SmartChunkSizeCalculator(); 71 | 72 | $this->assertEquals(10, $calculator->calculateChunkCount(1000, 100)); 73 | $this->assertEquals(5, $calculator->calculateChunkCount(1000, 200)); 74 | $this->assertEquals(1, $calculator->calculateChunkCount(100, 1000)); 75 | } 76 | 77 | public function test_get_recommendations() 78 | { 79 | $calculator = new SmartChunkSizeCalculator(); 80 | 81 | $data = array_fill(0, 10000, str_repeat('test', 100)); 82 | $recommendations = $calculator->getRecommendations($data, 'redis'); 83 | 84 | $this->assertArrayHasKey('total_items', $recommendations); 85 | $this->assertArrayHasKey('total_size', $recommendations); 86 | $this->assertArrayHasKey('avg_item_size', $recommendations); 87 | $this->assertArrayHasKey('optimal_chunk_size', $recommendations); 88 | $this->assertArrayHasKey('chunk_count', $recommendations); 89 | $this->assertArrayHasKey('driver', $recommendations); 90 | $this->assertArrayHasKey('should_chunk', $recommendations); 91 | 92 | $this->assertEquals(10000, $recommendations['total_items']); 93 | $this->assertEquals('redis', $recommendations['driver']); 94 | } 95 | 96 | public function test_should_chunk_recommendation() 97 | { 98 | $calculator = new SmartChunkSizeCalculator(); 99 | 100 | // Small data 101 | $smallData = array_fill(0, 100, 'test'); 102 | $smallRec = $calculator->getRecommendations($smallData); 103 | $this->assertFalse($smallRec['should_chunk']); 104 | 105 | // Large data 106 | $largeData = array_fill(0, 10000, str_repeat('test', 100)); 107 | $largeRec = $calculator->getRecommendations($largeData); 108 | $this->assertTrue($largeRec['should_chunk']); 109 | } 110 | 111 | public function test_is_chunk_size_safe() 112 | { 113 | $calculator = new SmartChunkSizeCalculator(); 114 | 115 | // Safe chunk size for memcached 116 | $this->assertTrue($calculator->isChunkSizeSafe(100, 1000, 'memcached')); 117 | 118 | // Unsafe chunk size for memcached (would exceed 1MB limit) 119 | $this->assertFalse($calculator->isChunkSizeSafe(10000, 1000, 'memcached')); 120 | } 121 | 122 | public function test_set_custom_driver_limit() 123 | { 124 | $calculator = new SmartChunkSizeCalculator(); 125 | 126 | $calculator->setDriverLimit('custom_driver', 5 * 1024 * 1024); // 5MB 127 | 128 | $this->assertEquals(5 * 1024 * 1024, $calculator->getDriverLimit('custom_driver')); 129 | } 130 | 131 | public function test_get_all_driver_limits() 132 | { 133 | $calculator = new SmartChunkSizeCalculator(); 134 | 135 | $limits = $calculator->getDriverLimits(); 136 | 137 | $this->assertIsArray($limits); 138 | $this->assertArrayHasKey('redis', $limits); 139 | $this->assertArrayHasKey('memcached', $limits); 140 | $this->assertArrayHasKey('database', $limits); 141 | } 142 | 143 | public function test_optimal_size_for_very_large_dataset() 144 | { 145 | $calculator = new SmartChunkSizeCalculator(); 146 | 147 | // 100MB dataset 148 | $largeData = array_fill(0, 100000, str_repeat('x', 1000)); 149 | $optimalSize = $calculator->calculateOptimalSize($largeData, 'redis'); 150 | 151 | // Should use smaller chunks for very large datasets 152 | $this->assertLessThanOrEqual(500, $optimalSize); 153 | } 154 | 155 | public function test_optimal_size_for_medium_dataset() 156 | { 157 | $calculator = new SmartChunkSizeCalculator(); 158 | 159 | // 500KB dataset 160 | $mediumData = array_fill(0, 5000, str_repeat('x', 100)); 161 | $optimalSize = $calculator->calculateOptimalSize($mediumData, 'redis'); 162 | 163 | // Should use larger chunks for medium datasets 164 | $this->assertGreaterThan(500, $optimalSize); 165 | $this->assertLessThanOrEqual(5000, $optimalSize); 166 | } 167 | 168 | public function test_handles_empty_array() 169 | { 170 | $calculator = new SmartChunkSizeCalculator(); 171 | 172 | $emptyData = []; 173 | $optimalSize = $calculator->calculateOptimalSize($emptyData); 174 | 175 | $this->assertEquals(0, $optimalSize); 176 | } 177 | 178 | public function test_minimum_chunk_size() 179 | { 180 | $calculator = new SmartChunkSizeCalculator(); 181 | 182 | // Even with very small items, should maintain minimum chunk size 183 | $data = array_fill(0, 10000, 'x'); 184 | $optimalSize = $calculator->calculateOptimalSize($data, 'memcached'); 185 | 186 | $this->assertGreaterThanOrEqual(100, $optimalSize); 187 | } 188 | 189 | public function test_chunk_size_with_different_default() 190 | { 191 | $calculator = new SmartChunkSizeCalculator(); 192 | 193 | $data = array_fill(0, 10000, 'test'); 194 | 195 | $size1 = $calculator->calculateOptimalSize($data, null, 1000); 196 | $size2 = $calculator->calculateOptimalSize($data, null, 5000); 197 | 198 | // Different defaults may affect the result 199 | $this->assertIsInt($size1); 200 | $this->assertIsInt($size2); 201 | } 202 | } 203 | 204 | -------------------------------------------------------------------------------- /tests/Unit/Strategies/AdaptiveCompressionStrategyTest.php: -------------------------------------------------------------------------------- 1 | 1KB 15 | 16 | $this->assertTrue($strategy->shouldApply($largeData)); 17 | } 18 | 19 | public function test_should_apply_returns_false_for_small_data() 20 | { 21 | $strategy = new AdaptiveCompressionStrategy(1024); 22 | 23 | $smallData = 'test'; 24 | 25 | $this->assertFalse($strategy->shouldApply($smallData)); 26 | } 27 | 28 | public function test_optimize_compresses_data() 29 | { 30 | $strategy = new AdaptiveCompressionStrategy(1024); 31 | 32 | $data = str_repeat('test', 500); 33 | $optimized = $strategy->optimize($data); 34 | 35 | $this->assertIsArray($optimized); 36 | $this->assertTrue($optimized['_sc_compressed']); 37 | $this->assertArrayHasKey('data', $optimized); 38 | $this->assertArrayHasKey('level', $optimized); 39 | } 40 | 41 | public function test_restore_decompresses_data() 42 | { 43 | $strategy = new AdaptiveCompressionStrategy(1024); 44 | 45 | $originalData = str_repeat('test', 500); 46 | $optimized = $strategy->optimize($originalData); 47 | $restored = $strategy->restore($optimized); 48 | 49 | $this->assertEquals($originalData, $restored); 50 | } 51 | 52 | public function test_adaptive_compression_selects_appropriate_level() 53 | { 54 | $strategy = new AdaptiveCompressionStrategy(1024); 55 | 56 | // Highly compressible data (repeated pattern) 57 | $compressibleData = str_repeat('aaaa', 1000); 58 | $optimized1 = $strategy->optimize($compressibleData); 59 | 60 | // Less compressible data (random-ish) 61 | $lessCompressibleData = str_repeat('abcdefghijklmnop', 250); 62 | $optimized2 = $strategy->optimize($lessCompressibleData); 63 | 64 | // Both should be compressed but may use different levels 65 | $this->assertTrue($optimized1['_sc_compressed']); 66 | $this->assertTrue($optimized2['_sc_compressed']); 67 | $this->assertArrayHasKey('level', $optimized1); 68 | $this->assertArrayHasKey('level', $optimized2); 69 | } 70 | 71 | public function test_compression_with_large_array() 72 | { 73 | $strategy = new AdaptiveCompressionStrategy(1024); 74 | 75 | $largeArray = array_fill(0, 10000, 'test_data'); 76 | $serialized = serialize($largeArray); 77 | 78 | $optimized = $strategy->optimize($serialized); 79 | $restored = $strategy->restore($optimized); 80 | 81 | $this->assertEquals($serialized, $restored); 82 | } 83 | 84 | public function test_get_identifier() 85 | { 86 | $strategy = new AdaptiveCompressionStrategy(1024); 87 | 88 | $this->assertEquals('adaptive_compression', $strategy->getIdentifier()); 89 | } 90 | 91 | public function test_compression_stats() 92 | { 93 | $strategy = new AdaptiveCompressionStrategy(1024); 94 | 95 | $data = str_repeat('test', 1000); 96 | $optimized = $strategy->optimize($data); 97 | $stats = $strategy->getCompressionStats($optimized); 98 | 99 | $this->assertArrayHasKey('level', $stats); 100 | $this->assertArrayHasKey('original_size', $stats); 101 | $this->assertArrayHasKey('compressed_size', $stats); 102 | $this->assertArrayHasKey('ratio', $stats); 103 | $this->assertArrayHasKey('savings_bytes', $stats); 104 | $this->assertArrayHasKey('savings_percent', $stats); 105 | } 106 | 107 | public function test_adaptive_compression_with_hot_data() 108 | { 109 | $strategy = new AdaptiveCompressionStrategy( 110 | 1024, 111 | 6, 112 | 1024, 113 | 0.5, 114 | 0.7, 115 | 100 // Low frequency threshold for testing 116 | ); 117 | 118 | $data = str_repeat('test', 500); 119 | 120 | // Simulate hot data by accessing it multiple times 121 | $cache = $this->app['cache']->store(); 122 | $key = 'test_hot_key'; 123 | 124 | // Increment access frequency 125 | for ($i = 0; $i < 150; $i++) { 126 | $cache->increment("_sc_access_freq_{$key}"); 127 | } 128 | 129 | $optimized = $strategy->optimize($data, ['key' => $key, 'cache' => $cache]); 130 | 131 | // Hot data should use lower compression level for faster access 132 | $this->assertTrue($optimized['_sc_compressed']); 133 | $this->assertLessThanOrEqual(6, $optimized['level']); 134 | } 135 | 136 | public function test_adaptive_compression_with_cold_data() 137 | { 138 | $strategy = new AdaptiveCompressionStrategy( 139 | 1024, 140 | 6, 141 | 1024, 142 | 0.5, 143 | 0.7, 144 | 100 145 | ); 146 | 147 | $data = str_repeat('test', 500); 148 | 149 | // Cold data (low access frequency) 150 | $cache = $this->app['cache']->store(); 151 | $key = 'test_cold_key'; 152 | 153 | $optimized = $strategy->optimize($data, ['key' => $key, 'cache' => $cache]); 154 | 155 | // Cold data may use higher compression for better space savings 156 | $this->assertTrue($optimized['_sc_compressed']); 157 | $this->assertGreaterThanOrEqual(1, $optimized['level']); 158 | } 159 | 160 | public function test_compression_ratio_tracking() 161 | { 162 | $strategy = new AdaptiveCompressionStrategy(1024); 163 | 164 | $data = str_repeat('test', 1000); 165 | $optimized = $strategy->optimize($data); 166 | 167 | $this->assertArrayHasKey('original_size', $optimized); 168 | $this->assertArrayHasKey('compressed_size', $optimized); 169 | 170 | $originalSize = $optimized['original_size']; 171 | $compressedSize = $optimized['compressed_size']; 172 | 173 | // Compressed size should be smaller 174 | $this->assertLessThan($originalSize, $compressedSize); 175 | } 176 | 177 | public function test_handles_non_compressible_data() 178 | { 179 | $strategy = new AdaptiveCompressionStrategy(1024); 180 | 181 | // Random data (not very compressible) 182 | $randomData = random_bytes(5000); 183 | 184 | $optimized = $strategy->optimize($randomData); 185 | $restored = $strategy->restore($optimized); 186 | 187 | $this->assertEquals($randomData, $restored); 188 | } 189 | 190 | public function test_compression_with_different_thresholds() 191 | { 192 | $strategy1 = new AdaptiveCompressionStrategy(1024); 193 | $strategy2 = new AdaptiveCompressionStrategy(10240); 194 | 195 | $data = str_repeat('test', 500); // ~2KB 196 | 197 | $this->assertTrue($strategy1->shouldApply($data)); 198 | $this->assertFalse($strategy2->shouldApply($data)); 199 | } 200 | } 201 | 202 | -------------------------------------------------------------------------------- /tests/Unit/Strategies/CompressionStrategyTest.php: -------------------------------------------------------------------------------- 1 | strategy = new CompressionStrategy(1024, 6); // 1KB threshold, level 6 16 | } 17 | 18 | public function test_get_identifier_returns_compression() 19 | { 20 | $this->assertEquals('compression', $this->strategy->getIdentifier()); 21 | } 22 | 23 | public function test_should_apply_returns_true_for_large_string() 24 | { 25 | $largeString = str_repeat('a', 2000); // 2KB string 26 | $this->assertTrue($this->strategy->shouldApply($largeString)); 27 | } 28 | 29 | public function test_should_apply_returns_false_for_small_string() 30 | { 31 | $smallString = str_repeat('a', 500); // 500 bytes string 32 | $this->assertFalse($this->strategy->shouldApply($smallString)); 33 | } 34 | 35 | public function test_should_apply_returns_true_for_large_array() 36 | { 37 | $largeArray = $this->createLargeTestData(50); // Creates large array 38 | $this->assertTrue($this->strategy->shouldApply($largeArray)); 39 | } 40 | 41 | public function test_should_apply_returns_false_for_small_array() 42 | { 43 | $smallArray = ['key' => 'value']; 44 | $this->assertFalse($this->strategy->shouldApply($smallArray)); 45 | } 46 | 47 | public function test_should_apply_returns_true_for_large_object() 48 | { 49 | $largeObject = (object) $this->createLargeTestData(50); 50 | $this->assertTrue($this->strategy->shouldApply($largeObject)); 51 | } 52 | 53 | public function test_should_apply_returns_false_for_non_compressible_types() 54 | { 55 | $this->assertFalse($this->strategy->shouldApply(123)); 56 | $this->assertFalse($this->strategy->shouldApply(123.45)); 57 | $this->assertFalse($this->strategy->shouldApply(true)); 58 | $this->assertFalse($this->strategy->shouldApply(null)); 59 | } 60 | 61 | public function test_should_apply_respects_driver_configuration() 62 | { 63 | $largeString = str_repeat('a', 2000); 64 | 65 | $context = [ 66 | 'driver' => 'redis', 67 | 'config' => [ 68 | 'drivers' => [ 69 | 'redis' => [ 70 | 'compression' => false 71 | ] 72 | ] 73 | ] 74 | ]; 75 | 76 | $this->assertFalse($this->strategy->shouldApply($largeString, $context)); 77 | } 78 | 79 | public function test_optimize_compresses_string_data() 80 | { 81 | $originalString = str_repeat('Hello World! ', 200); // Repetitive string compresses well 82 | $optimized = $this->strategy->optimize($originalString); 83 | 84 | $this->assertIsArray($optimized); 85 | $this->assertArrayHasKey('_sc_compressed', $optimized); 86 | $this->assertTrue($optimized['_sc_compressed']); 87 | $this->assertArrayHasKey('data', $optimized); 88 | $this->assertArrayHasKey('is_string', $optimized); 89 | $this->assertTrue($optimized['is_string']); 90 | 91 | // Verify the compressed data is base64 encoded 92 | $this->assertNotFalse(base64_decode($optimized['data'], true)); 93 | } 94 | 95 | public function test_optimize_compresses_array_data() 96 | { 97 | $originalArray = $this->createLargeTestData(50); 98 | $optimized = $this->strategy->optimize($originalArray); 99 | 100 | $this->assertIsArray($optimized); 101 | $this->assertArrayHasKey('_sc_compressed', $optimized); 102 | $this->assertTrue($optimized['_sc_compressed']); 103 | $this->assertArrayHasKey('data', $optimized); 104 | $this->assertArrayHasKey('is_string', $optimized); 105 | $this->assertFalse($optimized['is_string']); 106 | } 107 | 108 | public function test_optimize_compresses_object_data() 109 | { 110 | $originalObject = (object) $this->createLargeTestData(50); 111 | $optimized = $this->strategy->optimize($originalObject); 112 | 113 | $this->assertIsArray($optimized); 114 | $this->assertArrayHasKey('_sc_compressed', $optimized); 115 | $this->assertTrue($optimized['_sc_compressed']); 116 | $this->assertArrayHasKey('is_string', $optimized); 117 | $this->assertFalse($optimized['is_string']); 118 | } 119 | 120 | public function test_restore_returns_original_string() 121 | { 122 | $originalString = str_repeat('Hello World! ', 200); 123 | $optimized = $this->strategy->optimize($originalString); 124 | $restored = $this->strategy->restore($optimized); 125 | 126 | $this->assertEquals($originalString, $restored); 127 | } 128 | 129 | public function test_restore_returns_original_array() 130 | { 131 | $originalArray = $this->createLargeTestData(50); 132 | $optimized = $this->strategy->optimize($originalArray); 133 | $restored = $this->strategy->restore($optimized); 134 | 135 | $this->assertEquals($originalArray, $restored); 136 | } 137 | 138 | public function test_restore_returns_original_object() 139 | { 140 | $originalObject = (object) $this->createLargeTestData(50); 141 | $optimized = $this->strategy->optimize($originalObject); 142 | $restored = $this->strategy->restore($optimized); 143 | 144 | $this->assertEquals($originalObject, $restored); 145 | } 146 | 147 | public function test_restore_returns_unmodified_value_if_not_compressed() 148 | { 149 | $normalValue = 'not compressed'; 150 | $restored = $this->strategy->restore($normalValue); 151 | $this->assertEquals($normalValue, $restored); 152 | 153 | $normalArray = ['not' => 'compressed']; 154 | $restored = $this->strategy->restore($normalArray); 155 | $this->assertEquals($normalArray, $restored); 156 | } 157 | 158 | public function test_restore_returns_unmodified_value_if_invalid_compressed_format() 159 | { 160 | $invalidCompressed = [ 161 | '_sc_compressed' => false, // Wrong marker 162 | 'data' => 'some-data', 163 | 'is_string' => true 164 | ]; 165 | 166 | $restored = $this->strategy->restore($invalidCompressed); 167 | $this->assertEquals($invalidCompressed, $restored); 168 | } 169 | 170 | public function test_compression_reduces_size() 171 | { 172 | $repetitiveString = str_repeat('This is a test string that should compress very well. ', 100); 173 | $originalSize = strlen($repetitiveString); 174 | 175 | $optimized = $this->strategy->optimize($repetitiveString); 176 | $compressedSize = strlen($optimized['data']); 177 | 178 | // Base64 adds some overhead, but the compressed data should still be significantly smaller 179 | // than the original for repetitive data 180 | $this->assertLessThan($originalSize, $compressedSize); 181 | } 182 | 183 | public function test_compression_with_different_levels() 184 | { 185 | $data = str_repeat('Test data for compression level testing. ', 100); 186 | 187 | $strategy1 = new CompressionStrategy(1024, 1); // Low compression 188 | $strategy9 = new CompressionStrategy(1024, 9); // High compression 189 | 190 | $optimized1 = $strategy1->optimize($data); 191 | $optimized9 = $strategy9->optimize($data); 192 | 193 | // Higher compression level should produce smaller result 194 | $size1 = strlen($optimized1['data']); 195 | $size9 = strlen($optimized9['data']); 196 | 197 | $this->assertLessThanOrEqual($size1, $size9); 198 | 199 | // Both should restore to the same original data 200 | $restored1 = $strategy1->restore($optimized1); 201 | $restored9 = $strategy9->restore($optimized9); 202 | 203 | $this->assertEquals($data, $restored1); 204 | $this->assertEquals($data, $restored9); 205 | } 206 | 207 | public function test_compression_with_custom_threshold() 208 | { 209 | $strategy = new CompressionStrategy(5000, 6); // 5KB threshold 210 | 211 | $smallData = str_repeat('a', 2000); // 2KB 212 | $largeData = str_repeat('a', 6000); // 6KB 213 | 214 | $this->assertFalse($strategy->shouldApply($smallData)); 215 | $this->assertTrue($strategy->shouldApply($largeData)); 216 | } 217 | 218 | public function test_round_trip_compression_preserves_data_integrity() 219 | { 220 | $testCases = [ 221 | 'Simple string', 222 | str_repeat('Repetitive content ', 100), 223 | ['array' => 'data', 'nested' => ['deep' => 'value']], 224 | $this->createLargeTestData(30), 225 | (object) ['property' => 'value', 'nested' => (object) ['deep' => 'data']] 226 | ]; 227 | 228 | foreach ($testCases as $testCase) { 229 | $optimized = $this->strategy->optimize($testCase); 230 | $restored = $this->strategy->restore($optimized); 231 | 232 | $this->assertEquals($testCase, $restored, 233 | 'Round-trip compression should preserve data integrity for: ' . gettype($testCase)); 234 | } 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /tests/Unit/Strategies/SmartSerializationStrategyTest.php: -------------------------------------------------------------------------------- 1 | assertTrue($strategy->shouldApply('any_value')); 15 | $this->assertTrue($strategy->shouldApply(['array'])); 16 | $this->assertTrue($strategy->shouldApply(new \stdClass())); 17 | } 18 | 19 | public function test_optimize_serializes_data() 20 | { 21 | $strategy = new SmartSerializationStrategy(); 22 | 23 | $data = ['key' => 'value']; 24 | $optimized = $strategy->optimize($data); 25 | 26 | $this->assertIsArray($optimized); 27 | $this->assertTrue($optimized['_sc_serialized']); 28 | $this->assertArrayHasKey('method', $optimized); 29 | $this->assertArrayHasKey('data', $optimized); 30 | } 31 | 32 | public function test_restore_unserializes_data() 33 | { 34 | $strategy = new SmartSerializationStrategy(); 35 | 36 | $originalData = ['key' => 'value', 'number' => 123]; 37 | $optimized = $strategy->optimize($originalData); 38 | $restored = $strategy->restore($optimized); 39 | 40 | $this->assertEquals($originalData, $restored); 41 | } 42 | 43 | public function test_json_serialization_for_simple_data() 44 | { 45 | $strategy = new SmartSerializationStrategy('auto', true); 46 | 47 | $simpleData = ['key' => 'value', 'number' => 123, 'bool' => true]; 48 | $optimized = $strategy->optimize($simpleData); 49 | 50 | $this->assertEquals('json', $optimized['method']); 51 | 52 | $restored = $strategy->restore($optimized); 53 | $this->assertEquals($simpleData, $restored); 54 | } 55 | 56 | public function test_php_serialization_for_objects() 57 | { 58 | // Force PHP serialization mode 59 | $strategy = new SmartSerializationStrategy('php', false); 60 | 61 | // Use stdClass 62 | $object = new \stdClass(); 63 | $object->property = 'value'; 64 | $object->nested = new \stdClass(); 65 | $object->nested->data = 'nested_value'; 66 | 67 | $optimized = $strategy->optimize($object); 68 | 69 | // Should use PHP serialize 70 | $this->assertEquals('php', $optimized['method']); 71 | 72 | $restored = $strategy->restore($optimized); 73 | $this->assertEquals($object->property, $restored->property); 74 | $this->assertEquals($object->nested->data, $restored->nested->data); 75 | } 76 | 77 | public function test_forced_json_serialization() 78 | { 79 | $strategy = new SmartSerializationStrategy('json', false); 80 | 81 | $data = ['key' => 'value']; 82 | $optimized = $strategy->optimize($data); 83 | 84 | $this->assertEquals('json', $optimized['method']); 85 | } 86 | 87 | public function test_forced_php_serialization() 88 | { 89 | $strategy = new SmartSerializationStrategy('php', false); 90 | 91 | $data = ['key' => 'value']; 92 | $optimized = $strategy->optimize($data); 93 | 94 | $this->assertEquals('php', $optimized['method']); 95 | } 96 | 97 | public function test_igbinary_serialization_if_available() 98 | { 99 | if (!function_exists('igbinary_serialize')) { 100 | $this->markTestSkipped('igbinary extension not available'); 101 | } 102 | 103 | $strategy = new SmartSerializationStrategy('igbinary', false); 104 | 105 | $data = ['key' => 'value']; 106 | $optimized = $strategy->optimize($data); 107 | 108 | $this->assertEquals('igbinary', $optimized['method']); 109 | 110 | $restored = $strategy->restore($optimized); 111 | $this->assertEquals($data, $restored); 112 | } 113 | 114 | public function test_get_identifier() 115 | { 116 | $strategy = new SmartSerializationStrategy(); 117 | 118 | $this->assertEquals('smart_serialization', $strategy->getIdentifier()); 119 | } 120 | 121 | public function test_serialization_stats() 122 | { 123 | $strategy = new SmartSerializationStrategy(); 124 | 125 | $data = ['key' => 'value', 'number' => 123]; 126 | $stats = $strategy->getSerializationStats($data); 127 | 128 | $this->assertArrayHasKey('json', $stats); 129 | $this->assertArrayHasKey('php', $stats); 130 | $this->assertArrayHasKey('recommended', $stats); 131 | $this->assertArrayHasKey('best_size', $stats); 132 | } 133 | 134 | public function test_json_is_more_compact_for_simple_data() 135 | { 136 | $strategy = new SmartSerializationStrategy(); 137 | 138 | $simpleData = ['a' => 1, 'b' => 2, 'c' => 3]; 139 | $stats = $strategy->getSerializationStats($simpleData); 140 | 141 | // JSON should be recommended for simple data 142 | $this->assertEquals('json', $stats['recommended']); 143 | } 144 | 145 | public function test_handles_large_arrays() 146 | { 147 | $strategy = new SmartSerializationStrategy(); 148 | 149 | $largeArray = array_fill(0, 10000, 'test_data'); 150 | $optimized = $strategy->optimize($largeArray); 151 | $restored = $strategy->restore($optimized); 152 | 153 | $this->assertCount(10000, $restored); 154 | $this->assertEquals($largeArray, $restored); 155 | } 156 | 157 | public function test_handles_nested_arrays() 158 | { 159 | $strategy = new SmartSerializationStrategy(); 160 | 161 | $nestedData = [ 162 | 'level1' => [ 163 | 'level2' => [ 164 | 'level3' => ['value' => 'deep'] 165 | ] 166 | ] 167 | ]; 168 | 169 | $optimized = $strategy->optimize($nestedData); 170 | $restored = $strategy->restore($optimized); 171 | 172 | $this->assertEquals($nestedData, $restored); 173 | } 174 | 175 | public function test_handles_mixed_types() 176 | { 177 | $strategy = new SmartSerializationStrategy(); 178 | 179 | $mixedData = [ 180 | 'string' => 'value', 181 | 'int' => 123, 182 | 'float' => 45.67, 183 | 'bool' => true, 184 | 'null' => null, 185 | 'array' => [1, 2, 3], 186 | ]; 187 | 188 | $optimized = $strategy->optimize($mixedData); 189 | $restored = $strategy->restore($optimized); 190 | 191 | $this->assertEquals($mixedData, $restored); 192 | } 193 | 194 | public function test_fallback_to_php_for_unsupported_method() 195 | { 196 | $strategy = new SmartSerializationStrategy('igbinary', false); 197 | 198 | if (function_exists('igbinary_serialize')) { 199 | $this->markTestSkipped('igbinary is available, cannot test fallback'); 200 | } 201 | 202 | $data = ['key' => 'value']; 203 | $optimized = $strategy->optimize($data); 204 | 205 | // Should fallback to PHP serialize 206 | $this->assertEquals('php', $optimized['method']); 207 | } 208 | 209 | public function test_restore_handles_non_serialized_data() 210 | { 211 | $strategy = new SmartSerializationStrategy(); 212 | 213 | $plainData = 'not_serialized'; 214 | $restored = $strategy->restore($plainData); 215 | 216 | $this->assertEquals($plainData, $restored); 217 | } 218 | } 219 | 220 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | isDir() ? 'rmdir' : 'unlink'); 51 | $todo($fileinfo->getRealPath()); 52 | } 53 | } 54 | } 55 | --------------------------------------------------------------------------------