Skip to content

Commit 79925a9

Browse files
committed
Added queryResultCache
1 parent a83b6a8 commit 79925a9

File tree

12 files changed

+837
-21
lines changed

12 files changed

+837
-21
lines changed

docs/driver.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Creating a Driver with all config options
2525
'limit' => 500,
2626
'sortFields' => true,
2727
'useHydratorCache' => true,
28+
'useQueryResultCache' => true,
2829
'excludeFilters' => [Filters::LIKE],
2930
]);
3031
@@ -125,6 +126,27 @@ the duration of the request thereby saving possible multiple extracts for
125126
the same entity. Default is ``false``
126127

127128

129+
useQueryResultCache
130+
-------------------
131+
132+
When set to true query results will be cached for
133+
the duration of the request thereby preventing duplicate database queries
134+
with identical SQL and parameters. This is particularly useful for:
135+
136+
- Circular references in the graph
137+
- Queries accessing the same entity multiple times
138+
- Duplicate association queries
139+
140+
The cache is request-scoped and automatically cleared after each request.
141+
Performance benefits are most noticeable with complex, nested GraphQL queries
142+
that may execute the same database query multiple times.
143+
144+
Default is ``false``
145+
146+
**Note**: This caches the query results, not the hydrated entities.
147+
For entity caching, see ``useHydratorCache``.
148+
149+
128150
Functions
129151
=========
130152

docs/technical/config-reference.rst

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Class Overview
1818
public function getGroup(): string;
1919
public function getGroupSuffix(): ?string;
2020
public function getUseHydratorCache(): bool;
21+
public function getUseQueryResultCache(): bool;
2122
public function getLimit(): int;
2223
public function getGlobalEnable(): bool;
2324
public function getIgnoreFields(): array;
@@ -51,6 +52,7 @@ Available Options
5152
'group' => 'default', // string
5253
'groupSuffix' => null, // string|null
5354
'useHydratorCache' => false, // bool
55+
'useQueryResultCache' => false, // bool
5456
'limit' => 1000, // int
5557
'globalEnable' => false, // bool
5658
'ignoreFields' => [], // string[]
@@ -222,6 +224,74 @@ extracted once per request, and subsequent accesses return cached data.
222224

223225
Cache is request-scoped only. Cleared after each GraphQL query execution.
224226

227+
useQueryResultCache
228+
-------------------
229+
230+
**Type**: ``bool``
231+
232+
**Default**: ``false``
233+
234+
**Description**:
235+
236+
Enables request-scoped caching of database query results. When enabled, identical
237+
SQL queries with the same parameters are executed only once per request, with
238+
subsequent executions returning cached results.
239+
240+
**Use Cases**:
241+
242+
- Complex nested queries that may execute the same query multiple times
243+
- Circular references in the GraphQL query
244+
- Queries accessing the same associations repeatedly
245+
- Performance optimization for duplicate data access patterns
246+
247+
**How It Works**:
248+
249+
The cache generates a signature from the SQL query string and parameters. When a
250+
query is executed, the cache is checked first. If found, cached results are returned
251+
without database access.
252+
253+
**Performance Impact**:
254+
255+
.. code-block:: graphql
256+
257+
# Without cache: artist performances queried twice
258+
{
259+
artist1: artist(id: 1) {
260+
performances { edges { node { venue } } }
261+
}
262+
artist2: artist(id: 1) { # Same artist
263+
performances { edges { node { venue } } } # Duplicate query
264+
}
265+
}
266+
267+
# With cache: second performances query uses cached results
268+
new Config(['useQueryResultCache' => true]);
269+
270+
**Cache Statistics**:
271+
272+
.. code-block:: php
273+
274+
use ApiSkeletons\Doctrine\ORM\GraphQL\Cache\QueryResultCache;
275+
276+
$cache = $driver->get(QueryResultCache::class);
277+
$stats = $cache->getStats();
278+
279+
echo "Cache size: " . $stats['size'] . "\n";
280+
echo "Cache hits: " . $stats['hits'] . "\n";
281+
echo "Cache misses: " . $stats['misses'] . "\n";
282+
echo "Hit rate: " . ($stats['hitRate'] * 100) . "%\n";
283+
284+
**Scope**:
285+
286+
Cache is request-scoped only. Automatically cleared after each request.
287+
288+
**Difference from useHydratorCache**:
289+
290+
- ``useQueryResultCache``: Caches database query results (SQL level)
291+
- ``useHydratorCache``: Caches entity extraction results (hydration level)
292+
293+
Both can be enabled simultaneously for maximum caching.
294+
225295
limit
226296
-----
227297

