Skip to content

Commit

Permalink
Version 2.x with Connection proxy.
Browse files Browse the repository at this point in the history
  • Loading branch information
DarkGhostHunter committed Feb 18, 2022
1 parent ba99c92 commit 5ece884
Show file tree
Hide file tree
Showing 8 changed files with 325 additions and 312 deletions.
50 changes: 24 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ $database = DB::table('articles')->latest('published_at')->take(10)->cache()->ge
$eloquent = Article::latest('published_at')->take(10)->cache()->get();
```

The next time you call the **same** query, the result will be retrieved from the cache instead of running the SQL statement in the database, even if the result is empty, `null` or `false`.
The next time you call the **same** query, the result will be retrieved from the cache instead of running the `SELECT` SQL statement in the database, even if the results are empty, `null` or `false`.

Since it's [eager load unaware](#eager-load-unaware), you can also cache (or not) an eager loaded relation.

Expand Down Expand Up @@ -96,34 +96,20 @@ Article::latest('published_at')->take(200)->cache(wait: 5)->get();

The first process will acquire the lock for the given seconds and execute the query. The next processes will wait the same amount of seconds until the first process stores the result in the cache to retrieve it.

> If you need to use this across multiple processes, use the [cache lock](https://laravel.com/docs/cache#managing-locks-across-processes) directly.
### Idempotent queries

While the reason behind remembering a Query is to cache the data retrieved from a database, you can use this to your advantage to create [idempotent](https://en.wikipedia.org/wiki/Idempotence) queries.

For example, you can make this query only execute once every day for a given user ID.

```php
$key = auth()->user()->getAuthIdentifier();

Article::whereKey(54)->cache(now()->addHour(), "user:$key")->increment('unique_views');
```

Subsequent executions of this query won't be executed at all until the cache expires, so in the above example we have surprisingly created a "unique views" mechanic.
> If you need a more advanced locking mechanism, use the [cache lock](https://laravel.com/docs/cache#managing-locks-across-processes) directly.
## Forgetting results

If you need to forget a result anywhere in your application, you should use a named key for your cached query, as autogenerated query keys are difficult (or plain impossible) to guess. Once you do, use the `cache-query:forget` command with the name of the key.

```php
User::query()->cache(key: 'find_joe')->whereName('Joe')->whereAge(20)->first();
User::query()->cache(store: 'redis', key: 'find_joe')->whereName('Joe')->whereAge(20)->first();
```

```bash
php artisan cache-query:forget find_joe
php artisan cache-query:forget find_joe --store=redis

# Successfully removed [find_joe] from the [file] cache store.
# Successfully removed [find_joe] from the [redis] cache store.
```

## Caveats
Expand Down Expand Up @@ -153,7 +139,7 @@ User::query()->cache(key: 'find_joe')->whereAge(20)->whereName('Joe')->first();

### Eager load **unaware**

Since caching only works for the current query builder instance, an underlying Eager Load query won't be cached. This may be a blessing or a curse depending on your scenario.
Since caching only works for the current query builder instance, an underlying Eager Load query won't be cached, as it's executed later in the pipeline. This may be a blessing or a curse depending on your scenario.

```php
$page = 1;
Expand All @@ -163,7 +149,7 @@ User::with('posts', function ($posts) use ($page) {
})->cache()->find(1);
```

In the example, the `posts` eager load query results are never cached. To avoid that, you can use `cache()` on the eager loaded query. This way both the parent `user` query and the child `posts` query will be saved into the cache.
In the example, the `posts` eager load query results are never cached. To avoid that, you may use `cache()` on the eager loaded query. This way both the parent `user` query and the child `posts` query will be saved into the cache.

```php
$page = 1;
Expand All @@ -173,9 +159,21 @@ User::with('posts', function ($posts) use ($page) {
})->find(1);
```

Alternatively, you can cache the whole results manually using `remember()`:

```php
$page = 1;

