Test Setup Failed
Push — master ( 7a9f3f...238fba )
by Dimitrios
04:58
created

src/Translatable/Translatable.php (1 issue)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
namespace Dimsav\Translatable;
4
5
use Illuminate\Database\Eloquent\Model;
6
use Illuminate\Database\Eloquent\Builder;
7
use Illuminate\Database\Eloquent\Relations\Relation;
8
use Illuminate\Database\Query\Builder as QueryBuilder;
9
use Dimsav\Translatable\Exception\LocalesNotDefinedException;
10
11
trait Translatable
12
{
13
    protected $defaultLocale;
14
15
    /**
16
     * Alias for getTranslation().
17
     *
18
     * @param string|null $locale
19
     * @param bool        $withFallback
20
     *
21
     * @return \Illuminate\Database\Eloquent\Model|null
22
     */
23
    public function translate($locale = null, $withFallback = false)
24
    {
25
        return $this->getTranslation($locale, $withFallback);
26
    }
27
28
    /**
29
     * Alias for getTranslation().
30
     *
31
     * @param string $locale
32
     *
33
     * @return \Illuminate\Database\Eloquent\Model|null
34
     */
35
    public function translateOrDefault($locale)
36
    {
37
        return $this->getTranslation($locale, true);
38
    }
39
40
    /**
41
     * Alias for getTranslationOrNew().
42
     *
43
     * @param string $locale
44
     *
45
     * @return \Illuminate\Database\Eloquent\Model|null
46
     */
47
    public function translateOrNew($locale)
48
    {
49
        return $this->getTranslationOrNew($locale);
50
    }
51
52
    /**
53
     * @param string|null $locale
54
     * @param bool        $withFallback
55
     *
56
     * @return \Illuminate\Database\Eloquent\Model|null
57
     */
58
    public function getTranslation($locale = null, $withFallback = null)
59
    {
60
        $configFallbackLocale = $this->getFallbackLocale();
61
        $locale = $locale ?: $this->locale();
62
        $withFallback = $withFallback === null ? $this->useFallback() : $withFallback;
63
        $fallbackLocale = $this->getFallbackLocale($locale);
64
65
        if ($translation = $this->getTranslationByLocaleKey($locale)) {
66
            return $translation;
67
        }
68
        if ($withFallback && $fallbackLocale) {
69
            if ($translation = $this->getTranslationByLocaleKey($fallbackLocale)) {
70
                return $translation;
71
            }
72
            if ($translation = $this->getTranslationByLocaleKey($configFallbackLocale)) {
73
                return $translation;
74
            }
75
        }
76
77
        return null;
78
    }
79
80
    /**
81
     * @param string|null $locale
82
     *
83
     * @return bool
84
     */
85
    public function hasTranslation($locale = null)
86
    {
87
        $locale = $locale ?: $this->locale();
88
89
        foreach ($this->translations as $translation) {
90
            if ($translation->getAttribute($this->getLocaleKey()) == $locale) {
91
                return true;
92
            }
93
        }
94
95
        return false;
96
    }
97
98
    /**
99
     * @return string
100
     */
101
    public function getTranslationModelName()
102
    {
103
        return $this->translationModel ?: $this->getTranslationModelNameDefault();
104
    }
105
106
    /**
107
     * @return string
108
     */
109
    public function getTranslationModelNameDefault()
110
    {
111
        return get_class($this).config('translatable.translation_suffix', 'Translation');
112
    }
113
114
    /**
115
     * @return string
116
     */
117
    public function getRelationKey()
118
    {
119
        if ($this->translationForeignKey) {
120
            $key = $this->translationForeignKey;
121
        } elseif ($this->primaryKey !== 'id') {
122
            $key = $this->primaryKey;
123
        } else {
124
            $key = $this->getForeignKey();
125
        }
126
127
        return $key;
128
    }
129
130
    /**
131
     * @return string
132
     */
133
    public function getLocaleKey()
134
    {
135
        return $this->localeKey ?: config('translatable.locale_key', 'locale');
136
    }
137
138
    /**
139
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
140
     */
141
    public function translations()
142
    {
143
        return $this->hasMany($this->getTranslationModelName(), $this->getRelationKey());
144
    }
145
146
    /**
147
     * @return bool
148
     */
149
    private function usePropertyFallback()
150
    {
151
        return config('translatable.use_property_fallback', false);
152
    }
153
154
    /**
155
     * Returns the attribute value from fallback translation if value of attribute
156
     * is empty and the property fallback is enabled in the configuration.
157
     * in model.
158
     * @param $locale
159
     * @param $attribute
160
     * @return mixed
161
     */
162
    private function getAttributeOrFallback($locale, $attribute)
163
    {
164
        $value = $this->getTranslation($locale)->$attribute;
165
166
        $usePropertyFallback = $this->useFallback() && $this->usePropertyFallback();
167
        if (
168
            empty($value) &&
169
            $usePropertyFallback &&
170
            ($fallback = $this->getTranslation($this->getFallbackLocale(), true))
171
        ) {
172
            return $fallback->$attribute;
173
        }
174
175
        return $value;
176
    }
177
178
    /**
179
     * @param string $key
180
     *
181
     * @return mixed
182
     */
183
    public function getAttribute($key)
184
    {
185
        list($attribute, $locale) = $this->getAttributeAndLocale($key);
186
187
        if ($this->isTranslationAttribute($attribute)) {
188
            if ($this->getTranslation($locale) === null) {
189
                return $this->getAttributeValue($attribute);
190
            }
191
192
            // If the given $attribute has a mutator, we push it to $attributes and then call getAttributeValue
193
            // on it. This way, we can use Eloquent's checking for Mutation, type casting, and
194
            // Date fields.
195
            if ($this->hasGetMutator($attribute)) {
196
                $this->attributes[$attribute] = $this->getAttributeOrFallback($locale, $attribute);
197
198
                return $this->getAttributeValue($attribute);
199
            }
200
201
            return $this->getAttributeOrFallback($locale, $attribute);
202
        }
203
204
        return parent::getAttribute($key);
205
    }
206
207
    /**
208
     * @param string $key
209
     * @param mixed  $value
210
     *
211
     * @return $this
212
     */
213
    public function setAttribute($key, $value)
214
    {
215
        list($attribute, $locale) = $this->getAttributeAndLocale($key);
216
217
        if ($this->isTranslationAttribute($attribute)) {
218
            $this->getTranslationOrNew($locale)->$attribute = $value;
219
        } else {
220
            return parent::setAttribute($key, $value);
221
        }
222
223
        return $this;
224
    }
225
226
    /**
227
     * @param array $options
228
     *
229
     * @return bool
230
     */
231
    public function save(array $options = [])
232
    {
233
        if ($this->exists) {
234
            if ($this->isDirty()) {
235
                // If $this->exists and dirty, parent::save() has to return true. If not,
236
                // an error has occurred. Therefore we shouldn't save the translations.
237
                if (parent::save($options)) {
238
                    return $this->saveTranslations();
239
                }
240
241
                return false;
242
            } else {
243
                // If $this->exists and not dirty, parent::save() skips saving and returns
244
                // false. So we have to save the translations
245
                if ($saved = $this->saveTranslations()) {
246
                    $this->fireModelEvent('saved', false);
247
                    $this->fireModelEvent('updated', false);
248
                }
249
250
                return $saved;
251
            }
252
        } elseif (parent::save($options)) {
253
            // We save the translations only if the instance is saved in the database.
254
            return $this->saveTranslations();
255
        }
256
257
        return false;
258
    }
259
260
    /**
261
     * @param string $locale
262
     *
263
     * @return \Illuminate\Database\Eloquent\Model|null
264
     */
265
    protected function getTranslationOrNew($locale)
266
    {
267
        if (($translation = $this->getTranslation($locale, false)) === null) {
268
            $translation = $this->getNewTranslation($locale);
269
        }
270
271
        return $translation;
272
    }
273
274
    /**
275
     * @param array $attributes
276
     *
277
     * @throws \Illuminate\Database\Eloquent\MassAssignmentException
278
     * @return $this
279
     */
280
    public function fill(array $attributes)
281
    {
282
        foreach ($attributes as $key => $values) {
283
            if ($this->isKeyALocale($key)) {
284
                $this->getTranslationOrNew($key)->fill($values);
285
                unset($attributes[$key]);
286
            } else {
287
                list($attribute, $locale) = $this->getAttributeAndLocale($key);
288
                if ($this->isTranslationAttribute($attribute) and $this->isKeyALocale($locale)) {
289
                    $this->getTranslationOrNew($locale)->fill([$attribute => $values]);
290
                    unset($attributes[$key]);
291
                }
292
            }
293
        }
294
295
        return parent::fill($attributes);
296
    }
297
298
    /**
299
     * @param string $key
300
     */
301
    private function getTranslationByLocaleKey($key)
302
    {
303
        foreach ($this->translations as $translation) {
304
            if ($translation->getAttribute($this->getLocaleKey()) == $key) {
305
                return $translation;
306
            }
307
        }
308
309
        return null;
310
    }
311
312
    /**
313
     * @param null $locale
314
     *
315
     * @return string
316
     */
317
    private function getFallbackLocale($locale = null)
318
    {
319
        if ($locale && $this->isLocaleCountryBased($locale)) {
320
            if ($fallback = $this->getLanguageFromCountryBasedLocale($locale)) {
321
                return $fallback;
322
            }
323
        }
324
325
        return config('translatable.fallback_locale');
326
    }
327
328
    /**
329
     * @param $locale
330
     *
331
     * @return bool
332
     */
333
    private function isLocaleCountryBased($locale)
334
    {
335
        return strpos($locale, $this->getLocaleSeparator()) !== false;
336
    }
337
338
    /**
339
     * @param $locale
340
     *
341
     * @return string
342
     */
343
    private function getLanguageFromCountryBasedLocale($locale)
344
    {
345
        $parts = explode($this->getLocaleSeparator(), $locale);
346
347
        return array_get($parts, 0);
348
    }
349
350
    /**
351
     * @return bool|null
352
     */
353
    private function useFallback()
354
    {
355
        if (isset($this->useTranslationFallback) && $this->useTranslationFallback !== null) {
356
            return $this->useTranslationFallback;
357
        }
358
359
        return config('translatable.use_fallback');
360
    }
361
362
    /**
363
     * @param string $key
364
     *
365
     * @return bool
366
     */
367
    public function isTranslationAttribute($key)
368
    {
369
        return in_array($key, $this->translatedAttributes);
370
    }
371
372
    /**
373
     * @param string $key
374
     *
375
     * @throws \Dimsav\Translatable\Exception\LocalesNotDefinedException
376
     * @return bool
377
     */
378
    protected function isKeyALocale($key)
379
    {
380
        $locales = $this->getLocales();
381
382
        return in_array($key, $locales);
383
    }
384
385
    /**
386
     * @throws \Dimsav\Translatable\Exception\LocalesNotDefinedException
387
     * @return array
388
     */
389
    protected function getLocales()
390
    {
391
        $localesConfig = (array) config('translatable.locales');
392
393
        if (empty($localesConfig)) {
394
            throw new LocalesNotDefinedException('Please make sure you have run "php artisan config:publish dimsav/laravel-translatable" '.
395
                ' and that the locales configuration is defined.');
396
        }
397
398
        $locales = [];
399
        foreach ($localesConfig as $key => $locale) {
400
            if (is_array($locale)) {
401
                $locales[] = $key;
402
                foreach ($locale as $countryLocale) {
403
                    $locales[] = $key.$this->getLocaleSeparator().$countryLocale;
404
                }
405
            } else {
406
                $locales[] = $locale;
407
            }
408
        }
409
410
        return $locales;
411
    }
412
413
    /**
414
     * @return string
415
     */
416
    protected function getLocaleSeparator()
417
    {
418
        return config('translatable.locale_separator', '-');
419
    }
420
421
    /**
422
     * @return bool
423
     */
424
    protected function saveTranslations()
425
    {
426
        $saved = true;
427
        foreach ($this->translations as $translation) {
428
            if ($saved && $this->isTranslationDirty($translation)) {
429
                if (! empty($connectionName = $this->getConnectionName())) {
430
                    $translation->setConnection($connectionName);
431
                }
432
433
                $translation->setAttribute($this->getRelationKey(), $this->getKey());
434
                $saved = $translation->save();
435
            }
436
        }
437
438
        return $saved;
439
    }
440
441
    /**
442
     * @param array
443
     *
444
     * @return \Illuminate\Database\Eloquent\Model
445
     */
446
    public function replicateWithTranslations(array $except = null)
447
    {
448
        $newInstance = parent::replicate($except);
449
450
        unset($newInstance->translations);
451
        foreach ($this->translations as $translation) {
452
            $newTranslation = $translation->replicate();
453
            $newInstance->translations->add($newTranslation);
454
        }
455
456
        return  $newInstance;
457
    }
458
459
    /**
460
     * @param \Illuminate\Database\Eloquent\Model $translation
461
     *
462
     * @return bool
463
     */
464
    protected function isTranslationDirty(Model $translation)
465
    {
466
        $dirtyAttributes = $translation->getDirty();
467
        unset($dirtyAttributes[$this->getLocaleKey()]);
468
469
        return count($dirtyAttributes) > 0;
470
    }
471
472
    /**
473
     * @param string $locale
474
     *
475
     * @return \Illuminate\Database\Eloquent\Model
476
     */
477
    public function getNewTranslation($locale)
478
    {
479
        $modelName = $this->getTranslationModelName();
480
        $translation = new $modelName();
481
        $translation->setAttribute($this->getLocaleKey(), $locale);
482
        $this->translations->add($translation);
483
484
        return $translation;
485
    }
486
487
    /**
488
     * @param $key
489
     *
490
     * @return bool
491
     */
492
    public function __isset($key)
493
    {
494
        return $this->isTranslationAttribute($key) || parent::__isset($key);
495
    }
496
497
    /**
498
     * @param \Illuminate\Database\Eloquent\Builder $query
499
     * @param string                                $locale
500
     *
501
     * @return \Illuminate\Database\Eloquent\Builder|static
502
     */
503 View Code Duplication
    public function scopeTranslatedIn(Builder $query, $locale = null)
504
    {
505
        $locale = $locale ?: $this->locale();
506
507
        return $query->whereHas('translations', function (Builder $q) use ($locale) {
508
            $q->where($this->getLocaleKey(), '=', $locale);
509
        });
510
    }
511
512
    /**
513
     * @param \Illuminate\Database\Eloquent\Builder $query
514
     * @param string                                $locale
515
     *
516
     * @return \Illuminate\Database\Eloquent\Builder|static
517
     */
518 View Code Duplication
    public function scopeNotTranslatedIn(Builder $query, $locale = null)
519
    {
520
        $locale = $locale ?: $this->locale();
521
522
        return $query->whereDoesntHave('translations', function (Builder $q) use ($locale) {
523
            $q->where($this->getLocaleKey(), '=', $locale);
524
        });
525
    }
526
527
    /**
528
     * @param \Illuminate\Database\Eloquent\Builder $query
529
     *
530
     * @return \Illuminate\Database\Eloquent\Builder|static
531
     */
532
    public function scopeTranslated(Builder $query)
533
    {
534
        return $query->has('translations');
535
    }
536
537
    /**
538
     * Adds scope to get a list of translated attributes, using the current locale.
539
     * Example usage: Country::listsTranslations('name')->get()->toArray()
540
     * Will return an array with items:
541
     *  [
542
     *      'id' => '1',                // The id of country
543
     *      'name' => 'Griechenland'    // The translated name
544
     *  ].
545
     *
546
     * @param \Illuminate\Database\Eloquent\Builder $query
547
     * @param string                                $translationField
548
     */
549
    public function scopeListsTranslations(Builder $query, $translationField)
550
    {
551
        $withFallback = $this->useFallback();
552
        $translationTable = $this->getTranslationsTable();
553
        $localeKey = $this->getLocaleKey();
554
555
        $query
556
            ->select($this->getTable().'.'.$this->getKeyName(), $translationTable.'.'.$translationField)
557
            ->leftJoin($translationTable, $translationTable.'.'.$this->getRelationKey(), '=', $this->getTable().'.'.$this->getKeyName())
558
            ->where($translationTable.'.'.$localeKey, $this->locale());
559
        if ($withFallback) {
560
            $query->orWhere(function (Builder $q) use ($translationTable, $localeKey) {
561
                $q->where($translationTable.'.'.$localeKey, $this->getFallbackLocale())
562
                  ->whereNotIn($translationTable.'.'.$this->getRelationKey(), function (QueryBuilder $q) use (
563
                      $translationTable,
564
                      $localeKey
565
                  ) {
566
                      $q->select($translationTable.'.'.$this->getRelationKey())
567
                        ->from($translationTable)
568
                        ->where($translationTable.'.'.$localeKey, $this->locale());
569
                  });
570
            });
571
        }
572
    }
573
574
    /**
575
     * This scope eager loads the translations for the default and the fallback locale only.
576
     * We can use this as a shortcut to improve performance in our application.
577
     *
578
     * @param Builder $query
579
     */
580
    public function scopeWithTranslation(Builder $query)
581
    {
582
        $query->with([
583
            'translations' => function (Relation $query) {
584
                if ($this->useFallback()) {
585
                    $locale = $this->locale();
586
                    $countryFallbackLocale = $this->getFallbackLocale($locale); // e.g. de-DE => de
587
                    $locales = array_unique([$locale, $countryFallbackLocale, $this->getFallbackLocale()]);
588
589
                    return $query->whereIn($this->getTranslationsTable().'.'.$this->getLocaleKey(), $locales);
590
                }
591
592
                return $query->where($this->getTranslationsTable().'.'.$this->getLocaleKey(), $this->locale());
593
            },
594
        ]);
595
    }
596
597
    /**
598
     * This scope filters results by checking the translation fields.
599
     *
600
     * @param \Illuminate\Database\Eloquent\Builder $query
601
     * @param string                                $key
602
     * @param string                                $value
603
     * @param string                                $locale
604
     *
605
     * @return \Illuminate\Database\Eloquent\Builder|static
606
     */
607 View Code Duplication
    public function scopeWhereTranslation(Builder $query, $key, $value, $locale = null)
608
    {
609
        return $query->whereHas('translations', function (Builder $query) use ($key, $value, $locale) {
610
            $query->where($this->getTranslationsTable().'.'.$key, $value);
611
            if ($locale) {
612
                $query->where($this->getTranslationsTable().'.'.$this->getLocaleKey(), $locale);
613
            }
614
        });
615
    }
616
617
    /**
618
     * This scope filters results by checking the translation fields.
619
     *
620
     * @param \Illuminate\Database\Eloquent\Builder $query
621
     * @param string                                $key
622
     * @param string                                $value
623
     * @param string                                $locale
624
     *
625
     * @return \Illuminate\Database\Eloquent\Builder|static
626
     */
627 View Code Duplication
    public function scopeOrWhereTranslation(Builder $query, $key, $value, $locale = null)
628
    {
629
        return $query->orWhereHas('translations', function (Builder $query) use ($key, $value, $locale) {
630
            $query->where($this->getTranslationsTable().'.'.$key, $value);
631
            if ($locale) {
632
                $query->where($this->getTranslationsTable().'.'.$this->getLocaleKey(), $locale);
633
            }
634
        });
635
    }
636
637
    /**
638
     * This scope filters results by checking the translation fields.
639
     *
640
     * @param \Illuminate\Database\Eloquent\Builder $query
641
     * @param string                                $key
642
     * @param string                                $value
643
     * @param string                                $locale
644
     *
645
     * @return \Illuminate\Database\Eloquent\Builder|static
646
     */
647 View Code Duplication
    public function scopeWhereTranslationLike(Builder $query, $key, $value, $locale = null)
648
    {
649
        return $query->whereHas('translations', function (Builder $query) use ($key, $value, $locale) {
650
            $query->where($this->getTranslationsTable().'.'.$key, 'LIKE', $value);
651
            if ($locale) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $locale of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
652
                $query->where($this->getTranslationsTable().'.'.$this->getLocaleKey(), 'LIKE', $locale);
653
            }
654
        });
655
    }
