Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.
Common duplication problems, and corresponding solutions are:
Complex classes like MultilingualBehavior often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use MultilingualBehavior, and based on these observations, apply Extract Interface, too.
1 | <?php |
||
13 | class MultilingualBehavior extends Behavior |
||
14 | { |
||
15 | /** |
||
16 | * Multilingual attributes |
||
17 | * @var array |
||
18 | */ |
||
19 | public $attributes; |
||
20 | |||
21 | /** |
||
22 | * Available languages |
||
23 | * It can be a simple array: array('fr', 'en') or an associative array: array('fr' => 'Français', 'en' => 'English') |
||
24 | * For associative arrays, only the keys will be used. |
||
25 | * @var array |
||
26 | */ |
||
27 | public $languages; |
||
28 | |||
29 | /** |
||
30 | * @var string the default language. |
||
31 | * Example: 'en'. |
||
32 | */ |
||
33 | public $defaultLanguage; |
||
34 | |||
35 | /** |
||
36 | * @var string the name of the translation table |
||
37 | */ |
||
38 | public $tableName; |
||
39 | |||
40 | /** |
||
41 | * @var string the name of translation model class. |
||
42 | */ |
||
43 | public $langClassName; |
||
44 | |||
45 | /** |
||
46 | * @var string the name of the foreign key field of the translation table related to base model table. |
||
47 | */ |
||
48 | public $langForeignKey; |
||
49 | |||
50 | /** |
||
51 | * @var string the prefix of the localized attributes in the lang table. Here to avoid collisions in queries. |
||
52 | * In the translation table, the columns corresponding to the localized attributes have to be name like this: 'l_[name of the attribute]' |
||
53 | * and the id column (primary key) like this : 'l_id' |
||
54 | * Default to ''. |
||
55 | */ |
||
56 | public $localizedPrefix = ''; |
||
57 | |||
58 | /** |
||
59 | * @var string the name of the lang field of the translation table. Default to 'language'. |
||
60 | */ |
||
61 | public $languageField = 'language'; |
||
62 | |||
63 | /** |
||
64 | * @var boolean if this property is set to true required validators will be applied to all translation models. |
||
65 | * Default to false. |
||
66 | */ |
||
67 | public $requireTranslations = false; |
||
68 | |||
69 | /** |
||
70 | * @var boolean whether to force deletion of the associated translations when a base model is deleted. |
||
71 | * Not needed if using foreign key with 'on delete cascade'. |
||
72 | * Default to true. |
||
73 | */ |
||
74 | public $forceDelete = true; |
||
75 | |||
76 | /** |
||
77 | * @var boolean whether to dynamically create translation model class. |
||
78 | * If true, the translation model class will be generated on runtime with the use of the eval() function so no additional php file is needed. |
||
79 | * See {@link createLangClass()} |
||
80 | * Default to true. |
||
81 | */ |
||
82 | public $dynamicLangClass = true; |
||
83 | |||
84 | /** |
||
85 | * @var boolean whether to abridge the language ID. |
||
86 | * Default to true. |
||
87 | */ |
||
88 | public $abridge = true; |
||
89 | |||
90 | /** |
||
91 | * @var string the name of the primary key field of the base model. Defaults to first value of Model::primaryKey. |
||
92 | */ |
||
93 | public $ownerPrimaryKey; |
||
94 | |||
95 | /** |
||
96 | * @var boolean whether to check for existing translations on insert |
||
97 | * Default to false |
||
98 | */ |
||
99 | public $loadTranslationsOnInsert = false; |
||
100 | |||
101 | private $currentLanguage; |
||
102 | private $ownerClassName; |
||
103 | private $langClassShortName; |
||
104 | private $ownerClassShortName; |
||
105 | private $langAttributes = []; |
||
106 | |||
107 | /** |
||
108 | * @var array excluded validators |
||
109 | */ |
||
110 | private $excludedValidators = ['unique']; |
||
111 | |||
112 | /** |
||
113 | * @inheritdoc |
||
114 | */ |
||
115 | 19 | public function events() |
|
116 | { |
||
117 | return [ |
||
118 | 19 | ActiveRecord::EVENT_AFTER_FIND => 'afterFind', |
|
119 | 19 | ActiveRecord::EVENT_AFTER_UPDATE => 'afterUpdate', |
|
120 | 19 | ActiveRecord::EVENT_AFTER_INSERT => 'afterInsert', |
|
121 | 19 | ActiveRecord::EVENT_AFTER_DELETE => 'afterDelete', |
|
122 | 19 | ActiveRecord::EVENT_BEFORE_VALIDATE => 'beforeValidate', |
|
123 | 19 | ]; |
|
124 | } |
||
125 | |||
126 | /** |
||
127 | * @inheritdoc |
||
128 | */ |
||
129 | 19 | public function attach($owner) |
|
130 | { |
||
131 | /** @var ActiveRecord $owner */ |
||
132 | 19 | parent::attach($owner); |
|
133 | |||
134 | 19 | View Code Duplication | if (empty($this->languages) || !is_array($this->languages)) { |
135 | 1 | throw new InvalidConfigException('Please specify array of available languages for the ' . get_class($this) . ' in the ' |
|
136 | 1 | . get_class($this->owner) . ' or in the application parameters', 101); |
|
137 | } |
||
138 | |||
139 | 19 | if (array_values($this->languages) !== $this->languages) { //associative array |
|
140 | 19 | $this->languages = array_keys($this->languages); |
|
141 | 19 | } |
|
142 | |||
143 | 19 | $this->languages = array_unique(array_map(function ($language) { |
|
144 | 19 | return $this->getLanguageBaseName($language); |
|
145 | 19 | }, $this->languages)); |
|
146 | |||
147 | 19 | if (!$this->defaultLanguage) { |
|
148 | 1 | $this->defaultLanguage = isset(Yii::$app->params['defaultLanguage']) && Yii::$app->params['defaultLanguage'] ? |
|
149 | 1 | Yii::$app->params['defaultLanguage'] : Yii::$app->language; |
|
150 | 1 | } |
|
151 | |||
152 | 19 | $this->defaultLanguage = $this->getLanguageBaseName($this->defaultLanguage); |
|
153 | |||
154 | 19 | if (!$this->currentLanguage) { |
|
155 | 19 | $this->currentLanguage = $this->getLanguageBaseName(Yii::$app->language); |
|
156 | 19 | } |
|
157 | |||
158 | 19 | View Code Duplication | if (empty($this->attributes) || !is_array($this->attributes)) { |
159 | 1 | throw new InvalidConfigException('Please specify multilingual attributes for the ' . get_class($this) . ' in the ' |
|
160 | 1 | . get_class($this->owner), 103); |
|
161 | } |
||
162 | |||
163 | 19 | if (!$this->langClassName) { |
|
164 | 19 | $this->langClassName = get_class($this->owner) . 'Lang'; |
|
165 | 19 | } |
|
166 | |||
167 | 19 | $this->langClassShortName = $this->getShortClassName($this->langClassName); |
|
168 | 19 | $this->ownerClassName = get_class($this->owner); |
|
169 | 19 | $this->ownerClassShortName = $this->getShortClassName($this->ownerClassName); |
|
170 | |||
171 | /** @var ActiveRecord $className */ |
||
172 | 19 | $className = $this->ownerClassName; |
|
173 | 19 | if (!isset($this->ownerPrimaryKey)) { |
|
174 | 19 | $this->ownerPrimaryKey = $className::primaryKey()[0]; |
|
175 | 19 | } |
|
176 | |||
177 | 19 | if (!isset($this->langForeignKey)) { |
|
178 | 1 | throw new InvalidConfigException('Please specify langForeignKey for the ' . get_class($this) . ' in the ' |
|
179 | 1 | . get_class($this->owner), 105); |
|
180 | } |
||
181 | |||
182 | 19 | $rules = $owner->rules(); |
|
183 | 19 | $validators = $owner->getValidators(); |
|
184 | |||
185 | 19 | foreach ($rules as $rule) { |
|
186 | 19 | if (in_array($rule[1], $this->excludedValidators)) |
|
187 | 19 | continue; |
|
188 | |||
189 | 19 | $rule_attributes = is_array($rule[0]) ? $rule[0] : [$rule[0]]; |
|
190 | 19 | $attributes = array_intersect($this->attributes, $rule_attributes); |
|
191 | |||
192 | 19 | if (empty($attributes)) |
|
193 | 19 | continue; |
|
194 | |||
195 | 19 | $rule_attributes = []; |
|
196 | 19 | foreach ($attributes as $key => $attribute) { |
|
197 | 19 | foreach ($this->languages as $language) |
|
198 | 19 | if ($language != $this->defaultLanguage) |
|
199 | 19 | $rule_attributes[] = $this->getAttributeName($attribute, $language); |
|
200 | 19 | } |
|
201 | |||
202 | 19 | if (isset($rule['skipOnEmpty']) && !$rule['skipOnEmpty']) |
|
203 | 19 | $rule['skipOnEmpty'] = !$this->requireTranslations; |
|
204 | |||
205 | 19 | $params = array_slice($rule, 2); |
|
206 | |||
207 | 19 | if ($rule[1] !== 'required' || $this->requireTranslations) { |
|
208 | 19 | $validators[] = Validator::createValidator($rule[1], $owner, $rule_attributes, $params); |
|
209 | 19 | } elseif ($rule[1] === 'required') { |
|
210 | 18 | $validators[] = Validator::createValidator('safe', $owner, $rule_attributes, $params); |
|
211 | 18 | } |
|
212 | 19 | } |
|
213 | |||
214 | 19 | if ($this->dynamicLangClass) { |
|
215 | 19 | $this->createLangClass(); |
|
216 | 19 | } |
|
217 | |||
218 | 19 | $translation = new $this->langClassName; |
|
219 | 19 | View Code Duplication | foreach ($this->languages as $lang) { |
220 | 19 | foreach ($this->attributes as $attribute) { |
|
221 | 19 | $attributeName = $this->localizedPrefix . $attribute; |
|
222 | 19 | $this->setLangAttribute($this->getAttributeName($attribute, $lang), $translation->{$attributeName}); |
|
223 | 19 | if ($lang == $this->defaultLanguage) { |
|
224 | 19 | $this->setLangAttribute($attribute, $translation->{$attributeName}); |
|
225 | 19 | } |
|
226 | 19 | } |
|
227 | 19 | } |
|
228 | 19 | } |
|
229 | |||
230 | 19 | public function createLangClass() |
|
231 | { |
||
232 | 19 | if (!class_exists($this->langClassName, false)) { |
|
233 | 3 | $namespace = substr($this->langClassName, 0, strrpos($this->langClassName, '\\')); |
|
234 | eval(' |
||
235 | 3 | namespace ' . $namespace . '; |
|
236 | use yii\db\ActiveRecord; |
||
237 | 3 | class ' . $this->langClassShortName . ' extends ActiveRecord |
|
238 | { |
||
239 | public static function tableName() |
||
240 | { |
||
241 | 3 | return \'' . $this->tableName . '\'; |
|
242 | } |
||
243 | 3 | }'); |
|
244 | 3 | } |
|
245 | 19 | } |
|
246 | |||
247 | /** |
||
248 | * Relation to model translations |
||
249 | * @return ActiveQuery |
||
250 | */ |
||
251 | 8 | public function getTranslations() |
|
252 | { |
||
253 | 8 | return $this->owner->hasMany($this->langClassName, [$this->langForeignKey => $this->ownerPrimaryKey]); |
|
254 | } |
||
255 | |||
256 | /** |
||
257 | * Relation to model translation |
||
258 | * @param $language |
||
259 | * @return ActiveQuery |
||
260 | */ |
||
261 | 8 | public function getTranslation($language = null) |
|
262 | { |
||
263 | 8 | $language = $language ?: $this->getCurrentLanguage(); |
|
264 | 8 | return $this->owner->hasOne($this->langClassName, [$this->langForeignKey => $this->ownerPrimaryKey]) |
|
265 | 8 | ->where([$this->languageField => $language]); |
|
266 | } |
||
267 | |||
268 | /** |
||
269 | * Handle 'beforeValidate' event of the owner. |
||
270 | */ |
||
271 | 11 | public function beforeValidate() |
|
272 | { |
||
273 | 11 | foreach ($this->attributes as $attribute) { |
|
274 | 11 | $this->setLangAttribute($this->getAttributeName($attribute, $this->defaultLanguage), $this->getLangAttribute($attribute)); |
|
275 | 11 | } |
|
276 | 11 | } |
|
277 | |||
278 | /** |
||
279 | * Handle 'afterFind' event of the owner. |
||
280 | */ |
||
281 | 13 | public function afterFind() |
|
282 | { |
||
283 | /** @var ActiveRecord $owner */ |
||
284 | 13 | $owner = $this->owner; |
|
285 | |||
286 | 13 | if ($owner->isRelationPopulated('translations') && $related = $owner->getRelatedRecords()['translations']) { |
|
287 | 6 | $translations = $this->indexByLanguage($related); |
|
288 | 6 | foreach ($this->languages as $lang) { |
|
289 | 6 | foreach ($this->attributes as $attribute) { |
|
290 | 6 | View Code Duplication | foreach ($translations as $translation) { |
291 | 6 | if ($this->getLanguageBaseName($translation->{$this->languageField}) == $lang) { |
|
292 | 6 | $attributeName = $this->localizedPrefix . $attribute; |
|
293 | 6 | $this->setLangAttribute($this->getAttributeName($attribute, $lang), $translation->{$attributeName}); |
|
294 | |||
295 | 6 | if ($lang == $this->defaultLanguage) { |
|
296 | 6 | $this->setLangAttribute($attribute, $translation->{$attributeName}); |
|
297 | 6 | } |
|
298 | 6 | } |
|
299 | 6 | } |
|
300 | 6 | } |
|
301 | 6 | } |
|
302 | 6 | } else { |
|
303 | 8 | if (!$owner->isRelationPopulated('translation')) { |
|
304 | 6 | $owner->translation; |
|
305 | 6 | } |
|
306 | |||
307 | 8 | $translation = $owner->getRelatedRecords()['translation']; |
|
308 | 8 | if ($translation) { |
|
309 | 7 | foreach ($this->attributes as $attribute) { |
|
310 | 7 | $attribute_name = $this->localizedPrefix . $attribute; |
|
311 | 7 | $owner->setLangAttribute($attribute, $translation->$attribute_name); |
|
312 | 7 | } |
|
313 | 7 | } |
|
314 | } |
||
315 | |||
316 | 13 | foreach ($this->attributes as $attribute) { |
|
317 | 13 | if ($owner->hasAttribute($attribute) && $this->getLangAttribute($attribute)) { |
|
318 | 1 | $owner->setAttribute($attribute, $this->getLangAttribute($attribute)); |
|
319 | 1 | } |
|
320 | 13 | } |
|
321 | 13 | } |
|
322 | |||
323 | /** |
||
324 | * Handle 'afterInsert' event of the owner. |
||
325 | */ |
||
326 | 5 | public function afterInsert() |
|
327 | { |
||
328 | 5 | $translations = []; |
|
329 | 5 | if ($this->loadTranslationsOnInsert) |
|
330 | 5 | { |
|
331 | $translations = $this->indexByLanguage($this->getTranslations()->all()); |
||
332 | } |
||
333 | 5 | $this->saveTranslations($translations); |
|
334 | 5 | } |
|
335 | |||
336 | /** |
||
337 | * Handle 'afterUpdate' event of the owner. |
||
338 | */ |
||
339 | 5 | public function afterUpdate() |
|
340 | { |
||
341 | /** @var ActiveRecord $owner */ |
||
342 | 5 | $owner = $this->owner; |
|
343 | |||
344 | 5 | if ($owner->isRelationPopulated('translations')) { |
|
345 | 3 | $translations = $this->indexByLanguage($owner->getRelatedRecords()['translations']); |
|
346 | 3 | $this->saveTranslations($translations); |
|
347 | 3 | } |
|
348 | 5 | } |
|
349 | |||
350 | /** |
||
351 | * Handle 'afterDelete' event of the owner. |
||
352 | */ |
||
353 | 2 | public function afterDelete() |
|
354 | { |
||
355 | 2 | if ($this->forceDelete) { |
|
356 | /** @var ActiveRecord $owner */ |
||
357 | 2 | $owner = $this->owner; |
|
358 | 2 | $owner->unlinkAll('translations', true); |
|
359 | 2 | } |
|
360 | 2 | } |
|
361 | |||
362 | /** |
||
363 | * @param array $translations |
||
364 | */ |
||
365 | 8 | private function saveTranslations($translations = []) |
|
366 | { |
||
367 | /** @var ActiveRecord $owner */ |
||
368 | 8 | $owner = $this->owner; |
|
369 | |||
370 | 8 | foreach ($this->languages as $lang) { |
|
371 | 8 | $defaultLanguage = $lang == $this->defaultLanguage; |
|
372 | |||
373 | 8 | if (!isset($translations[$lang])) { |
|
374 | /** @var ActiveRecord $translation */ |
||
375 | 5 | $translation = new $this->langClassName; |
|
376 | 5 | $translation->{$this->languageField} = $lang; |
|
377 | 5 | $translation->{$this->langForeignKey} = $owner->getAttribute($this->ownerPrimaryKey); |
|
378 | 5 | } else { |
|
379 | 3 | $translation = $translations[$lang]; |
|
380 | } |
||
381 | |||
382 | 8 | $save = false; |
|
383 | 8 | foreach ($this->attributes as $attribute) { |
|
384 | 8 | $value = $defaultLanguage ? $owner->$attribute : $this->getLangAttribute($this->getAttributeName($attribute, $lang)); |
|
385 | |||
386 | 8 | if ($value !== null) { |
|
387 | 8 | $field = $this->localizedPrefix . $attribute; |
|
388 | 8 | $translation->$field = $value; |
|
389 | 8 | $save = true; |
|
390 | 8 | } |
|
391 | 8 | } |
|
392 | |||
393 | 8 | if ($translation->isNewRecord && !$save) |
|
394 | 8 | continue; |
|
395 | |||
396 | 8 | $translation->save(); |
|
397 | 8 | } |
|
398 | 8 | } |
|
399 | |||
400 | /** |
||
401 | * @inheritdoc |
||
402 | */ |
||
403 | 18 | public function canGetProperty($name, $checkVars = true) |
|
404 | { |
||
405 | 18 | return method_exists($this, 'get' . $name) || $checkVars && property_exists($this, $name) |
|
406 | 18 | || $this->hasLangAttribute($name); |
|
407 | } |
||
408 | |||
409 | /** |
||
410 | * @inheritdoc |
||
411 | */ |
||
412 | 10 | public function canSetProperty($name, $checkVars = true) |
|
413 | { |
||
414 | 10 | return $this->hasLangAttribute($name); |
|
415 | } |
||
416 | |||
417 | /** |
||
418 | * @inheritdoc |
||
419 | */ |
||
420 | 17 | public function __get($name) |
|
421 | { |
||
422 | try { |
||
423 | 17 | return parent::__get($name); |
|
424 | 15 | } catch (UnknownPropertyException $e) { |
|
425 | 15 | if ($this->hasLangAttribute($name)) return $this->getLangAttribute($name); |
|
426 | // @codeCoverageIgnoreStart |
||
427 | else throw $e; |
||
428 | // @codeCoverageIgnoreEnd |
||
429 | } |
||
430 | } |
||
431 | |||
432 | /** |
||
433 | * @inheritdoc |
||
434 | */ |
||
435 | 10 | public function __set($name, $value) |
|
436 | { |
||
437 | try { |
||
438 | 10 | parent::__set($name, $value); |
|
439 | 10 | } catch (UnknownPropertyException $e) { |
|
440 | 10 | if ($this->hasLangAttribute($name)) $this->setLangAttribute($name, $value); |
|
441 | // @codeCoverageIgnoreStart |
||
442 | else throw $e; |
||
443 | // @codeCoverageIgnoreEnd |
||
444 | } |
||
445 | 10 | } |
|
446 | |||
447 | /** |
||
448 | * @inheritdoc |
||
449 | * @codeCoverageIgnore |
||
450 | */ |
||
451 | public function __isset($name) |
||
459 | |||
460 | /** |
||
461 | * Whether an attribute exists |
||
462 | * @param string $name the name of the attribute |
||
463 | * @return boolean |
||
464 | */ |
||
465 | 15 | public function hasLangAttribute($name) |
|
466 | { |
||
467 | 15 | return array_key_exists($name, $this->langAttributes); |
|
468 | } |
||
469 | |||
470 | /** |
||
471 | * @param string $name the name of the attribute |
||
472 | * @return string the attribute value |
||
473 | */ |
||
474 | 15 | public function getLangAttribute($name) |
|
475 | { |
||
476 | 15 | return $this->hasLangAttribute($name) ? $this->langAttributes[$name] : null; |
|
477 | } |
||
478 | |||
479 | /** |
||
480 | * @param string $name the name of the attribute |
||
481 | * @param string $value the value of the attribute |
||
482 | */ |
||
483 | 19 | public function setLangAttribute($name, $value) |
|
484 | { |
||
485 | 19 | $this->langAttributes[$name] = $value; |
|
486 | 19 | } |
|
487 | |||
488 | /** |
||
489 | * @param $records |
||
490 | * @return array |
||
491 | */ |
||
492 | 6 | protected function indexByLanguage($records) |
|
493 | { |
||
494 | 6 | $sorted = array(); |
|
495 | 6 | foreach ($records as $record) { |
|
496 | 6 | $sorted[$record->{$this->languageField}] = $record; |
|
497 | 6 | } |
|
498 | 6 | unset($records); |
|
499 | 6 | return $sorted; |
|
500 | } |
||
501 | |||
502 | /** |
||
503 | * @param $language |
||
504 | * @return string |
||
505 | */ |
||
506 | 19 | protected function getLanguageBaseName($language) |
|
507 | { |
||
508 | 19 | return $this->abridge ? substr($language, 0, 2) : $language; |
|
509 | } |
||
510 | |||
511 | /** |
||
512 | * @param string $className |
||
513 | * @return string |
||
514 | */ |
||
515 | 19 | private function getShortClassName($className) |
|
516 | { |
||
517 | 19 | return substr($className, strrpos($className, '\\') + 1); |
|
518 | } |
||
519 | |||
520 | /** |
||
521 | * @return mixed|string |
||
522 | */ |
||
523 | 8 | public function getCurrentLanguage() |
|
527 | |||
528 | /** |
||
529 | * @param $attribute |
||
530 | * @param $language |
||
531 | * @return string |
||
532 | */ |
||
533 | 19 | protected function getAttributeName($attribute, $language) |
|
534 | { |
||
535 | 19 | $language = $this->abridge ? $language : Inflector::camel2id(Inflector::id2camel($language), "_"); |
|
536 | 19 | return $attribute . "_" . $language; |
|
537 | } |
||
538 | } |
||
539 |