Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhance Custom Rules: Introduce translatableExists and translatableUnique for Validating Translatable Attributes #406

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')],
]);
```

amjadbanimattar marked this conversation as resolved.
Show resolved Hide resolved
## Tutorials

- [How To Add Multilingual Support to Eloquent](https://laravel-news.com/how-to-add-multilingual-support-to-eloquent)
Expand Down
19 changes: 17 additions & 2 deletions src/Translatable/Contracts/Translatable.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ public static function disableDeleteTranslationsCascade(): void;

public static function enableDeleteTranslationsCascade(): void;

public function deleteTranslations($locales = null): void;
/**
* @param string|array<string>|null $locales
*/
public function deleteTranslations(string|array|null $locales = null): void;

public function getDefaultLocale(): ?string;

Expand All @@ -28,23 +31,35 @@ public function getTranslation(?string $locale = null, ?bool $withFallback = nul

public function getTranslationOrNew(?string $locale = null): Model;

/**
* @return array<string,array<string,mixed>>
*/
public function getTranslationsArray(): array;

public function hasTranslation(?string $locale = null): bool;

public function isTranslationAttribute(string $key): bool;

/**
* @param null|array<string> $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;

public function translateOrDefault(?string $locale = null): ?Model;

public function translateOrNew(?string $locale = null): Model;

/**
* @return HasOne<Model>
*/
public function translation(): HasOne;

/**
* @return HasMany<Model>
*/
public function translations(): HasMany;
}
9 changes: 8 additions & 1 deletion src/Translatable/Locales.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Translation\Translator as TranslatorContract;

/**
* @implements Arrayable<string, string>
* @implements ArrayAccess<string, string>
*/
class Locales implements Arrayable, ArrayAccess
{
/**
Expand All @@ -16,7 +20,7 @@ class Locales implements Arrayable, ArrayAccess
protected $config;

/**
* @var array
* @var array<string>
amjadbanimattar marked this conversation as resolved.
Show resolved Hide resolved
*/
protected $locales = [];

Expand All @@ -38,6 +42,9 @@ public function add(string $locale): void
$this->locales[$locale] = $locale;
}

/**
* @return array<string>
*/
public function all(): array
{
return array_values($this->locales);
Expand Down
26 changes: 21 additions & 5 deletions src/Translatable/TranslatableServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,43 @@

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()
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->publishes([
__DIR__ . '/../lang' => $this->app->langPath('vendor/translatable'),
], 'translatable-lang');
}

public function register()
public function register(): void
{
$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();
}

protected function registerTranslatableHelper()
protected function registerTranslatableHelper(): void
{
$this->app->singleton('translatable.locales', Locales::class);
$this->app->singleton(Locales::class);
Expand Down
39 changes: 33 additions & 6 deletions src/Translatable/Validation/RuleFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class RuleFactory
protected $suffix;

/**
* @var null|array
* @var null|array<string>
*/
protected $locales = null;

Expand All @@ -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<mixed> $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<string>|null $locales The locales to be used for translating rule attributes.
*
* @return array<string,mixed> 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 */
Expand All @@ -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<string>|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 */
Expand All @@ -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));
}
}
Expand All @@ -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<mixed> $input The input array of rules to be parsed.
*
* @return array<mixed> 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;
Expand Down Expand Up @@ -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';
}
}

Expand All @@ -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
Expand Down
101 changes: 101 additions & 0 deletions src/Translatable/Validation/Rules/TranslatableExists.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

declare(strict_types=1);

namespace Astrotomic\Translatable\Validation\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

/**
* Custom exists validation for translatable attributes
*
* @author Amjad BaniMattar <amjad.banimattar@gmail.com>
*/
class TranslatableExists implements ValidationRule
{
/**
* The ID that should be ignored.
*
* @var mixed
*/
protected mixed $ignore = null;
amjadbanimattar marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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)
amjadbanimattar marked this conversation as resolved.
Show resolved Hide resolved
{
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
amjadbanimattar marked this conversation as resolved.
Show resolved Hide resolved
{
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();
amjadbanimattar marked this conversation as resolved.
Show resolved Hide resolved

if (! $exists) {
$fail('translatable::validation.translatableExist')->translate();
}
}
}
}
Loading