AbstractNode   C
last analyzed

Complexity

Total Complexity 57

Size/Duplication

Total Lines 392
Duplicated Lines 0 %

Test Coverage

Coverage 91.96%

Importance

Changes 4
Bugs 1 Features 2
Metric Value
eloc 124
dl 0
loc 392
ccs 103
cts 112
cp 0.9196
rs 5.04
c 4
b 1
f 2
wmc 57

16 Methods

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

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

168
        if (!$this->parent->indexedData->hasIndex(/** @scrutinizer ignore-type */ $this->indexName)) {
Loading history...
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

168
        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...
169
            return [];
170
        }
171
172
        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

172
        return $this->parent->indexedData->getCriteria(/** @scrutinizer ignore-type */ $this->indexName, true);
Loading history...
173
    }
174
175
    /**
176
     * Register new node into NodeTree. Nodes used to convert flat results into tree representation
177 4114
     * using reference aggregations. Node would not be used to parse incoming row results.
178
     *
179 4114
     * @throws ParserException
180 4114
     */
181 3698
    public function linkNode(?string $container, self $node): void
182 3698
    {
183
        $node->parent = $this;
184 880
        if ($container !== null) {
185 744
            $this->nodes[$container] = $node;
186
            $node->container = $container;
187 880
        } else {
188 336
            if ($node instanceof ParentMergeNode) {
189
                $this->mergeParent = $node;
190
            }
191
            if ($node instanceof SubclassMergeNode) {
192 4114
                $this->mergeSubclass[] = $node;
193 4114
            }
194
        }
195 4114
196
        if ($node->indexName !== null) {
197
            foreach ($node->outerKeys as $key) {
198
                // foreach ($node->indexValues->getIndex($this->indexName) as $key) {
199 4114
                if (!\in_array($key, $this->columns, true)) {
200 4114
                    throw new ParserException("Unable to create reference, key `{$key}` does not exist.");
201
                }
202
            }
203
            if (!$this->indexedData->hasIndex($node->indexName)) {
204
                $this->indexedData->createIndex($node->indexName, $node->outerKeys);
205
            }
206
        }
207
    }
208
209
    /**
210
     * Register new node into NodeTree. Nodes used to convert flat results into tree representation
211 2834
     * using reference aggregations. Node will used to parse row results.
212
     *
213 2834
     * @throws ParserException
214 2834
     */
215
    public function joinNode(?string $container, self $node): void
216
    {
217
        $node->joined = true;
218
        $this->linkNode($container, $node);
219
    }
220
221
    /**
222 3646
     * Fetch sub node.
223
     *
224 3646
     * @throws ParserException
225 2
     */
226
    public function getNode(string $container): self
227
    {
228 3644
        if (!isset($this->nodes[$container])) {
229
            throw new ParserException("Undefined node `{$container}`.");
230
        }
231 744
232
        return $this->nodes[$container];
233 744
    }
234
235
    public function getParentMergeNode(): ParentMergeNode
236
    {
237
        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...
238
    }
239 6286
240
    /**
241 6286
     * @return SubclassMergeNode[]
242
     */
243
    public function getSubclassMergeNodes(): array
244 6286
    {
245
        return $this->mergeSubclass;
246 6286
    }
247 6286
248 328
    public function mergeInheritanceNodes(bool $includeRole = false): void
249
    {
250
        $this->mergeParent?->mergeInheritanceNodes();
251
        foreach ($this->mergeSubclass as $subclassNode) {
252
            $subclassNode->mergeInheritanceNodes($includeRole);
253
        }
254
    }
255
256
    public function __destruct()
257
    {
258
        $this->parent = null;
259
        $this->nodes = [];
260
        $this->indexedData = null;
261
        $this->duplicates = [];
262
    }
263
264
    /**
265
     * Mount record data into internal data storage under specified container using reference key
266
     * (inner key) and reference criteria (outer key value).
267
     *
268
     * Example (default ORM Loaders):
269
     *
270 2586
     *     $this->parent->mount('profile', 'id', 1, [
271
     *         'id' => 100,
272 2586
     *         'user_id' => 1,
273 264
     *         // ...
274
     *     ]);
275
     *
276 264
     * In this example "id" argument is inner key of "user" record and it's linked to outer key
277
     * "user_id" in "profile" record, which defines reference criteria as 1.
278
     *
279 2586
     * Attention, data WILL be referenced to new memory location!
280 2
     *
281
     * @throws ParserException
282
     */
