• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

liqueurdetoile / cakephp-orm-json / 3816986970

pending completion
3816986970

Pull #11

github

GitHub
Merge 5277f4d4a into 6364490a4
Pull Request #11: ci: Fix phinx update issue

898 of 1015 relevant lines covered (88.47%)

43.85 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.22
/src/DatField/DatFieldParserTrait.php
1
<?php
2
declare(strict_types=1);
3

4
namespace Lqdt\OrmJson\DatField;
5

6
use Cake\ORM\Entity;
7
use Lqdt\OrmJson\DatField\Exception\MissingPathInDataDatFieldException;
8
use Lqdt\OrmJson\DatField\Exception\UnparsableDatFieldException;
9
use Mustache_Engine;
10

11
/**
12
 * Utility class to parse datfield notation
13
 *
14
 * @license MIT
15
 * @author  Liqueur de Toile <contact@liqueurdetoile.com>
16
 */
17
trait DatFieldParserTrait
18
{
19
    /**
20
     * Applies a callback to data located based on key
21
     *
22
     * This way, one ca target any nested data by using datfield notation
23
     *
24
     * The data is passed by reference and will be updated accordingky
25
     *
26
     * In callback, the target value is passed by reference and can be modified at will
27
     *
28
     * @param  string   $key                    Field or datfield notation target
29
     * @param  array|\Cake\ORM\Entity|\ArrayObject     $data                   Data to update
30
     * @param  callable $callback               Callback to apply
31
     * @param  mixed $args                  Arguments to pass to callback
32
     * @return array|\Cake\ORM\Entity $data Updated data
33
     */
34
    public function applyCallbackToData(string $key, $data, callable $callback, ...$args)
35
    {
36
        $target = &$this->getDatFieldValueInData($key, $data, true);
17✔
37

38
        if (is_array($target) && $this->_isSequentialArray($target)) {
17✔
39
            foreach ($target as &$t) {
×
40
                $t = $callback($t, ...$args);
×
41
            }
42
        } else {
43
            $target = $callback($target, ...$args);
17✔
44
        }
45

46
        return $data;
17✔
47
    }
48

49
    /**
50
     * Deletes a datfield key in a data set
51
     *
52
     * @param  string $key Datfield
53
     * @param  array|\Cake\ORM\Entity|\ArrayObject $data Data
54
     * @param  bool $throwOnMissing If `true` an exception will be raised if facing a missing path node
55
     * @return mixed
56
     */
57
    public function deleteDatFieldValueInData(string $key, &$data, bool $throwOnMissing = false)
58
    {
59
        if (!$this->isDatField($key)) {
3✔
60
            unset($data[$key]);
×
61

62
            return $data;
×
63
        }
64

65
        $path = $this->_parseDatFieldIntoPath($key);
3✔
66
        $field = array_shift($path);
3✔
67
        $node = array_pop($path);
3✔
68
        $path = implode('.', $path);
3✔
69
        $key = !empty($path) ? "{$field}->{$path}" : $field;
3✔
70

71
        try {
72
            $parent = &$this->getDatFieldValueInData($key, $data, $throwOnMissing);
3✔
73

74
            if ($node === '*') {
3✔
75
                $parent = [];
1✔
76

77
                return $data;
1✔
78
            }
79

80
            if (is_numeric($node)) {
3✔
81
                unset($parent[(int)$node]);
1✔
82

83
                return $data;
1✔
84
            }
85

86
            if (is_string($node)) {
3✔
87
                if (is_array($parent) && $this->_isSequentialArray($parent)) {
3✔
88
                    foreach ($parent as &$item) {
1✔
89
                        unset($item[$node]);
1✔
90
                    }
91
                } else {
92
                    unset($parent[$node]);
3✔
93
                }
94
            }
95

96
            return $data;
3✔
97
        } catch (MissingPathInDataDatFieldException $err) {
×
98
            if ($throwOnMissing) {
×
99
                throw $err;
×
100
            }
101

102
            return $data;
×
103
        }
104
    }
105

106
    /**
107
     * Reads the datfield value in data and keeps a reference to the target node
108
     *
109
     * @param  string $key Datfield
110
     * @param  array|\Cake\ORM\Entity|\ArrayObject $data Data
111
     * @param  bool $throwOnMissing If `true` an exception will be raised if facing a missing path node
112
     * @return mixed
113
     */
114
    public function &getDatFieldValueInData(string $key, &$data, bool $throwOnMissing = false)
115
    {
116
        $path = $this->_parseDatFieldIntoPath($key);
183✔
117
        $chunk = &$data;
183✔
118

119
        while (($node = array_shift($path)) !== null) {
183✔
120
            if ($node === '*') {
183✔
121
                if (is_array($chunk) && $this->_isSequentialArray($chunk)) {
20✔
122
                    $remaining = implode('.', $path);
20✔
123

124
                    // Dead end array. Simply stop here
125
                    if (empty($remaining)) {
20✔
126
                        break;
10✔
127
                    }
128

129
                    // Process next chunk for each item and merge results
130
                    $subset = [];
12✔
131
                    foreach ($chunk as &$item) {
12✔
132
                        $next = &$this->getDatFieldValueInData($remaining, $item, $throwOnMissing);
12✔
133
                        if (is_array($next) && $this->_isSequentialArray($next)) {
12✔
134
                            $subset = array_merge($subset, $next);
4✔
135
                        } else {
136
                            $subset[] = &$next;
10✔
137
                        }
138
                    }
139

140
                    $chunk = &$subset;
12✔
141
                    break;
12✔
142
                }
143
            }
144

145
            if (is_numeric($node)) {
183✔
146
                $node = (int)$node;
14✔
147
                if (array_key_exists($node, $chunk)) {
14✔
148
                    $chunk = &$chunk[$node];
14✔
149
                    continue;
14✔
150
                }
151
            }
152

153
            if (is_string($node) && $this->_hasNode($node, $chunk)) {
183✔
154
                $chunk = &$chunk[$node];
171✔
155
                continue;
171✔
156
            }
157

158
            if ($throwOnMissing) {
40✔
159
                throw new MissingPathInDataDatFieldException([$key, $node]);
36✔
160
            }
161

162
            $ret = null;
12✔
163

164
            return $ret;
12✔
165
        }
166

167
        return $chunk;
169✔
168
    }
169

170
    /**
171
     * Checks that path exists in provided data
172
     *
173
     * @param  string  $key                Field or datfield
174
     * @param  array|\Cake\ORM\Entity|\ArrayObject  $data   Data
175
     * @return bool
176
     */
177
    public function hasDatFieldPathInData(string $key, $data): bool
178
    {
179
        try {
180
            $this->getDatFieldValueInData($key, $data, true);
28✔
181

182
            return true;
22✔
183
        } catch (MissingPathInDataDatFieldException $err) {
8✔
184
            return false;
8✔
185
        }
186
    }
187

188
    /**
189
     * Sets a value targetted by datfield in data
190
     *
191
     * Unless $throwOnMissing id set to `true`, the path will be created if it doesn't exist
192
     *
193
     * @param  string $key Datfield
194
     * @param  mixed  $value Value to apply
195
     * @param  array|\Cake\ORM\Entity|\ArrayObject $data Data
196
     * @param  bool $throwOnMissing If `true` an exception will be raised if facing a missing node in data
197
     * @return array|\Cake\ORM\Entity Updated data
198
     */
199
    public function setDatFieldValueInData(string $key, $value, $data, bool $throwOnMissing = false)
200
    {
201
        try {
202
            $chunk = &$this->getDatFieldValueInData($key, $data, true);
20✔
203
        } catch (MissingPathInDataDatFieldException $err) {
14✔
204
            if ($throwOnMissing) {
14✔
205
                throw $err;
1✔
206
            }
207

208
            // We need to create the nodes matching the path
209
            $path = $this->_parseDatFieldIntoPath($key);
14✔
210
            $chunk = &$data;
14✔
211
            $current = [];
14✔
212

213
            // Find the first missing node
214
            while ($node = array_shift($path)) {
14✔
215
                $current[] = $node;
14✔
216
                try {
217
                    $chunk = &$this->getDatFieldValueInData(implode('.', $current), $data, true);
14✔
218
                } catch (MissingPathInDataDatFieldException $err) {
14✔
219
                    $chunk = &$this->_createNodeInChunk($node, $chunk);
14✔
220
                }
221
            }
222
        }
223

224
        if (is_array($chunk)) {
20✔
225
            foreach ($chunk as &$item) {
1✔
226
                $item = $value;
1✔
227
            }
228
        } else {
229
            $chunk = $value;
20✔
230
        }
231

232
        return $data;
20✔
233
    }
234

235
    /**
236
     * Converts a datfield to a suitable alias string for querying
237
     * Non datfield strings will be returned unchanged
238
     *
239
     * @param  string $datfield  Datfield
240
     * @return string             Alias
241
     */
242
    public function aliasDatField(string $datfield): string
243
    {
244
        return $this->isDatField($datfield) ?
5✔
245
          strtolower($this->renderFromDatFieldAndTemplate(
5✔
246
              $datfield,
5✔
247
              '{{field}}{{separator}}{{path}}',
5✔
248
              '_'
5✔
249
          )) :
5✔
250
          $datfield;
5✔
251
    }
252

253
    /**
254
     * Return the requested part in datfield between `model`, `field` and `path`
255
     *
256
     * @param  string      $part       Datfield part
257
     * @param  string      $datfield   Datfield
258
     * @param  string|null $repository Repository name
259
     * @return string|null
260
     */
261
    public function getDatFieldPart(string $part, string $datfield, ?string $repository = null): ?string
262
    {
263
        if (!in_array($part, ['model', 'field', 'path'])) {
39✔
264
            throw new \Exception(
1✔
265
                'Requested part in DatField is not valid. It must be one between model, field or path'
1✔
266
            );
1✔
267
        }
268

269
        $parsed = $this->parseDatField($datfield, $repository);
39✔
270

271
        return $parsed[$part];
36✔
272
    }
273

274
    /**
275
     * Utility function to check if a field is datfield and if it's v1 or v2 notation
276
     *
277
     * @param   mixed $field Field name
278
     * @return  int   0 for non datfield strings, 1 for path@field notation and 2 for field->path notation
279
     */
280
    public function isDatField($field = null): int
281
    {
282
        if (!is_string($field)) {
263✔
283
            return 0;
11✔
284
        }
285

286
        if (preg_match('/^[\w\.\*\[\]]+(@|->)[\w\.\*\[\]]+$/i', $field) === 1) {
262✔
287
            return strpos($field, '@') !== false ? 1 : 2;
247✔
288
        }
289

290
        return 0;
182✔
291
    }
292

293
    /**
294
     * Merge missing original values in an entity after patching
295
     *
296
     * @param \Cake\ORM\Entity $entity Entity
297
     * @param  string|array<string>  $keys                 Fields to merge, defautls to all json fields
298
     * @return \Cake\ORM\Entity Updated entity
299
     */
300
    public function jsonMerge(Entity &$entity, $keys = ['*']): Entity
301
    {
302
        $original = $entity->getOriginalValues();
101✔
303

304
        if (is_string($keys)) {
101✔
305
            $keys = [$keys];
×
306
        }
307

308
        if ($keys === ['*']) {
101✔
309
            $keys = array_filter(array_keys($original), function ($field) use ($entity) {
×
310
                return is_array($entity->get($field));
×
311
            });
×
312
        }
313

314
        foreach ($original as $field => $previous) {
101✔
315
            if (!in_array($field, $keys)) {
101✔
316
                continue;
101✔
317
            }
318

319
            $current = $entity->get($field);
2✔
320

321
            // Skip if content are the same
322
            if ($previous === $current) {
2✔
323
                continue;
×
324
            }
325

326
            // Merge unmodified values in JSON field
327
            if (is_array($previous) && is_array($current)) {
2✔
328
                $previous = array_merge_recursive($previous, $current);
2✔
329
                $entity->set($field, $previous);
2✔
330
            }
331
        }
332

333
        return $entity;
101✔
334
    }
335

336
    /**
337
     * Parses a datfield into its parts, optionnally using repository name
338
     *
339
     * @param  string      $datfield   Datfield
340
     * @param  string|null $repository Repository name
341
     * @return array
342
     */
343
    public function parseDatField(string $datfield, ?string $repository = null): array
344
    {
345
        $type = $this->isDatField($datfield);
243✔
346

347
        if ($type === 0) {
243✔
348
            throw new UnparsableDatFieldException([$datfield]);
5✔
349
        }
350

351
        return $type === 1 ?
240✔
352
          $this->_parseDatFieldV1($datfield, $repository) :
46✔
353
          $this->_parseDatFieldV2($datfield, $repository);
240✔
354
    }
355

356
    /**
357
     * Returns a string from a template and datfield parts
358
     *
359
     * @param  string      $datfield   Datfield
360
     * @param  string      $template   Template
361
     * @param  string      $separator  Separator
362
     * @param  string|null $repository Repository name
363
     * @return string             [description]
364
     */
365
    public function renderFromDatFieldAndTemplate(
366
        string $datfield,
367
        string $template,
368
        string $separator = '_',
369
        ?string $repository = null
370
    ): string {
371
        $parts = $this->parseDatField($datfield, $repository);
160✔
372
        $parts['path'] = str_replace('.', $separator, $parts['path']);
160✔
373
        $parts['separator'] = $separator;
160✔
374
        $parts['sep'] = $separator;
160✔
375
        $mustache = new Mustache_Engine();
160✔
376

377
        return $mustache->render($template, $parts);
160✔
378
    }
379

380
    /**
381
     * Utility function to create a new node in a given chunk
382
     *
383
     * Node is returned by reference to allow value update
384
     *
385
     * @param  string $node                Type of node to create
386
     * @param  mixed  $chunk               Source node
387
     * @return mixed  new node
388
     */
389
    protected function &_createNodeInChunk(string $node, &$chunk)
390
    {
391
        if ($node === '*') {
14✔
392
            $chunk = ['__tmp__'];
1✔
393
            $ret = &$chunk[0];
1✔
394

395
            return $ret;
1✔
396
        }
397

398
        if (is_numeric($node)) {
14✔
399
            $node = (int)$node;
1✔
400
        }
401

402
        if (empty($chunk) || $chunk === '__tmp__') {
14✔
403
            $chunk = [];
11✔
404
        }
405

406
        $chunk[$node] = '__tmp__';
14✔
407
        $ret = &$chunk[$node];
14✔
408

409
        return $ret;
14✔
410
    }
411

412
    /**
413
     * Returns the key to store field state in entity as both V1 and V2 notation can be used
414
     *
415
     * As key must also be used to store path, simply converts v2 to v1 notation
416
     *
417
     * @param  string|null $datfield   Datfield
418
     * @return string|null Key for datfield
419
     */
420
    protected function _getDatFieldKey(?string $datfield): ?string
421
    {
422
        return $this->isDatField($datfield) ?
160✔
423
          $this->renderFromDatFieldAndTemplate($datfield, '{{field}}->{{path}}', '.') :
157✔
424
          $datfield;
160✔
425
    }
426

427
    /**
428
     * Checks that provided data has targetted node
429
     *
430
     * @param  string $node               Node key
431
     * @param  mixed  $data               Data
432
     * @return bool
433
     */
434
    protected function _hasNode(string $node, $data): bool
435
    {
436
        if ($data instanceof Entity) {
183✔
437
            $data = json_encode($data);
145✔
438
            if ($data !== false) {
145✔
439
                $data = json_decode($data, true);
145✔
440
            }
441
        }
442

443
        if (!is_array($data)) {
183✔
444
            return false;
11✔
445
        }
446

447
        return array_key_exists($node, $data);
181✔
448
    }
449

450
    /**
451
     * Checks if an array is sequential or not
452
     *
453
     * @param  array $arr Array to check
454
     * @return bool
455
     */
456
    protected function _isSequentialArray(array $arr): bool
457
    {
458
        return !(array_keys($arr) !== range(0, count($arr) - 1));
22✔
459
    }
460

461
    /**
462
     * Parses V1 datfields (path@field)
463
     *
464
     * @param  string $datfield                 Datfield to parse
465
     * @param  string|null $repository               Repository name
466
     * @return array  Datfield parts
467
     */
468
    protected function _parseDatFieldV1(string $datfield, ?string $repository): array
469
    {
470
        $parts = explode('@', $datfield);
46✔
471
        $path = array_shift($parts);
46✔
472
        $field = array_shift($parts);
46✔
473

474
        if (empty($path) || empty($field)) {
46✔
475
            throw new UnparsableDatFieldException([$datfield]);
×
476
        }
477

478
        return $this->_parseDatField($field, $path, $repository);
46✔
479
    }
480

481
    /**
482
     * Parses V2 datfields (field->path)
483
     *
484
     * @param  string $datfield                      Datfield to parse
485
     * @param  ?string $repository               Repository name
486
     * @return array  Datfield parts
487
     */
488
    protected function _parseDatFieldV2(string $datfield, ?string $repository): array
489
    {
490
        $parts = explode('->', $datfield);
200✔
491
        $field = array_shift($parts);
200✔
492
        $path = array_shift($parts);
200✔
493

494
        if (empty($path) || empty($field)) {
200✔
495
            throw new UnparsableDatFieldException([$datfield]);
×
496
        }
497

498
        return $this->_parseDatField($field, $path, $repository);
200✔
499
    }
500

501
    /**
502
     * Parses field and path from parts
503
     *
504
     * @param  string  $field Model/field part
505
     * @param  string  $path  Dotted path part
506
     * @param  ?string $repository  Repository alias
507
     * @return array  parsed parts of datfield
508
     */
509
    protected function _parseDatField(string $field, string $path, ?string $repository): array
510
    {
511
        $model = $repository;
240✔
512

513
        // Check if repository is prepended to path
514
        $parts = explode('.', $path);
240✔
515
        if ($parts[0] === $repository) {
240✔
516
            $model = array_shift($parts);
×
517
            $path = implode('.', $parts);
×
518
        }
519

520
        // Check if repository is prepended to field
521
        $parts = explode('.', $field);
240✔
522
        if (count($parts) > 1) {
240✔
523
            $model = array_shift($parts);
42✔
524
            $field = array_shift($parts);
42✔
525
        }
526

527
        return [
240✔
528
          'model' => $model,
240✔
529
          'field' => $field,
240✔
530
          'path' => $path,
240✔
531
        ];
240✔
532
    }
533

534
    /**
535
     * Transforms a datfield or a dotted path into an array
536
     * Indexes or joker are also transformed into path nodes
537
     *
538
     * @param  string $key Datfield or dotted path
539
     * @return array<int, string>       Path nodes
540
     */
541
    protected function _parseDatFieldIntoPath(string $key): array
542
    {
543
        if (!$this->isDatField($key)) {
183✔
544
            $parts = preg_split('/[\.\[\]]/', $key, 0, PREG_SPLIT_NO_EMPTY);
42✔
545
            if ($parts !== false) {
42✔
546
                return $parts;
42✔
547
            }
548

549
            throw new UnparsableDatFieldException('empty datfield');
×
550
        }
551

552
        ['field' => $field, 'path' => $path] = $this->parseDatField($key);
172✔
553
        /** @var array<int, string> $path */
554
        $path = preg_split('/[\.\[\]]/', $path, 0, PREG_SPLIT_NO_EMPTY);
172✔
555

556
        return array_merge([$field], $path);
172✔
557
    }
558
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc