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