Passed
Pull Request — master (#11)
by Loz
12:53
created

EagerLoadedDataList::eagerLoadHasMany()   B

Complexity

Conditions 6
Paths 17

Size

Total Lines 40
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 26
CRAP Score 6.151

Importance

Changes 4
Bugs 2 Features 0
Metric Value
eloc 30
c 4
b 2
f 0
dl 0
loc 40
ccs 26
cts 31
cp 0.8387
rs 8.8177
cc 6
nc 17
nop 3
crap 6.151
1
<?php
2
namespace Gurucomkz\EagerLoading;
3
4
use Exception;
5
use SilverStripe\Core\Config\Config;
6
use SilverStripe\ORM\DataList;
7
use SilverStripe\ORM\DataObject;
8
use SilverStripe\ORM\Queries\SQLSelect;
9
10
/**
11
 * Replaces DataList when EagerLoading is used. Fetches data when the main query is actually executed.
12
 * Appends related objects when a DataObject is actually created.
13
 */
14
class EagerLoadedDataList extends DataList
15
{
16
17
    const ID_LIMIT = 5000;
18
    public $withList = [];
19
    public $withListOriginal = [];
20
    public $eagerLoadingRelatedMaps = [
21
        'has_one' => [],
22
        'has_many' => [],
23
        'many_many' => [],
24
    ];
25
26 12
    public function __construct($classOrList)
27
    {
28 12
        if (is_string($classOrList)) {
29
            parent::__construct($classOrList);
30
        } else {
31 12
            parent::__construct($classOrList->dataClass());
32 12
            $this->dataQuery = $classOrList->dataQuery();
33
        }
34
    }
35
36
    public $eagerLoadingRelatedCache = [];
37 12
    public static function cloneFrom(DataList $list)
38
    {
39 12
        $clone = new EagerLoadedDataList($list);
40
41 12
        $clone->withList = $list->withList;
42 12
        $clone->withListOriginal = $list->withListOriginal;
43 12
        $clone->eagerLoadingRelatedCache = $list->eagerLoadingRelatedCache;
44 12
        return $clone;
45
    }
46
47
    /**
48
     * Create a DataObject from the given SQL row
49
     *
50
     * @param array $row
51
     * @return DataObject
52
     */
53 12
    public function createDataObject($row)
54
    {
55 12
        $this->prepareEagerRelations();
56 12
        $item = parent::createDataObject($row);
57
58 12
        $this->fulfillEagerRelations($item);
59 11
        return $item;
60
    }
61
62
    private $relationsPrepared = false;
63
64 12
    private function filterWithList($list)
65
    {
66 12
        return array_filter(
67 12
            $this->withList,
68 12
            function ($dep) use ($list) {
69 12
                return array_key_exists($dep[0], $list);
70 12
            }
71 12
        );
72
    }
73
74 12
    public function prepareEagerRelations()
75
    {
76 12
        if ($this->relationsPrepared) {
77 10
            return;
78
        }
79 12
        $this->relationsPrepared = true;
80 12
        $localClass = $this->dataClass();
81 12
        $config = Config::forClass($localClass);
82 12
        $hasOnes = (array) $config->get('has_one');
83 12
        $hasManys = (array) $config->get('has_many');
84 12
        $manyManys = (array) $config->get('many_many');
85 12
        $belongsManyManys = (array) $config->get('belongs_many_many');
86
87
        //collect has_ones
88 12
        $withHasOnes = $this->filterWithList($hasOnes);
89 12
        $withHasManys = $this->filterWithList($hasManys);
90 12
        $withManyManys = $this->filterWithList($manyManys);
91 12
        $withBelongsManyManys = $this->filterWithList($belongsManyManys);
92
93 12
        if (!count($withHasOnes) && !count($withHasManys) && !count($withManyManys) && !count($withBelongsManyManys)) {
94
            // Injector::inst()->get(LoggerInterface::class)
95
            // ->debug("Invalid names supplied for ->with(" . implode(', ', $this->withListOriginal) . ")");
96 1
            return;
97
        }
98
99 11
        $data = $this->column('ID');
100 11
        if (count($withHasOnes)) {
101 6
            $this->eagerLoadingPrepareCache($hasOnes, $withHasOnes);
102 6
            $this->eagerLoadHasOne($data, $hasOnes, $withHasOnes);
103
        }
104 11
        if (count($withHasManys)) {
105 3
            $this->eagerLoadingPrepareCache($hasManys, $withHasManys);
106 3
            $this->eagerLoadHasMany($data, $hasManys, $withHasManys);
107
        }
108 11
        if (count($withManyManys)) {
109 4
            $this->eagerLoadingPrepareCache($manyManys, $withManyManys);
110 4
            $this->eagerLoadManyMany($data, $manyManys, $withManyManys);
111
        }
112 11
        if (count($withBelongsManyManys)) {
113 1
            $this->eagerLoadingPrepareCache($belongsManyManys, $withBelongsManyManys);
114 1
            $this->eagerLoadManyMany($data, $belongsManyManys, $withBelongsManyManys);
115
        }
116
    }
117
118 6
    public function eagerLoadHasOne(&$ids, $hasOnes, $withHasOnes)
119
    {
120
        //collect required IDS
121 6
        $fields = ['ID'];
122 6
        foreach ($withHasOnes as $depSeq) {
123 6
            $dep = $depSeq[0];
124 6
            $fields[] = "\"{$dep}ID\"";
125
        }
126 6
        $table = DataObject::getSchema()->tableName($this->dataClass);
127 6
        $data = new SQLSelect($fields, '"' . $table . '"', ['"ID" IN (' . implode(',', $ids) . ')']);
128 6
        $data = Utils::EnsureArray($data->execute(), 'ID');
129
130 6
        foreach ($withHasOnes as $depSeq) {
131 6
            $dep = $depSeq[0];
132 6
            $depClass = $hasOnes[$dep];
133
134 6
            $descriptor = [
135 6
                'class' => $depClass,
136 6
                'localField' => "{$dep}ID",
137 6
                'map' => [],
138 6
            ];
139
140 6
            $descriptor['map'] = Utils::extractField($data, $descriptor['localField']);
141 6
            $uniqueIDs = array_unique($descriptor['map']);
142 6
            while (count($uniqueIDs)) {
143 6
                $IDsubset = array_splice($uniqueIDs, 0, self::ID_LIMIT);
144 6
                $result = DataObject::get($depClass)->filter('ID', $IDsubset);
145 6
                $this->extend('onEagerLoadHasOneMany', $result, $data, $depSeq, $withHasOnes);
146 6
                if (count($depSeq)>1) {
147 2
                    $result = $result
148 2
                        ->with(implode('.', array_slice($depSeq, 1)));
149
                }
150
151 6
                foreach ($result as $depRecord) {
152 6
                    $this->eagerLoadingRelatedCache[$depClass][$depRecord->ID] = $depRecord;
153
                }
154
            }
155
156 6
            $this->eagerLoadingRelatedMaps['has_one'][$dep] = $descriptor;
157
        }
158
    }
159
160 3
    public function eagerLoadHasMany($data, $hasManys, $withHasManys)
161
    {
162 3
        $localClass = $this->dataClass();
163 3
        $localClassTail = basename(str_replace('\\', '/', $localClass));
164
165 3
        foreach ($withHasManys as $depSeq) {
166 3
            $dep = $depSeq[0];
167 3
            $depClass = $hasManys[$dep];
168 3
            if (false !== strpos($depClass, '.')) {
169
                $dcSplit = explode('.', $depClass, 2);
170
                $depClass = $dcSplit[0];
171
                $localNameInDep = $dcSplit[1];
172
            } else {
173 3
                $localNameInDep = $localClassTail;
174
            }
175 3
            $depKey = "{$localNameInDep}ID";
176 3
            $descriptor = [
177 3
                'class' => $depClass,
178 3
                'remoteRelation' => $localNameInDep,
179 3
                'remoteField' => $depKey,
180 3
                'map' => [],
181 3
            ];
182 3
            $result = DataObject::get($depClass)->filter($depKey, $data);
183 3
            $this->extend('onEagerLoadHasMany', $result, $depKey, $data, $depSeq, $withHasManys);
184 3
            if (count($depSeq)>1) {
185
                $result = $result
186
                    ->with(implode('.', array_slice($depSeq, 1)));
187
            }
188
189 3
            $collection = [];
190
191 3
            foreach ($data as $localRecordID) {
192 3
                $collection[$localRecordID] = [];
193
            }
194 3
            foreach ($result as $depRecord) {
195 3
                $this->eagerLoadingRelatedCache[$depClass][$depRecord->ID] = $depRecord;
196 3
                $collection[$depRecord->$depKey][] = $depRecord->ID;
197
            }
198 3
            $descriptor['map'] = $collection;
199 3
            $this->eagerLoadingRelatedMaps['has_many'][$dep] = $descriptor;
200
        }
201
    }
202
203 5
    public function eagerLoadManyMany(&$data, $manyManys, $withManyManys)
204
    {
205 5
        $localClass = $this->dataClass();
206 5
        $schema = DataObject::getSchema();
207
208 5
        foreach ($withManyManys as $depSeq) {
209 5
            $dep = $depSeq[0];
210 5
            $depData = $manyManys[$dep];
211
212 5
            if (is_array($depData)) {
213 1
                if (!isset($depData['from']) || !isset($depData['to']) || !isset($depData['through'])) {
214
                    throw new Exception(sprintf('Incompatible "many_many through" configuration for %s.%s', $localClass, $dep));
215
                }
216 1
                $throughClass = $depData['through'];
217
                // determine the target data object
218 1
                $depClass = $schema->hasOneComponent($depData['through'], $depData['to']);
219 1
                if (!$depClass) {
220
                    throw new Exception(sprintf('Class %s does not have a $has_one component named', $depData['through'], $depData['to']));
221
                }
222
223 1
                $table = DataObject::getSchema()->tableName($throughClass);
224
225 1
                $childField = $depData['to']. 'ID';
226 1
                $parentField = $depData['from']. 'ID';
227
            } else {
228 4
                $depClass = $depData;
229 4
                $component = $schema->manyManyComponent($localClass, $dep);
230
231 4
                $table = $component['join'];
232 4
                $childField = $component['childField'];
233 4
                $parentField = $component['parentField'];
234
            }
235
236 5
            $descriptor = [
237 5
                'class' => $depClass,
238 5
                'map' => [],
239 5
            ];
240
241 5
            $idsQuery = SQLSelect::create(
242 5
                [
243 5
                    '"' . $childField . '"',
244 5
                    '"' . $parentField . '"',
245 5
                ],
246 5
                '"' . $table . '"',
247 5
                [
248 5
                    '"' . $parentField . '" IN (' . implode(',', $data) . ')'
249 5
                ]
250 5
            )->execute();
251
252 5
            $collection = [];
253 5
            $relListReverted = [];
254 5
            foreach ($idsQuery as $row) {
255 5
                $relID = $row[$childField];
256 5
                $localID = $row[$parentField];
257 5
                if (!isset($collection[$localID])) {
258 5
                    $collection[$localID] = [];
259
                }
260 5
                $collection[$localID][] = $relID;
261 5
                $relListReverted[$relID] = 1; //use ids as keys to avoid
262
            }
263
264 5
            if (count($relListReverted)) {
265 5
                $result = DataObject::get($depClass)->filter('ID', array_keys($relListReverted));
266 5
                $this->extend('onEagerLoadManyMany', $result, $data, $depSeq, $withManyManys);
267 5
                if (count($depSeq)>1) {
268
                    $result = $result
269
                        ->with(implode('.', array_slice($depSeq, 1)));
270
                }
271
272 5
                foreach ($result as $depRecord) {
273 5
                    $this->eagerLoadingRelatedCache[$depClass][$depRecord->ID] = $depRecord;
274
                }
275
            }
276
277 5
            $descriptor['map'] = $collection;
278 5
            $this->eagerLoadingRelatedMaps['many_many'][$dep] = $descriptor;
279
        }
280
    }
281
282 12
    public function fulfillEagerRelations(DataObject $item)
283
    {
284 12
        foreach ($this->eagerLoadingRelatedMaps['has_one'] as $dep => $depInfo) {
285 6
            $depClass = $depInfo['class'];
286 6
            if (isset($depInfo['map'][$item->ID])) {
287 6
                $depID = $depInfo['map'][$item->ID];
288 6
                if (isset($this->eagerLoadingRelatedCache[$depClass][$depID])) {
289 6
                    $depRecord = $this->eagerLoadingRelatedCache[$depClass][$depID];
290 6
                    $item->setComponent($dep, $depRecord);
291
                }
292
            }
293
        }
294
295 12
        foreach ($this->eagerLoadingRelatedMaps['has_many'] as $dep => $depInfo) {
296 3
            $depClass = $depInfo['class'];
297 3
            $collection = [];
298 3
            if (isset($depInfo['map'][$item->ID])) {
299 3
                foreach ($depInfo['map'][$item->ID] as $depID) {
300 3
                    if (isset($this->eagerLoadingRelatedCache[$depClass][$depID])) {
301 3
                        $depRecord = $this->eagerLoadingRelatedCache[$depClass][$depID];
302 3
                        $collection[] = $depRecord;
303
                    }
304
                }
305
            }
306 3
            if (!method_exists($item, 'addEagerRelation')) {
307 1
                throw new EagerLoadingException(
308 1
                    "Model {$item->ClassName} must include " .
309 1
                    EagerLoaderMultiAccessor::class .
310 1
                    " trait to use eager loading for \$has_many"
311 1
                );
312
            }
313 2
            $item->addEagerRelation($dep, $collection);
314
        }
315
316 11
        foreach ($this->eagerLoadingRelatedMaps['many_many'] as $dep => $depInfo) {
317 5
            $depClass = $depInfo['class'];
318 5
            $collection = [];
319 5
            if (isset($depInfo['map'][$item->ID])) {
320 5
                foreach ($depInfo['map'][$item->ID] as $depID) {
321
                    // foreach ($depIDlist as $depID) {
322 5
                        if (isset($this->eagerLoadingRelatedCache[$depClass][$depID])) {
323 5
                            $depRecord = $this->eagerLoadingRelatedCache[$depClass][$depID];
324 5
                            $collection[] = $depRecord;
325
                        }
326
                    // }
327
                }
328
            }
329 5
            if (!method_exists($item, 'addEagerRelation')) {
330
                throw new EagerLoadingException(
331
                    "Model {$item->ClassName} must include " .
332
                    EagerLoaderMultiAccessor::class .
333
                    " trait to use eager loading for \$many_many"
334
                );
335
            }
336 5
            $item->addEagerRelation($dep, $collection);
337
        }
338
    }
339
    /**
340
     * Returns a generator for this DataList
341
     *
342
     * @return \Generator&DataObject[]
343
     */
344
    public function getGenerator()
345
    {
346
        $query = $this->dataQuery()->execute();
347
348
        while ($row = $query->record()) {
349
            yield $this->createDataObject($row);
350
        }
351
    }
352
353 11
    private function eagerLoadingPrepareCache($all, $selected)
354
    {
355 11
        foreach ($selected as $depSeq) {
356 11
            $dep = $depSeq[0];
357 11
            $depClass = $all[$dep];
358 11
            $depClass = $depClass['through'] ?? $depClass;
359 11
            if (!isset($this->eagerLoadingRelatedCache[$depClass])) {
360 11
                $this->eagerLoadingRelatedCache[$depClass] = [];
361
            }
362
        }
363
    }
364
}
365