Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 218
0.00% covered (danger)
0.00%
0 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
CustomFieldService
0.00% covered (danger)
0.00%
0 / 218
0.00% covered (danger)
0.00%
0 / 26
26732
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 addDefinitions
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 setDefinitions
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getDefinitions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 extractListFields
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 extractDetailFields
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 validate
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 getLastError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 applyToModel
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 isCustomType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 handleCustomType
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 validateCustomType
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 setCustomTypeValue
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getFieldValue
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
210
 getCustomFieldValue
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
420
 getSelectLabel
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
90
 validateField
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
56
 validateString
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 validateSelect
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
90
 isOptionList
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 validateNumeric
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
72
 isEmpty
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 setFieldValue
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
72
 convertToDateTime
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 setCustomFieldValue
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 convertValueToString
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
342
1<?php
2
3declare(strict_types=1);
4
5namespace Satag\AmicronEntityBundle\Service;
6
7use Satag\AmicronEntityBundle\CustomType\CustomTypeHandler;
8
9class CustomFieldService
10{
11    private const array STANDARD_TYPES = [
12        'Select', 'MultiSelect', 'Radio', 'Checkbox', 'Switch', 'Rate', 'Slider',
13        'Color', 'DateRange', 'TimeRange', 'JSON', 'Password', 'URL', 'Email',
14        'Phone', 'Tags', 'Number', 'Integer', 'Float', 'String', 'Text', 'Boolean',
15        'Date', 'DateTime', 'Time', 'File', 'Image', 'RichText', 'Cascader', 'TreeSelect',
16    ];
17
18    /**
19     * @var array<string, array{
20     *     name: string,
21     *     key: string,
22     *     typ: string,
23     *     list?: bool,
24     *     listwidth?: int,
25     *     readonly?: bool,
26     *     firebird?: string,
27     *     placeholder?: string,
28     *     block?: 'json'|'yaml'|string,
29     *     required?: bool,
30     *     options?: array<int, array{value: mixed, label: string}>|array{optionsApi: string},
31     *     validationminlength?: int,
32     *     validationmaxlength?: int,
33     *     min?: int|float,
34     *     max?: int|float,
35     *     order?: int,
36     *     disabled?: bool
37     * }>
38     */
39    private array $definitions;
40
41    private string $lastError = '';
42
43    public function __construct(
44        private readonly PlatformConfiguration $platformConfiguration,
45        private readonly string $definitionKey
46    ) {
47        $this->definitions = (array) json_decode(
48            (string) $this->platformConfiguration->get($this->definitionKey),
49            true
50        );
51    }
52
53    /**
54     * @param array<string, array{
55     *     name: string,
56     *     key: string,
57     *     typ: string,
58     *     list?: bool,
59     *     listwidth?: int,
60     *     readonly?: bool,
61     *     firebird?: string,
62     *     placeholder?: string,
63     *     block?: 'json'|'yaml'|string,
64     *     required?: bool,
65     *     options?: array<int, array{value: mixed, label: string}>|array{optionsApi: string},
66     *     validationminlength?: int,
67     *     validationmaxlength?: int,
68     *     min?: int|float,
69     *     max?: int|float,
70     *     order?: int,
71     *     disabled?: bool
72     * }> $definitions
73     */
74    public function addDefinitions(array $definitions): self
75    {
76        foreach ($definitions as $key => $def) {
77            if ((isset($def['disabled'])) && ($def['disabled'] === true)) {
78                if (isset($this->definitions[$key])) {
79                    unset($this->definitions[$key]);
80                }
81
82                continue;
83            }
84
85            if (!isset($def['order'])) {
86                $def['order'] = 200;
87            }
88            $this->definitions[$key] = $def;
89        }
90
91        uasort($this->definitions, static fn ($a, $b) => ($a['order'] ?? 200) <=> ($b['order'] ?? 200));
92
93        return $this;
94    }
95
96    /**
97     * @param array<string, array{
98     *     name: string,
99     *     key: string,
100     *     typ: string,
101     *     list?: bool,
102     *     listwidth?: int,
103     *     readonly?: bool,
104     *     firebird?: string,
105     *     placeholder?: string,
106     *     block?: 'json'|'yaml'|string,
107     *     required?: bool,
108     *     options?: array<int, array{value: mixed, label: string}>|array{optionsApi: string},
109     *     validationminlength?: int,
110     *     validationmaxlength?: int,
111     *     min?: int|float,
112     *     max?: int|float,
113     *     order?: int,
114     *     disabled?: bool
115     * }> $definitions
116     */
117    public function setDefinitions(array $definitions): self
118    {
119        $this->definitions = $definitions;
120
121        return $this;
122    }
123
124    /**
125     * @return array<string, array{
126     *     name: string,
127     *     key: string,
128     *     typ: string,
129     *     list?: bool,
130     *     listwidth?: int,
131     *     readonly?: bool,
132     *     firebird?: string,
133     *     placeholder?: string,
134     *     block?: 'json'|'yaml'|string,
135     *     required?: bool,
136     *     options?: array<int, array{value: mixed, label: string}>|array{optionsApi: string},
137     *     validationminlength?: int,
138     *     validationmaxlength?: int,
139     *     min?: int|float,
140     *     max?: int|float,
141     *     order?: int,
142     *     disabled?: bool
143     * }>
144     */
145    public function getDefinitions(): array
146    {
147        return $this->definitions;
148    }
149
150    /**
151     * @return array<string, mixed>
152     */
153    public function extractListFields(object $model): array
154    {
155        $data = [];
156        foreach ($this->definitions as $field) {
157            if (isset($field['firebird'])) {
158                $value = $this->getFieldValue($model, $field);
159                if ($field['typ'] === 'Date' && $value instanceof \DateTime) {
160                    $data[$field['key']] = $value->format('Y-m-d');
161                } else {
162                    $data[$field['key']] = $value;
163                }
164            } elseif ($this->isCustomType($field['typ'])) {
165                $data[$field['key']] = $this->handleCustomType($model, $field, 'list');
166            } elseif ($field['typ'] === 'Select') {
167                $value = $this->getCustomFieldValue($model, $field);
168                $data[$field['key']] = $this->getSelectLabel($value, $field);
169            } else {
170                $data[$field['key']] = $this->getCustomFieldValue($model, $field);
171            }
172        }
173
174        return $data;
175    }
176
177    /**
178     * @return array<string, mixed>
179     */
180    public function extractDetailFields(object $model): array
181    {
182        $data = [];
183        foreach ($this->definitions as $field) {
184            if (isset($field['firebird'])) {
185                $value = $this->getFieldValue($model, $field);
186                if ($field['typ'] === 'Date' && $value instanceof \DateTime) {
187                    $data[$field['key']] = $value->format('Y-m-d');
188                } else {
189                    $data[$field['key']] = $value;
190                }
191            } elseif ($this->isCustomType($field['typ'])) {
192                $data[$field['key']] = $this->handleCustomType($model, $field, 'detail');
193            } else {
194                $data[$field['key']] = $this->getCustomFieldValue($model, $field);
195            }
196        }
197
198        return $data;
199    }
200
201    /**
202     * @param array<string, mixed> $params
203     */
204    public function validate(array $params): bool
205    {
206        foreach ($this->definitions as $field) {
207            if (!($field['readonly'] ?? false)) {
208                $value = $params[$field['key']] ?? null;
209                if ($this->isCustomType($field['typ'])) {
210                    if (!$this->validateCustomType($value, $field)) {
211                        return false;
212                    }
213                } elseif (!$this->validateField($value, $field)) {
214                    return false;
215                }
216            }
217        }
218
219        return true;
220    }
221
222    public function getLastError(): string
223    {
224        return $this->lastError;
225    }
226
227    /**
228     * @param array<string, mixed> $params
229     */
230    public function applyToModel(object $model, array $params): void
231    {
232        foreach ($this->definitions as $field) {
233            if (!($field['readonly'] ?? false)) {
234                $value = $params[$field['key']] ?? null;
235                if (isset($field['firebird'])) {
236                    $this->setFieldValue($model, $field, $value);
237                } elseif ($this->isCustomType($field['typ'])) {
238                    $this->setCustomTypeValue($model, $field, $value);
239                } else {
240                    $this->setCustomFieldValue($model, $field, $value);
241                }
242            }
243        }
244    }
245
246    private function isCustomType(string $type): bool
247    {
248        return !\in_array($type, self::STANDARD_TYPES, true);
249    }
250
251    /**
252     * @param array{
253     *     name: string,
254     *     key: string,
255     *     typ: string,
256     *     block?: 'json'|'yaml'|string,
257     *     options?: array<int, array{value: mixed, label: string}>|array{optionsApi: string}
258     * } $field
259     */
260    private function handleCustomType(object $model, array $field, string $context): mixed
261    {
262        $handlerClass = "Satag\\AmicronPlatform\\CustomType\\{$field['typ']}Handler";
263
264        if (class_exists($handlerClass)) {
265            $handler = new $handlerClass();
266            if ($handler instanceof CustomTypeHandler) {
267                return $context === 'list'
268                    ? $handler->getListValue($model, $field)
269                    : $handler->getDetailValue($model, $field);
270            }
271        }
272
273        return $this->getCustomFieldValue($model, $field);
274    }
275
276    /**
277     * @param array{
278     *     name: string,
279     *     key: string,
280     *     typ: string,
281     *     required?: bool
282     * } $field
283     */
284    private function validateCustomType(mixed $value, array $field): bool
285    {
286        $handlerClass = "Satag\\AmicronPlatform\\CustomType\\{$field['typ']}Handler";
287
288        if (class_exists($handlerClass)) {
289            $handler = new $handlerClass();
290            if ($handler instanceof CustomTypeHandler) {
291                $isValid = $handler->validate($value, $field);
292                if (!$isValid) {
293                    $this->lastError = $handler->getLastError();
294                }
295
296                return $isValid;
297            }
298        }
299
300        return true;
301    }
302
303    /**
304     * @param array{
305     *     name: string,
306     *     key: string,
307     *     typ: string
308     * } $field
309     */
310    private function setCustomTypeValue(object $model, array $field, mixed $value): void
311    {
312        $handlerClass = "Satag\\AmicronPlatform\\CustomType\\{$field['typ']}Handler";
313
314        if (class_exists($handlerClass)) {
315            $handler = new $handlerClass();
316            if ($handler instanceof CustomTypeHandler) {
317                $handler->setValue($model, $field, $value);
318
319                return;
320            }
321        }
322
323        $this->setCustomFieldValue($model, $field, $value);
324    }
325
326    /**
327     * @param array{firebird: string, key: string, typ: string} $field
328     */
329    private function getFieldValue(object $model, array $field): mixed
330    {
331        $firebird = $field['firebird'];
332        $val = null;
333        // Check if the firebird field contains a dot (relationship traversal)
334        if (str_contains($firebird, '.')) {
335            $parts = explode('.', $firebird);
336            $currentObject = $model;
337
338            // Traverse through the relationships
339            foreach ($parts as $index => $part) {
340                if ($currentObject === null) {
341                    $val = null;
342
343                    break;
344                }
345
346                $getter = 'get' . ucfirst($part);
347
348                if (!method_exists($currentObject, $getter)) {
349                    $val = null;
350
351                    break;
352                }
353
354                // If this is the last part, get the value
355                if ($index === \count($parts) - 1) {
356                    // Check if the getter accepts parameters
357                    $reflection = new \ReflectionMethod($currentObject, $getter);
358                    if ($reflection->getNumberOfRequiredParameters() > 0) {
359                        $val = $currentObject->$getter($field['key']);
360                    } else {
361                        $val = $currentObject->$getter();
362                    }
363                } else {
364                    $currentObject = $currentObject->$getter();
365                }
366            }
367        } else {
368            // Original behavior for simple firebird fields
369            $getter = 'get' . ucfirst($firebird);
370            $val = $model->$getter($field['key']);
371        }
372
373        return match ($field['typ']) {
374            'Switch', 'Boolean' => $val === '1' || $val === 'true' || $val === true,
375            'Number', 'Integer', 'Float' => $val !== null && $val !== '' ? (float) $val : null,
376            default => $val ?? ''
377        };
378    }
379
380    /**
381     * @param object $model
382     * @param array{
383     *     key: string,
384     *     typ: string,
385     *     block?: 'json'|'yaml'|string
386     * } $field
387     * @return mixed
388     */
389    private function getCustomFieldValue(object $model, array $field): mixed
390    {
391        /** @var array{block?: string, key: string, typ: string} $field */
392        if (isset($field['block']) && $field['block'] === 'json') {
393            if (!method_exists($model, 'getCustomStringFieldJson')) {
394                throw new \BadMethodCallException('getCustomStringFieldJson missing on model ' . $model::class);
395            }
396            $val = $model->getCustomStringFieldJson($field['key']);
397        } else {
398            if (!method_exists($model, 'getCustomStringFieldYaml')) {
399                throw new \BadMethodCallException('getCustomStringFieldYaml missing on model ' . $model::class);
400            }
401            $val = $model->getCustomStringFieldYaml($field['key']);
402        }
403
404        return match ($field['typ']) {
405            'Switch', 'Boolean' => $val === '1' || $val === 'true' || $val === true,
406            'Number', 'Integer', 'Float' => $val !== null && $val !== '' ? (float) $val : null,
407            'MultiSelect', 'Checkbox', 'Tags' => \is_array($val) ? $val : ($val ? explode(',', (string) $val) : []),
408            'DateRange', 'TimeRange' => \is_array($val) ? $val : [],
409            'JSON' => \is_string($val) ? $val : ((string) (json_encode($val) ?: '')),
410            default => $val ?? ''
411        };
412    }
413
414    /**
415     * @param array{options?: array<int, array{value: mixed, label: string}>|array{optionsApi: string}} $field
416     */
417    private function getSelectLabel(mixed $value, array $field): string
418    {
419        if ($value === null || $value === '' || !isset($field['options'])) {
420            return '';
421        }
422        // Wenn die Optionen keine echte Optionsliste sind (z. B. nur optionsApi), gebe den Wert zurück
423        if (isset($field['options']['optionsApi']) || !$this->isOptionList($field['options'])) {
424            return (string) $value;
425        }
426        foreach ($field['options'] as $opt) {
427            if (isset($opt['value']) && (string) $opt['value'] === (string) $value) {
428                return $opt['label'];
429            }
430        }
431
432        return (string) $value;
433    }
434
435    /**
436     * @param array{
437     *     name: string,
438     *     typ: string,
439     *     required?: bool
440     * } $field
441     */
442    private function validateField(mixed $value, array $field): bool
443    {
444        if (($field['required'] ?? false) && $this->isEmpty($value, $field['typ'])) {
445            $this->lastError = $field['name'] . ' ist ein Pflichtfeld';
446
447            return false;
448        }
449
450        return match ($field['typ']) {
451            'String', 'Text' => $this->validateString($value, $field),
452            'Select' => $this->validateSelect($value, $field),
453            'Number', 'Integer', 'Float' => $this->validateNumeric($value, $field),
454            default => true,
455        };
456    }
457
458    /**
459     * @param array{
460     *     name: string,
461     *     validationminlength?: int,
462     *     validationmaxlength?: int
463     * } $f
464     */
465    private function validateString(mixed $val, array $f): bool
466    {
467        if ($val === null || $val === '') {
468            return true;
469        }
470        $min = $f['validationminlength'] ?? 0;
471        $max = $f['validationmaxlength'] ?? 255;
472        $len = mb_strlen((string) $val);
473        if ($len < $min) {
474            $this->lastError = $f['name'] . ' muss mindestens ' . $min . ' Zeichen lang sein';
475
476            return false;
477        }
478        if ($len > $max) {
479            $this->lastError = $f['name'] . ' darf maximal ' . $max . ' Zeichen lang sein';
480
481            return false;
482        }
483
484        return true;
485    }
486
487    /**
488     * @param array{
489     *     name: string,
490     *     options?: array<int, array{value: mixed, label: string}>|array{optionsApi: string}
491     * } $f
492     */
493    private function validateSelect(mixed $val, array $f): bool
494    {
495        if ($val === null || $val === '' || !isset($f['options'])) {
496            return true;
497        }
498        // Wenn die Optionen keine echte Optionsliste sind (z. B. nur optionsApi vorhanden),
499        // kann serverseitig nicht sinnvoll validiert werden → akzeptieren.
500        if (isset($f['options']['optionsApi']) || !$this->isOptionList($f['options'])) {
501            return true;
502        }
503        foreach ($f['options'] as $opt) {
504            if (isset($opt['value']) && (string) $opt['value'] === (string) $val) {
505                return true;
506            }
507        }
508        $this->lastError = $f['name'] . ' enthält einen ungültigen Wert';
509
510        return false;
511    }
512
513    /**
514     * Prüft, ob die übergebenen Optionen eine echte Liste von Optionen sind
515     * (Array von Arrays mit mind. dem Key 'value').
516     */
517    /**
518     * @param mixed $options
519     */
520    private function isOptionList(mixed $options): bool
521    {
522        if (!\is_array($options) || $options === []) {
523            return false;
524        }
525        $hasValid = array_any($options, static fn ($opt) => \is_array($opt) && \array_key_exists('value', $opt));
526
527        return $hasValid;
528    }
529
530    /**
531     * @param array{
532     *     name: string,
533     *     min?: int|float,
534     *     max?: int|float
535     * } $f
536     */
537    private function validateNumeric(mixed $val, array $f): bool
538    {
539        if ($val === null || $val === '') {
540            return true;
541        }
542        if (!is_numeric($val)) {
543            $this->lastError = $f['name'] . ' muss eine gültige Zahl sein';
544
545            return false;
546        }
547        $num = (float) $val;
548        if (isset($f['min']) && $num < $f['min']) {
549            $this->lastError = $f['name'] . ' muss mindestens ' . $f['min'] . ' sein';
550
551            return false;
552        }
553        if (isset($f['max']) && $num > $f['max']) {
554            $this->lastError = $f['name'] . ' darf maximal ' . $f['max'] . ' sein';
555
556            return false;
557        }
558
559        return true;
560    }
561
562    private function isEmpty(mixed $val, string $type): bool
563    {
564        return match ($type) {
565            'Switch', 'Boolean' => false,
566            'MultiSelect', 'Checkbox', 'Tags', 'DateRange', 'TimeRange' => empty($val),
567            default => $val === null || $val === ''
568        };
569    }
570
571    /**
572     * @param array{firebird: string, typ: string} $field
573     */
574    private function setFieldValue(object $model, array $field, mixed $value): void
575    {
576        $setter = 'set' . ucfirst($field['firebird']);
577        match ($field['typ']) {
578            'Switch', 'Boolean' => $model->$setter($value ? '1' : '0'),
579            'Number', 'Integer', 'Float' => $model->$setter($value !== null && $value !== '' ? (string) $value : ''),
580            'Date' => $model->$setter($this->convertToDateTime($value)),
581            default => $model->$setter((string) ($value ?? ''))
582        };
583    }
584
585    private function convertToDateTime(mixed $value): ?\DateTime
586    {
587        if ($value === null || $value === '') {
588            return null;
589        }
590
591        if ($value instanceof \DateTime) {
592            return $value;
593        }
594
595        try {
596            return new \DateTime((string) $value);
597        } catch (\Exception) {
598            return null;
599        }
600    }
601
602    /**
603     * @param object $model
604     * @param array{
605     *     key: string,
606     *     typ: string,
607     *     block?: 'json'|'yaml'|string
608     * } $field
609     * @param mixed $value
610     */
611    private function setCustomFieldValue(object $model, array $field, mixed $value): void
612    {
613        $val = $this->convertValueToString($value, $field['typ']);
614
615        /** @var array{block?: string, key: string, typ: string} $field */
616        if (isset($field['block']) && $field['block'] === 'json') {
617            if (!method_exists($model, 'setCustomStringFieldJson')) {
618                throw new \BadMethodCallException('setCustomStringFieldJson missing on model ' . $model::class);
619            }
620            $model->setCustomStringFieldJson($field['key'], $val);
621        } else {
622            if (!method_exists($model, 'setCustomStringFieldYaml')) {
623                throw new \BadMethodCallException('setCustomStringFieldYaml missing on model ' . $model::class);
624            }
625            $model->setCustomStringFieldYaml($field['key'], $val);
626        }
627    }
628
629    private function convertValueToString(mixed $value, string $type): string
630    {
631        return match ($type) {
632            'Switch', 'Boolean' => $value ? '1' : '0',
633            'Number', 'Integer', 'Float' => $value !== null && $value !== '' ? (string) $value : '',
634            'MultiSelect', 'Checkbox', 'Tags' => \is_array($value) ? implode(',', $value) : (string) ($value ?? ''),
635            'DateRange', 'TimeRange' => \is_array($value) ? ((string) (json_encode($value) ?: '')) : (string) ($value ?? ''),
636            'JSON' => \is_array($value) || \is_object($value) ? ((string) (json_encode($value) ?: '{}')) : (string) ($value ?? '{}'),
637            'Rate', 'Slider' => (string) ($value ?? '0'),
638            'Color' => (string) ($value ?? '#409eff'),
639            default => (string) ($value ?? '')
640        };
641    }
642}