Completed
Push — master ( 780a3f...97fd28 )
by Misha
04:00
created

DataBag::remove()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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