Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.70% covered (success)
98.70%
152 / 154
93.75% covered (success)
93.75%
15 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
UniversalValidationEngine
99.35% covered (success)
99.35%
152 / 153
93.75% covered (success)
93.75%
15 / 16
94
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
9
 validateField
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 validateStructuralIntegrity
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 validateHierarchyCycle
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 validateOwnerAssignment
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
7.01
 validateUiType
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
16
 validateJsonField
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 validateRules
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 validateCrossFieldRules
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 validateAfterOrEqual
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
9
 validateMaxDurationHours
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
9
 validateMin
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 validateMax
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 validateFormatAndAllowed
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 checkUnique
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Engine\Application\Validator;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Exception\ValidationException;
12use App\Core\Engine\Domain\Model\FieldMetadata;
13use App\Core\Engine\Domain\Service\HierarchyCycleValidator;
14use PDO;
15
16/**
17 * Universal Validation Engine.
18 *
19 * Validates a data payload against all FieldMetadata rules for a module.
20 * Supports: mandatory, unique (via DB check), regex, range (min/max),
21 * allowed_values list, email/IP format, and self-referencing hierarchy cycle validation.
22 *
23 * @package App\Core\Engine\Application\Validator
24 */
25final readonly class UniversalValidationEngine
26{
27    private HierarchyCycleValidator $cycleValidator;
28
29    /**
30     * UniversalValidationEngine constructor.
31     *
32     * @param PDO                          $pdo            Database connection for unique constraint checks.
33     * @param HierarchyCycleValidator|null $cycleValidator Optional cycle validator instance.
34     */
35    public function __construct(
36        private PDO $pdo,
37        ?HierarchyCycleValidator $cycleValidator = null
38    ) {
39        $this->cycleValidator = $cycleValidator ?? new HierarchyCycleValidator($pdo);
40    }
41
42    /**
43     * Validates a data payload against all field metadata rules.
44     *
45     * Validates every writable field in the FieldMetadata list.
46     * Collects all errors and throws ValidationException if any are found.
47     *
48     * @param array<int, FieldMetadata> $fields   All field metadata for the module.
49     * @param array<string, mixed>      $data     Input data payload (field_key => value).
50     * @param string                    $table    Target database table name.
51     * @param int|null                  $recordId Existing record ID for updates (null for create).
52     * @throws ValidationException When one or more field values fail validation.
53     */
54    public function validate(
55        array  $fields,
56        array  $data,
57        string $table,
58        ?int   $recordId,
59        array  $contextData = []
60    ): void {
61        $errors = [];
62        $payload = $contextData !== [] ? array_merge($contextData, $data) : $data;
63
64        foreach ($fields as $field) {
65            if ($field->isSystem || $field->isReadonly) {
66                continue;
67            }
68
69            if ($recordId !== null && !array_key_exists($field->fieldKey, $data)) {
70                continue;
71            }
72
73            $value = $data[$field->fieldKey] ?? null;
74            $error = $this->validateField($field, $value, $payload, $table, $recordId);
75
76            if ($error !== null) {
77                $errors[$field->fieldKey] = $error;
78            }
79        }
80
81        if ($errors !== []) {
82            throw new ValidationException($errors);
83        }
84    }
85
86    /**
87     * Validates a single field value against all applicable rules.
88     *
89     * @param FieldMetadata        $field    Field definition and rules.
90     * @param mixed                $value    The input value to validate.
91     * @param array<string, mixed> $data     Full input payload for cross-field checks.
92     * @param string               $table    Target table for unique checks.
93     * @param int|null             $recordId Existing record ID for update unique checks.
94     * @return string|null Error message or null if valid.
95     */
96    private function validateField(
97        FieldMetadata $field,
98        mixed         $value,
99        array         $data,
100        string        $table,
101        ?int          $recordId
102    ): ?string {
103        if ($value === null || $value === '') {
104            return $field->isMandatory ? sprintf('Field "%s" is required.', $field->label) : null;
105        }
106
107        $integrityError = $this->validateStructuralIntegrity($field, $value, $data, $table, $recordId);
108        if ($integrityError !== null) {
109            return $integrityError;
110        }
111
112        return $this->validateUiType($field, $value) ?? $this->validateRules($field, $value, $data);
113    }
114
115    /**
116     * Validates hierarchy cycle, uniqueness integrity, and structure assignment rules.
117     *
118     * @param FieldMetadata        $field    Field definition.
119     * @param mixed                $value    The input value to validate.
120     * @param array<string, mixed> $data     Full payload data.
121     * @param string               $table    Target database table.
122     * @param int|null             $recordId Record ID for update checks.
123     * @return string|null Error message or null if valid.
124     */
125    private function validateStructuralIntegrity(
126        FieldMetadata $field,
127        mixed         $value,
128        array         $data,
129        string        $table,
130        ?int          $recordId
131    ): ?string {
132        $cycleError = $this->validateHierarchyCycle($field, $value, $table, $recordId);
133        if ($cycleError !== null) {
134            return $cycleError;
135        }
136
137        $ownerError = $this->validateOwnerAssignment($field, $value, $data);
138        if ($ownerError !== null) {
139            return $ownerError;
140        }
141
142        return $field->isUnique ? $this->checkUnique($field, $value, $table, $recordId) : null;
143    }
144
145    /**
146     * Validates that setting parent_id does not create a hierarchy cycle.
147     *
148     * @param FieldMetadata $field    Field definition.
149     * @param mixed         $value    Value to test.
150     * @param string        $table    Target database table.
151     * @param int|null      $recordId Record ID for update checks.
152     * @return string|null Error message or null.
153     */
154    private function validateHierarchyCycle(
155        FieldMetadata $field,
156        mixed         $value,
157        string        $table,
158        ?int          $recordId
159    ): ?string {
160        if ($field->fieldKey === 'parent_id' && is_numeric($value) && $table !== '') {
161            try {
162                $this->cycleValidator->validateNoCycle($table, $recordId, (int) $value);
163            } catch (ValidationException $e) {
164                return $e->getMessage();
165            }
166        }
167
168        return null;
169    }
170
171    /**
172     * Validates that owner assignment to a structure has active users assigned.
173     *
174     * @param FieldMetadata        $field Field definition.
175     * @param mixed                $value Value to test.
176     * @param array<string, mixed> $data  Full payload data.
177     * @return string|null Error message or null.
178     */
179    private function validateOwnerAssignment(FieldMetadata $field, mixed $value, array $data): ?string
180    {
181        if ($field->fieldKey === 'owner') {
182            $ownerType = (string) ($data['owner_type'] ?? 'user');
183            if ($ownerType === 'structure' && is_numeric($value)) {
184                $count = 0;
185                foreach (['c_rel_users_structure', 'a_rel_users_structure'] as $relTbl) {
186                    try {
187                        $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `{$relTbl}` WHERE `structure_id` = :sid");
188                        $stmt->execute([':sid' => (int) $value]);
189                        $count = (int) $stmt->fetchColumn();
190                        break;
191                    } catch (\Throwable) {
192                        // Check next candidate
193                    }
194                }
195                if ($count === 0) {
196                    return sprintf(
197                        'Cannot assign "%s" to an organizational unit that has no active users.',
198                        $field->label
199                    );
200                }
201            }
202        }
203
204        return null;
205    }
206
207    /**
208     * Validates field value format against its UiType definition.
209     *
210     * @param FieldMetadata $field Field definition.
211     * @param mixed         $value Non-empty value to validate.
212     * @return string|null Error message or null if valid.
213     */
214    private function validateUiType(FieldMetadata $field, mixed $value): ?string
215    {
216        return match ($field->uitypeName) {
217            'email_input', 'email' => !filter_var($value, FILTER_VALIDATE_EMAIL)
218                ? sprintf('Field "%s" must be a valid email address.', $field->label)
219                : null,
220            'url_input', 'url', 'website' => (!filter_var($value, FILTER_VALIDATE_URL)
221                || !preg_match('#^https?://#i', (string) $value))
222                ? sprintf('Field "%s" must be a valid URL starting with http:// or https://.', $field->label)
223                : null,
224            'ip_address' => !filter_var($value, FILTER_VALIDATE_IP)
225                ? sprintf('Field "%s" must be a valid IPv4 or IPv6 address.', $field->label)
226                : null,
227            'integer_number', 'bigint_number' => (filter_var($value, FILTER_VALIDATE_INT) === false)
228                ? sprintf('Field "%s" must be an integer number.', $field->label)
229                : null,
230            'decimal_number', 'currency_amount' => !is_numeric($value)
231                ? sprintf('Field "%s" must be a valid number.', $field->label)
232                : null,
233            'boolean_toggle', 'boolean', 'toggle' => !in_array(
234                $value,
235                [0, 1, '0', '1', true, false, 'true', 'false'],
236                true
237            )
238                ? sprintf('Field "%s" must be a boolean value.', $field->label)
239                : null,
240            'json_display', 'json_array', 'fields_selector' => $this->validateJsonField($field, $value),
241            default => null,
242        };
243    }
244
245    /**
246     * Validates that field contains valid JSON string.
247     */
248    private function validateJsonField(FieldMetadata $field, mixed $value): ?string
249    {
250        if (is_string($value) && function_exists('json_validate') && !json_validate($value)) {
251            return sprintf('Field "%s" must be valid JSON.', $field->label);
252        }
253
254        return null;
255    }
256
257    /**
258     * Validates field value against validation_rules JSON configuration.
259     *
260     * @param FieldMetadata        $field Field definition with validation rules.
261     * @param mixed                $value Non-empty value to validate.
262     * @param array<string, mixed> $data  Full input data payload for cross-field validation.
263     * @return string|null Error message or null if valid.
264     */
265    private function validateRules(FieldMetadata $field, mixed $value, array $data): ?string
266    {
267        $rules = $field->validationRules;
268
269        $error = $this->validateMin($field, $value, $rules)
270            ?? $this->validateMax($field, $value, $rules)
271            ?? $this->validateFormatAndAllowed($field, $value, $rules);
272
273        return $error ?? $this->validateCrossFieldRules($field, $value, $data, $rules);
274    }
275
276    /**
277     * Validates cross-field relational rules (after_or_equal, max_duration_hours).
278     *
279     * @param FieldMetadata        $field Field definition.
280     * @param mixed                $value Current field value.
281     * @param array<string, mixed> $data  Full input payload.
282     * @param array<string, mixed> $rules Decoded validation rules.
283     * @return string|null Error message or null if valid.
284     */
285    private function validateCrossFieldRules(
286        FieldMetadata $field,
287        mixed         $value,
288        array         $data,
289        array         $rules
290    ): ?string {
291        $afterError = $this->validateAfterOrEqual($field, $value, $data, $rules);
292        if ($afterError !== null) {
293            return $afterError;
294        }
295
296        return $this->validateMaxDurationHours($field, $value, $data, $rules);
297    }
298
299    /**
300     * Validates that field date is after or equal to target field date.
301     *
302     * @param FieldMetadata        $field Field definition.
303     * @param mixed                $value Current field value.
304     * @param array<string, mixed> $data  Full input payload.
305     * @param array<string, mixed> $rules Decoded validation rules.
306     * @return string|null Error message or null if valid.
307     */
308    private function validateAfterOrEqual(
309        FieldMetadata $field,
310        mixed         $value,
311        array         $data,
312        array         $rules
313    ): ?string {
314        $targetKey = is_string($rules['after_or_equal'] ?? null) ? $rules['after_or_equal'] : null;
315        $targetVal = $targetKey !== null ? ($data[$targetKey] ?? null) : null;
316        if ($targetKey === null || $targetVal === null || $targetVal === '') {
317            return null;
318        }
319
320        $targetTime = strtotime((string) $targetVal);
321        $currTime   = strtotime((string) $value);
322
323        if ($targetTime !== false && $currTime !== false && $currTime < $targetTime) {
324            return sprintf('Field "%s" cannot be earlier than "%s".', $field->label, $targetKey);
325        }
326
327        return null;
328    }
329
330    /**
331     * Validates that duration between target field and current field does not exceed hours limit.
332     *
333     * @param FieldMetadata        $field Field definition.
334     * @param mixed                $value Current field value.
335     * @param array<string, mixed> $data  Full input payload.
336     * @param array<string, mixed> $rules Decoded validation rules.
337     * @return string|null Error message or null if valid.
338     */
339    private function validateMaxDurationHours(
340        FieldMetadata $field,
341        mixed         $value,
342        array         $data,
343        array         $rules
344    ): ?string {
345        $refKey = is_string($rules['after_or_equal'] ?? null) ? $rules['after_or_equal'] : 'start_date';
346        $refVal = $data[$refKey] ?? null;
347        if (!isset($rules['max_duration_hours'])
348            || !is_numeric($rules['max_duration_hours'])
349            || $refVal === null
350            || $refVal === ''
351        ) {
352            return null;
353        }
354
355        $startTime = strtotime((string) $refVal);
356        $endTime   = strtotime((string) $value);
357        $maxHours  = (float) $rules['max_duration_hours'];
358
359        if ($startTime !== false && $endTime !== false && ($endTime - $startTime) > ($maxHours * 3600)) {
360            return sprintf(
361                'Duration between "%s" and "%s" cannot exceed %s hours.',
362                $refKey,
363                $field->label,
364                (string) $maxHours
365            );
366        }
367
368        return null;
369    }
370
371    /**
372     * Validates min length / numeric range.
373     *
374     * @param FieldMetadata        $field Field metadata.
375     * @param mixed                $value Input value.
376     * @param array<string, mixed> $rules Rule config.
377     *
378     * @return string|null Error message or null.
379     */
380    private function validateMin(FieldMetadata $field, mixed $value, array $rules): ?string
381    {
382        if (isset($rules['min_length']) && mb_strlen((string) $value) < (int) $rules['min_length']) {
383            return sprintf(
384                'Field "%s" length must be at least %d characters.',
385                $field->label,
386                (int) $rules['min_length']
387            );
388        }
389        if (isset($rules['min'])) {
390            $numValue = is_numeric($value) ? (float) $value : mb_strlen((string) $value);
391            if ($numValue < (float) $rules['min']) {
392                return sprintf('Field "%s" must be at least %s.', $field->label, (string) $rules['min']);
393            }
394        }
395        return null;
396    }
397
398    /**
399     * Validates max length / numeric range.
400     *
401     * @param FieldMetadata        $field Field metadata.
402     * @param mixed                $value Input value.
403     * @param array<string, mixed> $rules Rule config.
404     *
405     * @return string|null Error message or null.
406     */
407    private function validateMax(FieldMetadata $field, mixed $value, array $rules): ?string
408    {
409        if (isset($rules['max_length']) && mb_strlen((string) $value) > (int) $rules['max_length']) {
410            return sprintf(
411                'Field "%s" length must not exceed %d characters.',
412                $field->label,
413                (int) $rules['max_length']
414            );
415        }
416        if (isset($rules['max'])) {
417            $numValue = is_numeric($value) ? (float) $value : mb_strlen((string) $value);
418            if ($numValue > (float) $rules['max']) {
419                return sprintf('Field "%s" must not exceed %s.', $field->label, (string) $rules['max']);
420            }
421        }
422        return null;
423    }
424
425    /**
426     * Validates regex format and allowed values enum.
427     *
428     * @param FieldMetadata        $field Field metadata.
429     * @param mixed                $value Input value.
430     * @param array<string, mixed> $rules Rule config.
431     *
432     * @return string|null Error message or null.
433     */
434    private function validateFormatAndAllowed(FieldMetadata $field, mixed $value, array $rules): ?string
435    {
436        if (isset($rules['regex']) && is_string($rules['regex']) && !preg_match($rules['regex'], (string)$value)) {
437            return sprintf('Field "%s" has an invalid format.', $field->label);
438        }
439
440        if (
441            isset($rules['allowed_values'])
442            && is_array($rules['allowed_values'])
443            && !in_array($value, $rules['allowed_values'], true)
444        ) {
445            return sprintf('Field "%s" contains an invalid value.', $field->label);
446        }
447
448        return null;
449    }
450
451    /**
452     * Checks whether a field value already exists in the table (unique constraint).
453     *
454     * @param FieldMetadata $field    Field metadata for the unique check.
455     * @param mixed         $value    Value to check for uniqueness.
456     * @param string        $table    Target database table name.
457     * @param int|null      $recordId Exclude this ID from the check (for updates).
458     * @return string|null Error message or null if unique.
459     */
460    private function checkUnique(
461        FieldMetadata $field,
462        mixed         $value,
463        string        $table,
464        ?int          $recordId
465    ): ?string {
466        $columnName = $field->fieldKey;
467        $sql = sprintf('SELECT COUNT(*) FROM `%s` WHERE `%s` = :value', $table, $columnName);
468        $params = [':value' => $value];
469
470        if ($recordId !== null) {
471            $sql .= ' AND `id` != :id';
472            $params[':id'] = $recordId;
473        }
474
475        $stmt = $this->pdo->prepare($sql);
476        $stmt->execute($params);
477        $count = (int) $stmt->fetchColumn();
478
479        if ($count > 0) {
480            return sprintf('Field "%s" value must be unique; this value already exists.', $field->label);
481        }
482
483        return null;
484    }
485}