@@ -696,6 +766,7 @@ Production Configuration
696766
'groupSuffix' => '',
697767
'sortFields' => true,
698768
'useHydratorCache' => true,
769+
'useQueryResultCache' => true,
699770
'excludeFilters' => [
700771
Filters::CONTAINS, // Expensive on large datasets
701772
],
@@ -787,7 +858,7 @@ Best Practices
787858
4. **Use Groups for Multiple APIs**: Separate public/admin/internal APIs with different groups
788859
5. **Clean Type Names**: Use ``entityPrefix`` and ``groupSuffix`` for readable type names
789860
6. **Exclude Sensitive Fields**: Always use ``ignoreFields`` with ``globalEnable``
790-
7. **Profile Before Caching**: Only enable ``useHydratorCache`` if profiling shows benefit
861+
7. **Profile Before Caching**: Only enable ``useHydratorCache`` and ``useQueryResultCache`` if profiling shows benefit
791862
8. **Document Custom Configs**: Comment why you're using non-default values
792863

793864
Common Pitfalls
@@ -796,6 +867,6 @@ Common Pitfalls
796867
1. **globalEnable in Production**: Security risk - always use explicit attributes
797868
2. **Same groupSuffix for Different Groups**: Causes type name collisions
798869
3. **Too High Limits**: Can cause memory exhaustion and slow queries
799-
4. **useHydratorCache Always On**: Wastes memory for simple queries
870+
4. **Cache Always On**: ``useHydratorCache`` and ``useQueryResultCache`` waste memory for simple queries
800871
5. **Empty entityPrefix**: Results in ugly type names like ``App_Entity_Artist_default``
801872
6. **Forgetting ignoreFields**: Exposes sensitive data with ``globalEnable``

docs/technical/performance.rst

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,64 @@ Enable only when profiling shows repeated entity extraction:
154154
- Memory-constrained environments
155155
- Single-access patterns
156156

157+
useQueryResultCache
158+
-------------------
159+
160+
Enable to cache identical database queries within a single request:
161+
162+
.. code-block:: php
163+
164+
new Config(['useQueryResultCache' => true]);
165+
166+
**When to Enable**:
167+
168+
- Complex GraphQL queries that may execute the same query multiple times
169+
- Queries with circular references or repeated associations
170+
- Deep nested queries accessing the same data paths
171+
- When profiling shows duplicate SQL queries
172+
173+
**When to Disable**:
174+
175+
- Simple queries without duplication
176+
- Memory-constrained environments
177+
- When query patterns show no duplication
178+
179+
**How It Works**:
180+
181+
The cache uses a signature based on SQL + parameters to identify identical queries.
182+
When a query is executed, the cache is checked first. If found, the cached results
183+
are returned without hitting the database. The cache is request-scoped and
184+
automatically cleared after the request completes.
185+
186+
**Performance Impact**:
187+
188+
.. code-block:: php
189+
190+
// Without cache: executes same query twice
191+
query {
192+
artist1: artist(id: 1) {
193+
performances { edges { node { venue } } }
194+
}
195+
artist2: artist(id: 1) { # Same artist
196+
performances { edges { node { venue } } } # Duplicate query
197+
}
198+
}
199+
200+
// With cache: second query uses cached results
201+
// Queries reduced from 4 to 3 (artist query + performances query cached)
202+
203+
**Cache Statistics**:
204+
205+
.. code-block:: php
206+
207+
$cache = $driver->get(QueryResultCache::class);
208+
$stats = $cache->getStats();
209+
210+
echo "Cache size: " . $stats['size'] . "\n";
211+
echo "Hits: " . $stats['hits'] . "\n";
212+
echo "Misses: " . $stats['misses'] . "\n";
213+
echo "Hit rate: " . ($stats['hitRate'] * 100) . "%\n";
214+
157215
**Profiling Example**:
158216

