Passed
Push — master ( 67ae9e...086d14 )
by Misha
40s queued 11s
created

DataBag::remove()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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