Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.92% covered (success)
98.92%
184 / 186
95.45% covered (success)
95.45%
21 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
FieldMetadata
99.46% covered (success)
99.46%
184 / 185
95.45% covered (success)
95.45%
21 / 22
48
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
 hasRelation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isTitleField
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 getIconClass
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getFormDisplayMode
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 isHeroDisplay
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isHiddenInView
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 isVisibleInList
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isVisibleInDetail
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isVisibleInCreate
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 isExcludedCreateField
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 isVisibleInQuickCreate
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isVisibleInEdit
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 isBulkEditable
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getDisplayFormat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatDateTime
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 toArray
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
1
 isGlobalSearchable
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 jsonSerialize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fromRow
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withRelationTable
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
1
 withReadonly
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
1
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\Domain\Model;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\Support\FieldMetadataHydrator;
12use App\Core\Engine\Domain\Model\Support\FieldUiConfiguration;
13
14/**
15 * Field Metadata Value Object.
16 *
17 * Immutable value object representing a single field definition from
18 * a_core_field_records. Contains all engine-level information needed
19 * to render, validate, and transform a field value across all modules.
20 *
21 * @package App\Core\Engine\Domain\Model
22 */
23final readonly class FieldMetadata implements \JsonSerializable
24{
25    /** Form presentation display modes. */
26    public const string DISPLAY_MODE_ICON_LABEL_VALUE = 'icon_label_value';
27    public const string DISPLAY_MODE_VALUE_ONLY = 'value_only';
28    public const string DISPLAY_MODE_LABEL_VALUE = 'label_value';
29    public const string DISPLAY_MODE_STACKED_CARD = 'stacked_card';
30
31    /**
32     * FieldMetadata constructor.
33     *
34     * @param int         $id               Field primary key.
35     * @param int         $moduleId         FK to a_core_module_records.
36     * @param string      $fieldKey         Machine key used in PHP and JSON payloads.
37     * @param string      $columnExpression SQL column expression (e.g., 'c.name').
38     * @param string      $label            Human-readable display label.
39     * @param string      $uitypeName       UiType name (e.g., 'string_input').
40     * @param bool        $isSortable       Whether grid can sort by this field.
41     * @param bool        $isFilterable     Whether grid can filter by this field.
42     * @param bool        $isMandatory      Whether field is required on write.
43     * @param bool        $isUnique         Whether field value must be unique.
44     * @param bool        $isSystem         Whether field is hidden from UI.
45     * @param bool        $isReadonly       Whether field cannot be written via CRUD.
46     * @param int         $sortOrder        Display order position in grid/form.
47     * @param bool        $isSummary        Whether field is displayed on Kanban cards.
48     * @param bool        $isQuickCreate    Whether field is displayed in quick create mode.
49     * @param bool        $isGlobalSearch   Whether field is searchable in global search.
50     * @param int|null    $sectionId        FK to a_core_section_records.
51     * @param int|null    $picklistId       FK to a_core_picklist_records (if picklist-bound).
52     * @param string|null $relationModule   Target module name for entity references.
53     * @param string|null $relationTable    SQL target table for LEFT JOIN.
54     * @param string|null $relationKey      FK column on the target table.
55     * @param string|null $relationLabel    Label column on the target table.
56     * @param string|null $relationDisplay  Optional display expression (e.g., CONCAT).
57     * @param array<string, mixed> $validationRules Decoded JSON validation rules.
58     * @param array<string, string> $filterOptions  Decoded JSON key-label option map.
59     * @param string|null $defaultValue     Default value for new records.
60     * @param string|null $placeholder      UI placeholder hint text.
61     * @param string|null $hiddenViews      Comma-separated views where field is hidden (list,create,edit,detail).
62     */
63    public function __construct(
64        public readonly int     $id,
65        public readonly int     $moduleId,
66        public readonly string  $fieldKey,
67        public readonly string  $columnExpression,
68        public readonly string  $label,
69        public readonly string  $uitypeName,
70        public readonly bool    $isSortable,
71        public readonly bool    $isFilterable,
72        public readonly bool    $isMandatory,
73        public readonly bool    $isUnique,
74        public readonly bool    $isSystem,
75        public readonly bool    $isReadonly,
76        public readonly int     $sortOrder,
77        public readonly bool    $isSummary = false,
78        public readonly bool    $isQuickCreate = false,
79        public readonly bool    $isGlobalSearch = false,
80        public readonly ?int    $sectionId = null,
81        public readonly ?int    $picklistId = null,
82        public readonly ?string $relationModule = null,
83        public readonly ?string $relationTable = null,
84        public readonly ?string $relationKey = null,
85        public readonly ?string $relationLabel = null,
86        public readonly ?string $relationDisplay = null,
87        public readonly array   $validationRules = [],
88        public readonly array   $filterOptions = [],
89        public readonly ?string $defaultValue = null,
90        public readonly ?string $placeholder = null,
91        public readonly ?string $hiddenViews = null,
92    ) {
93    }
94
95    /**
96     * Checks whether this field is a relational type with a JOIN target.
97     *
98     * @return bool True if field has an entity reference with a relation table.
99     */
100    public function hasRelation(): bool
101    {
102        return $this->relationTable !== null && $this->relationKey !== null;
103    }
104
105    /**
106     * Checks whether this field acts as a primary title/link in grids and breadcrumbs.
107     *
108     * @return bool True if field is configured as a link or matches title heuristics.
109     */
110    public function isTitleField(): bool
111    {
112        if (isset($this->validationRules['is_link'])) {
113            return (bool)$this->validationRules['is_link'];
114        }
115
116        if ($this->isSystem) {
117            return false;
118        }
119
120        return in_array($this->fieldKey, FieldUiConfiguration::TITLE_FIELD_KEYS, true)
121            || str_ends_with($this->fieldKey, '_name')
122            || str_ends_with($this->fieldKey, '_title');
123    }
124
125    /**
126     * Returns a Bootstrap Icon class corresponding to the field UiType or key.
127     *
128     * @return string CSS icon class (e.g. 'bi bi-type').
129     */
130    public function getIconClass(): string
131    {
132        return (new FieldUiConfiguration())->resolveIconClass(
133            $this->fieldKey,
134            $this->uitypeName,
135            $this->validationRules
136        );
137    }
138
139    /**
140     * Resolves the form presentation display mode for this field.
141     *
142     * @return string One of 'icon_label_value', 'value_only', 'label_value', 'stacked_card'.
143     */
144    public function getFormDisplayMode(): string
145    {
146        return (new FieldUiConfiguration())->resolveFormDisplayMode(
147            $this->validationRules
148        );
149    }
150
151    /**
152     * Checks whether this field is configured to render as a prominent Hero Title (Value Only).
153     *
154     * @return bool True if field should render in value_only hero mode.
155     */
156    public function isHeroDisplay(): bool
157    {
158        return $this->getFormDisplayMode() === self::DISPLAY_MODE_VALUE_ONLY;
159    }
160
161    /**
162     * Checks whether this field is marked as hidden in a specific view mode.
163     *
164     * @param string $view View mode to check ('list', 'create', 'edit', 'detail').
165     * @return bool True if field is configured to be hidden in the given view.
166     */
167    public function isHiddenInView(string $view): bool
168    {
169        if ($this->hiddenViews === null || $this->hiddenViews === '') {
170            return false;
171        }
172
173        $views = str_contains($this->hiddenViews, '[')
174            ? FieldMetadataHydrator::decodeJsonArray($this->hiddenViews)
175            : array_map('trim', explode(',', $this->hiddenViews));
176
177        return in_array(strtolower($view), array_map('strtolower', $views), true);
178    }
179
180    /**
181     * Checks if field should be visible in list/grid views.
182     *
183     * @return bool True if visible in list view.
184     */
185    public function isVisibleInList(): bool
186    {
187        return !$this->isHiddenInView('list');
188    }
189
190    /**
191     * Checks if field should be visible in detail view.
192     *
193     * @return bool True if visible in detail view.
194     */
195    public function isVisibleInDetail(): bool
196    {
197        if ($this->isHiddenInView('detail')) {
198            return false;
199        }
200
201        if (isset($this->validationRules['is_visible_detail'])) {
202            return (bool)$this->validationRules['is_visible_detail'];
203        }
204
205        return true;
206    }
207
208    /**
209     * Checks if field should be visible in create form.
210     *
211     * @return bool True if visible in create form.
212     */
213    public function isVisibleInCreate(): bool
214    {
215        if (
216            $this->isHiddenInView('create')
217            || ($this->isReadonly && $this->isSystem)
218            || $this->isExcludedCreateField()
219        ) {
220            return false;
221        }
222
223        return isset($this->validationRules['is_visible_create'])
224            ? (bool) $this->validationRules['is_visible_create']
225            : true;
226    }
227
228    /**
229     * Checks if field key or uitype is excluded from create form.
230     */
231    private function isExcludedCreateField(): bool
232    {
233        return in_array($this->uitypeName, ['favorite', 'pin', 'pinned', 'autonumber'], true)
234            || in_array($this->fieldKey, [
235                'id',
236                'created_at',
237                'updated_at',
238                'created_by',
239                'c_uid',
240                'c_etag',
241                'c_name',
242                'sync_status',
243                'last_sync_at',
244                'total_runs',
245                'last_run_at',
246                'record_status',
247                'special_access',
248                'sequence',
249                'formatted_name',
250                'is_favorite',
251                'is_pinned',
252            ], true);
253    }
254
255    /**
256     * Checks if field should be visible in quick create form.
257     *
258     * @return bool True if visible in quick create form.
259     */
260    public function isVisibleInQuickCreate(): bool
261    {
262        if (!$this->isVisibleInCreate()) {
263            return false;
264        }
265
266        if (isset($this->validationRules['is_visible_quick_create'])) {
267            return (bool)$this->validationRules['is_visible_quick_create'];
268        }
269
270        return $this->isQuickCreate;
271    }
272
273    /**
274     * Checks if field should be visible in edit form.
275     *
276     * @return bool True if visible in edit form.
277     */
278    public function isVisibleInEdit(): bool
279    {
280        if (
281            $this->isHiddenInView('edit')
282            || in_array($this->uitypeName, ['favorite', 'pin', 'pinned'], true)
283            || in_array($this->fieldKey, ['is_favorite', 'is_pinned'], true)
284        ) {
285            return false;
286        }
287
288        return isset($this->validationRules['is_visible_edit'])
289            ? (bool) $this->validationRules['is_visible_edit']
290            : true;
291    }
292
293    /**
294     * Determines whether this field is eligible for mass/bulk update actions.
295     *
296     * @return bool True if field can be modified in bulk operations.
297     */
298    public function isBulkEditable(): bool
299    {
300        if ($this->isReadonly || $this->isSystem) {
301            return false;
302        }
303
304        $nonEditableKeys = ['id', 'created_at', 'updated_at', 'created_by', 'updated_by', 'conditions'];
305        if (in_array($this->fieldKey, $nonEditableKeys, true)) {
306            return false;
307        }
308
309        $nonEditableUiTypes = ['autonumber', 'prefix_number', 'filter_conditions', 'json_display'];
310        return !in_array($this->uitypeName, $nonEditableUiTypes, true);
311    }
312
313    /**
314     * Returns the configured display format for date/time fields.
315     *
316     * @return string Configured display format (e.g. 'datetime_seconds', 'date_only', 'relative').
317     */
318    public function getDisplayFormat(): string
319    {
320        return (new FieldUiConfiguration())->resolveDisplayFormat($this->uitypeName, $this->validationRules);
321    }
322
323    /**
324     * Formats a date/time value according to this field's display format configuration.
325     *
326     * @param mixed  $value   Raw database or timestamp value.
327     * @param string $locale  Target locale ('pl' or 'en').
328     * @param bool   $wrapTag Whether to wrap relative output in HTML <time> tag.
329     * @return string Formatted date/time string.
330     */
331    public function formatDateTime(mixed $value, string $locale = 'pl', bool $wrapTag = false): string
332    {
333        return \App\Core\Engine\Application\Formatter\DateTimeFormatter::format(
334            $value,
335            $this->getDisplayFormat(),
336            $locale,
337            $wrapTag
338        );
339    }
340
341    /**
342     * Serializes FieldMetadata to associative array for JSON payloads.
343     *
344     * @return array<string, mixed>
345     */
346    public function toArray(): array
347    {
348        return [
349            'id'                => $this->id,
350            'module_id'         => $this->moduleId,
351            'field_key'         => $this->fieldKey,
352            'column_expression' => $this->columnExpression,
353            'label'             => $this->label,
354            'uitype_name'       => $this->uitypeName,
355            'is_sortable'       => $this->isSortable,
356            'is_filterable'     => $this->isFilterable,
357            'is_mandatory'      => $this->isMandatory,
358            'is_unique'         => $this->isUnique,
359            'is_system'         => $this->isSystem,
360            'is_readonly'       => $this->isReadonly,
361            'is_summary'        => $this->isSummary,
362            'is_quick_create'   => $this->isQuickCreate,
363            'is_global_search'  => $this->isGlobalSearch,
364            'sort_order'        => $this->sortOrder,
365            'section_id'        => $this->sectionId,
366            'picklist_id'       => $this->picklistId,
367            'relation_module'   => $this->relationModule,
368            'relation_table'    => $this->relationTable,
369            'relation_key'      => $this->relationKey,
370            'relation_label'    => $this->relationLabel,
371            'relation_display'  => $this->relationDisplay,
372            'validation_rules'  => $this->validationRules,
373            'filter_options'    => $this->filterOptions,
374            'default_value'     => $this->defaultValue,
375            'placeholder'       => $this->placeholder,
376            'hidden_views'        => $this->hiddenViews,
377            'is_hidden_in_create' => $this->isHiddenInView('create'),
378            'is_hidden_in_edit'   => $this->isHiddenInView('edit'),
379            'is_hidden_in_detail' => $this->isHiddenInView('detail'),
380            'is_hidden_in_list'   => $this->isHiddenInView('list'),
381            'is_link'             => $this->isTitleField(),
382            'form_display_mode'   => $this->getFormDisplayMode(),
383            'is_hero_display'     => $this->isHeroDisplay(),
384        ];
385    }
386
387    /**
388     * Checks whether this field is marked for global search across modules.
389     *
390     * @return bool True if field is included in global search.
391     */
392    public function isGlobalSearchable(): bool
393    {
394        if ($this->isSystem) {
395            return false;
396        }
397
398        return $this->isGlobalSearch;
399    }
400
401    /**
402     * JsonSerializable implementation.
403     *
404     * @return array<string, mixed>
405     */
406    public function jsonSerialize(): array
407    {
408        return $this->toArray();
409    }
410
411    /**
412     * Creates a FieldMetadata instance from a raw database row array.
413     *
414     * @param array<string, mixed> $row Raw database row from a_core_field_records.
415     * @return self Hydrated FieldMetadata value object.
416     */
417    public static function fromRow(array $row): self
418    {
419        return (new FieldMetadataHydrator())->hydrateFromRow($row);
420    }
421
422    /**
423     * Clones the field metadata with a different relation table name.
424     *
425     * @param string $relationTable New target relation table name.
426     * @return self New immutable instance with the updated relation table.
427     */
428    public function withRelationTable(string $relationTable): self
429    {
430        return new self(
431            $this->id,
432            $this->moduleId,
433            $this->fieldKey,
434            $this->columnExpression,
435            $this->label,
436            $this->uitypeName,
437            $this->isSortable,
438            $this->isFilterable,
439            $this->isMandatory,
440            $this->isUnique,
441            $this->isSystem,
442            $this->isReadonly,
443            $this->sortOrder,
444            $this->isSummary,
445            $this->isQuickCreate,
446            $this->isGlobalSearch,
447            $this->sectionId,
448            $this->picklistId,
449            $this->relationModule,
450            $relationTable,
451            $this->relationKey,
452            $this->relationLabel,
453            $this->relationDisplay,
454            $this->validationRules,
455            $this->filterOptions,
456            $this->defaultValue,
457            $this->placeholder,
458            $this->hiddenViews,
459        );
460    }
461
462    /**
463     * Clones the field metadata with an overridden isReadonly flag.
464     *
465     * @param bool $isReadonly Whether the field should be treated as read-only.
466     * @return self New immutable instance with the updated read-only flag.
467     */
468    public function withReadonly(bool $isReadonly): self
469    {
470        return new self(
471            $this->id,
472            $this->moduleId,
473            $this->fieldKey,
474            $this->columnExpression,
475            $this->label,
476            $this->uitypeName,
477            $this->isSortable,
478            $this->isFilterable,
479            $this->isMandatory,
480            $this->isUnique,
481            $this->isSystem,
482            $isReadonly,
483            $this->sortOrder,
484            $this->isSummary,
485            $this->isQuickCreate,
486            $this->isGlobalSearch,
487            $this->sectionId,
488            $this->picklistId,
489            $this->relationModule,
490            $this->relationTable,
491            $this->relationKey,
492            $this->relationLabel,
493            $this->relationDisplay,
494            $this->validationRules,
495            $this->filterOptions,
496            $this->defaultValue,
497            $this->placeholder,
498            $this->hiddenViews,
499        );
500    }
501}
502