Passed
Push — master ( 5793b3...8414a2 )
by Misha
01:02 queued 10s
created

DataBag::filterIds()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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