Completed
Pull Request — master (#2)
by Keoghan
03:19
created

Transformer::applyRules()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 0
dl 0
loc 10
rs 9.4285
c 0
b 0
f 0
1
<?php
2
3
namespace Konsulting\Laravel\Transformer;
4
5
use Closure;
6
use Illuminate\Support\Arr;
7
use Illuminate\Support\Collection;
8
use Konsulting\Laravel\Transformer\RulePacks\RulePack;
9
use Konsulting\Laravel\Transformer\Exceptions\InvalidRule;
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
                $input = $this->data->fromDot($field)->first();
169
170
                if ($parameters instanceof Closure) {
171
                    $result = $parameters($input);
172
                } elseif ($parameters instanceof TransformRule) {
173
                    $result = $parameters->setTransformer($this)->apply($input);
174
                } else {
175
                    $result = $this->{$this->getRuleMethod($rule)}($input, ...$parameters);
176
                }
177
178
                if ($this->shouldDrop()) {
179
                    $this->data->forget($field);
180
181
                    return;
182
                }
183
184
                $this->replaceDataValue($field, $result);
185
186
                if ($this->shouldBail()) {
187
                    return;
188
                }
189
            }
190
        }
191
    }
192
193
    /**
194
     * Indicate that the current loop should bail.
195
     *
196
     * @param bool $bail
197
     */
198
    public function bail(bool $bail = true)
199
    {
200
        $this->bail = $bail;
201
    }
202
203
    /**
204
     * Indicate that the current field should be dropped.
205
     *
206
     * @param bool $drop
207
     */
208
    public function drop(bool $drop = true)
209
    {
210
        $this->drop = $drop;
211
    }
212
213
    /**
214
     * Construct the method name to call, given the name of the rule.
215
     *
216
     * @param $rule
217
     * @return string
218
     * @throws InvalidRule
219
     */
220
    protected function getRuleMethod($rule) : string
221
    {
222
        return 'rule' . str_replace('_', '', ucwords($rule, '_'));
223
    }
224
225
    /**
226
     * Check if the current loop should bail.
227
     *
228
     * @return bool
229
     */
230
    protected function shouldBail() : bool
231
    {
232
        return $this->bail;
233
    }
234
235
    /**
236
     * Check if the current field should be dropped.
237
     *
238
     * @return bool
239
     */
240
    protected function shouldDrop() : bool
241
    {
242
        return $this->drop;
243
    }
244
245
    /**
246
     * Match the loaded rule to fields in the data, based on the $field expression provided.
247
     *
248
     * @return self
249
     */
250
    protected function matchRulesToFields() : self
251
    {
252
        $this->matchedRules = [];
253
254
        foreach ($this->rules as $fieldExpression => $ruleSet) {
255
            foreach ($this->findMatchingFields($fieldExpression) as $fieldName => $indices) {
256
                $this->matchedRules[$fieldName][] = [
257
                    'fieldExpression' => $fieldExpression,
258
                    'indices'         => $indices,
259
                    'set'             => $ruleSet,
260
                ];
261
            }
262
        }
263
264
        return $this;
265
    }
266
267
    /**
268
     * Parse fieldExpression to match all the fields in the data we need to transform. It passes back an array of field
269
     * names with a set of 'indices' associated to each field name (these are where we match wildcards).
270
     *
271
     * @param $fieldExpression
272
     * @return array
273
     */
274
    protected function findMatchingFields($fieldExpression) : array
275
    {
276
        if ($fieldExpression == '**') {
277
            return array_fill_keys(explode('|', $this->dataKeysForRegex), []);
278
        }
279
280
        $matches = [];
281
        $regex = str_replace(['.', '*'], ['\.', '([^\\.|]+)'], $fieldExpression);
282
        preg_match_all("/({$regex})/", $this->dataKeysForRegex, $matches, PREG_SET_ORDER);
283
284
        return array_reduce($matches, function ($results, $match) {
285
            $results[$match[0]] = array_slice($match, 2);
286
287
            return $results;
288
        }, []);
289
    }
290
291
    /**
292
     * Return a key/value array of rules/parameters.
293
     *
294
     * @param $set
295
     * @return array
296
     */
297
    protected function parseRuleSet($set) : array
298
    {
299
        $set = is_array($set) ? $set : explode('|', $set);
300
        $ruleSet = [];
301
302
        foreach ($set as $expression) {
303
            $ruleSet = array_merge($ruleSet, $this->parseRuleExpression($expression));
304
        }
305
306
        return $ruleSet;
307
    }
308
309
    /**
310
     * Split a rule expression into the rule name and any parameters present.
311
     *
312
     * @param $expression
313
     * @return mixed
314
     */
315
    protected function parseRuleExpression($expression) : array
