Passed
Pull Request — 2.x (#423)
by Maxim
18:06
created

AbstractNode::isSkippedNode()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 0
Metric Value
cc 3
eloc 4
nc 3
nop 1
dl 0
loc 8
ccs 0
cts 0
cp 0
crap 12
rs 10
c 0
b 0
f 0
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
        $iterate = array_merge(
101
            $this->mergeParent === null ? [] : [$this->mergeParent],
102 4072
            $this->nodes,
103 336
            $this->mergeSubclass
104
        );
105
106
        if ($this->isSkippedNode($data)) {
107
            return \count($this->columns)
108 6308
                + \array_reduce($iterate, static fn (int $cnt, AbstractNode $node) => $cnt + \count($node->columns), 0);
109 3656
        }
110
111
        if ($this->deduplicate($data)) {
112 3656
            foreach ($this->indexedData->getIndexes() as $index) {
113
                try {
114
                    $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

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

170
        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

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

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