Test Failed
Push — master ( 566e99...add919 )
by Juuso
03:24
created

DataLoader   A

Complexity

Total Complexity 33

Size/Duplication

Total Lines 283
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 6

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
wmc 33
lcom 1
cbo 6
dl 0
loc 283
ccs 117
cts 117
cp 1
rs 9.3999
c 0
b 0
f 0

13 Methods

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