316
    {
317
        if ($expression instanceof Closure) {
318
            return [$expression->bindTo($this)];
319
        }
320
321
        if ($expression instanceof TransformRule) {
322
            return [$expression];
323
        }
324
325
        return $this->parseTextRuleExpression($expression);
326
    }
327
328
    /**
329
     * @param string $expression
330
     *
331
     * @return array
332
     */
333
    protected function parseTextRuleExpression(string $expression): array
334
    {
335
        $split = explode(':', $expression, 2);
336
337
        $rule = $this->validateRule($split[0]);
338
        $parameters = empty($split[1]) ? [] : str_getcsv($split[1]);
339
340
        return [$rule => $parameters];
341
    }
342
343
    /**
344
     * @param $rule
345
     *
346
     * @return string
347
     * @throws InvalidRule
348
     */
349
    protected function validateRule($rule) : string
350
    {
351
        if (! isset($this->ruleMethods[$this->getRuleMethod($rule)])) {
352
            throw new InvalidRule($rule);
353
        }
354
355
        return $rule;
356
    }
357
358
    /**
359
     * @param $name
360
     * @param $parameters
361
     * @return mixed
362
     */
363
    public function __call($name, $parameters)
364
    {
365
        if (substr($name, 0, 4) == 'rule' && $this->ruleMethods[$name]) {
366
            $value = array_shift($parameters);
367
            $rulePack = $this->rulePacks[$this->ruleMethods[$name]];
368
369
            return $rulePack->$name($value, ...$parameters);
370
        }
371
    }
372
373
    /**
374
     * Register multiple rule packs.
375
     *
376
     * @param array $rulePacks
377
     * @return Transformer
378
     */
379
    public function addRulePacks(array $rulePacks) : self
380
    {
381
        foreach ($rulePacks as $rulePack) {
382
            $this->addRulePack(new $rulePack);
383
        }
384
385
        return $this;
386
    }
387
388
    /**
389
     * Register a rule pack.
390
     *
391
     * @param RulePack|string $rulePack
392
     * @return Transformer
393
     */
394
    public function addRulePack($rulePack) : self
395
    {
396
        $rulePackClass = $this->getClassName($rulePack);
397
        $rulePack = new $rulePackClass;
398
399
        if (! ($rulePack instanceof RulePack)) {
400
            throw new \UnexpectedValueException('RulePack must be an instance of ' . RulePack::class);
401
        }
402
403
        if (! $this->hasRulePack($rulePack)) {
404
            $this->rulePacks[$rulePackClass] = $rulePack->transformer($this);
405
406
            $ruleMethods = array_fill_keys($rulePack->provides(), $rulePackClass);
407
            $this->ruleMethods = array_merge($this->ruleMethods, $ruleMethods);
408
        }
409
410
        return $this;
411
    }
412
413
    /**
414
     * Check if the Transformer instance has a given rule pack.
415
     *
416
     * @param RulePack|string $rulePack
417
     * @return bool
418
     */
419
    public function hasRulePack($rulePack) : bool
420
    {
421
        $rulePackClass = $this->getClassName($rulePack);
422
423
        return in_array($rulePackClass, array_keys($this->rulePacks));
424
    }
425
426
    /**
427
     * Return an array of all loaded rule packs.
428
     *
429
     * @return array
430
     */
431
    public function rulePacks() : array
432
    {
433
        return array_keys($this->rulePacks);
434
    }
435
436
    /**
437
     * Return class name if input is object, otherwise return input.
438
     *
439
     * @param string|object $class
440
     * @return string
441
     */
442
    protected function getClassName($class) : string
443
    {
444
        return is_string($class) ? $class : get_class($class);
445
    }
446
447
    /**
448
     * Parse the parameters that have been passed in and try to find an associated value in the current data
449
     * collection, by replacing wildcards with the indices that are kept for the current loop.
450
     *
451
     * @param $parameter
452
     * @return mixed
453
     */
454
    public function getValue($parameter)
455
    {
456
        return $this->data->dotGet(sprintf(str_replace('*', '%s', $parameter), ...$this->loopIndices));
457
    }
458
459
    /**
460
     * Remove the current field from the data array, and merge in the new values. For dot-delimited arrays, remove all
461
     * fields that are being worked on, e.g. when a date array of day, month, year is being combined into a string.
462
     *
463
     * @param string $field
464
     * @param mixed  $result
465
     */
466
    protected function replaceDataValue($field, $result)
467
    {
468
        $this->data = $this->data
469
            ->reject(function ($value, $key) use ($field) {
470
                return preg_match("/^{$field}/", $key);
471
            })
472
            ->merge(Arr::dot([$field => $result]));
473
    }
474
}
475