cache()->remember('cache_whole_results', function () use ($page) {
User::with('posts', function ($posts) use ($page) {
return $posts()->cache()->forPage($page);
})->find(1);
})
```

### Cannot delete autogenerated keys

When the cache auto-generates a query key, [it's hashed](#operations-are-not-commutative), making it extremely difficult to remove it from the cache. If you need to remove the results from the cache at any given time, use a custom key and [use the included `cache-query:forget` command](#forgetting-results):
When a key is not issued, the cache crates a hash based on the query, making it extremely difficult to remove it from the cache. If you need to remove the results from the cache at any given time, you should use a custom key and [the included `cache-query:forget` command](#forgetting-results).

## PhpStorm stubs

Expand All @@ -189,11 +187,9 @@ The file gets published into the `.stubs` folder of your project. You should poi

## How it works?

When you use `cache()`, it will wrap the base builder into a `CacheAwareProxy` proxy calls to it. At the same time, it injects a callback that runs _before_ is sent to the database for execution.
When you use `cache()`, it will wrap the connection into a `CacheAwareProxy` proxy, and proxy all method calls to it.

This callback will check if the results are in the cache. On cache hit, it throws an exception to interrupt the query, which is recovered by the `CacheAwareProxy`, returning the results.

For the Eloquent Builder, this wraps happens below it, so all calls pass through the `CacheAwareProxy` before hitting the real base builder.
Once a `SELECT` statement is executed, it will check if the results are in the cache before executing the query. On Cache hit, it will return the cached results.

## Laravel Octane compatibility

Expand All @@ -204,6 +200,8 @@ For the Eloquent Builder, this wraps happens below it, so all calls pass through

There should be no problems using this package with Laravel Octane.

## [Upgrading](UPGRADE.md)

## Security

If you discover any security related issues, please email [email protected] instead of using the issue tracker.
Expand Down
21 changes: 21 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Upgrading

## From 1.x

### Idempotent queries

[Idempotent](https://en.wikipedia.org/wiki/Idempotence) queries have been removed. Cached queries only work for `SELECT` procedures, like `first()` or `get()`.

As an alternative, you can use `remember()` from your application cache for the same effect:

```php
cache()->remember('idempotent', function () {
Article::whereKey(10)->increment('unique_views');

return true;
})
```

### Query Hash

If for some reason you where using the query hashes, 2.x incorporates the connection name into the hash.
202 changes: 202 additions & 0 deletions src/CacheAwareConnectionProxy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
<?php

namespace Laragear\CacheQuery;

use DateInterval;
use DateTimeInterface;
use Illuminate\Cache\NoLock;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Contracts\Cache\LockProvider;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Database\ConnectionInterface;
use LogicException;
use function array_shift;
use function base64_encode;
use function cache;
use function config;
use function implode;
use function md5;

class CacheAwareConnectionProxy
{
/**
* Create a new Cache Aware Connection Proxy instance.
*
* @param \Illuminate\Database\ConnectionInterface $connection
* @param \Illuminate\Contracts\Cache\Repository $repository
* @param string $cachePrefix
* @param string $queryKey
* @param \DateTimeInterface|\DateInterval|int $ttl
* @param int $lockWait
*/
public function __construct(
public ConnectionInterface $connection,
protected Repository $repository,
protected string $cachePrefix,
protected string $queryKey,
protected DateTimeInterface|DateInterval|int $ttl,
protected int $lockWait,
) {
//
}

/**
* Run a select statement against the database.
*
* @param string $query
* @param array $bindings
* @param bool $useReadPdo
* @return array
*/
public function select($query, $bindings = [], $useReadPdo = true): mixed
{
$key = $this->cachePrefix.'|'.($this->queryKey ?: $this->getQueryHash($query, $bindings));

[$cached, $results] = $this->checkForCachedResult($key);

if ($cached) {
return $results;
}

$results = $this->connection->select($query, $bindings, $useReadPdo);

$this->repository->put($key, $results, $this->ttl);

return $results;
}

/**
* Run a select statement and return a single result.
*
* @param string $query
* @param array $bindings
* @param bool $useReadPdo
* @return mixed
*/
public function selectOne($query, $bindings = [], $useReadPdo = true)
{
$records = $this->select($query, $bindings, $useReadPdo);

return array_shift($records);
}

/**
* Checks if the result exists in the cache, and returns in.
*
* @param string $key
* @return mixed
*/
protected function checkForCachedResult(string $key): mixed
{
return $this->retrieveLock($key)->block($this->lockWait, function () use ($key) {
$has = $this->repository->has($key);

return [$has, $has ? $this->repository->get($key) : null];
});
}

/**
* Hashes the incoming query for using as cache key.
*
* @param string $query
* @param array $bindings
* @return string
*/
protected function getQueryHash(string $query, array $bindings): string
{
return base64_encode(md5($this->connection->getDatabaseName().$query.implode('', $bindings), true));
}

/**
* Retrieves the lock to use before getting the results.
*
* @param string $key
* @return \Illuminate\Contracts\Cache\Lock
*/
protected function retrieveLock(string $key): Lock
{
if (!$this->lockWait) {
return new NoLock($key, $this->lockWait);
}

return $this->repository->getStore()->lock($key, $this->lockWait);
}

/**
* Pass-through all properties to the underlying connection.
*
* @param string $name
* @return mixed
*/
public function __get(string $name): mixed
{
return $this->connection->{$name};
}

/**
* Pass-through all properties to the underlying connection.
*
* @param string $name
* @param mixed $value
* @return void
* @noinspection MagicMethodsValidityInspection
*/
public function __set(string $name, mixed $value): void
{
$this->connection->{$name} = $value;
}

/**
* Pass-through all method calls to the underlying connection.
*
* @param string $name
* @param array $arguments
* @return mixed
*/
public function __call(string $name, array $arguments)
{
return $this->connection->{$name}(...$arguments);
}

/**
* Create a new CacheAwareProxy instance.
*
* @param \Illuminate\Database\ConnectionInterface $connection
* @param \DateTimeInterface|\DateInterval|int $ttl
* @param string $key
* @param int $lockWait
* @param string|null $store
* @return static
*/
public static function crateNewInstance(
ConnectionInterface $connection,
DateTimeInterface|DateInterval|int $ttl,
string $key,
int $lockWait,
?string $store,
): static {
$repository = static::store($store, (bool) $lockWait);

return new static($connection, $repository, config('cache-query.prefix'), $key, $ttl, $lockWait);
}

/**
* Returns the store to se for caching.
*
* @param string|null $store
* @param bool $lockable
* @return \Illuminate\Contracts\Cache\Repository
*/
protected static function store(?string $store, bool $lockable = false): Repository
{
$repository = cache()->store($store ?? config('cache-query.store'));

if ($lockable && !$repository->getStore() instanceof LockProvider) {
$store ??= cache()->getDefaultDriver();

throw new LogicException("The [$store] cache does not support atomic locks.");
}

return $repository;
}
}
Loading

0 comments on commit 5ece884

Please sign in to comment.