Test Failed
Push — master ( 76879c...8a3be9 )
by Tomas
02:23
created

AbstractArgumentBuilder::normalizeFields()   B

Complexity

Conditions 8
Paths 13

Size

Total Lines 37
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 21
dl 0
loc 37
rs 8.4444
c 0
b 0
f 0
cc 8
nc 13
nop 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Feedo\ArgumentBuilder;
6
7
use Feedo\ArgumentBuilder\Validator\ArgumentBuilderTypeValidator;
8
use Feedo\ArgumentBuilder\Validator\TypeValidatorInterface;
9
10
/**
11
 * Class AbstractArgumentBuilder.
12
 *
13
 * @author Denis Voytyuk <[email protected]>
14
 */
15
abstract class AbstractArgumentBuilder implements ArgumentBuilderInterface
16
{
17
    public const ARGUMENT_TYPE_MIXED = 0;
18
    public const ARGUMENT_TYPE_ARGUMENT_BUILDER = 1;
19
    public const ARGUMENT_TYPE_NUMERIC = 2;
20
    public const ARGUMENT_TYPE_ENUM = 3;
21
    public const ARGUMENT_TYPE_BOOLEAN = 4;
22
23
    protected $args = array();
24
25
    protected $fields = array(
26
        //'arg1' => self::ARGUMENT_TYPE_MIXED,
27
        //'arg2' => SomeArgumentBuilder::class,
28
    );
29
30
    public function __construct()
31
    {
32
        $this->load();
33
        $this->normalizeFields();
34
    }
35
36
    protected function load()
37
    {
38
    }
39
40
    /**
41
     * Translates a camel case string into a string with underscores (e.g. firstName -&gt; first_name).
42
     *
43
     * @param string $str String in camel case format
44
     *
45
     * @return string $str Translated into underscore format
46
     */
47
    private function camelCaseToSnakeCase($str)
48
    {
49
        $str[0] = strtolower($str[0]);
50
51
        return preg_replace_callback('/([A-Z])/', function ($c) {
52
            return '_'.strtolower($c[1]);
53
        }, $str);
54
    }
55
56
    /**
57
     * Translates a snake case string into a camel-cased string (e.g. first_name -&gt; FirstName).
58
     *
59
     * @param string $str
60
     *
61
     * @return string
62
     */
63
    private function snakeCaseToCamelCase($str)
64
    {
65
        return implode(array_map('ucfirst', explode('_', $str)));
66
    }
67
68
    /**
69
     * Normalizes field definitions so that they have the same format (expands shortcuts to array).
70
     *
71
     * @throws Exception\InvalidDefinitionException
72
     */
73
    private function normalizeFields()
74
    {
75
        foreach ($this->fields as $name => $field) {
76
            // Consider string value as a class name
77
            if (is_string($field)) {
78
                $this->fields[$name] = array(
79
                    'type' => self::ARGUMENT_TYPE_ARGUMENT_BUILDER,
80
                    'class' => $field,
81
                    'validator' => new ArgumentBuilderTypeValidator($name, $field),
82
                );
83
84
            // consider numeric values as type name
85
            } elseif (is_int($field)) {
86
                $this->fields[$name] = array(
87
                    'type' => $field,
88
                    'validator' => null,
89
                );
90
            }
91
92
            if (!is_array($this->fields[$name])) {
93
                throw new Exception\InvalidDefinitionException(
94
                    'Field description must be either string (shortcut for class name), or int (shortcut for field type) or array (full form)'
95
                );
96
            }
97
98
            if (!array_key_exists('type', $this->fields[$name])) {
99
                throw new Exception\InvalidDefinitionException(
100
                    'Field type is not defined'
101
                );
102
            }
103
104
            if (
105
                self::ARGUMENT_TYPE_ARGUMENT_BUILDER === $this->fields[$name]['type']
106
                && empty($this->fields[$name]['class'])
107
            ) {
108
                throw new Exception\InvalidDefinitionException(
109
                    'Field of type ARGUMENT_TYPE_ARGUMENT_BUILDER must have class defined'
110
                );
111
            }
112
        }
113
    }
114
115
    /**
116
     * Validates non-null fields according to the field type and validator (if given).
117
     *
118
     * @param string $field
119
     * @param mixed  $value
120
     *
121
     * @return bool
122
     *
123
     * @throws Exception\InvalidDefinitionException
124
     */
125
    private function validateFieldValue($field, $value)
126
    {
127
        if (null === $value) {
128
            return true;
129
        }
130
131
        if (
132
            !array_key_exists('validator', $this->fields[$field])
133
            || null === $this->fields[$field]['validator']
134
            || false === $this->fields[$field]['validator']
135
        ) {
136
            return true;
137
        }
138
139
        $validator = $this->fields[$field]['validator'];
140
141
        if ($validator instanceof TypeValidatorInterface) {
142
            return $validator->validate($value);
143
        }
144
145
        if (!is_callable($validator)) {
146
            throw new Exception\InvalidDefinitionException(
147
                sprintf('Validator for the field "%s" is defined but is not callable', $field)
148
            );
149
        }
150
151
        return call_user_func($this->fields[$field]['validator'], $value);
152
    }
153
154
    /**
155
     * @param string $name
156
     * @param array  $arguments
157
     *
158
     * @return mixed
159
     *
160
     * @throws Exception\UndefinedMethodException
161
     */
162
    public function __call($name, $arguments)
163
    {
164
        foreach (array('callGet', 'callSet', 'callUnset') as $method) {
165
            try {
166
                return $this->{$method}($name, $arguments);
167
            } catch (Exception\UnmatchedCallTypeException $e) {
168
                // continue cycle
169
            }
170
        }
171
172
        throw new Exception\UndefinedMethodException($name);
173
    }
174
175
    /**
176
     * Provides $builder->getName($arguments...).
177
     *
178
     * @param string $name
179
     * @param array  $arguments
180
     *
181
     * @return mixed|null
182
     *
183
     * @throws Exception\UnmatchedCallTypeException
184
     * @throws Exception\InvalidArgumentException
185
     * @throws Exception\UndefinedMethodException
186
     */
187
    private function callGet($name, $arguments)
188
    {
189
        if (!preg_match('/^get([A-Z\-][A-Za-z0-9]+)$/', $name, $matches)) {
190
            throw new Exception\UnmatchedCallTypeException();
191
        }
192
193
        $field = lcfirst($matches[1]);
194
        $field = $this->camelCaseToSnakeCase($field);
195
196
        if (!array_key_exists($field, $this->fields)) {
197
            throw new Exception\UndefinedMethodException($name);
198
        }
199
200
        if (self::ARGUMENT_TYPE_ARGUMENT_BUILDER !== $this->fields[$field]['type'] && 0 !== count($arguments)) {
201
            throw new Exception\InvalidArgumentException('Method '.__CLASS__.'::'.$name.'() must take exactly 0 arguments');
202
        }
203
204
        if (self::ARGUMENT_TYPE_ARGUMENT_BUILDER === $this->fields[$field]['type'] && count($arguments) > 0) {
205
            if (!is_string($arguments[0])) {
206
                throw new Exception\InvalidArgumentException(
207
                    'Method '.__CLASS__.'::'.$name.'() expects the first parameter to be string if you want to get sub-value'
208
                );
209
            }
210
211
            if (!empty($this->args[$field])) {
212
                return call_user_func_array(array($this->args[$field], 'get'.$this->snakeCaseToCamelCase($arguments[0])), array_slice($arguments, 1));
213
            }
214
        }
215
216
        return isset($this->args[$field]) ? $this->args[$field] : null;
217
    }
218
219
    /**
220
     * Provides $builder->setName($value).
221
     *
222
     * @param string $name
223
     * @param array  $arguments
224
     *
225
     * @return $this
226
     *
227
     * @throws Exception\InvalidDefinitionException
228
     * @throws Exception\UndefinedMethodException
229
     * @throws Exception\UnmatchedCallTypeException
230
     */
231
    private function callSet($name, $arguments)
232
    {
233
        if (!preg_match('/^set([A-Z\-][A-Za-z0-9]+)$/', $name, $matches)) {
234
            throw new Exception\UnmatchedCallTypeException();
235
        }
236
237
        $field = $matches[1];
238
        if ('-' !== $field[0]) {
239
            $field = lcfirst($matches[1]);
240
        }
241
        $field = $this->camelCaseToSnakeCase($field);
242
243
        if (!array_key_exists($field, $this->fields)) {
244
            throw new Exception\UndefinedMethodException($name);
245
        }
246
247
        if (self::ARGUMENT_TYPE_ARGUMENT_BUILDER !== $this->fields[$field]['type'] && 1 !== count($arguments)) {
248
            throw new Exception\InvalidArgumentException('Method '.__CLASS__.'::'.$name.'() must take exactly 1 argument');
249
        }
250
251
        $value = $arguments[0];
252
253
        if (self::ARGUMENT_TYPE_ARGUMENT_BUILDER === $this->fields[$field]['type'] && count($arguments) > 1) {
254
            if (!is_string($arguments[0])) {
255
                throw new Exception\InvalidArgumentException(
256
                    'Method '.__CLASS__.'::'.$name.'() expects the first parameter to be string'
257
                );
258
            }
259
260
            $class = $this->fields[$field]['class'];
261
            $value = isset($this->args[$field]) ? $this->args[$field] : new $class();
262
263
            call_user_func_array(array($value, 'set'.$this->snakeCaseToCamelCase($arguments[0])), array_slice($arguments, 1));
264
        } elseif (!$this->validateFieldValue($field, $value)) {
265
            throw new Exception\InvalidArgumentException(sprintf('Invalid value "%s" for field "%s"', $value, $field));
266
        }
267
268
        $this->args[$field] = $value;
269
270
        return $this;
271
    }
272
273
    /**
274
     * Provides $builder->unsetName().
275
     *
276
     * @param string $name
277
     * @param array  $arguments
278
     *
279
     * @return $this
280
     *
281
     * @throws Exception\UnmatchedCallTypeException
282
     * @throws Exception\UndefinedMethodException
283
     */
284
    private function callUnset($name, $arguments)
285
    {
286
        if (!preg_match('/^unset([A-Z\-][A-Za-z0-9]+)$/', $name, $matches)) {
287
            throw new Exception\UnmatchedCallTypeException();
288
        }
289
290
        $field = $matches[1];
291
        if ('-' !== $field[0]) {
292
            $field = lcfirst($matches[1]);
293
        }
294
        $field = $this->camelCaseToSnakeCase($field);
295
296
        // Check if field is defined for this ArgumentBuilder
297
        if (!array_key_exists($field, $this->fields)) {
298
            throw new Exception\UndefinedMethodException($name);
299
        }
300
301
        // Check argument count
302
        if (self::ARGUMENT_TYPE_ARGUMENT_BUILDER !== $this->fields[$field]['type'] && 0 !== count($arguments)) {
303
            throw new Exception\InvalidArgumentException('Method '.__CLASS__.'::'.$name.'() must take exactly 0 arguments');
304
        }
305
306
        // no parameters means plain unsetName() call, so no syntatic sugar logic involved
307
        if (0 === count($arguments)) {
308
            unset($this->args[$field]);
309
310
            return $this;
311
        }
312
313
        // At this point we already filtered out zero arguments and non-ARGUMENT_BUILDER types,
314
        // so we expect that the first argument is string because it's a name for a field.
315
        // Let's check that!
316
        if (!is_string($arguments[0])) {
317
            throw new Exception\InvalidArgumentException(
318
                'Method '.__CLASS__.'::'.$name.'() expects the first parameter to be string if you want to unset sub-value'
319
            );
320
        }
321
322
        // To call unset on a sub-ArgumentBuilder, we need to make sure it exists (otherwise there is nothing to unset)
323
        if (!empty($this->args[$field])) {
324
            // Allow syntactic sugar (pass unset call recursively)
325
            call_user_func_array(array($this->args[$field], 'unset'.$this->snakeCaseToCamelCase($arguments[0])), array_slice($arguments, 1));
326
        }
327
328
        return $this;
329
    }
330
331
    private function transformValue($field, $value)
332
    {
333
        if (self::ARGUMENT_TYPE_BOOLEAN === $this->fields[$field]['type']) {
334
            if ('any' === $value) {
335
                return $value;
336
            }
337
338
            return $value ? 'true' : 'false';
339
        }
340
341
        return $value;
342
    }
343
344
    /**
345
     * Returns array of arguments.
346
     *
347
     * @return array
348
     */
349
    public function build()
350
    {
351
        $result = array();
352
353
        foreach ($this->args as $key => $arg) {
354
            if ($arg instanceof ArgumentBuilderInterface) {
355
                $result[$key] = $arg->build(); //@todo: missing Circular Reference check
356
            } else {
357
                $result[$key] = $this->transformValue($key, $arg);
358
            }
359
        }
360
361
        return $result;
362
    }
363
364
    /**
365
     * @return string
366
     */
367
    public function __toString()
368
    {
369
        return http_build_query($this->build());
370
    }
371
}
372