Passed
Push — master ( b9e5a8...d13599 )
by Alexander
08:59
created

framework/behaviors/AttributeTypecastBehavior.php (1 issue)

Labels
Severity
1
<?php
2
/**
3
 * @link https://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license https://www.yiiframework.com/license/
6
 */
7
8
namespace yii\behaviors;
9
10
use yii\base\Behavior;
11
use yii\base\InvalidArgumentException;
12
use yii\base\Model;
13
use yii\db\BaseActiveRecord;
14
use yii\helpers\StringHelper;
15
use yii\validators\BooleanValidator;
16
use yii\validators\NumberValidator;
17
use yii\validators\StringValidator;
18
19
/**
20
 * AttributeTypecastBehavior provides an ability of automatic model attribute typecasting.
21
 * This behavior is very useful in case of usage of ActiveRecord for the schema-less databases like MongoDB or Redis.
22
 * It may also come in handy for regular [[\yii\db\ActiveRecord]] or even [[\yii\base\Model]], allowing to maintain
23
 * strict attribute types after model validation.
24
 *
25
 * This behavior should be attached to [[\yii\base\Model]] or [[\yii\db\BaseActiveRecord]] descendant.
26
 *
27
 * You should specify exact attribute types via [[attributeTypes]].
28
 *
29
 * For example:
30
 *
31
 * ```php
32
 * use yii\behaviors\AttributeTypecastBehavior;
33
 *
34
 * class Item extends \yii\db\ActiveRecord
35
 * {
36
 *     public function behaviors()
37
 *     {
38
 *         return [
39
 *             'typecast' => [
40
 *                 'class' => AttributeTypecastBehavior::class,
41
 *                 'attributeTypes' => [
42
 *                     'amount' => AttributeTypecastBehavior::TYPE_INTEGER,
43
 *                     'price' => AttributeTypecastBehavior::TYPE_FLOAT,
44
 *                     'is_active' => AttributeTypecastBehavior::TYPE_BOOLEAN,
45
 *                 ],
46
 *                 'typecastAfterValidate' => true,
47
 *                 'typecastBeforeSave' => false,
48
 *                 'typecastAfterFind' => false,
49
 *             ],
50
 *         ];
51
 *     }
52
 *
53
 *     // ...
54
 * }
55
 * ```
56
 *
57
 * Tip: you may left [[attributeTypes]] blank - in this case its value will be detected
58
 * automatically based on owner validation rules.
59
 * Following example will automatically create same [[attributeTypes]] value as it was configured at the above one:
60
 *
61
 * ```php
62
 * use yii\behaviors\AttributeTypecastBehavior;
63
 *
64
 * class Item extends \yii\db\ActiveRecord
65
 * {
66
 *
67
 *     public function rules()
68
 *     {
69
 *         return [
70
 *             ['amount', 'integer'],
71
 *             ['price', 'number'],
72
 *             ['is_active', 'boolean'],
73
 *         ];
74
 *     }
75
 *
76
 *     public function behaviors()
77
 *     {
78
 *         return [
79
 *             'typecast' => [
80
 *                 'class' => AttributeTypecastBehavior::class,
81
 *                 // 'attributeTypes' will be composed automatically according to `rules()`
82
 *             ],
83
 *         ];
84
 *     }
85
 *
86
 *     // ...
87
 * }
88
 * ```
89
 *
90
 * This behavior allows automatic attribute typecasting at following cases:
91
 *
92
 * - after successful model validation
93
 * - before model save (insert or update)
94
 * - after model find (found by query or refreshed)
95
 *
96
 * You may control automatic typecasting for particular case using fields [[typecastAfterValidate]],
97
 * [[typecastBeforeSave]] and [[typecastAfterFind]].
98
 * By default typecasting will be performed only after model validation.
99
 *
100
 * Note: you can manually trigger attribute typecasting anytime invoking [[typecastAttributes()]] method:
101
 *
102
 * ```php
103
 * $model = new Item();
104
 * $model->price = '38.5';
105
 * $model->is_active = 1;
106
 * $model->typecastAttributes();
107
 * ```
108
 *
109
 * @author Paul Klimov <[email protected]>
110
 * @since 2.0.10
111
 */