656
657
    /**
658
     * This scope filters results by checking the translation fields.
659
     *
660
     * @param \Illuminate\Database\Eloquent\Builder $query
661
     * @param string                                $key
662
     * @param string                                $value
663
     * @param string                                $locale
664
     *
665
     * @return \Illuminate\Database\Eloquent\Builder|static
666
     */
667 View Code Duplication
    public function scopeOrWhereTranslationLike(Builder $query, $key, $value, $locale = null)
668
    {
669
        return $query->orWhereHas('translations', function (Builder $query) use ($key, $value, $locale) {
670
            $query->where($this->getTranslationsTable().'.'.$key, 'LIKE', $value);
671
            if ($locale) {
672
                $query->where($this->getTranslationsTable().'.'.$this->getLocaleKey(), 'LIKE', $locale);
673
            }
674
        });
675
    }
676
677
    /**
678
     * @return array
679
     */
680
    public function attributesToArray()
681
    {
682
        $attributes = parent::attributesToArray();
683
684
        if (! $this->relationLoaded('translations') && ! $this->toArrayAlwaysLoadsTranslations()) {
685
            return $attributes;
686
        }
687
688
        $hiddenAttributes = $this->getHidden();
689
690
        foreach ($this->translatedAttributes as $field) {
691
            if (in_array($field, $hiddenAttributes)) {
692
                continue;
693
            }
694
695
            if ($translations = $this->getTranslation()) {
696
                $attributes[$field] = $translations->$field;
697
            }
698
        }
699
700
        return $attributes;
701
    }
