AbstractNode::deduplicate()
last analyzed

Size

Total Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 1
c 0
b 0
f 0
nc 1
1
<?php
2
/**
3
 * Spiral, Core Components
4
 *
5
 * @author Wolfy-J
6
 */
7
8
namespace Spiral\ORM\Entities\Nodes;
9
10
use Spiral\ORM\Exceptions\NodeException;
11
12
/**
13
 * Represents data node in a tree with ability to parse line of results, split it into sub
14
 * relations, aggregate reference keys and etc.
15
 *
16
 * Nodes can be used as to parse one big and flat query, or when multiple queries provide their
17
 * data into one dataset, in both cases flow is identical from standpoint of Nodes (but offsets are
18
 * different).
19
 *
20
 * @todo NodeInterface, extension
21
 */
22
abstract class AbstractNode
23
{
24
    /**
25
     * Set of keys to be aggregated by Parser while parsing results.
26
     *
27
     * @var array
28
     */
29
    private $trackReferences = [];
30
31
    /**
32
     * Tree parts associated with reference keys and key values.
33
     *
34
     * $this->collectedReferences[id][ID_VALUE] = [ITEM1, ITEM2, ...].
35
     *
36
     * @var array
37
     */
38
    private $references = [];
39
40
    /**
41
     * Indicates that node data is joined to parent row.
42
     *
43
     * @var bool
44
     */
45
    private $joined = false;
46
47
    /**
48
     * Column names to be used to hydrate based on given query rows.
49
     *
50
     * @var array
51
     */
52
    protected $columns = [];
53
54
    /**
55
     * @var int
56
     */
57
    private $countColumns = 0;
58
59
    /**
60
     * Declared column which must be aggregated in a parent node. i.e. Parent Key
61
     *
62
     * @var null|string
63
     */
64
    protected $outerKey = null;
65
66
    /**
67
     * Node location in a tree. Set when node is registered.
68
     *
69
     * @invisible
70
     * @var string
71
     */
72
    protected $container;
73
74
    /**
75
     * @invisible
76
     * @var AbstractNode
77
     */
78
    protected $parent;
79
80
    /**
81
     * @var AbstractNode[]
82
     */
83
    protected $nodes = [];
84
85
    /**
86
     * @param array       $columns  When columns are empty original line will be returned as result.
87
     * @param string|null $outerKey Defines column name in parent Node to be aggregated.
88
     */
89
    public function __construct(array $columns, string $outerKey = null)
90
    {
91
        $this->columns = $columns;
92
        $this->countColumns = count($columns);
93
        $this->outerKey = $outerKey;
94
    }
95
96
    /**
97
     * Convert node into joined form (node will automatically parse parent row).
98
     *
99
     * @param bool $joined
100
     *
101
     * @return AbstractNode
102
     */
103
    public function asJoined(bool $joined = true)
104
    {
105
        $node = clone $this;
106
        $node->joined = $joined;
107
108
        return $node;
109
    }
110
111
    /**
112
     * Get list of reference key values aggregated by parent.
113
     *
114
     * @return array
115
     *
116
     * @throws NodeException
117
     */
118
    public function getReferences(): array
119
    {
120
        if (empty($this->parent)) {
121
            throw new NodeException("Unable to aggregate reference values, parent is missing");
122
        }
123
124
        if (empty($this->parent->references[$this->outerKey])) {
125
            return [];
126
        }
127
128
        return array_keys($this->parent->references[$this->outerKey]);
129
    }
130
131
    /**
132
     * Register new node into NodeTree. Nodes used to convert flat results into tree representation
133
     * using reference aggregations.
134
     *
135
     * @param string       $container
136
     * @param AbstractNode $node
137
     *
138
     * @throws NodeException
139
     */
140
    final public function registerNode(string $container, AbstractNode $node)
141
    {
142
        $node->container = $container;
143
        $node->parent = $this;
144
145
        $this->nodes[$container] = $node;
146
147
        if (!empty($node->outerKey)) {
148
            //This will make parser to aggregate such key in order to be used in later statement
149
            $this->trackReference($node->outerKey);
150
        }
151
    }
152
153
    /**
154
     * Destructing.
155
     */
156
    public function __destruct()
157
    {
158
        $this->parent = null;
159
        $this->nodes = [];
160
        $this->references = [];
161
        $this->trackReferences = [];
162
    }
163
164
    /**
165
     * Fetch sub node.
166
     *
167
     * @param string $container
168
     *
169
     * @return AbstractNode
170
     *
171
     * @throws NodeException
172
     */
173
    final public function fetchNode(string $container): AbstractNode
174
    {
175
        if (!isset($this->nodes[$container])) {
176
            throw new NodeException("Undefined node {$container}");
177
        }
178
179
        return $this->nodes[$container];
180
    }
181
182
    /**
183
     * Parser result work, fetch data and mount it into parent tree.
184
     *
185
     * @param int   $dataOffset
186
     * @param array $row
187
     *
188
     * @return int Must return number of handled columns.
189
     */
190
    final public function parseRow(int $dataOffset, array $row): int
191
    {
192
        //Fetching Node specific data from resulted row
193
        $data = $this->fetchData($dataOffset, $row);
194
195
        if ($this->deduplicate($data)) {
196
            //Create reference keys
197
            $this->collectReferences($data);
198
199
            //Make sure that all nested relations are registered
200
            $this->ensurePlaceholders($data);
201
202
            //Add data into result set
203
            $this->pushData($data);
204
        } elseif (!empty($this->parent)) {
205
            //Registering duplicates rows in each parent row
206
            $this->pushData($data);
207
        }
208
209
        $innerOffset = 0;
210
        foreach ($this->nodes as $container => $node) {
211
            if ($node->joined) {
212
                /**
213
                 * We are looking into branch like structure:
214
                 * node
215
                 *  - node
216
                 *      - node
217
                 *      - node
218
                 * node
219
                 *
220
                 * This means offset has to be calculated using all nested nodes
221
                 */
222
                $innerColumns = $node->parseRow($this->countColumns + $dataOffset, $row);
223
224
                //Counting next selection offset
225
                $dataOffset += $innerColumns;
226
227
                //Counting nested tree offset
228
                $innerOffset += $innerColumns;
229
            }
230
        }
231
232
        return $this->countColumns + $innerOffset;
233
    }
234
235
    /**
236
     * In many cases (for example if you have INLOAD of HAS_MANY relation) record data can be
237
     * replicated by many result rows (duplicated). To prevent incorrect data linking we have to
238
     * deduplicate such records.
239
     *
240
     * Method will return true if data is unique and handled previously or false in opposite case.
241
     *
242
     * Provided data array will be automatically linked with it's unique state using references
243
     * (pointer will receive different address).
244
     *
245
     * @param array $data Reference to parsed record data, reference will be pointed to valid and
246
     *                    existed data segment if such data was already parsed.
247
     *
248
     * @return bool Must return TRUE what data is unique in this selection.
249
     */
250
    abstract protected function deduplicate(array &$data): bool;
251
252
    /**
253
     * Register data result.
254
     *
255
     * @param array $data
256
     */
257
    abstract protected function pushData(array &$data);
258
259
    /**
260
     * Mount record data into internal data storage under specified container using reference key
261
     * (inner key) and reference criteria (outer key value).
262
     *
263
     * Example (default ORM Loaders):
264
     * $this->parent->mount('profile', 'id', 1, [
265
     *      'id' => 100,
266
     *      'user_id' => 1,
267
     *      ...
268
     * ]);
269
     *
270
     * In this example "id" argument is inner key of "user" record and it's linked to outer key
271
     * "user_id" in "profile" record, which defines reference criteria as 1.
272
     *
273
     * Attention, data WILL be referenced to new memory location!
274
     *
275
     * @param string $container
276
     * @param string $key
277
     * @param mixed  $criteria
278
     * @param array  $data      Data must be referenced to existed set if it was registered
279
     *                          previously.
280
     *
281
     * @throws NodeException
282
     */
283
    final protected function mount(
284
        string $container,
285
        string $key,
286
        $criteria,
287
        array &$data
288
    ) {
289
        if (!array_key_exists($criteria, $this->references[$key])) {
290
            throw new NodeException("Undefined reference {$key}.{$criteria}");
291
        }
292
293
        foreach ($this->references[$key][$criteria] as &$subset) {
294
            if (isset($subset[$container])) {
295
                //Back reference!
296
                $data = &$subset[$container];
297
            } else {
298
                $subset[$container] = &$data;
299
            }
300
301
            unset($subset);
302
        }
303
    }
304
305
    /**
306
     * Mount record data into internal data storage under specified container using reference key
307
     * (inner key) and reference criteria (outer key value).
308
     *
309
     * Example (default ORM Loaders):
310
     * $this->parent->mountArray('comments', 'id', 1, [
311
     *      'id' => 100,
312
     *      'user_id' => 1,
313
     *      ...
314
     * ]);
315
     *
316
     * In this example "id" argument is inner key of "user" record and it's linked to outer key
317
     * "user_id" in "profile" record, which defines reference criteria as 1.
318
     *
319
     * Add added records will be added as array items.
320
     *
321
     * @param string $container
322
     * @param string $key
323
     * @param mixed  $criteria
324
     * @param array  $data      Data must be referenced to existed set if it was registered
325
     *                          previously.
326
     *
327
     * @throws NodeException
328
     */
329
    final protected function mountArray(
330
        string $container,
331
        string $key,
332
        $criteria,
333
        array &$data
334
    ) {
335
        if (!array_key_exists($criteria, $this->references[$key])) {
336
            throw new NodeException("Undefined reference {$key}.{$criteria}");
337
        }
338
339
        foreach ($this->references[$key][$criteria] as &$subset) {
340
            if (!in_array($data, $subset[$container])) {
341
                $subset[$container][] = &$data;
342
            }
343
344
            unset($subset);
345
            continue;
346
        }
347
    }
348
349
    /**
350
     * Fetch record columns from query row, must use data offset to slice required part of query.
351
     *
352
     * @param int   $dataOffset
353
     * @param array $line
354
     *
355
     * @return array
356
     */
357
    protected function fetchData(int $dataOffset, array $line): array
358
    {
359
        if (empty($this->columns)) {
360
            return $line;
361
        }
362
363
        try {
364
            //Combine column names with sliced piece of row
365
            return array_combine(
366
                $this->columns,
367
                array_slice($line, $dataOffset, $this->countColumns)
368
            );
369
        } catch (\Exception $e) {
370
            throw new NodeException("Unable to parse incoming row", $e->getCode(), $e);
371
        }
372
    }
373
374
    /**
375
     * Create internal references cache based on requested keys. For example, if we have request for
376
     * "id" as reference key, every record will create following structure:
377
     * $this->references[id][ID_VALUE] = ITEM.
378
     *
379
     * Only deduplicated data must be collected!
380
     *
381
     * @see deduplicate()
382
     *
383
     * @param array $data
384
     */
385
    private function collectReferences(array &$data)
386
    {
387
        foreach ($this->trackReferences as $key) {
388
            //Adding reference(s)
389
            $this->references[$key][$data[$key]][] = &$data;
390
        }
391
    }
392
393
    /**
394
     * Create placeholders for each of sub nodes.
395
     *
396
     * @param array $data
397
     */
398
    private function ensurePlaceholders(array &$data)
399
    {
400
        //Let's force placeholders for every sub loaded
401
        foreach ($this->nodes as $name => $node) {
402
            $data[$name] = $node instanceof ArrayInterface ? [] : null;
403
        }
404
    }
405
406
    /**
407
     * Add key to be tracked
408
     *
409
     * @param string $key
410
     *
411
     * @throws NodeException
412
     */
413
    private function trackReference(string $key)
414
    {
415
        if (!in_array($key, $this->columns)) {
416
            throw new NodeException("Unable to create reference, key {$key} does not exist");
417
        }
418
419
        if (!in_array($key, $this->trackReferences)) {
420
            //We are only tracking unique references
421
            $this->trackReferences[] = $key;
422
        }
423
    }
424
}