283 2586
    protected function mount(string $container, string $index, array $criteria, array &$data): void
284 2586
    {
285
        if ($criteria === self::LAST_REFERENCE) {
286 336
            if (!$this->indexedData->hasIndex($index)) {
287
                return;
288 2586
            }
289
            $criteria = $this->indexedData->getLastItemKeys($index);
290
        }
291 2586
292
        if ($this->indexedData->getItemsCount($index, $criteria) === 0) {
293
            throw new ParserException(\sprintf('Undefined reference `%s` "%s".', $index, \implode(':', $criteria)));
294
        }
295
296
        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...
297
            if (isset($subset[$container])) {
298
                // back reference!
299
                $data = &$subset[$container];
300
            } else {
301
                $subset[$container] = &$data;
302
            }
303
304
            unset($subset);
305
        }
306
    }
307
308
    /**
309
     * Mount record data into internal data storage under specified container using reference key
310
     * (inner key) and reference criteria (outer key value).
311
     *
312
     * Example (default ORM Loaders):
313 1916
     *
314
     *     $this->parent->mountArray('comments', 'id', 1, [
315 1916
     *         'id' => 100,
316
     *         'user_id' => 1,
317
     *         // ...
318
     *     ]);
319 1916
     *
320 1916
     * In this example "id" argument is inner key of "user" record and it's linked to outer key
321 1916
     * "user_id" in "profile" record, which defines reference criteria as 1.
322
     *
323
     * Add added records will be added as array items.
324 1916
     *
325
     * @throws ParserException
326
     */
327
    protected function mountArray(string $container, string $index, mixed $criteria, array &$data): void
328
    {
329
        if (!$this->indexedData->hasIndex($index)) {
330 880
            throw new ParserException("Undefined index `{$index}`.");
331
        }
332 880
333
        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...
334
            if (!\in_array($data, $subset[$container], true)) {
335
                $subset[$container][] = &$data;
336
            }
337
        }
338
        unset($subset);
339 880
    }
340
341
    /**
342
     * @throws ParserException
343 880
     */
344 880
    protected function mergeData(string $index, array $criteria, array $data, bool $overwrite): void
345 880
    {
346
        if ($criteria === self::LAST_REFERENCE) {
347
            if (!$this->indexedData->hasIndex($index)) {
348
                return;
349
            }
350
            $criteria = $this->indexedData->getLastItemKeys($index);
351
        }
352
353
        if ($this->indexedData->getItemsCount($index, $criteria) === 0) {
354
            throw new ParserException(\sprintf('Undefined reference `%s` "%s".', $index, \implode(':', $criteria)));
355
        }
356
357 6308
        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...
358
            $subset = $overwrite ? \array_merge($subset, $data) : \array_merge($data, $subset);
359
            unset($subset);
360
        }
361 6308
    }
362 6308
363 6308
    /**
364
     * Register data result.
365 2
     */
366 2
    abstract protected function push(array &$data);
367 2
368 2
    /**
369
     * Fetch record columns from query row, must use data offset to slice required part of query.
370
     */
371
    protected function fetchData(int $dataOffset, array $line): array
372
    {
373
        try {
374 3798
            //Combine column names with sliced piece of row
375
            return \array_combine(
376 3798
                $this->columns,
377 3798
                \array_slice($line, $dataOffset, \count($this->columns)),
378 3798
            );
379
        } catch (\Throwable $e) {
380 3798
            throw new ParserException(
381
                'Unable to parse incoming row: ' . $e->getMessage(),
382
                $e->getCode(),
383
                $e,
384
            );
385
        }
386
    }
387
388
    protected function intersectData(array $keys, array $data): array
389
    {
390
        $result = [];
391
        foreach ($keys as $key) {
392
            $result[$key] = $data[$key];
393
        }
394
395
        return $result;
396
    }
397
398
    /**
399
     * @param array<string, mixed> $data
400
     *
401
     * @return bool True if any PK field is empty
402
     */
403
    private function isEmptyPrimaryKey(array $data): bool
404
    {
405
        foreach ($this->duplicateCriteria as $key) {
406
            if ($data[$key] === null) {
407
                return true;
408
            }
409
        }
410
411
        return false;
412
    }
413
}
414