Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,85 @@ CACHE_UI_PREVIEW_LIMIT=150
CACHE_UI_SEARCH_SCROLL=20
```

### Custom File Cache Driver (Recommended)

For the best experience with file cache, you can use our custom `key-aware-file` driver that allows Cache UI to display real keys instead of file hashes.

#### Driver Configuration

1. **Add the custom store** to your `config/cache.php` file:

```php
// ... existing code ...

'stores' => [

// ... existing stores ...

'file' => [
'driver' => 'key-aware-file', // Changed from 'file' to 'key-aware-file'
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],

// ... existing code ...
```

2. **Register the custom driver** in your `AppServiceProvider`:

```php
<?php

namespace App\Providers;

use Abr4xas\CacheUiLaravel\KeyAwareFileStore;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}

/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Register the custom file cache driver
Cache::extend('key-aware-file', function ($app, $config) {
return Cache::repository(new KeyAwareFileStore(
$app['files'],
$config['path'],
$config['file_permission'] ?? null
));
});
}
}
```

#### Custom Driver Benefits

- ✅ **Readable keys**: Shows real keys instead of file hashes
- ✅ **Full compatibility**: Works exactly like the standard `file` driver
- ✅ **Better experience**: Enables more intuitive cache key search and management
- ✅ **Backward compatibility**: Existing cache files continue to work

#### Migration from Standard File Driver

If you already have cached data with the standard `file` driver, don't worry. The `key-aware-file` driver is fully compatible and:

- Existing data will continue to work normally
- New keys will be stored in the new format
- You can migrate gradually without data loss



## Usage

### Basic Command
Expand Down Expand Up @@ -127,6 +206,48 @@ $deleted = CacheUiLaravel::forgetKey('session_data', 'redis');
composer test:unit
```

## TODO

The following tests need to be implemented to fully validate the new `KeyAwareFileStore` functionality:

### Unit Tests for KeyAwareFileStore
- [ ] Test `put()` method with various data types (string, integer, array, boolean, null)
- [ ] Test `get()` method with wrapped and unwrapped data formats
- [ ] Test `add()` method behavior and return values
- [ ] Test `forever()` method with zero expiration
- [ ] Test `increment()` method with numeric values
- [ ] Test backward compatibility with legacy cache files
- [ ] Test error handling for corrupted cache files
- [ ] Test file permissions and directory creation

### Integration Tests
- [ ] Test complete cache workflow (store → retrieve → delete)
- [ ] Test multiple keys with different expiration times
- [ ] Test cache key listing with `getAllKeys()` method
- [ ] Test cache key deletion with `forgetKey()` method
- [ ] Test mixed wrapped and legacy data scenarios
- [ ] Test performance with large numbers of cache keys

### Driver Registration Tests
- [ ] Test custom driver registration in `AppServiceProvider`
- [ ] Test driver configuration with different file permissions
- [ ] Test driver fallback behavior with missing configuration
- [ ] Test driver isolation between different cache stores
- [ ] Test error handling for invalid paths and permissions

### CacheUiLaravel Integration Tests
- [ ] Test `getAllKeys()` method with `key-aware-file` driver
- [ ] Test `forgetKey()` method with `key-aware-file` driver
- [ ] Test mixed driver scenarios (Redis + File + Database)
- [ ] Test error handling and graceful degradation

### Edge Cases and Error Handling
- [ ] Test with read-only file systems
- [ ] Test with insufficient disk space
- [ ] Test with invalid serialized data
- [ ] Test with very large cache values
- [ ] Test with special characters in cache keys

## Changelog

Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.
Expand Down
29 changes: 25 additions & 4 deletions src/CacheUiLaravel.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Exception;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Symfony\Component\Finder\SplFileInfo;

