Passed
Push — master ( 9dbdd9...d5a428 )
by Alexander
04:15
created

framework/behaviors/AttributeTypecastBehavior.php (1 issue)

1
<?php
2
/**
3
 * @link http://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license http://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 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 8
    public static function clearAutoDetectedAttributeTypes()
195
    {
196 8
        self::$autoDetectedAttributeTypes = [];
197 8
    }
198
199
    /**
200
     * {@inheritdoc}
201
     */
202 208
    public function attach($owner)
203
    {
204 208
        parent::attach($owner);
205
206 208
        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 208
    }
214
215
    /**
216
     * Typecast owner attributes according to [[attributeTypes]].
217
     * @param array $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 197
    public function typecastAttributes($attributeNames = null)
222
    {
223 197
        $attributeTypes = [];
224
225 197
        if ($attributeNames === null) {
226 197
            $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 197
        foreach ($attributeTypes as $attribute => $type) {
237 197
            $value = $this->owner->{$attribute};
238 197
            if ($this->skipOnNull && $value === null) {
239 6
                continue;
240
            }
241 197
            $this->owner->{$attribute} = $this->typecastValue($value, $type);
242
        }
243 197
    }
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 197
    protected function typecastValue($value, $type)
252
    {
253 197
        if (is_scalar($type)) {
254 193
            if (is_object($value) && method_exists($value, '__toString')) {
255
                $value = $value->__toString();
256
            }
257
258
            switch ($type) {
259 193
                case self::TYPE_INTEGER:
260 3
                    return (int) $value;
261 193
                case self::TYPE_FLOAT:
262 3
                    return (float) $value;
263 193
                case self::TYPE_BOOLEAN:
264 3
                    return (bool) $value;
265 193
                case self::TYPE_STRING:
266 193
                    if (is_float($value)) {
267
                        return StringHelper::floatToString($value);
268
                    }
269 193
                    return (string) $value;
270
                default:
271
                    throw new InvalidArgumentException("Unsupported type '{$type}'");
272
            }
273
        }
274
275 7
        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
                foreach ((array) $validator->attributes as $attribute) {
297 1
                    $attributeTypes[ltrim($attribute, '!')] = $type;
298
                }
299
            }
300
        }
301
302 1
        return $attributeTypes;
303
    }
304
305
    /**
306
     * {@inheritdoc}
307
     */
308 208
    public function events()
309
    {
310 208
        $events = [];
311
312 208
        if ($this->typecastAfterValidate) {
313 8
            $events[Model::EVENT_AFTER_VALIDATE] = 'afterValidate';
314
        }
315 208
        if ($this->typecastBeforeSave) {
316 7
            $events[BaseActiveRecord::EVENT_BEFORE_INSERT] = 'beforeSave';
317 7
            $events[BaseActiveRecord::EVENT_BEFORE_UPDATE] = 'beforeSave';
318
        }
319 208
        if ($this->typecastAfterSave) {
320 1
            $events[BaseActiveRecord::EVENT_AFTER_INSERT] = 'afterSave';
321 1
            $events[BaseActiveRecord::EVENT_AFTER_UPDATE] = 'afterSave';
322
        }
323 208
        if ($this->typecastAfterFind) {
324 207
            $events[BaseActiveRecord::EVENT_AFTER_FIND] = 'afterFind';
325
        }
326
327 208
        return $events;
328
    }
329
330
    /**
331
     * Handles owner 'afterValidate' event, ensuring attribute typecasting.
332
     * @param \yii\base\Event $event event instance.
333
     */
334 2
    public function afterValidate($event)
335
    {
336 2
        if (!$this->owner->hasErrors()) {
337 2
            $this->typecastAttributes();
338
        }
339 2
    }
340
341
    /**
342
     * Handles owner 'beforeInsert' and 'beforeUpdate' events, ensuring attribute typecasting.
343
     * @param \yii\base\Event $event event instance.
344
     */
345 4
    public function beforeSave($event)
346
    {
347 4
        $this->typecastAttributes();
348 4
    }
349
350
    /**
351
     * Handles owner 'afterInsert' and 'afterUpdate' events, ensuring attribute typecasting.
352
     * @param \yii\base\Event $event event instance.
353
     * @since 2.0.14
354
     */
355 1
    public function afterSave($event)
0 ignored issues
show
The parameter $event is not used and could be removed. ( Ignorable by Annotation )

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

355
    public function afterSave(/** @scrutinizer ignore-unused */ $event)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
356
    {
357 1
        $this->typecastAttributes();
358 1
    }
359
360
    /**
361
     * Handles owner 'afterFind' event, ensuring attribute typecasting.
362
     * @param \yii\base\Event $event event instance.
363
     */
364 192
    public function afterFind($event)
365
    {
366 192
        $this->typecastAttributes();
367 192
    }
368
}
369