Passed
Push — master ( 9742c9...0c0f23 )
by Juuso
03:29 queued 56s
created

DataLoader   A

Complexity

Total Complexity 34

Size/Duplication

Total Lines 294
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 6

Test Coverage

Coverage 0%

Importance

Changes 0
Metric Value
wmc 34
lcom 1
cbo 6
dl 0
loc 294
ccs 0
cts 159
cp 0
rs 9.68
c 0
b 0
f 0

14 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 11 1
B load() 0 30 6
A loadMany() 0 11 1
A clear() 0 4 1
A clearAll() 0 4 1
A prime() 0 10 3
A scheduleDispatch() 0 14 2
A dispatchQueue() 0 13 4
A dispatchQueueBatch() 0 23 2
A dispatchQueueInMultipleBatches() 0 10 2
A handleSuccessfulDispatch() 0 11 3
A handleFailedDispatch() 0 8 2
A validateBatchPromiseOutput() 0 19 3
A validateBatchPromise() 0 10 3
1
<?php
2
3
declare(strict_types=1);
4
5
namespace leinonen\DataLoader;
6
7
use React\Promise\Promise;
8
use function React\Promise\all;
9
use function React\Promise\reject;
10
use React\EventLoop\LoopInterface;
11
use function React\Promise\resolve;
12
use React\Promise\ExtendedPromiseInterface;
13
14
final class DataLoader implements DataLoaderInterface
15
{
16
    /**
17
     * @var callable
18
     */
19
    private $batchLoadFunction;
20
21
    /**
22
     * @var array
23
     */
24
    private $promiseQueue = [];
25
26
    /**
27
     * @var CacheMapInterface
28
     */
29
    private $promiseCache;
30
31
    /**
32
     * @var LoopInterface
33
     */
34
    private $eventLoop;
35
36
    /**
37
     * @var DataLoaderOptions
38
     */
39
    private $options;
40
41
    /**
42
     * Initiates a new DataLoader.
43
     *
44
     * @param callable $batchLoadFunction The function which will be called for the batch loading.
45
     * It must accept an array of keys and returns a Promise which resolves to an array of values.
46
     * @param LoopInterface $loop
47
     * @param CacheMapInterface $cacheMap
48
     * @param null|DataLoaderOptions $options
49
     */
50
    public function __construct(
51
        callable $batchLoadFunction,
52
        LoopInterface $loop,
53
        CacheMapInterface $cacheMap,
54
        DataLoaderOptions $options = null
55
    ) {
56
        $this->batchLoadFunction = $batchLoadFunction;
57
        $this->eventLoop = $loop;
58
        $this->promiseCache = $cacheMap;
59
        $this->options = $options ?? new DataLoaderOptions();
60
    }
61
62
    /**
63
     * {@inheritdoc}
64
     */
65
    public function load($key): ExtendedPromiseInterface
66
    {
67
        if ($key === null) {
68
            throw new \InvalidArgumentException(self::class . '::load must be called with a value, but got null');
69
        }
70
71
        if ($this->options->shouldCache() && $this->promiseCache->get($key)) {
72
            return $this->promiseCache->get($key);
73
        }
74
75
        $promise = new Promise(
76
            function (callable $resolve, callable $reject) use ($key) {
77
                $this->promiseQueue[] = [
78
                    'key' => $key,
79
                    'resolve' => $resolve,
80
                    'reject' => $reject,
81
                ];
82
83
                if (\count($this->promiseQueue) === 1) {
84
                    $this->scheduleDispatch();
85
                }
86
            }
87
        );
88
89
        if ($this->options->shouldCache()) {
90
            $this->promiseCache->set($key, $promise);
91
        }
92
93
        return $promise;
94
    }
95
96
    /**
97
     * {@inheritdoc}
98
     */
99
    public function loadMany(array $keys): ExtendedPromiseInterface
100
    {
101
        return all(
102
            \array_map(
103
                function ($key) {
104
                    return $this->load($key);
105
                },
106
                $keys
107
            )
108
        );
109
    }
110
111
    /**
112
     * {@inheritdoc}
113
     */
114
    public function clear($key): void
115
    {
116
        $this->promiseCache->delete($key);
117
    }
118
119
    /**
120
     * {@inheritdoc}
121
     */
122
    public function clearAll(): void
123
    {
124
        $this->promiseCache->clear();
125
    }
126
127
    /**
128
     * {@inheritdoc}
129
     */
130
    public function prime($key, $value): void
131
    {
132
        if (! $this->promiseCache->get($key)) {
133
            // Cache a rejected promise if the value is an Exception, in order to match
134
            // the behavior of load($key).
135
            $promise = $value instanceof \Exception ? reject($value) : resolve($value);
136
137
            $this->promiseCache->set($key, $promise);
138
        }
139
    }
140
141
    /**
142
     * Schedules the dispatch to happen on the next tick of the EventLoop
143
     * If batching is disabled, schedule the dispatch immediately.
144
     *
145
     * @return void
146
     */
147
    private function scheduleDispatch(): void
148
    {
149
        if ($this->options->shouldBatch()) {
150
            $this->eventLoop->futureTick(
151
                function () {
152
                    $this->dispatchQueue();
153
                }
154
            );
155
156
            return;
157
        }
158
159
        $this->dispatchQueue();
160
    }
161
162
    /**
163
     * Resets and dispatches the DataLoaders queue.
164
     *
165
     * @return void
166
     */
167
    private function dispatchQueue(): void
168
    {
169
        $queue = $this->promiseQueue;
170
        $this->promiseQueue = [];
171
172
        $maxBatchSize = $this->options->getMaxBatchSize();
173
174
        if ($maxBatchSize !== null && $maxBatchSize > 0 && $maxBatchSize < count($queue)) {
175
            $this->dispatchQueueInMultipleBatches($queue, $maxBatchSize);
176
        } else {
177
            $this->dispatchQueueBatch($queue);
178
        }
179
    }
180
181
    /**
182
     * Dispatches a batch of a queue. The given batch can also be the whole queue.
183
     *
184
     * @param array $batch
185
     */
186
    private function dispatchQueueBatch($batch)
187
    {
188
        $keys = \array_column($batch, 'key');
189
        $batchLoadFunction = $this->batchLoadFunction;
190
191
        /** @var Promise $batchPromise */
192
        $batchPromise = $batchLoadFunction($keys);
193
194
        try {
195
            $this->validateBatchPromise($batchPromise);
196
        } catch (DataLoaderException $exception) {
197
            return $this->handleFailedDispatch($batch, $exception);
198
        }
199
200
        $batchPromise->then(
201
            function ($values) use ($batch, $keys) {
202
                $this->validateBatchPromiseOutput($values, $keys);
203
                $this->handleSuccessfulDispatch($batch, $values);
204
            }
205
        )->then(null, function ($error) use ($batch) {
206
            $this->handleFailedDispatch($batch, $error);
207
        });
208
    }
209
210
    /**
211
     * Dispatches the given queue in multiple batches.
212
     *
213
     * @param array $queue
214
     * @param int $maxBatchSize
215
     *
216
     * @return void
217
     */
218
    private function dispatchQueueInMultipleBatches(array $queue, $maxBatchSize): void
219
    {
220
        $numberOfBatchesToDispatch = \count($queue) / $maxBatchSize;
221
222
        for ($i = 0; $i < $numberOfBatchesToDispatch; $i++) {
223
            $this->dispatchQueueBatch(
224
                \array_slice($queue, $i * $maxBatchSize, $maxBatchSize)
225
            );
226
        }
227
    }
228
229
    /**
230
     * Handles the batch by resolving the promises and rejecting ones that return Exceptions.
231
     *
232
     * @param array $batch
233
     * @param array $values
234
     */
235
    private function handleSuccessfulDispatch(array $batch, array $values): void
236
    {
237
        foreach ($batch as $index => $queueItem) {
238
            $value = $values[$index];
239
            if ($value instanceof \Exception) {
240
                $queueItem['reject']($value);
241
            } else {
242
                $queueItem['resolve']($value);
243
            }
244
        }
245
    }
246
247
    /**
248
     * Handles the failed batch dispatch.
249
     *
250
     * @param array $batch
251
     * @param \Exception $error
252
     */
253
    private function handleFailedDispatch(array $batch, \Exception $error)
254
    {
255
        foreach ($batch as $index => $queueItem) {
256
            // We don't want to cache individual loads if the entire batch dispatch fails.
257
            $this->clear($queueItem['key']);
258
            $queueItem['reject']($error);
259
        }
260
    }
261
262
    /**
263
     * Validates the batch promise's output.
264
     *
265
     * @param array $values Values from resolved promise.
266
     * @param array $keys Keys which the DataLoaders load was called with
267
     *
268
     * @throws DataLoaderException
269
     */
270
    private function validateBatchPromiseOutput($values, $keys): void
271
    {
272
        if (! \is_array($values)) {
273
            throw new DataLoaderException(
274
                self::class . ' must be constructed with a function which accepts ' .
275
                'an array of keys and returns a Promise which resolves to an array of values ' .
276
                \sprintf('not return a Promise: %s.', \gettype($values))
277
            );
278
        }
279
280
        if (\count($values) !== \count($keys)) {
281
            throw new DataLoaderException(
282
                self::class . ' must be constructed with a function which accepts ' .
283
                'an array of keys and returns a Promise which resolves to an array of values, but ' .
284
                'the function did not return a Promise of an array of the same length as the array of keys.' .
285
                \sprintf("\n Keys: %s\n Values: %s\n", \count($keys), \count($values))
286
            );
287
        }
288
    }
289
290
    /**
291
     * Validates the batch promise returned from the batch load function.
292
     *
293
     * @param $batchPromise
294
     *
295
     * @throws DataLoaderException
296
     */
297
    private function validateBatchPromise($batchPromise): void
298
    {
299
        if (! $batchPromise || ! \is_callable([$batchPromise, 'then'])) {
300
            throw new DataLoaderException(
301
                self::class . ' must be constructed with a function which accepts ' .
302
                'an array of keys and returns a Promise which resolves to an array of values ' .
303
                \sprintf('the function returned %s.', \gettype($batchPromise))
304
            );
305
        }
306
    }
307
}
308