Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.47% covered (success)
92.47%
86 / 93
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
HasValidation
92.47% covered (success)
92.47%
86 / 93
75.00% covered (warning)
75.00%
6 / 8
50.02
0.00% covered (danger)
0.00%
0 / 1
 bootHasValidation
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
5
 initializeHasValidation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setupRules
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
12
 buildForeignKeyRules
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getRules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepareAttributesForValidation
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
6.97
 getUsersTable
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
8.43
 autoPopulateFields
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
14
1<?php
2
3declare(strict_types=1);
4
5namespace DevToolbelt\LaravelEloquentPlus\Concerns;
6
7use DevToolbelt\LaravelEloquentPlus\Exceptions\ValidationException;
8use Illuminate\Support\Facades\Validator;
9use Illuminate\Support\Str;
10use ReflectionClass;
11use ReflectionException;
12use Throwable;
13
14/**
15 * Trait for automatic model validation based on defined rules.
16 *
17 * Provides validation functionality that runs before save operations.
18 * Rules can be defined in the model and are automatically merged with
19 * default rules for common columns.
20 *
21 * @package DevToolbelt\LaravelEloquentPlus\Concerns
22 */
23trait HasValidation
24{
25    /**
26     * The validation rules for the model attributes.
27     *
28     * Rules are automatically merged with default rules for common columns
29     * (primary key, timestamps, audit columns) during initialization.
30     *
31     * @var array<string, array<int, mixed>|string>
32     */
33    protected array $rules = [];
34
35    /**
36     * Boot the HasValidation trait.
37     *
38     * Registers model event listeners to validate attributes
39     * before creating and updating operations.
40     *
41     * @return void
42     * @throws ValidationException|Throwable
43     */
44    protected static function bootHasValidation(): void
45    {
46        $validateCallback = static function (self $model): void {
47            $model->autoPopulateFields();
48            $model->beforeValidate();
49
50            if (empty($model->rules)) {
51                return;
52            }
53
54            $attributes = $model->prepareAttributesForValidation();
55            $validator = Validator::make($attributes, $model->rules);
56
57            if ($validator->passes()) {
58                return;
59            }
60
61            $errors = [];
62            $messages = $validator->errors()->toArray();
63
64            foreach ($validator->failed() as $field => $failedRules) {
65                $i = 0;
66                $fieldMessages = $messages[$field] ?? [];
67
68                foreach ($failedRules as $ruleName => $params) {
69                    $errors[] = [
70                        'field' => $field,
71                        'error' => strtolower($ruleName),
72                        'value' => $attributes[$field] ?? null,
73                        'message' => $fieldMessages[$i] ?? null,
74                    ];
75                    $i++;
76                }
77            }
78
79            throw new ValidationException($errors);
80        };
81
82        static::creating($validateCallback);
83        static::updating($validateCallback);
84    }
85
86    /**
87     * Initialize the HasValidation trait.
88     *
89     * @return void
90     */
91    protected function initializeHasValidation(): void
92    {
93        $this->setupRules();
94    }
95
96    /**
97     * Set up default validation rules based on model attributes.
98     *
99     * Automatically adds validation rules for common columns if they exist:
100     * - Primary key: nullable integer
101     * - external_id: required UUID string with 36 characters
102     * - Timestamp columns: date validation
103     * - Audit columns (created_by, updated_by, deleted_by): integer/uuid with exists rule
104     *
105     * Custom rules defined in the model are merged after default rules,
106     * allowing them to override defaults.
107     *
108     * @return void
109     */
110    private function setupRules(): void
111    {
112        $usersTable = $this->getUsersTable();
113        $defaultRules = [];
114
115        if ($this->hasAttribute($this->primaryKey)) {
116            $defaultRules[$this->primaryKey] = ['nullable', 'integer'];
117        }
118
119        if ($this->usesExternalId()) {
120            $column = $this->getExternalIdColumn();
121            $defaultRules[$column] = ['required', 'uuid', 'string', 'size:36'];
122        }
123
124        if ($this->hasAttribute(self::CREATED_AT)) {
125            $defaultRules[self::CREATED_AT] = ['required', 'date'];
126        }
127
128        if ($this->usesBlamable() && $this->hasAttribute(self::CREATED_BY)) {
129            $defaultRules[self::CREATED_BY] = $this->buildForeignKeyRules($usersTable, false);
130        }
131
132        if ($this->hasAttribute(self::UPDATED_AT)) {
133            $defaultRules[self::UPDATED_AT] = ['nullable', 'date'];
134        }
135
136        if ($this->usesBlamable() && $this->hasAttribute(self::UPDATED_BY)) {
137            $defaultRules[self::UPDATED_BY] = $this->buildForeignKeyRules($usersTable, false);
138        }
139
140        if ($this->hasAttribute(self::DELETED_AT)) {
141            $defaultRules[self::DELETED_AT] = ['nullable', 'date'];
142        }
143
144        if ($this->usesBlamable() && $this->hasAttribute(self::DELETED_BY)) {
145            $defaultRules[self::DELETED_BY] = $this->buildForeignKeyRules($usersTable, false);
146        }
147
148        $this->rules = [...$defaultRules, ...$this->rules];
149    }
150
151    /**
152     * Build validation rules for a foreign key field.
153     *
154     * The field type is determined by the 'eloquent-plus.blamable_field_type' config:
155     * - 'integer' (default): validates as integer with exists check
156     * - 'string': validates as string without exists check
157     *
158     * @param string $table The related table name
159     * @param bool $required Whether the field is required
160     * @return array<int, string>
161     */
162    private function buildForeignKeyRules(string $table, bool $required = true): array
163    {
164        $requiredRule = $required ? 'required' : 'nullable';
165        $fieldType = config('devToolbelt.eloquent-plus.blamable_field_type', 'integer');
166
167        if ($fieldType === 'string') {
168            return [$requiredRule, 'string'];
169        }
170
171        return [$requiredRule, 'integer', "exists:{$table},{$this->primaryKey}"];
172    }
173
174    /**
175     * Get the validation rules for the model.
176     *
177     * @return array<string, array<int, mixed>|string>
178     */
179    public function getRules(): array
180    {
181        return $this->rules;
182    }
183
184    /**
185     * Prepare attributes for validation.
186     *
187     * Uses raw attributes by default to ensure proper validation of types like boolean.
188     * For array fields, decodes JSON strings to arrays since they are stored as JSON internally.
189     *
190     * @return array<string, mixed>
191     */
192    protected function prepareAttributesForValidation(): array
193    {
194        $rawAttributes = $this->getAttributes();
195        $attributes = [];
196
197        foreach ($rawAttributes as $key => $value) {
198            $fieldRules = $this->rules[$key] ?? [];
199
200            // For array rules with JSON string values, decode to array
201            if (is_array($fieldRules) && in_array('array', $fieldRules) && is_string($value)) {
202                $decoded = json_decode($value, true);
203                $attributes[$key] = json_last_error() === JSON_ERROR_NONE ? $decoded : $value;
204                continue;
205            }
206
207            $attributes[$key] = $value;
208        }
209
210        return $attributes;
211    }
212
213    /**
214     * Get the users table name from Laravel Auth configuration.
215     *
216     * Attempts to resolve the table name from the configured User model.
217     * Falls back to 'users' if the model is not configured or doesn't exist.
218     *
219     * @return string
220     */
221    protected function getUsersTable(): string
222    {
223        static $tableName = null;
224
225        if ($tableName !== null) {
226            return $tableName;
227        }
228
229        $userModel = config('auth.providers.users.model');
230
231        if ($userModel === null || !class_exists($userModel)) {
232            return 'users';
233        }
234
235        try {
236            $reflection = new ReflectionClass($userModel);
237            $property = $reflection->getProperty('table');
238
239            if ($property->hasDefaultValue() && !is_null($property->getDefaultValue())) {
240                return $property->getDefaultValue();
241            }
242        } catch (ReflectionException) {
243        }
244
245        $className = class_basename($userModel);
246
247        return $tableName = Str::snake(Str::pluralStudly($className));
248    }
249
250    /**
251     * Autopopulate fields before validation.
252     *
253     * Automatically populates required fields that have automatic mechanisms:
254     * - created_at: Set to current timestamp if timestamps are enabled
255     * - created_by: Set to authenticated user ID if available
256     * - updated_at: Set to current timestamp if timestamps are enabled
257     * - updated_by: Set to authenticated user ID if available
258     *
259     * @return void
260     * @throws ValidationException
261     * @throws Throwable
262     */
263    protected function autoPopulateFields(): void
264    {
265        $now = $this->freshTimestamp();
266
267        // Autopopulate created_at if not set and model is new
268        if ($this->timestamps && !$this->exists && empty($this->getAttribute(static::CREATED_AT))) {
269            $this->setAttribute(static::CREATED_AT, $now);
270        }
271
272        // Autopopulate updated_at if not set
273        if ($this->timestamps && empty($this->getAttribute(static::UPDATED_AT))) {
274            $this->setAttribute(static::UPDATED_AT, $now);
275        }
276
277        // Autopopulate created_by if not set, model is new, blamable enabled, and column exists
278        if ($this->usesBlamable()) {
279            $createdByColumn = $this->getCreatedByColumn();
280            $isCreatedByEmpty = empty($this->getAttribute($createdByColumn));
281
282            if ($this->hasAttribute($createdByColumn) && !$this->exists && $isCreatedByEmpty) {
283                $userId = $this->getBlamableUserId();
284                if ($userId !== null) {
285                    $this->setAttribute($createdByColumn, $userId);
286                }
287            }
288
289            // Autopopulate updated_by if not set and column exists
290            $updatedByColumn = $this->getUpdatedByColumn();
291            if ($this->hasAttribute($updatedByColumn) && empty($this->getAttribute($updatedByColumn))) {
292                $userId = $this->getBlamableUserId();
293                if ($userId !== null) {
294                    $this->setAttribute($updatedByColumn, $userId);
295                }
296            }
297        }
298    }
299}