Passed
Pull Request — master (#10)
by Misha
02:02
created

DataBag   F

Complexity

Total Complexity 62

Size/Duplication

Total Lines 390
Duplicated Lines 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
eloc 132
dl 0
loc 390
rs 3.44
c 4
b 0
f 0
wmc 62

19 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 2 1
A fromEntityData() 0 5 1
A create() 0 3 1
A addEntityData() 0 5 1
A filter_ids() 0 11 2
A generateHash() 0 3 1
A isDirty() 0 9 3
B removeIndexed() 0 39 7
A addNewEntry() 0 13 3
A guardAgainstInvalidPath() 0 8 5
A get() 0 8 2
B getIndexed() 0 28 8
A remove() 0 16 3
A getState() 0 6 2
A setByPath() 0 12 2
C setSubPathOrIndexed() 0 43 13
A set() 0 12 2
A getByPath() 0 22 4
A hasEntityData() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like DataBag 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 DataBag, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Communibase;
6
7
/**
8
 * Class DataBag
9
 *
10
 * It's a bag, for CB data. If we need to create a CB object from CB data (array) we can use this dataBag object as a
11
 * private entity class property. The dataBag can contain one or more entities. For each entity we can get/set
12
 * properties by path. If we need to persist the entity back into CB use getState to fetch the (updated) data array.
13
 *
14
 * @package Communibase\DataBag
15
 * @author Kingsquare ([email protected])
16
 * @copyright Copyright (c) Kingsquare BV (http://www.kingsquare.nl)
17
 */
18
final class DataBag
19
{
20
    /**
21
     * The bag!
22
     */
23
    private $data;
24
25
    /**
26
     * Original data hash for isDirty check
27
     *
28
     * @var string[]
29
     */
30
    private $hashes;
31
32
    /**
33
     * If we have multiple identical get calls in the same request use the cached result
34
     *
35
     * @var array
36
     */
37
    private $cache = [];
38
39
    /**
40
     * Private constructor, use the named constructors below
41
     */
42
    private function __construct()
43
    {
44
    }
45
46
    public static function create(): DataBag
47
    {
48
        return new self();
49
    }
50
51
    public static function fromEntityData(string $entityType, array $data): DataBag
52
    {
53
        $dataBag = new self();
54
        $dataBag->addEntityData($entityType, $data);
55
        return $dataBag;
56
    }
57
58
    /**
59
     * Add additional entities
60
     */
61
    public function addEntityData(string $entityType, array $data): DataBag
62
    {
63
        $this->data[$entityType] = $data;
64
        $this->hashes[$entityType] = $this->generateHash($data);
65
        return $this;
66
    }
67
68
    /**
69
     * Fetch a value from the databag.
70
     *
71
     * $path can be:
72
     * - person.firstName               direct property
73
     * - person.emailAddresses.0        indexed by numeric position
74
     * - person.addresses.visit         indexed by 'type' property
75
     * - person.addresses.visit.street  indexed by 'type' property + get specific property
76
     *
77
     * @param string $path path to the target
78
     * @param mixed $default return value if there's no data
79
     *
80
     * @return mixed
81
     */
82
    private function getByPath(string $path, $default = null)
83
    {
84
        [$entityType, $path] = explode('.', $path, 2);
85
86
        // Direct property
87
        if (strpos($path, '.') === false) {
88
            return $this->data[$entityType][$path] ?? $default;
89
        }
90
91
        [$path, $index] = explode('.', $path, 2);
92
93
        if (empty($this->data[$entityType][$path])) {
94
            return $default;
95
        }
96
97
        // Sub path
98
        if (\array_key_exists($index, $this->data[$entityType][$path])) {
99
            return $this->data[$entityType][$path][$index] ?? $default;
100
        }
101
102
        // Indexed
103
        return $this->getIndexed((array)$this->data[$entityType][$path], $index, $default);
104
    }
105
106
    /**
107
     * @param mixed $default
108
     *
109
     * @return mixed
110
     */
111
    private function getIndexed(array $nodes, string $index, $default)
112
    {
113
        $field = null;
114
        if (strpos($index, '.') > 0) {
115
            [$index, $field] = explode('.', $index, 2);
116
        }
117
118
        $translatedIndex = $index;
119
120
        if (!is_numeric($index)) {
121
            $translatedIndex = null;
122
            foreach ($nodes as $nodeIndex => $node) {
123
                if (isset($node['type']) && $node['type'] === $index) {
124
                    $translatedIndex = $nodeIndex;
125
                    break;
126
                }
127
            }
128
        }
129
130
        if ($translatedIndex === null) {
131
            return $default;
132
        }
133
134
        if ($field === null) {
135
            return $nodes[$translatedIndex] ?? $default;
136
        }
137
138
        return $nodes[$translatedIndex][$field] ?? $default;
139
    }
140
141
    /**
142
     * Fetch a cached value from the databag.
143
     *
144
     * $path can be:
145
     * - person.firstName               direct property
146
     * - person.emailAddresses.0        indexed by numeric position
147
     * - person.addresses.visit         indexed by 'type' property
148
     * - person.addresses.visit.street  indexed by 'type' property + get specific property
149
     *
150
     * @param string $path path to the target
151
     * @param mixed $default return value if there's no data
152
     *
153
     * @return mixed|null
154
     * @throws InvalidDataBagPathException
155
     */
156
    public function get(string $path, $default = null)
157
    {
158
        $this->guardAgainstInvalidPath($path);
159
160
        if (!\array_key_exists($path, $this->cache)) {
161
            $this->cache[$path] = $this->getByPath($path, $default);
162
        }
163
        return $this->cache[$path];
164
    }
165
166
    /**
167
     * Set a value in the bag.
168
     *
169
     * @param string $path path to the target (see get() for examples)
170
     * @param mixed $value new value
171
     *
172
     * @throws InvalidDataBagPathException
173
     */
174
    public function set(string $path, $value): void
175
    {
176
        $this->guardAgainstInvalidPath($path);
177
178
        unset($this->cache[$path]);
179
180
        if ($value === null) {
181
            $this->remove($path);
182
            return;
183
        }
184
185
        $this->setByPath($path, $value);
186
    }
187
188
    /**
189
     * @param mixed $value
190
     */
191
    private function setByPath(string $path, $value): void
192
    {
193
        [$entityType, $path] = explode('.', $path, 2);
194
195
        // Direct property
196
        if (strpos($path, '.') === false) {
197
            $this->data[$entityType][$path] = $value;
198
            return;
199
        }
200
201
        // Sub path or indexed
202
        $this->setSubPathOrIndexed($entityType, $path, $value);
203
    }
204
205
    /**
206
     * @param mixed $value
207
     */
208
    private function setSubPathOrIndexed(string $entityType, string $path, $value): void
209
    {
210
        [$path, $index] = explode('.', $path, 2);
211
212
        // Sub path
213
        if (!\is_array($value) && !\is_numeric($index) && \strpos($index, '.') === false) {
214
            $this->data[$entityType][$path][$index] = $value;
215
            return;
216
        }
217
218
        $field = null;
219
        if (strpos($index, '.') > 0) {
220
            [$index, $field] = explode('.', $index, 2);
221
        }
222
223
        $target = $index;
224
        if (!is_numeric($index)) {
225
            if (\is_array($value)) {
226
                $value['type'] = $index;
227
            }
228
            $index = null;
229
            if (isset($this->data[$entityType][$path])) {
230
                foreach ((array)$this->data[$entityType][$path] as $nodeIndex => $node) {
231
                    if (isset($node['type']) && $node['type'] === $target) {
232
                        $index = $nodeIndex;
233
                        break;
234
                    }
235
                }
236
            }
237
        }
238
239
        // No index found, new entry
240
        if ($index === null) {
241
            $this->addNewEntry($entityType, $path, $field, $target, $value);
242
            return;
243
        }
244
245
        // Use found index
246
        if ($field === null) {
247
            $this->data[$entityType][$path][$index] = $value;
248
            return;
249
        }
250
        $this->data[$entityType][$path][$index][$field] = $value;
251
    }
252
253
    /**
254
     * @param mixed $value
255
     */
256
    private function addNewEntry(string $entityType, string $path, ?string $field, string $target, $value): void
257
    {
258
        if ($field === null) {
259
            $this->data[$entityType][$path][] = $value;
260
            return;
261
        }
262
        $value = [
263
            $field => $value
264
        ];
265
        if (!is_numeric($target)) {
266
            $value['type'] = $target;
267
        }
268
        $this->data[$entityType][$path][] = $value;
269
    }
270
271
    /**
272
     * Check if a certain entity type exists in the dataBag
273
     *
274
     * @return bool true if the entity type exists
275
     */
276
    public function hasEntityData(string $entityType): bool
277
    {
278
        return isset($this->data[$entityType]);
279
    }
280
281
    /**
282
     * Remove a property from the bag.
283
     *
284
     * @param string $path path to the target (see get() for examples)
285
     * @param bool $removeAll remove all when the index is numeric (to prevent a new value after re-indexing)
286
     *
287
     * @throws InvalidDataBagPathException
288
     */
289
    public function remove(string $path, $removeAll = true): void
290
    {
291
        $this->guardAgainstInvalidPath($path);
292
293
        [$entityType, $path] = explode('.', $path, 2);
294
295
        // Direct property
296
        if (strpos($path, '.') === false) {
297
            if (!isset($this->data[$entityType][$path])) {
298
                return;
299
            }
300
            $this->data[$entityType][$path] = null;
301
            return;
302
        }
303
304
        $this->removeIndexed($path, $entityType, $removeAll);
305
    }
306
307
    private function removeIndexed(string $path, string $entityType, bool $removeAll): void
308
    {
309
        [$path, $index] = explode('.', $path);
310
311
        // Target doesn't exist, nothing to remove
312
        if (empty($this->data[$entityType][$path])) {
313
            return;
314
        }
315
316
        if (is_numeric($index)) {
317
            $index = (int)$index;
318
            if ($removeAll) {
319
                // Remove all (higher) values to prevent a new value after re-indexing
320
                if ($index === 0) {
321
                    $this->data[$entityType][$path] = null;
322
                    return;
323
                }
324
                $this->data[$entityType][$path] = \array_slice($this->data[$entityType][$path], 0, $index);
325
                return;
326
            }
327
            unset($this->data[$entityType][$path][$index]);
328
        } else {
329
            // Filter out all nodes of the specified type
330
            $this->data[$entityType][$path] = array_filter(
331
                $this->data[$entityType][$path],
332
                static function ($node) use ($index) {
333
                    return empty($node['type']) || $node['type'] !== $index;
334
                }
335
            );
336
        }
337
338
        // If we end up with an empty array make it NULL
339
        if (empty($this->data[$entityType][$path])) {
340
            $this->data[$entityType][$path] = null;
341
            return;
342
        }
343
344
        // Re-index
345
        $this->data[$entityType][$path] = array_values($this->data[$entityType][$path]);
346
    }
347
348
    /**
349
     * Check if the initial data has changed
350
     *
351
     * @param string $entityType entity type to check
352
     *
353
     * @return bool|null true if changed, false if not and null if the entity type is not set
354
     */
355
    public function isDirty(string $entityType): ?bool
356
    {
357
        if (!isset($this->data[$entityType])) {
358
            return null;
359
        }
360
        if (empty($this->hashes[$entityType])) {
361
            return true;
362
        }
363
        return $this->hashes[$entityType] !== $this->generateHash($this->getState($entityType));
364
    }
365
366
    private function generateHash(array $data): string
367
    {
368
        return md5(serialize($this->filter_ids($data)));
369
    }
370
371
    private function filter_ids(array $data): array
372
    {
373
        array_walk(
374
            $data,
375
            function (&$value) {
376
                if (\is_array($value)) {
377
                    $value = $this->filter_ids($value);
378
                }
379
            }
380
        );
381
        return array_diff_key($data, ['_id' => null]);
382
    }
383
384
    /**
385
     * Get the raw data array
386
     *
387
     * @param string|null $entityType only get the specified type (optional)
388
     */
389
    public function getState(string $entityType = null): array
390
    {
391
        if ($entityType === null) {
392
            return $this->data;
393
        }
394
        return $this->data[$entityType] ?? [];
395
    }
396
397
    /**
398
     * @throws InvalidDataBagPathException
399
     */
400
    private function guardAgainstInvalidPath(string $path): void
401
    {
402
        if ($path === '' // empty
403
            || strpos($path, '..') !== false // has .. somewhere
404
            || substr($path, -1) === '.' // ends with .
405
            || \in_array(strpos($path, '.'), [false, 0], true) // starts with or doesnt have any .
406
        ) {
407
            throw new InvalidDataBagPathException('Invalid path provided: ' . $path);
408
        }
409
    }
410
}
411