Passed
Pull Request — 2.x (#423)
by Aleksei
16:14
created

AbstractNode   C

Complexity

Total Complexity 56

Size/Duplication

Total Lines 386
Duplicated Lines 0 %

Test Coverage

Coverage 91.96%

Importance

Changes 3
Bugs 1 Features 2
Metric Value
eloc 123
dl 0
loc 386
ccs 103
cts 112
cp 0.9196
rs 5.5199
c 3
b 1
f 2
wmc 56

16 Methods

Rating   Name   Duplication   Size   Complexity  
A __destruct() 0 6 1
A __construct() 0 7 2
A mergeInheritanceNodes() 0 5 2
B linkNode() 0 24 8
A fetchData() 0 13 2
A getNode() 0 7 2
C parseRow() 0 68 12
A intersectData() 0 7 2
A mount() 0 22 6
A getReferenceValues() 0 10 3
A getSubclassMergeNodes() 0 3 1
A mergeData() 0 16 6
A joinNode() 0 4 1
A isEmptyPrimaryKey() 0 8 3
A getParentMergeNode() 0 3 1
A mountArray() 0 12 4

How to fix   Complexity   

Complex Class

Complex classes like AbstractNode often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use AbstractNode, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Cycle\ORM\Parser;
6
7
use Cycle\ORM\Exception\ParserException;
8
use Cycle\ORM\Parser\Traits\DuplicateTrait;
9
use Throwable;
10
11
/**
12
 * Represents data node in a tree with ability to parse line of results, split it into sub
13
 * relations, aggregate reference keys and etc.
14
 *
15
 * Nodes can be used as to parse one big and flat query, or when multiple queries provide their
16
 * data into one dataset, in both cases flow is identical from standpoint of Nodes (but offsets are
17
 * different).
18
 *
19
 * @internal
20
 */
21
abstract class AbstractNode
22
{
23
    use DuplicateTrait;
24
25
    // Indicates tha data must be placed at the last registered reference
26
    protected const LAST_REFERENCE = ['~'];
27
28
    /**
29
     * Indicates that node data is joined to parent row and must receive part of incoming row
30
     * subset.
31
     */
32
    protected bool $joined = false;
33
34
    /**
35
     * Declared column list which must be aggregated in a parent node. i.e. Parent Key
36
     *
37
     * @var string[]
38
     */
39
    protected array $outerKeys;
40
41
    /**
42
     * Node location in a tree. Set when node is registered.
43
     *
44
     * @internal
45
     */
46
    protected ?string $container = null;
47
48
    /** @internal */
49
    protected ?self $parent = null;
50
51
    /** @var array<string, AbstractNode> */
52
    protected array $nodes = [];
53
54
    protected ?ParentMergeNode $mergeParent = null;
55
56
    /** @var SubclassMergeNode[]  */
57
    protected array $mergeSubclass = [];
58
59
    protected ?string $indexName;
60
61
    /**
62
     * Indexed keys and values associated with references
63
     *
64
     * @internal
65
     */
66
    protected ?MultiKeyCollection $indexedData = null;
67
68
    /**
69
     * @param string[] $columns  List of columns node must fetch from the row.
70
     *                           When columns are empty original line will be returned as result.
71
     * @param string[]|null $outerKeys Defines column name in parent Node to be aggregated.
72
     */
73 6410
    public function __construct(
74
        protected array $columns,
75
        array $outerKeys = null
76
    ) {
77 6410
        $this->indexName = empty($outerKeys) ? null : implode(':', $outerKeys);
78 6410
        $this->outerKeys = $outerKeys ?? [];
79 6410
        $this->indexedData = new MultiKeyCollection();
80
    }
81
82 6394
    public function __destruct()
83
    {
84 6394
        $this->parent = null;
85 6394
        $this->nodes = [];
86 6394
        $this->indexedData = null;
87 6394
        $this->duplicates = [];
88
    }
89
90
    /**
91
     * Parse given row of data and populate reference tree.
92
     *
93
     * @return int Must return number of parsed columns.
94
     */
95 6308
    public function parseRow(int $offset, array $row): int
96
    {
97 6308
        $data = $this->fetchData($offset, $row);
98
99 6308
        $innerOffset = 0;
100 6308
        $relatedNodes = \array_merge(
101
            $this->mergeParent === null ? [] : [$this->mergeParent],
102 4072
            $this->nodes,
103 336
            $this->mergeSubclass
104
        );
105
106
        if ($this->isEmptyPrimaryKey($data)) {
107
            // Skip all columns which are related to current node and sub nodes.
108 6308
            return \count($this->columns)
109 3656
                + \array_reduce(
110
                    $relatedNodes,
111
                    static fn (int $cnt, AbstractNode $node): int => $cnt + \count($node->columns),
112 3656
                    0,
113
                );
114
        }
115 6308
116 780
        if ($this->deduplicate($data)) {
117
            foreach ($this->indexedData->getIndexes() as $index) {
118 434
                try {
119
                    $this->indexedData->addItem($index, $data);
0 ignored issues
show
Bug introduced by
The method addItem() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

119
                    $this->indexedData->/** @scrutinizer ignore-call */ 
120
                                        addItem($index, $data);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
120
                } catch (Throwable) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
121 6304
                }
122 6304
            }
123 6304
124 6304
            //Let's force placeholders for every sub loaded
125 6304
            foreach ($this->nodes as $name => $node) {
126
                if ($node instanceof ParentMergeNode) {
127 6304
                    continue;
128 4072
                }
129 2522
                $data[$name] = $node instanceof ArrayNode ? [] : null;
130
            }
131
132
            $this->push($data);
133
        } elseif ($this->parent !== null) {
134
            // register duplicate rows in each parent row
135
            $this->push($data);
136
        }
137
138
        foreach ($relatedNodes as $node) {
139
            if (!$node->joined) {
140
                continue;
141
            }
142 2792
143
            /**
144
             * We are looking into branch like structure:
145 2792
             * node
146
             *  - node
147
             *      - node
148 2792
             *      - node
149
             * node
150
             *
151 6304
             * This means offset has to be calculated using all nested nodes
152
             */
153
            $innerColumns = $node->parseRow(\count($this->columns) + $offset, $row);
154
155
            //Counting next selection offset
156
            $offset += $innerColumns;
157
158
            //Counting nested tree offset
159 2382
            $innerOffset += $innerColumns;
160
        }
161 2382
162 2
        return \count($this->columns) + $innerOffset;
163
    }
164 2380
165
    /**
166
     * Get list of reference key values aggregated by parent.
167
     *
168 2380
     * @throws ParserException
169
     */
170
    public function getReferenceValues(): array
171
    {
172
        if ($this->parent === null) {
173
            throw new ParserException('Unable to aggregate reference values, parent is missing.');
174
        }
175
        if (!$this->parent->indexedData->hasIndex($this->indexName)) {
0 ignored issues
show
Bug introduced by
The method hasIndex() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

175
        if (!$this->parent->indexedData->/** @scrutinizer ignore-call */ hasIndex($this->indexName)) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
Bug introduced by
It seems like $this->indexName can also be of type null; however, parameter $index of Cycle\ORM\Parser\MultiKeyCollection::hasIndex() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

175
        if (!$this->parent->indexedData->hasIndex(/** @scrutinizer ignore-type */ $this->indexName)) {
Loading history...
176
            return [];
177 4114
        }
178
179 4114
        return $this->parent->indexedData->getCriteria($this->indexName, true);
0 ignored issues
show
Bug introduced by
It seems like $this->indexName can also be of type null; however, parameter $index of Cycle\ORM\Parser\MultiKeyCollection::getCriteria() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

179
        return $this->parent->indexedData->getCriteria(/** @scrutinizer ignore-type */ $this->indexName, true);
Loading history...
180 4114
    }
181 3698
182 3698
    /**
183
     * Register new node into NodeTree. Nodes used to convert flat results into tree representation
184 880
     * using reference aggregations. Node would not be used to parse incoming row results.
185 744
     *
186
     * @throws ParserException
187 880
     */
188 336
    public function linkNode(?string $container, self $node): void
189
    {
190
        $node->parent = $this;
191
        if ($container !== null) {
192 4114
            $this->nodes[$container] = $node;
193 4114
            $node->container = $container;
194
        } else {
195 4114
            if ($node instanceof ParentMergeNode) {
196
                $this->mergeParent = $node;
197
            }
198
            if ($node instanceof SubclassMergeNode) {
199 4114
                $this->mergeSubclass[] = $node;
200 4114
            }
201
        }
202
203
        if ($node->indexName !== null) {
204
            foreach ($node->outerKeys as $key) {
205
                // foreach ($node->indexValues->getIndex($this->indexName) as $key) {
206
                if (!in_array($key, $this->columns, true)) {
207
                    throw new ParserException("Unable to create reference, key `{$key}` does not exist.");
208
                }
209
            }
210
            if (!$this->indexedData->hasIndex($node->indexName)) {
211 2834
                $this->indexedData->createIndex($node->indexName, $node->outerKeys);
212
            }
213 2834
        }
214 2834
    }
215
216
    /**
217
     * Register new node into NodeTree. Nodes used to convert flat results into tree representation
218
     * using reference aggregations. Node will used to parse row results.
219
     *
220
     * @throws ParserException
221
     */
222 3646
    public function joinNode(?string $container, self $node): void
223
    {
224 3646
        $node->joined = true;
225 2
        $this->linkNode($container, $node);
226
    }
227
228 3644
    /**
229
     * Fetch sub node.
230
     *
231 744
     * @throws ParserException
232
     */
233 744
    public function getNode(string $container): self
234
    {
235
        if (!isset($this->nodes[$container])) {
236
            throw new ParserException("Undefined node `{$container}`.");
237
        }
238
239 6286
        return $this->nodes[$container];
240
    }
241 6286
242
    public function getParentMergeNode(): ParentMergeNode
243
    {
244 6286
        return $this->mergeParent;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->mergeParent could return the type null which is incompatible with the type-hinted return Cycle\ORM\Parser\ParentMergeNode. Consider adding an additional type-check to rule them out.
Loading history...
245
    }
246 6286
247 6286
    /**
248 328
     * @return SubclassMergeNode[]
249
     */
250
    public function getSubclassMergeNodes(): array
251
    {
252
        return $this->mergeSubclass;
253
    }
254
255
    public function mergeInheritanceNodes(bool $includeRole = false): void
256
    {
257
        $this->mergeParent?->mergeInheritanceNodes();
258
        foreach ($this->mergeSubclass as $subclassNode) {
259
            $subclassNode->mergeInheritanceNodes($includeRole);
260
        }
261
    }
262
263
    /**
264
     * Mount record data into internal data storage under specified container using reference key
265
     * (inner key) and reference criteria (outer key value).
266
     *
267
     * Example (default ORM Loaders):
268
     * $this->parent->mount('profile', 'id', 1, [
269
     *      'id' => 100,
270 2586
     *      'user_id' => 1,
271
     *      ...
272 2586
     * ]);
273 264
     *
274
     * In this example "id" argument is inner key of "user" record and it's linked to outer key
275
     * "user_id" in "profile" record, which defines reference criteria as 1.
276 264
     *
277
     * Attention, data WILL be referenced to new memory location!
278
     *
279 2586
     * @throws ParserException
280 2
     */
281
    protected function mount(string $container, string $index, array $criteria, array &$data): void
282
    {
283 2586
        if ($criteria === self::LAST_REFERENCE) {
284 2586
            if (!$this->indexedData->hasIndex($index)) {
285
                return;
286 336
            }
287
            $criteria = $this->indexedData->getLastItemKeys($index);
288 2586
        }
289
290
        if ($this->indexedData->getItemsCount($index, $criteria) === 0) {
291 2586
            throw new ParserException(sprintf('Undefined reference `%s` "%s".', $index, implode(':', $criteria)));
292
        }
293
294
        foreach ($this->indexedData->getItemsSubset($index, $criteria) as &$subset) {
0 ignored issues
show
Bug introduced by
The expression $this->indexedData->getI...bset($index, $criteria) cannot be used as a reference.

Let?s assume that you have the following foreach statement:

foreach ($array as &$itemValue) { }

$itemValue is assigned by reference. This is possible because the expression (in the example $array) can be used as a reference target.

However, if we were to replace $array with something different like the result of a function call as in

foreach (getArray() as &$itemValue) { }

then assigning by reference is not possible anymore as there is no target that could be modified.

Available Fixes

1. Do not assign by reference
foreach (getArray() as $itemValue) { }
2. Assign to a local variable first
$array = getArray();
foreach ($array as &$itemValue) {}
3. Return a reference
function &getArray() { $array = array(); return $array; }

foreach (getArray() as &$itemValue) { }
Loading history...
295
            if (isset($subset[$container])) {
296
                // back reference!
297
                $data = &$subset[$container];
298
            } else {
299
                $subset[$container] = &$data;
300
            }
301
302
            unset($subset);
303
        }
304
    }
305
306
    /**
307
     * Mount record data into internal data storage under specified container using reference key
308
     * (inner key) and reference criteria (outer key value).
309
     *
310
     * Example (default ORM Loaders):
311
     * $this->parent->mountArray('comments', 'id', 1, [
312
     *      'id' => 100,
313 1916
     *      'user_id' => 1,
314
     *      ...
315 1916
     * ]);
316
     *
317
     * In this example "id" argument is inner key of "user" record and it's linked to outer key
318
     * "user_id" in "profile" record, which defines reference criteria as 1.
319 1916
     *
320 1916
     * Add added records will be added as array items.
321 1916
     *
322
     * @throws ParserException
323
     */
324 1916
    protected function mountArray(string $container, string $index, mixed $criteria, array &$data): void
325
    {
326
        if (!$this->indexedData->hasIndex($index)) {
327
            throw new ParserException("Undefined index `{$index}`.");
328
        }
329
330 880
        foreach ($this->indexedData->getItemsSubset($index, $criteria) as &$subset) {
0 ignored issues
show
Bug introduced by
The expression $this->indexedData->getI...bset($index, $criteria) cannot be used as a reference.

Let?s assume that you have the following foreach statement:

foreach ($array as &$itemValue) { }

$itemValue is assigned by reference. This is possible because the expression (in the example $array) can be used as a reference target.

However, if we were to replace $array with something different like the result of a function call as in

foreach (getArray() as &$itemValue) { }

then assigning by reference is not possible anymore as there is no target that could be modified.

Available Fixes

1. Do not assign by reference
foreach (getArray() as $itemValue) { }
2. Assign to a local variable first
$array = getArray();
foreach ($array as &$itemValue) {}
3. Return a reference
function &getArray() { $array = array(); return $array; }

foreach (getArray() as &$itemValue) { }
Loading history...
331
            if (!in_array($data, $subset[$container], true)) {
332 880
                $subset[$container][] = &$data;
333
            }
334
        }
335
        unset($subset);
336
    }
337
338
    /**
339 880
     * @throws ParserException
340
     */
341
    protected function mergeData(string $index, array $criteria, array $data, bool $overwrite): void
342
    {
343 880
        if ($criteria === self::LAST_REFERENCE) {
344 880
            if (!$this->indexedData->hasIndex($index)) {
345 880
                return;
346
            }
347
            $criteria = $this->indexedData->getLastItemKeys($index);
348
        }
349
350
        if ($this->indexedData->getItemsCount($index, $criteria) === 0) {
351
            throw new ParserException(sprintf('Undefined reference `%s` "%s".', $index, implode(':', $criteria)));
352
        }
353
354
        foreach ($this->indexedData->getItemsSubset($index, $criteria) as &$subset) {
0 ignored issues
show
Bug introduced by
The expression $this->indexedData->getI...bset($index, $criteria) cannot be used as a reference.

Let?s assume that you have the following foreach statement:

foreach ($array as &$itemValue) { }

$itemValue is assigned by reference. This is possible because the expression (in the example $array) can be used as a reference target.

However, if we were to replace $array with something different like the result of a function call as in

foreach (getArray() as &$itemValue) { }

then assigning by reference is not possible anymore as there is no target that could be modified.

Available Fixes

1. Do not assign by reference
foreach (getArray() as $itemValue) { }
2. Assign to a local variable first
$array = getArray();
foreach ($array as &$itemValue) {}
3. Return a reference
function &getArray() { $array = array(); return $array; }

foreach (getArray() as &$itemValue) { }
Loading history...
355
            $subset = $overwrite ? array_merge($subset, $data) : array_merge($data, $subset);
356
            unset($subset);
357 6308
        }
358
    }
359
360
    /**
361 6308
     * Register data result.
362 6308
     */
363 6308
    abstract protected function push(array &$data);
364
365 2
    /**
366 2
     * Fetch record columns from query row, must use data offset to slice required part of query.
367 2
     */
368 2
    protected function fetchData(int $dataOffset, array $line): array
369
    {
370
        try {
371
            //Combine column names with sliced piece of row
372
            return \array_combine(
373
                $this->columns,
374 3798
                \array_slice($line, $dataOffset, \count($this->columns))
375
            );
376 3798
        } catch (Throwable $e) {
377 3798
            throw new ParserException(
378 3798
                'Unable to parse incoming row: ' . $e->getMessage(),
379
                $e->getCode(),
380 3798
                $e
381
            );
382
        }
383
    }
384
385
    protected function intersectData(array $keys, array $data): array
386
    {
387
        $result = [];
388
        foreach ($keys as $key) {
389
            $result[$key] = $data[$key];
390
        }
391
        return $result;
392
    }
393
394
    /**
395
     * @param array<string, mixed> $data
396
     *
397
     * @return bool True if any PK field is empty
398
     */
399
    private function isEmptyPrimaryKey(array $data): bool
400
    {
401
        foreach ($this->duplicateCriteria as $key) {
402
            if ($data[$key] === null) {
403
                return true;
404
            }
405
        }
406
        return false;
407
    }
408
}
409