Completed
Push — master ( e16ac9...cdb9e2 )
by Robin
05:50
created

Transformer::parseRuleExpression()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 1
dl 0
loc 9
rs 9.6666
c 0
b 0
f 0
1
<?php
2
3
namespace Konsulting\Laravel\Transformer;
4
5
use Illuminate\Support\Arr;
6
use Illuminate\Support\Collection;
7
use Konsulting\Laravel\Transformer\Exceptions\InvalidRule;
8
use Konsulting\Laravel\Transformer\RulePacks\RulePack;
9
use UnexpectedValueException;
10
11
class Transformer
12
{
13
    /**
14
     * Associative array of data to be processed.
15
     *
16
     * @var Collection
17
     */
18
    protected $data;
19
20
    /**
21
     * A pipe separated listing of dot-formatted data keys, used for regex matching of fields.
22
     *
23
     * @var String
24
     */
25
    protected $dataKeysForRegex = '';
26
27
    /**
28
     * Associative array of rules to be applied to the data.
29
     *
30
     * @var array
31
     */
32
    protected $rules = [];
33
34
    /**
35
     * An array of rules matched to fields, used during transform only.
36
     *
37
     * @var array
38
     */
39
    protected $matchedRules = [];
40
41
    /**
42
     * Flag indicating that rule processing should be halted.
43
     *
44
     * @var bool
45
     */
46
    protected $bail;
47
48
    /**
49
     * Flag indicating that rule processing should be halted and we should drop the current field.
50
     *
51
     * @var bool
52
     */
53
    protected $drop;
54
55
    /**
56
     * Index of loaded RulePacks
57
     *
58
     * @var array
59
     */
60
    protected $rulePacks = [];
61
62
    /**
63
     * Index of transformation rules, linking to their parent RulePack
64
     *
65
     * @var array
66
     */
67
    protected $ruleMethods = [];
68
69
    /**
70
     * Track the indices that the current field has during execution of the rules
71
     *
72
     * @var array
73
     */
74
    protected $loopIndices = [];
75
76
    /**
77
     * Transformer constructor.
78
     *
79
     * @param  array|string $rulePacks
80
     * @param  array        $rules
81
     */
82
    public function __construct($rulePacks = [], $rules = [])
83
    {
84
        $this->addRulePacks((array) $rulePacks)->setRules($rules);
85
    }
86
87
    /**
88
     * Set the rules that will be applied to the data.
89
     *
90
     * @param array $rules
91
     * @return self
92
     */
93
    public function setRules(array $rules = []) : self
94
    {
95
        $this->rules = [];
96
97
        foreach ($rules as $fieldExpression => $ruleSet) {
98
            $this->rules[$fieldExpression] = $this->parseRuleSet($ruleSet);
99
        }
100
101
        return $this;
102
    }
103
104
    /**
105
     * Set the data that rules are to be applied to.
106
     *
107
     * @param array $data
108
     * @return self
109
     */
110
    public function setData(array $data = []) : self
111
    {
112
        $this->data = Collection::make($data)->dot();
113
        $this->dataKeysForRegex = $this->data->keys()->implode('|');
114
115
        return $this;
116
    }
117
118
    /**
119
     * Perform the transformation
120
     *
121
     * @param array $data
122
     * @param array $rules
123
     * @return Collection
124
     */
125
    public function transform(array $data, array $rules = null) : Collection
126
    {
127
        $this->setData($data);
128
129
        if ($rules) {
130
            $this->setRules($rules);
131
        }
132
133
        $this->applyRules();
134
135
        return $this->data->fromDot();
136
    }
137
138
    /**
139
     * Apply the matched rules to the input
140
     *
141
     * @return self
142
     */
143
    protected function applyRules() : self
144
    {
145
        $this->matchRulesToFields();
146
147
        foreach (array_keys($this->matchedRules) as $field) {
148
            $this->executeRules($field);
149
        }
150
151
        return $this;
152
    }
153
154
    /**
155
     * Execute the array of rules.
156
     *
157
     * @param $field
158
     */
159
    protected function executeRules($field)
160
    {
161
        $this->bail(false);
162
        $this->drop(false);
163
164
        foreach ($this->matchedRules[$field] as $set) {
165
            $this->loopIndices = $set['indices'];
166
167
            foreach ($set['set'] as $rule => $parameters) {
168
                $ruleMethod = $this->getRuleMethod($rule);
169
170
                $result = $this->{$ruleMethod}($this->data->fromDot($field)->first(), ...$parameters);
171
172
                if ($this->shouldDrop()) {
173
                    $this->data->forget($field);
174
175
                    return;
176
                }
177
178
                $this->replaceDataValue($field, $result);
179
180
                if ($this->shouldBail()) {
181
                    return;
182
                }
183
            }
184
        }
185
    }
186
187
    /**
188
     * Indicate that the current loop should bail.
189
     *
190
     * @param bool $bail
191
     */
192
    public function bail(bool $bail = true)
193
    {
194
        $this->bail = $bail;
195
    }
196
197
    /**
198
     * Indicate that the current field should be dropped.
199
     *
200
     * @param bool $drop
201
     */
202
    public function drop(bool $drop = true)
203
    {
204
        $this->drop = $drop;
205
    }
206
207
    /**
208
     * Construct the method name to call, given the name of the rule.
209
     *
210
     * @param $rule
211
     * @return string
212
     * @throws InvalidRule
213
     */
214
    protected function getRuleMethod($rule) : string
215
    {
216
        return 'rule' . str_replace('_', '', ucwords($rule, '_'));
217
    }
218
219
    /**
220
     * Check if the current loop should bail.
221
     *
222
     * @return bool
223
     */
224
    protected function shouldBail() : bool
225
    {
226
        return $this->bail;
227
    }
228
229
    /**
230
     * Check if the current field should be dropped.
231
     *
232
     * @return bool
233
     */
234
    protected function shouldDrop() : bool
235
    {
236
        return $this->drop;
237
    }
238
239
    /**
240
     * Match the loaded rule to fields in the data, based on the $field expression provided.
241
     *
242
     * @return self
243
     */
244
    protected function matchRulesToFields() : self
245
    {
246
        $this->matchedRules = [];
247
248
        foreach ($this->rules as $fieldExpression => $ruleSet) {
249
            foreach ($this->findMatchingFields($fieldExpression) as $fieldName => $indices) {
250
                $this->matchedRules[$fieldName][] = [
251
                    'fieldExpression' => $fieldExpression,
252
                    'indices'         => $indices,
253
                    'set'             => $ruleSet,
254
                ];
255
            }
256
        }
257
258
        return $this;
259
    }
260
261
    /**
262
     * Parse fieldExpression to match all the fields in the data we need to transform. It passes back an array of field
263
     * names with a set of 'indices' associated to each field name (these are where we match wildcards).
264
     *
265
     * @param $fieldExpression
266
     * @return array
267
     */
268
    protected function findMatchingFields($fieldExpression) : array
269
    {
270
        if ($fieldExpression == '**') {
271
            return array_fill_keys(explode('|', $this->dataKeysForRegex), []);
272
        }
273
274
        $matches = [];
275
        $regex = str_replace(['.', '*'], ['\.', '([^\\.|]+)'], $fieldExpression);
276
        preg_match_all("/({$regex})/", $this->dataKeysForRegex, $matches, PREG_SET_ORDER);
277
278
        return array_reduce($matches, function ($results, $match) {
279
            $results[$match[0]] = array_slice($match, 2);
280
281
            return $results;
282
        }, []);
283
    }
284
285
    /**
286
     * Return a key/value array of rules/parameters.
287
     *
288
     * @param $set
289
     * @return array
290
     */
291
    protected function parseRuleSet($set) : array
292
    {
293
        $ruleSet = [];
294
295
        foreach (explode('|', $set) as $expression) {
296
            $ruleSet = array_merge($ruleSet, $this->parseRuleExpression($expression));
297
        }
298
299
        return $ruleSet;
300
    }
301
302
    /**
303
     * Split a rule expression into the rule name and any parameters present.
304
     *
305
     * @param $expression
306
     * @return mixed
307
     */
308
    protected function parseRuleExpression($expression) : array
309
    {
310
        $split = explode(':', $expression, 2);
311
312
        $rule = $this->validateRule($split[0]);
313
        $parameters = empty($split[1]) ? [] : str_getcsv($split[1]);
314
315
        return [$rule => $parameters];
316
    }
317
318
    /**
319
     * @param $rule
320
     *
321
     * @return string
322
     * @throws InvalidRule
323
     */
324
    protected function validateRule($rule) : string
325
    {
326
        if ( ! isset($this->ruleMethods[$this->getRuleMethod($rule)])) {
327
            throw new InvalidRule($rule);
328
        }
329
330
        return $rule;
331
    }
332
333
    /**
334
     * @param $name
335
     * @param $parameters
336
     * @return mixed
337
     */
338
    public function __call($name, $parameters)
339
    {
340
        if (substr($name, 0, 4) == 'rule' && $this->ruleMethods[$name]) {
341
            $value = array_shift($parameters);
342
            $rulePack = $this->rulePacks[$this->ruleMethods[$name]];
343
344
            return $rulePack->$name($value, ...$parameters);
345
        }
346
    }
347
348
    /**
349
     * Register multiple rule packs.
350
     *
351
     * @param array $rulePacks
352
     * @return Transformer
353
     */
354
    public function addRulePacks(array $rulePacks) : self
355
    {
356
        foreach ($rulePacks as $rulePack) {
357
            $this->addRulePack(new $rulePack);
358
        }
359
360
        return $this;
361
    }
362
363
    /**
364
     * Register a rule pack.
365
     *
366
     * @param RulePack|String $rulePack
367
     * @return Transformer
368
     */
369
    public function addRulePack($rulePack) : self
370
    {
371
        $rulePackClass = $this->getClassName($rulePack);
372
        $rulePack = new $rulePackClass;
373
374
        if ( ! ($rulePack instanceof RulePack)) {
375
            throw new UnexpectedValueException('RulePack must be an instance of ' . RulePack::class);
376
        }
377
378
        if ( ! $this->hasRulePack($rulePack)) {
379
            $this->rulePacks[$rulePackClass] = $rulePack->transformer($this);
380
381
            $ruleMethods = array_fill_keys($rulePack->provides(), $rulePackClass);
382
            $this->ruleMethods = array_merge($this->ruleMethods, $ruleMethods);
383
        }
384
385
        return $this;
386
    }
387
388
    /**
389
     * Check if the Transformer instance has a given rule pack.
390
     *
391
     * @param RulePack|String $rulePack
392
     * @return bool
393
     */
394
    public function hasRulePack($rulePack) : bool
395
    {
396
        $rulePackClass = $this->getClassName($rulePack);
397
398
        return in_array($rulePackClass, array_keys($this->rulePacks));
399
    }
400
401
    /**
402
     * Return an array of all loaded rule packs.
403
     *
404
     * @return array
405
     */
406
    public function rulePacks() : array
407
    {
408
        return array_keys($this->rulePacks);
409
    }
410
411
    /**
412
     * Return class name if input is object, otherwise return input.
413
     *
414
     * @param string|object $class
415
     * @return string
416
     */
417
    protected function getClassName($class) : string
418
    {
419
        return is_string($class) ? $class : get_class($class);
420
    }
421
422
    /**
423
     * Parse the parameters that have been passed in and try to find an associated value in the current data
424
     * collection, by replacing wildcards with the indices that are kept for the current loop.
425
     *
426
     * @param $parameter
427
     * @return mixed
428
     */
429
    public function getValue($parameter)
430
    {
431
        return $this->data->dotGet(sprintf(str_replace('*', '%s', $parameter), ...$this->loopIndices));
432
    }
433
434
    /**
435
     * Remove the current field from the data array, and merge in the new values. For dot-delimited arrays, remove all
436
     * fields that are being worked on, e.g. when a date array of day, month, year is being combined into a string.
437
     *
438
     * @param string $field
439
     * @param mixed  $result
440
     */
441
    protected function replaceDataValue($field, $result)
442
    {
443
        $this->data = $this->data
444
            ->reject(function ($value, $key) use ($field) {
445
                return preg_match("/^{$field}/", $key);
446
            })
447
            ->merge(Arr::dot([$field => $result]));
448
    }
449
}
450