From b2cd5ee716010b188d84c334cfbc3a7fcc50e4a5 Mon Sep 17 00:00:00 2001 From: Amjad BaniMattar Date: Tue, 23 Apr 2024 00:20:03 +0300 Subject: [PATCH 1/6] Add Custom Rules translatableExists, and translatableUnique to validate translatable attributes --- README.md | 57 +++++ .../TranslatableServiceProvider.php | 20 +- .../Validation/Rules/TranslatableExists.php | 101 +++++++++ .../Validation/Rules/TranslatableUnique.php | 101 +++++++++ src/lang/en/validation.php | 8 + tests/CustomValidationTest.php | 199 ++++++++++++++++++ 6 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 src/Translatable/Validation/Rules/TranslatableExists.php create mode 100644 src/Translatable/Validation/Rules/TranslatableUnique.php create mode 100644 src/lang/en/validation.php create mode 100644 tests/CustomValidationTest.php diff --git a/README.md b/README.md index 6b04000e..31b89ec6 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,63 @@ $post = Post::create($data); echo $post->translate('fr')->title; // Mon premier post ``` +### **Validating Unique and Exists Rule** + +```php +use Astrotomic\Translatable\Validation\Rules\TranslatableUnique; +... + +$person = new Person(['name' => 'john doe']); +$person->save(); + +// Option 1 +$data = [ + 'name' => 'john doe', + 'email' => 'john@example.com' +]; +$validator = Validator::make($data, [ + 'name' => ['required', new TranslatableUnique(Person::class, 'name')], +]); + +// Option 2 +$data = [ + 'name:en' => 'john doe', + 'email' => 'john@example.com' +]; + +$validator = Validator::make($data, [ + 'name:en' => ['required', Rule::translatableUnique(Person::class, 'name:en')], +]); + +``` + +```php +use Astrotomic\Translatable\Validation\Rules\TranslatableExists; +... + +$person = new Person(['name' => 'john doe']); +$person->save(); + +// Option 1 +$data = [ + 'name' => 'john doe', + 'email' => 'john@example.com' +]; +$validator = Validator::make($data, [ + 'name' => ['required', new TranslatableExists(Person::class, 'name')], +]); + +// Option 2 +$data = [ + 'name:en' => 'john doe', + 'email' => 'john@example.com' +]; + +$validator = Validator::make($data, [ + 'name:en' => ['required', Rule::translatableExists(Person::class, 'name:en')], +]); +``` + ## Tutorials - [How To Add Multilingual Support to Eloquent](https://laravel-news.com/how-to-add-multilingual-support-to-eloquent) diff --git a/src/Translatable/TranslatableServiceProvider.php b/src/Translatable/TranslatableServiceProvider.php index 8bbf1d38..837114e6 100644 --- a/src/Translatable/TranslatableServiceProvider.php +++ b/src/Translatable/TranslatableServiceProvider.php @@ -2,23 +2,39 @@ namespace Astrotomic\Translatable; +use Astrotomic\Translatable\Validation\Rules\TranslatableExists; +use Astrotomic\Translatable\Validation\Rules\TranslatableUnique; use Illuminate\Support\ServiceProvider; +use Illuminate\Validation\Rule; class TranslatableServiceProvider extends ServiceProvider { public function boot() { $this->publishes([ - __DIR__.'/../config/translatable.php' => config_path('translatable.php'), + __DIR__ . '/../config/translatable.php' => config_path('translatable.php'), ], 'translatable'); + + $this->loadTranslationsFrom(__DIR__ . '/../lang', 'translatable'); + $this->publishes([ + __DIR__ . '/../lang' => $this->app->langPath('vendor/translatable'), + ], 'translatable-lang'); } public function register() { $this->mergeConfigFrom( - __DIR__.'/../config/translatable.php', 'translatable' + __DIR__ . '/../config/translatable.php', + 'translatable' ); + Rule::macro('translatableUnique', function (string $model, string $field): TranslatableUnique { + return new TranslatableUnique($model, $field); + }); + Rule::macro('translatableExists', function (string $model, string $field): TranslatableExists { + return new TranslatableExists($model, $field); + }); + $this->registerTranslatableHelper(); } diff --git a/src/Translatable/Validation/Rules/TranslatableExists.php b/src/Translatable/Validation/Rules/TranslatableExists.php new file mode 100644 index 00000000..5802cf6a --- /dev/null +++ b/src/Translatable/Validation/Rules/TranslatableExists.php @@ -0,0 +1,101 @@ + + */ +class TranslatableExists implements ValidationRule +{ + /** + * The ID that should be ignored. + * + * @var mixed + */ + protected mixed $ignore = null; + + /** + * The name of the ID column. + * + * @var string + */ + protected string $idColumn = 'id'; + + /** + * The default locale + * + * @var string + */ + protected ?string $locale = null; + + public function __construct(private string $model, private string $field) + { + if (Str::contains($field, ':')) { + [$this->field, $this->locale] = explode(':', $field); + } + // + } + + /** + * Ignore the given ID during the unique check. + * + * @param mixed $id + * @param string|null $idColumn + * @return $this + */ + public function ignore(mixed $id, ?string $idColumn = null): self + { + if ($id instanceof Model) { + return $this->ignoreModel($id, $idColumn); + } + + $this->ignore = $id; + $this->idColumn = $idColumn ?? 'id'; + + return $this; + } + + /** + * Ignore the given model during the unique check. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string|null $idColumn + * @return $this + */ + public function ignoreModel(Model $model, ?string $idColumn = null): self + { + $this->idColumn = $idColumn ?? $model->getKeyName(); + $this->ignore = $model->{$this->idColumn}; + + return $this; + } + + /** + * Run the validation rule. + * + * @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + */ + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! empty($value)) { + $query = $this->model::whereTranslation($this->field, $value, $this->locale); + if ($this->ignore) { + $query->whereNot($this->idColumn, $this->ignore); + } + $exists = $query->exists(); + + if (! $exists) { + $fail('translatable::validation.translatableExist')->translate(); + } + } + } +} diff --git a/src/Translatable/Validation/Rules/TranslatableUnique.php b/src/Translatable/Validation/Rules/TranslatableUnique.php new file mode 100644 index 00000000..babb2188 --- /dev/null +++ b/src/Translatable/Validation/Rules/TranslatableUnique.php @@ -0,0 +1,101 @@ + + */ +class TranslatableUnique implements ValidationRule +{ + /** + * The ID that should be ignored. + * + * @var mixed + */ + protected mixed $ignore = null; + + /** + * The name of the ID column. + * + * @var string + */ + protected string $idColumn = 'id'; + + /** + * The default locale + * + * @var string + */ + protected ?string $locale = null; + + public function __construct(private string $model, private string $field) + { + if (Str::contains($field, ':')) { + [$this->field, $this->locale] = explode(':', $field); + } + // + } + + /** + * Ignore the given ID during the unique check. + * + * @param mixed $id + * @param string|null $idColumn + * @return $this + */ + public function ignore(mixed $id, ?string $idColumn = null): self + { + if ($id instanceof Model) { + return $this->ignoreModel($id, $idColumn); + } + + $this->ignore = $id; + $this->idColumn = $idColumn ?? 'id'; + + return $this; + } + + /** + * Ignore the given model during the unique check. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string|null $idColumn + * @return $this + */ + public function ignoreModel(Model $model, ?string$idColumn = null): self + { + $this->idColumn = $idColumn ?? $model->getKeyName(); + $this->ignore = $model->{$this->idColumn}; + + return $this; + } + + /** + * Run the validation rule. + * + * @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + */ + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! empty($value)) { + $query = $this->model::whereTranslation($this->field, $value, $this->locale); + if ($this->ignore) { + $query->whereNot($this->idColumn, $this->ignore); + } + $exists = $query->exists(); + + if ($exists) { + $fail('translatable::validation.translatableUnique')->translate(); + } + } + } +} diff --git a/src/lang/en/validation.php b/src/lang/en/validation.php new file mode 100644 index 00000000..0071b3c4 --- /dev/null +++ b/src/lang/en/validation.php @@ -0,0 +1,8 @@ + 'The :attribute value is already in use.', + 'translatableExist' => 'The :attribute value does not exist.', +]; diff --git a/tests/CustomValidationTest.php b/tests/CustomValidationTest.php new file mode 100644 index 00000000..75509f4e --- /dev/null +++ b/tests/CustomValidationTest.php @@ -0,0 +1,199 @@ + 'john doe']); + $person->save(); + + $data = [ + 'name' => 'john andrew', + 'email' => 'john@example.com' + ]; + + $validator = Validator::make($data, [ + 'name' => ['required', new TranslatableUnique(Person::class, 'name')], + ]); + + if ($validator->fails()) { + self::assertTrue(false); + } else { + self::assertTrue(true); + } + } + + /** + * Validate that the field is unique and fails. + * + * @test + * @return void + */ + public function validate_field_unique_fails(): void + { + $person = new Person(['name' => 'john doe']); + $person->save(); + + $data = [ + 'name:en' => 'john doe', + 'email' => 'john@example.com' + ]; + + $validator = Validator::make($data, [ + 'name:en' => ['required', new TranslatableUnique(Person::class, 'name:en')], + ]); + + if ($validator->fails() && $validator->errors()->first() === Lang::get('translatable::validation.translatableUnique', ['attribute' => 'name:en'])) { + self::assertTrue(true); + } else { + self::assertTrue(false); + } + } + + /** + * Validate that the field rule for unique fails. + * + * @test + * @return void + */ + public function validate_field_rule_unique_fails(): void + { + $person = new Person(['name' => 'john doe']); + $person->save(); + + $data = [ + 'name:en' => 'john doe', + 'email' => 'john@example.com' + ]; + + $validator = Validator::make($data, [ + 'name:en' => ['required', Rule::translatableUnique(Person::class, 'name:en')], + ]); + + if ($validator->fails() && $validator->errors()->first() === Lang::get('translatable::validation.translatableUnique', ['attribute' => 'name:en'])) { + self::assertTrue(true); + } else { + self::assertTrue(false); + } + } + + /** + * Validate that the field exists. + * + * @test + * @return void + */ + public function validate_field_exists(): void + { + $person = new Person(['name' => 'john doe']); + $person->save(); + + $data = [ + 'name' => 'john andrew', + 'email' => 'john@example.com' + ]; + + $validator = Validator::make($data, [ + 'name' => ['required', new TranslatableExists(Person::class, 'name')], + ]); + + if ($validator->fails()) { + self::assertTrue(true); + } else { + self::assertTrue(false); + } + } + + /** + * Validate that the field exists and fails. + * + * @test + * @return void + */ + public function validate_field_exists_fails(): void + { + $person = new Person(['name' => 'john doe']); + $person->save(); + + $data = [ + 'name' => 'john doe', + 'email' => 'john@example.com' + ]; + + $validator = Validator::make($data, [ + 'name' => ['required', new TranslatableExists(Person::class, 'name')], + ]); + + if ($validator->fails() && $validator->errors()->first() === Lang::get('translatable::validation.TranslatableExists', ['attribute' => 'name'])) { + self::assertTrue(false); + } else { + self::assertTrue(true); + } + } + + /** + * Validate that the field rule for exists fails. + * + * @test + * @return void + */ + public function validate_field_rule_exists_fails(): void + { + $person = new Person(['name' => 'john doe']); + $person->save(); + + $data = [ + 'name' => 'john doe', + 'email' => 'john@example.com' + ]; + + $validator = Validator::make($data, [ + 'name' => ['required', Rule::translatableExists(Person::class, 'name')], + ]); + + if ($validator->fails() && $validator->errors()->first() === Lang::get('translatable::validation.TranslatableExists', ['attribute' => 'name'])) { + self::assertTrue(false); + } else { + self::assertTrue(true); + } + } + + /** + * Set up the test environment before each test. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + app('config')->set('translatable.locales', [ + 'en', + 'de' => [ + 'DE', + 'AT', + ], + ]); + + app(Locales::class)->load(); + } +} From be1c5de83800ae41a9d1ac6cfbd2c3b6e02bfced Mon Sep 17 00:00:00 2001 From: Amjad BaniMattar Date: Tue, 23 Apr 2024 01:02:34 +0300 Subject: [PATCH 2/6] Add missing PHPDocs to fix phpstan issues. --- src/Translatable/Contracts/Translatable.php | 19 ++++++++- src/Translatable/Locales.php | 9 ++++- .../TranslatableServiceProvider.php | 6 +-- src/Translatable/Validation/RuleFactory.php | 39 ++++++++++++++++--- 4 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/Translatable/Contracts/Translatable.php b/src/Translatable/Contracts/Translatable.php index 748546d0..7741a3b1 100644 --- a/src/Translatable/Contracts/Translatable.php +++ b/src/Translatable/Contracts/Translatable.php @@ -18,7 +18,10 @@ public static function disableDeleteTranslationsCascade(): void; public static function enableDeleteTranslationsCascade(): void; - public function deleteTranslations($locales = null): void; + /** + * @param string|array|null $locales + */ + public function deleteTranslations(string|array|null $locales = null): void; public function getDefaultLocale(): ?string; @@ -28,15 +31,21 @@ public function getTranslation(?string $locale = null, ?bool $withFallback = nul public function getTranslationOrNew(?string $locale = null): Model; + /** + * @return array> + */ public function getTranslationsArray(): array; public function hasTranslation(?string $locale = null): bool; public function isTranslationAttribute(string $key): bool; + /** + * @param null|array $except + */ public function replicateWithTranslations(?array $except = null): Model; - public function setDefaultLocale(?string $locale); + public function setDefaultLocale(?string $locale): self; public function translate(?string $locale = null, bool $withFallback = false): ?Model; @@ -44,7 +53,13 @@ public function translateOrDefault(?string $locale = null): ?Model; public function translateOrNew(?string $locale = null): Model; + /** + * @return HasOne + */ public function translation(): HasOne; + /** + * @return HasMany + */ public function translations(): HasMany; } diff --git a/src/Translatable/Locales.php b/src/Translatable/Locales.php index a288ee90..d9df423f 100644 --- a/src/Translatable/Locales.php +++ b/src/Translatable/Locales.php @@ -8,6 +8,10 @@ use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Translation\Translator as TranslatorContract; +/** + * @implements Arrayable + * @implements ArrayAccess + */ class Locales implements Arrayable, ArrayAccess { /** @@ -16,7 +20,7 @@ class Locales implements Arrayable, ArrayAccess protected $config; /** - * @var array + * @var array */ protected $locales = []; @@ -38,6 +42,9 @@ public function add(string $locale): void $this->locales[$locale] = $locale; } + /** + * @return array + */ public function all(): array { return array_values($this->locales); diff --git a/src/Translatable/TranslatableServiceProvider.php b/src/Translatable/TranslatableServiceProvider.php index 837114e6..a8f9384c 100644 --- a/src/Translatable/TranslatableServiceProvider.php +++ b/src/Translatable/TranslatableServiceProvider.php @@ -9,7 +9,7 @@ class TranslatableServiceProvider extends ServiceProvider { - public function boot() + public function boot(): void { $this->publishes([ __DIR__ . '/../config/translatable.php' => config_path('translatable.php'), @@ -21,7 +21,7 @@ public function boot() ], 'translatable-lang'); } - public function register() + public function register(): void { $this->mergeConfigFrom( __DIR__ . '/../config/translatable.php', @@ -38,7 +38,7 @@ public function register() $this->registerTranslatableHelper(); } - protected function registerTranslatableHelper() + protected function registerTranslatableHelper(): void { $this->app->singleton('translatable.locales', Locales::class); $this->app->singleton(Locales::class); diff --git a/src/Translatable/Validation/RuleFactory.php b/src/Translatable/Validation/RuleFactory.php index fee16dbb..9da47900 100644 --- a/src/Translatable/Validation/RuleFactory.php +++ b/src/Translatable/Validation/RuleFactory.php @@ -28,7 +28,7 @@ class RuleFactory protected $suffix; /** - * @var null|array + * @var null|array */ protected $locales = null; @@ -39,6 +39,17 @@ public function __construct(Repository $config, ?int $format = null, ?string $pr $this->suffix = $suffix ?? $config->get('translatable.rule_factory.suffix'); } + /** + * Create a set of validation rules. + * + * @param array $rules The validation rules to be parsed. + * @param int|null $format The format to be used for parsing (e.g., 'dot' or 'bracket'). + * @param string|null $prefix The prefix to be applied to each rule key. + * @param string|null $suffix The suffix to be applied to each rule key. + * @param array|null $locales The locales to be used for translating rule attributes. + * + * @return array The parsed validation rules. + */ public static function make(array $rules, ?int $format = null, ?string $prefix = null, ?string $suffix = null, ?array $locales = null): array { /** @var RuleFactory $factory */ @@ -49,6 +60,15 @@ public static function make(array $rules, ?int $format = null, ?string $prefix = return $factory->parse($rules); } + /** + * Set the locales to be used for translating rule attributes. + * + * @param array|null $locales The locales to be set. If null, all available locales will be used. + * + * @return self + * + * @throws \InvalidArgumentException If a provided locale is not defined in the available locales. + */ public function setLocales(?array $locales = null): self { /** @var Locales */ @@ -61,7 +81,7 @@ public function setLocales(?array $locales = null): self } foreach ($locales as $locale) { - if (! $helper->has($locale)) { + if (!$helper->has($locale)) { throw new InvalidArgumentException(sprintf('The locale [%s] is not defined in available locales.', $locale)); } } @@ -71,12 +91,19 @@ public function setLocales(?array $locales = null): self return $this; } + /** + * Parse the input array of rules, applying format and translation to translatable attributes. + * + * @param array $input The input array of rules to be parsed. + * + * @return array The parsed array of rules. + */ public function parse(array $input): array { $rules = []; foreach ($input as $key => $value) { - if (! $this->isTranslatable($key)) { + if (!$this->isTranslatable($key)) { $rules[$key] = $value; continue; @@ -127,10 +154,10 @@ protected function getReplacement(string $locale): string { switch ($this->format) { case self::FORMAT_KEY: - return '$1:'.$locale; + return '$1:' . $locale; default: case self::FORMAT_ARRAY: - return $locale.'.$1'; + return $locale . '.$1'; } } @@ -139,7 +166,7 @@ protected function getPattern(): string $prefix = preg_quote($this->prefix); $suffix = preg_quote($this->suffix); - return '/'.$prefix.'([^\.'.$prefix.$suffix.']+)'.$suffix.'/'; + return '/' . $prefix . '([^\.' . $prefix . $suffix . ']+)' . $suffix . '/'; } protected function isTranslatable(string $key): bool From b46b3cc18140a54af3ecba7938f58569dcae86d6 Mon Sep 17 00:00:00 2001 From: Amjad BaniMattar Date: Tue, 23 Apr 2024 21:21:00 +0300 Subject: [PATCH 3/6] - Move Documentation from ReadME to Docs - Fix PHPDocs, and Code Style --- .gitignore | 1 + README.md | 57 -------- docs/SUMMARY.md | 1 + docs/usage/custom-validation-rule.md | 107 +++++++++++++++ src/Translatable/Contracts/Translatable.php | 6 +- src/Translatable/Translatable.php | 3 +- .../TranslatableServiceProvider.php | 8 +- src/Translatable/Validation/RuleFactory.php | 30 ++--- .../Validation/Rules/TranslatableExists.php | 50 +++---- .../Validation/Rules/TranslatableUnique.php | 71 +--------- tests/CustomValidationTest.php | 123 ++++++++++-------- 11 files changed, 232 insertions(+), 225 deletions(-) create mode 100644 docs/usage/custom-validation-rule.md diff --git a/.gitignore b/.gitignore index d054a8cb..9b1af550 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /.phpunit.cache /build/ /coverage.clover +.history diff --git a/README.md b/README.md index 31b89ec6..6b04000e 100644 --- a/README.md +++ b/README.md @@ -68,63 +68,6 @@ $post = Post::create($data); echo $post->translate('fr')->title; // Mon premier post ``` -### **Validating Unique and Exists Rule** - -```php -use Astrotomic\Translatable\Validation\Rules\TranslatableUnique; -... - -$person = new Person(['name' => 'john doe']); -$person->save(); - -// Option 1 -$data = [ - 'name' => 'john doe', - 'email' => 'john@example.com' -]; -$validator = Validator::make($data, [ - 'name' => ['required', new TranslatableUnique(Person::class, 'name')], -]); - -// Option 2 -$data = [ - 'name:en' => 'john doe', - 'email' => 'john@example.com' -]; - -$validator = Validator::make($data, [ - 'name:en' => ['required', Rule::translatableUnique(Person::class, 'name:en')], -]); - -``` - -```php -use Astrotomic\Translatable\Validation\Rules\TranslatableExists; -... - -$person = new Person(['name' => 'john doe']); -$person->save(); - -// Option 1 -$data = [ - 'name' => 'john doe', - 'email' => 'john@example.com' -]; -$validator = Validator::make($data, [ - 'name' => ['required', new TranslatableExists(Person::class, 'name')], -]); - -// Option 2 -$data = [ - 'name:en' => 'john doe', - 'email' => 'john@example.com' -]; - -$validator = Validator::make($data, [ - 'name:en' => ['required', Rule::translatableExists(Person::class, 'name:en')], -]); -``` - ## Tutorials - [How To Add Multilingual Support to Eloquent](https://laravel-news.com/how-to-add-multilingual-support-to-eloquent) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index c82963f5..d506fa42 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -20,3 +20,4 @@ - [Attributes](usage/attributes.md) - [Forms](usage/forms.md) - [Pivot Model](usage/pivot-model.md) +- [Custom Validation Rules](usage/custom-validation-rule.md) diff --git a/docs/usage/custom-validation-rule.md b/docs/usage/custom-validation-rule.md new file mode 100644 index 00000000..2f3b31fa --- /dev/null +++ b/docs/usage/custom-validation-rule.md @@ -0,0 +1,107 @@ +### **Validating Unique and Exists Rule** + +You can use custom rules to validate unique and exists rules for translatable attributes. + +#### TranslatableUnique + +Ensure that the attribute value is unique by checking its absence in the database; if the value already exists, raise a validation exception. + +##### Option 1 + +```php +use Astrotomic\Translatable\Validation\Rules\TranslatableUnique; +... + +$person = new Person(['name' => 'john doe']); +$person->save(); + +$data = [ + 'name' => 'john doe', + 'email' => 'john@example.com' +]; +$validator = Validator::make($data, [ + 'name' => ['required', new TranslatableUnique(Person::class, 'name')], +]); + +``` + +##### Option 2 + +```php +use Astrotomic\Translatable\Validation\Rules\TranslatableUnique; +... + +$person = new Person(['name' => 'john doe']); +$person->save(); + +$data = [ + 'name:en' => 'john doe', + 'email' => 'john@example.com' +]; + +$validator = Validator::make($data, [ + 'name:en' => ['required', Rule::translatableUnique(Person::class, 'name:en')], +]); + +``` + +##### Option 2 + +```php +use Astrotomic\Translatable\Validation\Rules\TranslatableUnique; +... + +$person = new Person(['name' => 'john doe']); +$person->save(); + +$data = [ + 'name:en' => 'john doe', + 'email' => 'john@example.com' +]; + +$validator = Validator::make($data, [ + 'name:en' => ['required', Rule::translatableUnique(Person::class, 'name:en')], +]); + +``` + + +#### TranslatableExists + +Verify if the attribute value exists by confirming its presence in the database; if the value does not exist, raise a validation exception. + + +##### Option 1 +```php +use Astrotomic\Translatable\Validation\Rules\TranslatableExists; +... + +$person = new Person(['name' => 'john doe']); +$person->save(); + +$data = [ + 'name' => 'john doe', + 'email' => 'john@example.com' +]; +$validator = Validator::make($data, [ + 'name' => ['required', new TranslatableExists(Person::class, 'name')], +]); +``` + +##### Option 2 +```php +use Astrotomic\Translatable\Validation\Rules\TranslatableExists; +... + +$person = new Person(['name' => 'john doe']); +$person->save(); + +$data = [ + 'name:en' => 'john doe', + 'email' => 'john@example.com' +]; + +$validator = Validator::make($data, [ + 'name:en' => ['required', Rule::translatableExists(Person::class, 'name:en')], +]); +``` \ No newline at end of file diff --git a/src/Translatable/Contracts/Translatable.php b/src/Translatable/Contracts/Translatable.php index 7741a3b1..022844fd 100644 --- a/src/Translatable/Contracts/Translatable.php +++ b/src/Translatable/Contracts/Translatable.php @@ -19,7 +19,7 @@ public static function disableDeleteTranslationsCascade(): void; public static function enableDeleteTranslationsCascade(): void; /** - * @param string|array|null $locales + * @param string|array|null $locales */ public function deleteTranslations(string|array|null $locales = null): void; @@ -31,7 +31,7 @@ public function getTranslation(?string $locale = null, ?bool $withFallback = nul public function getTranslationOrNew(?string $locale = null): Model; - /** + /** * @return array> */ public function getTranslationsArray(): array; @@ -41,7 +41,7 @@ public function hasTranslation(?string $locale = null): bool; public function isTranslationAttribute(string $key): bool; /** - * @param null|array $except + * @param null|array $except */ public function replicateWithTranslations(?array $except = null): Model; diff --git a/src/Translatable/Translatable.php b/src/Translatable/Translatable.php index 41f5f8d4..94d6e8cf 100644 --- a/src/Translatable/Translatable.php +++ b/src/Translatable/Translatable.php @@ -2,6 +2,7 @@ namespace Astrotomic\Translatable; +use Astrotomic\Translatable\Contracts\Translatable as TranslatableContract; use Astrotomic\Translatable\Traits\Relationship; use Astrotomic\Translatable\Traits\Scopes; use Illuminate\Database\Eloquent\Collection; @@ -305,7 +306,7 @@ public function setAttribute($key, $value) return parent::setAttribute($key, $value); } - public function setDefaultLocale(?string $locale) + public function setDefaultLocale(?string $locale): TranslatableContract { $this->defaultLocale = $locale; diff --git a/src/Translatable/TranslatableServiceProvider.php b/src/Translatable/TranslatableServiceProvider.php index a8f9384c..4e5fbc48 100644 --- a/src/Translatable/TranslatableServiceProvider.php +++ b/src/Translatable/TranslatableServiceProvider.php @@ -12,19 +12,19 @@ class TranslatableServiceProvider extends ServiceProvider public function boot(): void { $this->publishes([ - __DIR__ . '/../config/translatable.php' => config_path('translatable.php'), + __DIR__.'/../config/translatable.php' => config_path('translatable.php'), ], 'translatable'); - $this->loadTranslationsFrom(__DIR__ . '/../lang', 'translatable'); + $this->loadTranslationsFrom(__DIR__.'/../lang', 'translatable'); $this->publishes([ - __DIR__ . '/../lang' => $this->app->langPath('vendor/translatable'), + __DIR__.'/../lang' => $this->app->langPath('vendor/translatable'), ], 'translatable-lang'); } public function register(): void { $this->mergeConfigFrom( - __DIR__ . '/../config/translatable.php', + __DIR__.'/../config/translatable.php', 'translatable' ); diff --git a/src/Translatable/Validation/RuleFactory.php b/src/Translatable/Validation/RuleFactory.php index 9da47900..470d7dde 100644 --- a/src/Translatable/Validation/RuleFactory.php +++ b/src/Translatable/Validation/RuleFactory.php @@ -42,12 +42,11 @@ public function __construct(Repository $config, ?int $format = null, ?string $pr /** * Create a set of validation rules. * - * @param array $rules The validation rules to be parsed. - * @param int|null $format The format to be used for parsing (e.g., 'dot' or 'bracket'). - * @param string|null $prefix The prefix to be applied to each rule key. - * @param string|null $suffix The suffix to be applied to each rule key. - * @param array|null $locales The locales to be used for translating rule attributes. - * + * @param array $rules The validation rules to be parsed. + * @param int|null $format The format to be used for parsing (e.g., 'dot' or 'bracket'). + * @param string|null $prefix The prefix to be applied to each rule key. + * @param string|null $suffix The suffix to be applied to each rule key. + * @param array|null $locales The locales to be used for translating rule attributes. * @return array The parsed validation rules. */ public static function make(array $rules, ?int $format = null, ?string $prefix = null, ?string $suffix = null, ?array $locales = null): array @@ -63,10 +62,8 @@ public static function make(array $rules, ?int $format = null, ?string $prefix = /** * Set the locales to be used for translating rule attributes. * - * @param array|null $locales The locales to be set. If null, all available locales will be used. - * - * @return self - * + * @param array|null $locales The locales to be set. If null, all available locales will be used. + * * @throws \InvalidArgumentException If a provided locale is not defined in the available locales. */ public function setLocales(?array $locales = null): self @@ -81,7 +78,7 @@ public function setLocales(?array $locales = null): self } foreach ($locales as $locale) { - if (!$helper->has($locale)) { + if (! $helper->has($locale)) { throw new InvalidArgumentException(sprintf('The locale [%s] is not defined in available locales.', $locale)); } } @@ -94,8 +91,7 @@ public function setLocales(?array $locales = null): self /** * Parse the input array of rules, applying format and translation to translatable attributes. * - * @param array $input The input array of rules to be parsed. - * + * @param array $input The input array of rules to be parsed. * @return array The parsed array of rules. */ public function parse(array $input): array @@ -103,7 +99,7 @@ public function parse(array $input): array $rules = []; foreach ($input as $key => $value) { - if (!$this->isTranslatable($key)) { + if (! $this->isTranslatable($key)) { $rules[$key] = $value; continue; @@ -154,10 +150,10 @@ protected function getReplacement(string $locale): string { switch ($this->format) { case self::FORMAT_KEY: - return '$1:' . $locale; + return '$1:'.$locale; default: case self::FORMAT_ARRAY: - return $locale . '.$1'; + return $locale.'.$1'; } } @@ -166,7 +162,7 @@ protected function getPattern(): string $prefix = preg_quote($this->prefix); $suffix = preg_quote($this->suffix); - return '/' . $prefix . '([^\.' . $prefix . $suffix . ']+)' . $suffix . '/'; + return '/'.$prefix.'([^\.'.$prefix.$suffix.']+)'.$suffix.'/'; } protected function isTranslatable(string $key): bool diff --git a/src/Translatable/Validation/Rules/TranslatableExists.php b/src/Translatable/Validation/Rules/TranslatableExists.php index 5802cf6a..1c47fa95 100644 --- a/src/Translatable/Validation/Rules/TranslatableExists.php +++ b/src/Translatable/Validation/Rules/TranslatableExists.php @@ -6,60 +6,60 @@ use Closure; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; /** * Custom exists validation for translatable attributes - * + * * @author Amjad BaniMattar */ class TranslatableExists implements ValidationRule { /** * The ID that should be ignored. - * - * @var mixed */ - protected mixed $ignore = null; + protected null|int|string $ignore = null; /** - * The name of the ID column. - * - * @var string + * The name of the ID column of ignored model. */ protected string $idColumn = 'id'; /** * The default locale - * - * @var string */ protected ?string $locale = null; - public function __construct(private string $model, private string $field) + /** + * @param class-string $model + * @param string $field The field to check for existents + */ + public function __construct(protected string $model, protected string $field) { + if (! class_exists($model)) { + throw new \Exception("Class '$model' does not exist."); + } + if (Str::contains($field, ':')) { [$this->field, $this->locale] = explode(':', $field); } - // } /** * Ignore the given ID during the unique check. * - * @param mixed $id - * @param string|null $idColumn * @return $this */ - public function ignore(mixed $id, ?string $idColumn = null): self + public function ignore(int|string|Model $id, ?string $idColumn = null): self { if ($id instanceof Model) { return $this->ignoreModel($id, $idColumn); } $this->ignore = $id; - $this->idColumn = $idColumn ?? 'id'; + $this->idColumn = $idColumn ?? ((new $this->model())->getKeyName()); return $this; } @@ -67,8 +67,6 @@ public function ignore(mixed $id, ?string $idColumn = null): self /** * Ignore the given model during the unique check. * - * @param \Illuminate\Database\Eloquent\Model $model - * @param string|null $idColumn * @return $this */ public function ignoreModel(Model $model, ?string $idColumn = null): self @@ -80,18 +78,24 @@ public function ignoreModel(Model $model, ?string $idColumn = null): self } /** - * Run the validation rule. + * Validate the given attribute against the exists constraint, or throw ValidationException. * + * @param string $attribute attribute name + * @param mixed $value attribute value * @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * + * @throws \Illuminate\Validation\ValidationException */ public function validate(string $attribute, mixed $value, Closure $fail): void { if (! empty($value)) { - $query = $this->model::whereTranslation($this->field, $value, $this->locale); - if ($this->ignore) { - $query->whereNot($this->idColumn, $this->ignore); - } - $exists = $query->exists(); + $exists = $this->model::query() + ->whereTranslation($this->field, $value, $this->locale) + ->when( + $this->ignore, + fn (Builder $query) => $query->whereNot($this->idColumn, $this->ignore) + ) + ->exists(); if (! $exists) { $fail('translatable::validation.translatableExist')->translate(); diff --git a/src/Translatable/Validation/Rules/TranslatableUnique.php b/src/Translatable/Validation/Rules/TranslatableUnique.php index babb2188..acacb641 100644 --- a/src/Translatable/Validation/Rules/TranslatableUnique.php +++ b/src/Translatable/Validation/Rules/TranslatableUnique.php @@ -6,81 +6,16 @@ use Closure; use Illuminate\Contracts\Validation\ValidationRule; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Str; /** * Custom unique validation for translatable attributes - * + * * @author Amjad BaniMattar */ -class TranslatableUnique implements ValidationRule +class TranslatableUnique extends TranslatableExists implements ValidationRule { /** - * The ID that should be ignored. - * - * @var mixed - */ - protected mixed $ignore = null; - - /** - * The name of the ID column. - * - * @var string - */ - protected string $idColumn = 'id'; - - /** - * The default locale - * - * @var string - */ - protected ?string $locale = null; - - public function __construct(private string $model, private string $field) - { - if (Str::contains($field, ':')) { - [$this->field, $this->locale] = explode(':', $field); - } - // - } - - /** - * Ignore the given ID during the unique check. - * - * @param mixed $id - * @param string|null $idColumn - * @return $this - */ - public function ignore(mixed $id, ?string $idColumn = null): self - { - if ($id instanceof Model) { - return $this->ignoreModel($id, $idColumn); - } - - $this->ignore = $id; - $this->idColumn = $idColumn ?? 'id'; - - return $this; - } - - /** - * Ignore the given model during the unique check. - * - * @param \Illuminate\Database\Eloquent\Model $model - * @param string|null $idColumn - * @return $this - */ - public function ignoreModel(Model $model, ?string$idColumn = null): self - { - $this->idColumn = $idColumn ?? $model->getKeyName(); - $this->ignore = $model->{$this->idColumn}; - - return $this; - } - - /** - * Run the validation rule. + * Validate if the given attribute is unique. * * @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail */ diff --git a/tests/CustomValidationTest.php b/tests/CustomValidationTest.php index 75509f4e..f2c5d307 100644 --- a/tests/CustomValidationTest.php +++ b/tests/CustomValidationTest.php @@ -10,8 +10,10 @@ use Illuminate\Support\Facades\Lang; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; use PHPUnit\Framework\Attributes\Test; use Tests\Eloquent\Person; +use Tests\Eloquent\Vegetable; final class CustomValidationTest extends TestCase { @@ -19,7 +21,6 @@ final class CustomValidationTest extends TestCase * Validate that the field is unique. * * @test - * @return void */ public function validate_field_unique(): void { @@ -28,25 +29,20 @@ public function validate_field_unique(): void $data = [ 'name' => 'john andrew', - 'email' => 'john@example.com' + 'email' => 'john@example.com', ]; $validator = Validator::make($data, [ 'name' => ['required', new TranslatableUnique(Person::class, 'name')], ]); - if ($validator->fails()) { - self::assertTrue(false); - } else { - self::assertTrue(true); - } + self::assertFalse($validator->fails()); } /** * Validate that the field is unique and fails. * * @test - * @return void */ public function validate_field_unique_fails(): void { @@ -55,25 +51,20 @@ public function validate_field_unique_fails(): void $data = [ 'name:en' => 'john doe', - 'email' => 'john@example.com' + 'email' => 'john@example.com', ]; - $validator = Validator::make($data, [ - 'name:en' => ['required', new TranslatableUnique(Person::class, 'name:en')], - ]); + $this->expectException(ValidationException::class); - if ($validator->fails() && $validator->errors()->first() === Lang::get('translatable::validation.translatableUnique', ['attribute' => 'name:en'])) { - self::assertTrue(true); - } else { - self::assertTrue(false); - } + Validator::make($data, [ + 'name:en' => ['required', new TranslatableUnique(Person::class, 'name:en')], + ])->validate(); } /** * Validate that the field rule for unique fails. * * @test - * @return void */ public function validate_field_rule_unique_fails(): void { @@ -82,25 +73,68 @@ public function validate_field_rule_unique_fails(): void $data = [ 'name:en' => 'john doe', - 'email' => 'john@example.com' + 'email' => 'john@example.com', ]; - $validator = Validator::make($data, [ + $this->expectException(ValidationException::class); + $this->expectExceptionMessage(Lang::get('translatable::validation.translatableUnique', ['attribute' => 'name:en'])); + + Validator::make($data, [ 'name:en' => ['required', Rule::translatableUnique(Person::class, 'name:en')], - ]); + ])->validate(); + + } + + /** + * Validate that the field rule for unique pass on update, ignoring model + * + * @test + */ + public function validate_field_rule_unique_pass_on_update_ignore_model(): void + { + $vegetable = new Vegetable(['name' => 'Potatoes', 'quantity' => '5']); + $vegetable->save(); + + $data = [ + 'name:en' => 'Potatoes', + 'quantity' => '3', + ]; + + Validator::make($data, [ + 'name:en' => ['required', Rule::translatableUnique(Vegetable::class, 'name:en')->ignore($vegetable)], + ])->validate(); + + $this->assertTrue(true); + + } + + /** + * Validate that the field rule for unique pass on update. + * + * @test + */ + public function validate_field_rule_unique_pass_on_update_ignore_int(): void + { + $vegetable = new Vegetable(['name' => 'Potatoes', 'quantity' => '5']); + $vegetable->save(); + + $data = [ + 'name:en' => 'Potatoes', + 'quantity' => '3', + ]; + + Validator::make($data, [ + 'name:en' => ['required', Rule::translatableUnique(Vegetable::class, 'name:en')->ignore(1)], + ])->validate(); + + $this->assertTrue(true); - if ($validator->fails() && $validator->errors()->first() === Lang::get('translatable::validation.translatableUnique', ['attribute' => 'name:en'])) { - self::assertTrue(true); - } else { - self::assertTrue(false); - } } /** * Validate that the field exists. * * @test - * @return void */ public function validate_field_exists(): void { @@ -109,25 +143,21 @@ public function validate_field_exists(): void $data = [ 'name' => 'john andrew', - 'email' => 'john@example.com' + 'email' => 'john@example.com', ]; - $validator = Validator::make($data, [ - 'name' => ['required', new TranslatableExists(Person::class, 'name')], - ]); + $this->expectException(ValidationException::class); + $this->expectExceptionMessage(Lang::get('translatable::validation.translatableExist', ['attribute' => 'name'])); - if ($validator->fails()) { - self::assertTrue(true); - } else { - self::assertTrue(false); - } + Validator::make($data, [ + 'name' => ['required', new TranslatableExists(Person::class, 'name')], + ])->validate(); } /** * Validate that the field exists and fails. * * @test - * @return void */ public function validate_field_exists_fails(): void { @@ -136,25 +166,20 @@ public function validate_field_exists_fails(): void $data = [ 'name' => 'john doe', - 'email' => 'john@example.com' + 'email' => 'john@example.com', ]; $validator = Validator::make($data, [ 'name' => ['required', new TranslatableExists(Person::class, 'name')], ]); - if ($validator->fails() && $validator->errors()->first() === Lang::get('translatable::validation.TranslatableExists', ['attribute' => 'name'])) { - self::assertTrue(false); - } else { - self::assertTrue(true); - } + self::assertFalse($validator->fails()); } /** * Validate that the field rule for exists fails. * * @test - * @return void */ public function validate_field_rule_exists_fails(): void { @@ -163,24 +188,18 @@ public function validate_field_rule_exists_fails(): void $data = [ 'name' => 'john doe', - 'email' => 'john@example.com' + 'email' => 'john@example.com', ]; $validator = Validator::make($data, [ 'name' => ['required', Rule::translatableExists(Person::class, 'name')], ]); - if ($validator->fails() && $validator->errors()->first() === Lang::get('translatable::validation.TranslatableExists', ['attribute' => 'name'])) { - self::assertTrue(false); - } else { - self::assertTrue(true); - } + self::assertFalse($validator->fails()); } /** * Set up the test environment before each test. - * - * @return void */ protected function setUp(): void { From 4f7a95993bf697e61a805efdcddbbce915952118 Mon Sep 17 00:00:00 2001 From: Amjad BaniMattar Date: Wed, 1 May 2024 20:42:59 +0300 Subject: [PATCH 4/6] Updated to support Laravel 9, the package now utilizes the deprecated InvokableRule interface instead of the new one introduced in Laravel 10. Future updates will accommodate the removal of Laravel 9 support. --- .../Validation/Rules/TranslatableExists.php | 19 +++++++++++++++++-- .../Validation/Rules/TranslatableUnique.php | 19 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/Translatable/Validation/Rules/TranslatableExists.php b/src/Translatable/Validation/Rules/TranslatableExists.php index 1c47fa95..f26fc4fd 100644 --- a/src/Translatable/Validation/Rules/TranslatableExists.php +++ b/src/Translatable/Validation/Rules/TranslatableExists.php @@ -5,7 +5,7 @@ namespace Astrotomic\Translatable\Validation\Rules; use Closure; -use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Contracts\Validation\InvokableRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; @@ -14,8 +14,11 @@ * Custom exists validation for translatable attributes * * @author Amjad BaniMattar + * + * @TODO should be updated to use use Illuminate\Contracts\Validation\ValidationRule; when this package drop of Laravel 9 support + * instead using detracted interface InvokableRule */ -class TranslatableExists implements ValidationRule +class TranslatableExists implements InvokableRule { /** * The ID that should be ignored. @@ -102,4 +105,16 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } } } + + /** + * Laravel 9 compatibility (InvokableRule interface) + * + * @param string $attribute + * @param mixed $value + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + */ + public function __invoke($attribute, $value, $fail): void + { + $this->validate($attribute, $value, $fail); + } } diff --git a/src/Translatable/Validation/Rules/TranslatableUnique.php b/src/Translatable/Validation/Rules/TranslatableUnique.php index acacb641..ed2439a2 100644 --- a/src/Translatable/Validation/Rules/TranslatableUnique.php +++ b/src/Translatable/Validation/Rules/TranslatableUnique.php @@ -5,14 +5,17 @@ namespace Astrotomic\Translatable\Validation\Rules; use Closure; -use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Contracts\Validation\InvokableRule; /** * Custom unique validation for translatable attributes * + * @TODO should be updated to use use Illuminate\Contracts\Validation\ValidationRule; when this package drop of Laravel 9 support + * instead using detracted interface InvokableRule + * * @author Amjad BaniMattar */ -class TranslatableUnique extends TranslatableExists implements ValidationRule +class TranslatableUnique extends TranslatableExists implements InvokableRule { /** * Validate if the given attribute is unique. @@ -33,4 +36,16 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } } } + + /** + * Laravel 9 compatibility (InvokableRule interface) + * + * @param string $attribute + * @param mixed $value + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + */ + public function __invoke($attribute, $value, $fail): void + { + $this->validate($attribute, $value, $fail); + } } From 5ba1a6e2a7fc660654f55454fa292a6a58ef8845 Mon Sep 17 00:00:00 2001 From: Amjad BaniMattar Date: Wed, 1 May 2024 20:54:46 +0300 Subject: [PATCH 5/6] Refactor: Remove unnecessary 'ignore older' configuration --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9b1af550..d054a8cb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,3 @@ /.phpunit.cache /build/ /coverage.clover -.history From 33bcf272a09846427915f4ee16d3b48156e0e82e Mon Sep 17 00:00:00 2001 From: Amjad BaniMattar Date: Wed, 17 Jul 2024 12:04:17 +0300 Subject: [PATCH 6/6] Fix Import Rule Class in the Documentation --- docs/usage/custom-validation-rule.md | 4 ++-- src/Translatable/Locales.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/usage/custom-validation-rule.md b/docs/usage/custom-validation-rule.md index 2f3b31fa..a96ea854 100644 --- a/docs/usage/custom-validation-rule.md +++ b/docs/usage/custom-validation-rule.md @@ -48,7 +48,7 @@ $validator = Validator::make($data, [ ##### Option 2 ```php -use Astrotomic\Translatable\Validation\Rules\TranslatableUnique; +use Illuminate\Validation\Rule; ... $person = new Person(['name' => 'john doe']); @@ -90,7 +90,7 @@ $validator = Validator::make($data, [ ##### Option 2 ```php -use Astrotomic\Translatable\Validation\Rules\TranslatableExists; +use Illuminate\Validation\Rule; ... $person = new Person(['name' => 'john doe']); diff --git a/src/Translatable/Locales.php b/src/Translatable/Locales.php index d9df423f..4e805ff1 100644 --- a/src/Translatable/Locales.php +++ b/src/Translatable/Locales.php @@ -20,7 +20,7 @@ class Locales implements Arrayable, ArrayAccess protected $config; /** - * @var array + * @var array */ protected $locales = [];