159217
.. code-block:: php

src/Cache/QueryResultCache.php

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ApiSkeletons\Doctrine\ORM\GraphQL\Cache;
6+
7+
use Doctrine\ORM\Query;
8+
9+
use function count;
10+
use function md5;
11+
use function serialize;
12+
13+
/**
14+
* Request-scoped cache for query results.
15+
*
16+
* Caches query results based on SQL + parameters signature to prevent
17+
* duplicate database queries within a single GraphQL request. This is
18+
* particularly useful for:
19+
* - Circular references in the graph
20+
* - Queries accessing the same entity multiple times
21+
* - Duplicate association queries
22+
*
23+
* The cache is stored in memory and is automatically cleared after
24+
* the request completes.
25+
*/
26+
class QueryResultCache
27+
{
28+
/** @var array<string, mixed[]> */
29+
private array $cache = [];
30+
31+
/** @var array<string, int> */
32+
private array $hits = [];
33+
34+
/** @var array<string, int> */
35+
private array $misses = [];
36+
37+
/**
38+
* Get cached results for a query if available
39+
*
40+
* @return mixed[]|null Returns cached results or null if not cached
41+
*/
42+
public function get(Query $query): array|null
43+
{
44+
$cacheKey = $this->getCacheKey($query);
45+
46+
if (isset($this->cache[$cacheKey])) {
47+
$this->hits[$cacheKey] = ($this->hits[$cacheKey] ?? 0) + 1;
48+
49+
return $this->cache[$cacheKey];
50+
}
51+
52+
$this->misses[$cacheKey] = ($this->misses[$cacheKey] ?? 0) + 1;
53+
54+
return null;
55+
}
56+
57+
/**
58+
* Store query results in cache
59+
*
60+
* @param mixed[] $results
61+
*/
62+
public function set(Query $query, array $results): void
63+
{
64+
$cacheKey = $this->getCacheKey($query);
65+
$this->cache[$cacheKey] = $results;
66+
}
67+
68+
/**
69+
* Check if query results are cached
70+
*/
71+
public function has(Query $query): bool
72+
{
73+
return isset($this->cache[$this->getCacheKey($query)]);
74+
}
75+
76+
/**
77+
* Clear all cached results
78+
*/
79+
public function clear(): void
80+
{
81+
$this->cache = [];
82+
$this->hits = [];
83+
$this->misses = [];
84+
}
85+
86+
/**
87+
* Get cache statistics
88+
*
89+
* @return array{size: int, hits: int, misses: int, hitRate: float}
90+
*/
91+
public function getStats(): array
92+
{
93+
$totalHits = 0;
94+
$totalMisses = 0;
95+
96+
foreach ($this->hits as $hits) {
97+
$totalHits += $hits;
98+
}
99+
100+
foreach ($this->misses as $misses) {
101+
$totalMisses += $misses;
102+
}
103+
104+
$totalRequests = $totalHits + $totalMisses;
105+
$hitRate = $totalRequests > 0 ? $totalHits / $totalRequests : 0.0;
106+
107+
return [
108+
'size' => count($this->cache),
109+
'hits' => $totalHits,
110+
'misses' => $totalMisses,
111+
'hitRate' => $hitRate,
112+
];
113+
}
114+
115+
/**
116+
* Generate cache key from query SQL and parameters
117+
*/
118+
private function getCacheKey(Query $query): string
119+
{
120+
$sql = $query->getSQL();
121+
$parameters = $query->getParameters()->toArray();
122+
123+
// Normalize parameters for consistent cache keys
124+
$normalizedParams = [];
125+
foreach ($parameters as $param) {
126+
$normalizedParams[$param->getName()] = $param->getValue();
127+
}
128+
129+
return md5($sql . serialize($normalizedParams));
130+
}
131+
}

0 commit comments

Comments
 (0)