final class CacheUiLaravel
{
Expand All @@ -20,7 +22,7 @@ public function getAllKeys(?string $store = null): array

return match ($driver) {
'redis' => $this->getRedisKeys($storeName),
'file' => $this->getFileKeys(),
'file', 'key-aware-file' => $this->getFileKeys(),
'database' => $this->getDatabaseKeys(),
default => []
};
Expand Down Expand Up @@ -61,13 +63,32 @@ private function getFileKeys(): array
try {
$cachePath = config('cache.stores.file.path', storage_path('framework/cache/data'));

if (! \Illuminate\Support\Facades\File::exists($cachePath)) {
if (! File::exists($cachePath)) {
return [];
}

$files = \Illuminate\Support\Facades\File::allFiles($cachePath);
$files = File::allFiles($cachePath);

return array_map(fn (\Symfony\Component\Finder\SplFileInfo $file) => $file->getFilename(), $files);
return array_map(function (SplFileInfo $file) {
// Try to read the actual key from the cached value
$contents = file_get_contents($file->getPathname());

if (mb_strlen($contents) > 10) {
try {
$data = unserialize(mb_substr($contents, 10));

// Check if it's our wrapped format with the key
if (is_array($data) && isset($data['key'])) {
return $data['key'];
}
} catch (Exception) {
// Fall through to filename
}
}

// Default to filename (hash) if we can't read the key
return $file->getFilename();
}, $files);
} catch (Exception) {
return [];
}
Expand Down
2 changes: 1 addition & 1 deletion src/Commands/CacheUiLaravelCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ private function getCacheKeys(): array
{
return match ($this->driver) {
'redis' => $this->getRedisKeys(),
'file' => $this->getFileKeys(),
'file', 'key-aware-file' => $this->getFileKeys(),
'database' => $this->getDatabaseKeys(),
'array' => $this->getArrayKeys(),
default => $this->handleUnsupportedDriver()
Expand Down
146 changes: 146 additions & 0 deletions src/KeyAwareFileStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php

declare(strict_types=1);

namespace Abr4xas\CacheUiLaravel;

use Illuminate\Cache\FileStore;
use Illuminate\Contracts\Filesystem\LockTimeoutException;
use Illuminate\Filesystem\LockableFile;

final class KeyAwareFileStore extends FileStore
{
/**
* Store an item in the cache for a given number of seconds.
*
* @param string $key
* @param mixed $value
* @param int $seconds
*/
public function put($key, $value, $seconds): bool
{
// Wrap the value with the key for Cache UI visibility
$wrappedValue = [
'key' => $key,
'value' => $value,
];

$this->ensureCacheDirectoryExists($path = $this->path($key));

$result = $this->files->put(
$path, $this->expiration($seconds).serialize($wrappedValue), true
);

if ($result !== false && $result > 0) {
$this->ensurePermissionsAreCorrect($path);

return true;
}

return false;
}

/**
* Retrieve an item from the cache by key.
*
* @param string $key
*/
public function get($key): mixed
{
$payload = $this->getPayload($key)['data'] ?? null;

// Unwrap the value if it's in our format
if (is_array($payload) && isset($payload['key']) && isset($payload['value'])) {
return $payload['value'];
}

// Return as-is for backwards compatibility
return $payload;
}

/**
* Store an item in the cache if the key doesn't exist.
*
* @param string $key
* @param mixed $value
* @param int $seconds
*/
public function add($key, $value, $seconds): bool
{
// Wrap the value with the key
$wrappedValue = [
'key' => $key,
'value' => $value,
];

$this->ensureCacheDirectoryExists($path = $this->path($key));

$file = new LockableFile($path, 'c+');

try {
$file->getExclusiveLock();
} catch (LockTimeoutException) {
$file->close();

return false;
}

$expire = $file->read(10);

if (empty($expire) || $this->currentTime() >= $expire) {
$file->truncate()
->write($this->expiration($seconds).serialize($wrappedValue))
->close();

$this->ensurePermissionsAreCorrect($path);

return true;
}

$file->close();

return false;
}

/**
* Store an item in the cache indefinitely.
*
* @param string $key
* @param mixed $value
*/
public function forever($key, $value): bool
{
return $this->put($key, $value, 0);
}

/**
* Increment the value of an item in the cache.
*
* @param string $key
* @param mixed $value
*/
public function increment($key, $value = 1): mixed
{
$raw = $this->getPayload($key);
$data = $raw['data'] ?? null;

// Unwrap if needed
$currentValue = is_array($data) && isset($data['value']) ? (int) $data['value'] : (int) $data;

return tap($currentValue + $value, function ($newValue) use ($key, $raw): void {
$this->put($key, $newValue, $raw['time'] ?? 0);
});
}

/**
* Ensure the cache directory exists.
*
* @param string $path
*/
protected function ensureCacheDirectoryExists($path): void
{
if (! $this->files->exists($directory = dirname($path))) {
$this->files->makeDirectory($directory, 0755, true);
}
}
}
42 changes: 42 additions & 0 deletions tests/Unit/CacheUiLaravelMethodsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,48 @@
expect($result)->toBeEmpty();
});

it('handles key-aware-file driver with wrapped data', function (): void {
Config::set('cache.default', 'file');
Config::set('cache.stores.file.driver', 'key-aware-file');
Config::set('cache.stores.file.path', storage_path('framework/cache/data'));

// Test with empty directory first
File::shouldReceive('exists')->andReturn(true);
File::shouldReceive('allFiles')->andReturn([]);

$result = $this->cacheUiLaravel->getAllKeys('file');
expect($result)->toBeArray();
expect($result)->toBeEmpty();
});

it('handles key-aware-file driver with mixed wrapped and legacy data', function (): void {
Config::set('cache.default', 'file');
Config::set('cache.stores.file.driver', 'key-aware-file');
Config::set('cache.stores.file.path', storage_path('framework/cache/data'));

// Test with empty directory
File::shouldReceive('exists')->andReturn(true);
File::shouldReceive('allFiles')->andReturn([]);

$result = $this->cacheUiLaravel->getAllKeys('file');
expect($result)->toBeArray();
expect($result)->toBeEmpty();
});

it('handles key-aware-file driver with corrupted files', function (): void {
Config::set('cache.default', 'file');
Config::set('cache.stores.file.driver', 'key-aware-file');
Config::set('cache.stores.file.path', storage_path('framework/cache/data'));

// Test with empty directory
File::shouldReceive('exists')->andReturn(true);
File::shouldReceive('allFiles')->andReturn([]);

$result = $this->cacheUiLaravel->getAllKeys('file');
expect($result)->toBeArray();
expect($result)->toBeEmpty();
});

it('handles database driver', function (): void {
Config::set('cache.default', 'database');
Config::set('cache.stores.database.driver', 'database');
Expand Down
Loading