702
703
    /**
704
     * @return array
705
     */
706
    public function getTranslationsArray()
707
    {
708
        $translations = [];
709
710
        foreach ($this->translations as $translation) {
711
            foreach ($this->translatedAttributes as $attr) {
712
                $translations[$translation->{$this->getLocaleKey()}][$attr] = $translation->{$attr};
713
            }
714
        }
715
716
        return $translations;
717
    }
718
719
    /**
720
     * @return string
721
     */
722
    private function getTranslationsTable()
723
    {
724
        return app()->make($this->getTranslationModelName())->getTable();
725
    }
726
727
    /**
728
     * @return string
729
     */
730
    protected function locale()
731
    {
732
        if ($this->defaultLocale) {
733
            return $this->defaultLocale;
734
        }
735
736
        return config('translatable.locale')
737
            ?: app()->make('translator')->getLocale();
738
    }
739
740
    /**
741
     * Set the default locale on the model.
742
     *
743
     * @param $locale
744
     *
745
     * @return $this
746
     */
747
    public function setDefaultLocale($locale)
748
    {
749
        $this->defaultLocale = $locale;
750
751
        return $this;
752
    }
753
754
    /**
755
     * Get the default locale on the model.
756
     *
757
     * @return mixed
758
     */
759
    public function getDefaultLocale()