112
class AttributeTypecastBehavior extends Behavior
113
{
114
    const TYPE_INTEGER = 'integer';
115
    const TYPE_FLOAT = 'float';
116
    const TYPE_BOOLEAN = 'boolean';
117
    const TYPE_STRING = 'string';
118
119
    /**
120
     * @var Model|BaseActiveRecord the owner of this behavior.
121
     */
122
    public $owner;
123
    /**
124
     * @var array|null attribute typecast map in format: attributeName => type.
125
     * Type can be set via PHP callable, which accept raw value as an argument and should return
126
     * typecast result.
127
     * For example:
128
     *
129
     * ```php
130
     * [
131
     *     'amount' => 'integer',
132
     *     'price' => 'float',
133
     *     'is_active' => 'boolean',
134
     *     'date' => function ($value) {
135
     *         return ($value instanceof \DateTime) ? $value->getTimestamp(): (int) $value;
136
     *     },
137
     * ]
138
     * ```
139
     *
140
     * If not set, attribute type map will be composed automatically from the owner validation rules.
141
     */
142
    public $attributeTypes;
143
    /**
144
     * @var bool whether to skip typecasting of `null` values.
145
     * If enabled attribute value which equals to `null` will not be type-casted (e.g. `null` remains `null`),
146
     * otherwise it will be converted according to the type configured at [[attributeTypes]].
147
     */
148
    public $skipOnNull = true;
149
    /**
150
     * @var bool whether to perform typecasting after owner model validation.
151
     * Note that typecasting will be performed only if validation was successful, e.g.
152
     * owner model has no errors.
153
     * Note that changing this option value will have no effect after this behavior has been attached to the model.
154
     */
155
    public $typecastAfterValidate = true;
156
    /**
157
     * @var bool whether to perform typecasting before saving owner model (insert or update).
158
     * This option may be disabled in order to achieve better performance.
159
     * For example, in case of [[\yii\db\ActiveRecord]] usage, typecasting before save
160
     * will grant no benefit an thus can be disabled.
161
     * Note that changing this option value will have no effect after this behavior has been attached to the model.
162
     */
163
    public $typecastBeforeSave = false;
164
    /**
165
     * @var bool whether to perform typecasting after saving owner model (insert or update).
166
     * This option may be disabled in order to achieve better performance.
167
     * For example, in case of [[\yii\db\ActiveRecord]] usage, typecasting after save
168
     * will grant no benefit an thus can be disabled.
169
     * Note that changing this option value will have no effect after this behavior has been attached to the model.
170
     * @since 2.0.14
171
     */
172
    public $typecastAfterSave = false;
173
    /**
174
     * @var bool whether to perform typecasting after retrieving owner model data from
175
     * the database (after find or refresh).
176
     * This option may be disabled in order to achieve better performance.
177
     * For example, in case of [[\yii\db\ActiveRecord]] usage, typecasting after find
178
     * will grant no benefit in most cases an thus can be disabled.
179
     * Note that changing this option value will have no effect after this behavior has been attached to the model.
180
     */
181
    public $typecastAfterFind = false;
182
183
    /**
184
     * @var array internal static cache for auto detected [[attributeTypes]] values
185
     * in format: ownerClassName => attributeTypes
186
     */
187
    private static $autoDetectedAttributeTypes = [];
188
189
190
    /**
191
     * Clears internal static cache of auto detected [[attributeTypes]] values
192
     * over all affected owner classes.
193
     */
194 9
    public static function clearAutoDetectedAttributeTypes()
195
    {
196 9
        self::$autoDetectedAttributeTypes = [];
197 9
    }
198
199
    /**
200
     * {@inheritdoc}
201
     */
202 132
    public function attach($owner)
203
    {
204 132
        parent::attach($owner);
205
206 132
        if ($this->attributeTypes === null) {
207 1
            $ownerClass = get_class($this->owner);
208 1
            if (!isset(self::$autoDetectedAttributeTypes[$ownerClass])) {
209 1
                self::$autoDetectedAttributeTypes[$ownerClass] = $this->detectAttributeTypes();
210
            }
211 1
            $this->attributeTypes = self::$autoDetectedAttributeTypes[$ownerClass];
212
        }
213 132
    }
214
215
    /**
216
     * Typecast owner attributes according to [[attributeTypes]].
217
     * @param array|null $attributeNames list of attribute names that should be type-casted.
218
     * If this parameter is empty, it means any attribute listed in the [[attributeTypes]]
219
     * should be type-casted.
220
     */
221 125
    public function typecastAttributes($attributeNames = null)
222
    {
223 125
        $attributeTypes = [];
224
225 125
        if ($attributeNames === null) {
226 125
            $attributeTypes = $this->attributeTypes;
227
        } else {
228
            foreach ($attributeNames as $attribute) {
229
                if (!isset($this->attributeTypes[$attribute])) {
230
                    throw new InvalidArgumentException("There is no type mapping for '{$attribute}'.");
231
                }
232
                $attributeTypes[$attribute] = $this->attributeTypes[$attribute];
233
            }
234
        }
235
236 125
        foreach ($attributeTypes as $attribute => $type) {
237 125
            $value = $this->owner->{$attribute};
238 125
            if ($this->skipOnNull && $value === null) {
239 6
                continue;
240
            }
241 125
            $this->owner->{$attribute} = $this->typecastValue($value, $type);
242
        }
243 125
    }
244
245
    /**
246
     * Casts the given value to the specified type.
247
     * @param mixed $value value to be type-casted.
248
     * @param string|callable $type type name or typecast callable.
249
     * @return mixed typecast result.
250
     */
251 125
    protected function typecastValue($value, $type)
252
    {
253 125
        if (is_scalar($type)) {
254 121
            if (is_object($value) && method_exists($value, '__toString')) {
255
                $value = $value->__toString();
256
            }
257
258
            switch ($type) {
259 121
                case self::TYPE_INTEGER:
260 4
                    return (int) $value;
261 121
                case self::TYPE_FLOAT:
262 4
                    return (float) $value;
263 121
                case self::TYPE_BOOLEAN:
264 4
                    return (bool) $value;
265 121
                case self::TYPE_STRING:
266 121
                    if (is_float($value)) {
267
                        return StringHelper::floatToString($value);
268
                    }
269 121
                    return (string) $value;
270
                default:
271
                    throw new InvalidArgumentException("Unsupported type '{$type}'");
272
            }
273
        }
274
275 8
        return call_user_func($type, $value);
276
    }
277
278
    /**
279
     * Composes default value for [[attributeTypes]] from the owner validation rules.
280
     * @return array attribute type map.
281
     */
282 1
    protected function detectAttributeTypes()
283
    {
284 1
        $attributeTypes = [];
285 1
        foreach ($this->owner->getValidators() as $validator) {
286 1
            $type = null;
287 1
            if ($validator instanceof BooleanValidator) {
288 1
                $type = self::TYPE_BOOLEAN;
289 1
            } elseif ($validator instanceof NumberValidator) {
290 1
                $type = $validator->integerOnly ? self::TYPE_INTEGER : self::TYPE_FLOAT;
291 1
            } elseif ($validator instanceof StringValidator) {
292 1
                $type = self::TYPE_STRING;
293
            }
294
295 1
            if ($type !== null) {
296 1
                $attributeTypes += array_fill_keys($validator->getAttributeNames(), $type);
297
            }
298
        }
299
300 1
        return $attributeTypes;
301
    }
302
303
    /**
304
     * {@inheritdoc}
305
     */
306 132
    public function events()
307
    {
308 132
        $events = [];
309
310 132
        if ($this->typecastAfterValidate) {
311 9
            $events[Model::EVENT_AFTER_VALIDATE] = 'afterValidate';
312
        }
313 132
        if ($this->typecastBeforeSave) {
314 8
            $events[BaseActiveRecord::EVENT_BEFORE_INSERT] = 'beforeSave';
315 8
            $events[BaseActiveRecord::EVENT_BEFORE_UPDATE] = 'beforeSave';
316
        }
317 132
        if ($this->typecastAfterSave) {
318 1
            $events[BaseActiveRecord::EVENT_AFTER_INSERT] = 'afterSave';
319 1
            $events[BaseActiveRecord::EVENT_AFTER_UPDATE] = 'afterSave';
320
        }
321 132
        if ($this->typecastAfterFind) {
322 131
            $events[BaseActiveRecord::EVENT_AFTER_FIND] = 'afterFind';
323
        }
324
325 132
        return $events;
326
    }
327
328
    /**
329
     * Handles owner 'afterValidate' event, ensuring attribute typecasting.
330
     * @param \yii\base\Event $event event instance.
331
     */
332 2
    public function afterValidate($event)
333
    {
334 2
        if (!$this->owner->hasErrors()) {
335 2
            $this->typecastAttributes();
336
        }
337 2
    }
338
339
    /**
340
     * Handles owner 'beforeInsert' and 'beforeUpdate' events, ensuring attribute typecasting.
341
     * @param \yii\base\Event $event event instance.
342
     */
343 5
    public function beforeSave($event)
344
    {
345 5
        $this->typecastAttributes();
346 5
    }
347
348
    /**
349
     * Handles owner 'afterInsert' and 'afterUpdate' events, ensuring attribute typecasting.
350
     * @param \yii\base\Event $event event instance.
351
     * @since 2.0.14
352
     */
353 1
    public function afterSave($event)
354
    {
355 1
        $this->typecastAttributes();
356 1
    }
357
358
    /**
359
     * Handles owner 'afterFind' event, ensuring attribute typecasting.
360
     * @param \yii\base\Event $event event instance.
361
     */
362 120
    public function afterFind($event)
363
    {
364 120
        $this->typecastAttributes();
365
366 120
        $this->resetOldAttributes();
367 120
    }
368
369
    /**
370
     * Resets the old values of the named attributes.
371
     */
372 120
    protected function resetOldAttributes()
373
    {
374 120
        if ($this->attributeTypes === null) {
375
            return;
376
        }
377
378 120
        $attributes = array_keys($this->attributeTypes);
379
380 120
        foreach ($attributes as $attribute) {
381 120
            if ($this->owner->canSetOldAttribute($attribute)) {
0 ignored issues
show
The method canSetOldAttribute() does not exist on yii\base\Model. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

381
            if ($this->owner->/** @scrutinizer ignore-call */ canSetOldAttribute($attribute)) {
Loading history...
382 120
                $this->owner->setOldAttribute($attribute, $this->owner->{$attribute});
383
            }
384
        }
385 120
    }
386
}
387