760
    {
761
        return $this->defaultLocale;
762
    }
763
764
    /**
765
     * Deletes all translations for this model.
766
     *
767
     * @param string|array|null $locales The locales to be deleted (array or single string)
768
     *                                   (e.g., ["en", "de"] would remove these translations).
769
     */
770
    public function deleteTranslations($locales = null)
771
    {
772
        if ($locales === null) {
773
            $translations = $this->translations()->get();
774
        } else {
775
            $locales = (array) $locales;
776
            $translations = $this->translations()->whereIn($this->getLocaleKey(), $locales)->get();
777
        }
778
        foreach ($translations as $translation) {
779
            $translation->delete();
780
        }
781
782
        // we need to manually "reload" the collection built from the relationship
783
        // otherwise $this->translations()->get() would NOT be the same as $this->translations
784
        $this->load('translations');
785
    }
786
787
    /**
788
     * @param $key
789
     *
790
     * @return array
791
     */
792
    private function getAttributeAndLocale($key)
793
    {
794
        if (str_contains($key, ':')) {
795
            return explode(':', $key);
796
        }
797
798
        return [$key, $this->locale()];
799
    }
800
801
    /**
802
     * @return bool
803
     */
804
    private function toArrayAlwaysLoadsTranslations()
805
    {
806
        return config('translatable.to_array_always_loads_translations', true);
807
    }
808
}
809