├── .editorconfig ├── .github ├── FUNDING.yml ├── SECURITY.md └── workflows │ └── run-tests.yml ├── LICENSE.md ├── README.md ├── composer.json └── src ├── UniqueTranslationRule.php ├── UniqueTranslationServiceProvider.php └── UniqueTranslationValidator.php /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.yml] 15 | indent_size = 2 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: ivanvermeyen 2 | custom: https://paypal.me/ivanvermeyen 3 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | If you discover any security related issues, please email ivan@codezero.be instead of using the issue tracker. 4 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: true 10 | matrix: 11 | php: [ 8.0, 8.1, 8.2, 8.3 ] 12 | laravel: [ 8.*, 9.*, 10.*, 11.* ] 13 | dependency-version: [ prefer-stable ] 14 | exclude: 15 | - laravel: 10.* 16 | php: 8.0 17 | - laravel: 11.* 18 | php: 8.0 19 | - laravel: 11.* 20 | php: 8.1 21 | include: 22 | - laravel: 6.* 23 | php: 7.2 24 | testbench: 4.* 25 | - laravel: 6.* 26 | php: 8.0 27 | testbench: 4.* 28 | - laravel: 7.* 29 | php: 7.2 30 | testbench: 5.* 31 | - laravel: 7.* 32 | php: 8.0 33 | testbench: 5.* 34 | - laravel: 8.* 35 | php: 7.3 36 | testbench: 6.* 37 | - laravel: 8.* 38 | testbench: 6.* 39 | - laravel: 9.* 40 | testbench: 7.* 41 | - laravel: 10.* 42 | testbench: 8.* 43 | - laravel: 11.* 44 | testbench: 9.* 45 | 46 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} 47 | 48 | services: 49 | mysql: 50 | image: mysql:5.7 51 | env: 52 | MYSQL_ALLOW_EMPTY_PASSWORD: yes 53 | MYSQL_DATABASE: testing 54 | ports: 55 | - 3306 56 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 57 | 58 | steps: 59 | - name: Checkout code 60 | uses: actions/checkout@v2 61 | 62 | - name: Cache dependencies 63 | uses: actions/cache@v2 64 | with: 65 | path: ~/.composer/cache/files 66 | key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 67 | 68 | - name: Setup PHP 69 | uses: shivammathur/setup-php@v2 70 | with: 71 | php-version: ${{ matrix.php }} 72 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick 73 | coverage: pcov 74 | 75 | - name: Install dependencies 76 | run: composer update --with="orchestra/testbench:${{ matrix.testbench }}" --prefer-dist --no-interaction --no-progress 77 | 78 | - name: Execute tests 79 | run: vendor/bin/phpunit --coverage-clover=coverage.xml 80 | env: 81 | DB_PORT: ${{ job.services.mysql.ports[3306] }} 82 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Ivan Vermeyen () 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Unique Translation 2 | 3 | ## IMPORTANT: March 2022 4 | 5 | [![Support Ukraine](https://raw.githubusercontent.com/hampusborgos/country-flags/main/png100px/ua.png)](https://github.com/hampusborgos/country-flags/blob/main/png100px/ua.png) 6 | 7 | It's horrible to see what is happening now in Ukraine, as Russian army is 8 | [bombarding houses, hospitals and kindergartens](https://twitter.com/DavidCornDC/status/1501620037785997316). 9 | 10 | Please [check out supportukrainenow.org](https://supportukrainenow.org/) for the ways how you can help people there. 11 | Spread the word. 12 | 13 | And if you are from Russia and you are against this war, please express your protest in some way. 14 | I know you can get punished for this, but you are one of the hopes of those innocent people. 15 | 16 | --- 17 | 18 | [![GitHub release](https://img.shields.io/github/release/codezero-be/laravel-unique-translation.svg?style=flat-square)](https://github.com/codezero-be/laravel-unique-translation/releases) 19 | [![Laravel](https://img.shields.io/badge/laravel-11-red?style=flat-square&logo=laravel&logoColor=white)](https://laravel.com) 20 | [![License](https://img.shields.io/packagist/l/codezero/laravel-unique-translation.svg?style=flat-square)](LICENSE.md) 21 | [![Build Status](https://img.shields.io/github/actions/workflow/status/codezero-be/laravel-unique-translation/run-tests.yml?style=flat-square&logo=github&logoColor=white&label=tests)](https://github.com/codezero-be/laravel-unique-translation/actions) 22 | [![Code Coverage](https://img.shields.io/codacy/coverage/bb5f876fb1a94aa0a426fd31a2656e5b/master?style=flat-square)](https://app.codacy.com/gh/codezero-be/laravel-unique-translation) 23 | [![Code Quality](https://img.shields.io/codacy/grade/bb5f876fb1a94aa0a426fd31a2656e5b/master?style=flat-square)](https://app.codacy.com/gh/codezero-be/laravel-unique-translation) 24 | [![Total Downloads](https://img.shields.io/packagist/dt/codezero/laravel-unique-translation.svg?style=flat-square)](https://packagist.org/packages/codezero/laravel-unique-translation) 25 | 26 | [![ko-fi](https://www.ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R3UQ8V) 27 | 28 | #### Check if a translated value in a JSON column is unique in the database. 29 | 30 | Imagine you want store a `slug` for a `Post` model in different languages. 31 | 32 | The amazing [`spatie/laravel-translatable`](https://github.com/spatie/laravel-translatable) package makes this a cinch! 33 | 34 | But then you want to make sure each translation is unique for its language. 35 | 36 | That's where this package comes in to play. 37 | 38 | This package also supports [`spatie/nova-translatable`](https://github.com/spatie/nova-translatable/) in case you are using [Laravel Nova](https://nova.laravel.com/) and [`filamentphp/spatie-laravel-translatable-plugin`](https://github.com/filamentphp/spatie-laravel-translatable-plugin) in case you are using [Filament](https://filamentphp.com/). 39 | 40 | ## ✅ Requirements 41 | 42 | - PHP ^7.2 or PHP ^8.0 43 | - MySQL >= 5.7 44 | - [Laravel](https://laravel.com/) >= 6 45 | - [spatie/laravel-translatable](https://github.com/spatie/laravel-translatable) ^4.4|^5.0|^6.0 46 | - [spatie/nova-translatable](https://github.com/spatie/nova-translatable/) ^3.0 47 | - [filamentphp/spatie-laravel-translatable-plugin](https://github.com/filamentphp/spatie-laravel-translatable-plugin) ^3.0 48 | 49 | ## 📦 Installation 50 | 51 | Require the package via Composer: 52 | 53 | ``` 54 | composer require codezero/laravel-unique-translation 55 | ``` 56 | Laravel will automatically register the [ServiceProvider](https://github.com/codezero-be/laravel-unique-translation/blob/master/src/UniqueTranslationServiceProvider.php). 57 | 58 | ## 🛠 Usage 59 | 60 | For the following examples, I will use a `slug` in a `posts` table as the subject of our validation. 61 | 62 | ### ☑️ Validate a Single Translation 63 | 64 | Your form can submit a single slug: 65 | 66 | ```html 67 | 68 | ``` 69 | 70 | We can then check if it is unique **in the current locale**: 71 | 72 | ```php 73 | $attributes = request()->validate([ 74 | 'slug' => 'required|unique_translation:posts', 75 | ]); 76 | ``` 77 | 78 | You could also use the Rule instance: 79 | 80 | ```php 81 | use CodeZero\UniqueTranslation\UniqueTranslationRule; 82 | 83 | $attributes = request()->validate([ 84 | 'slug' => ['required', UniqueTranslationRule::for('posts')], 85 | ]); 86 | ``` 87 | 88 | ### ☑️ Validate an Array of Translations 89 | 90 | Your form can also submit an array of slugs. 91 | 92 | ```html 93 | 94 | 95 | ``` 96 | 97 | We need to validate the entire array in this case. Mind the `slug.*` key. 98 | 99 | ```php 100 | $attributes = request()->validate([ 101 | 'slug.*' => 'unique_translation:posts', 102 | // or... 103 | 'slug.*' => UniqueTranslationRule::for('posts'), 104 | ]); 105 | ``` 106 | 107 | ### ☑️ Specify a Column 108 | 109 | Maybe your form field has a name of `post_slug` and your database field `slug`: 110 | 111 | ```php 112 | $attributes = request()->validate([ 113 | 'post_slug.*' => 'unique_translation:posts,slug', 114 | // or... 115 | 'post_slug.*' => UniqueTranslationRule::for('posts', 'slug'), 116 | ]); 117 | ``` 118 | 119 | ### ☑️ Specify a Database Connection 120 | 121 | If you are using multiple database connections, you can specify which one to use by prepending it to the table name, separated by a dot: 122 | 123 | ```php 124 | $attributes = request()->validate([ 125 | 'slug.*' => 'unique_translation:db_connection.posts', 126 | // or... 127 | 'slug.*' => UniqueTranslationRule::for('db_connection.posts'), 128 | ]); 129 | ``` 130 | 131 | ### ☑️ Ignore a Record with ID 132 | 133 | If you're updating a record, you may want to ignore the post itself from the unique check. 134 | 135 | ```php 136 | $attributes = request()->validate([ 137 | 'slug.*' => "unique_translation:posts,slug,{$post->id}", 138 | // or... 139 | 'slug.*' => UniqueTranslationRule::for('posts')->ignore($post->id), 140 | ]); 141 | ``` 142 | 143 | ### ☑️ Ignore Records with a Specific Column and Value 144 | 145 | If your ID column has a different name, or you just want to use another column: 146 | 147 | ```php 148 | $attributes = request()->validate([ 149 | 'slug.*' => 'unique_translation:posts,slug,ignore_value,ignore_column', 150 | // or... 151 | 'slug.*' => UniqueTranslationRule::for('posts')->ignore('ignore_value', 'ignore_column'), 152 | ]); 153 | ``` 154 | 155 | ### ☑️ Use Additional Where Clauses 156 | 157 | You can add 4 types of where clauses to the rule. 158 | 159 | #### `where` 160 | 161 | ```php 162 | $attributes = request()->validate([ 163 | 'slug.*' => "unique_translation:posts,slug,null,null,column,value", 164 | // or... 165 | 'slug.*' => UniqueTranslationRule::for('posts')->where('column', 'value'), 166 | ]); 167 | ``` 168 | 169 | #### `whereNot` 170 | 171 | ```php 172 | $attributes = request()->validate([ 173 | 'slug.*' => "unique_translation:posts,slug,null,null,column,!value", 174 | // or... 175 | 'slug.*' => UniqueTranslationRule::for('posts')->whereNot('column', 'value'), 176 | ]); 177 | ``` 178 | 179 | #### `whereNull` 180 | 181 | ```php 182 | $attributes = request()->validate([ 183 | 'slug.*' => "unique_translation:posts,slug,null,null,column,NULL", 184 | // or... 185 | 'slug.*' => UniqueTranslationRule::for('posts')->whereNull('column'), 186 | ]); 187 | ``` 188 | 189 | #### `whereNotNull` 190 | 191 | ```php 192 | $attributes = request()->validate([ 193 | 'slug.*' => "unique_translation:posts,slug,null,null,column,NOT_NULL", 194 | // or... 195 | 'slug.*' => UniqueTranslationRule::for('posts')->whereNotNull('column'), 196 | ]); 197 | ``` 198 | 199 | ### ☑️ Laravel Nova 200 | 201 | If you are using [Laravel Nova](https://nova.laravel.com/) in combination with [`spatie/nova-translatable`](https://github.com/spatie/nova-translatable/), then you can add the validation rule like this: 202 | 203 | ```php 204 | Text::make(__('Slug'), 'slug') 205 | ->creationRules('unique_translation:posts,slug') 206 | ->updateRules('unique_translation:posts,slug,{{resourceId}}'); 207 | ``` 208 | 209 | ### ☑️ Filament 210 | 211 | If you are using [Filament](https://filamentphp.com/) in combination with [`filamentphp/spatie-laravel-translatable-plugin`](https://github.com/filamentphp/spatie-laravel-translatable-plugin), then you can add the validation rule like this: 212 | 213 | ```php 214 | TextInput::make('slug') 215 | ->title(__('Slug')) 216 | ->rules([ 217 | UniqueTranslationRule::for('posts', 'slug') 218 | ]) 219 | ``` 220 | 221 | ```php 222 | TextInput::make('slug') 223 | ->title(__('Slug')) 224 | ->rules([ 225 | fn (Get $get) => UniqueTranslationRule::for('posts', 'slug')->ignore($get('id')) 226 | ]) 227 | ``` 228 | 229 | ## 🖥 Example 230 | 231 | Your existing `slug` column (JSON) in a `posts` table: 232 | 233 | ```json 234 | { 235 | "en":"not-abc", 236 | "nl":"abc" 237 | } 238 | ``` 239 | 240 | Your form input to create a new record: 241 | 242 | 243 | ```html 244 | 245 | 246 | ``` 247 | 248 | Your validation logic: 249 | 250 | ```php 251 | $attributes = request()->validate([ 252 | 'slug.*' => 'unique_translation:posts', 253 | ]); 254 | ``` 255 | 256 | The result is that `slug[en]` is valid, since the only `en` value in the database is `not-abc`. 257 | 258 | And `slug[nl]` would fail, because there already is a `nl` value of `abc`. 259 | 260 | ## ⚠️ Error Messages 261 | 262 | You can pass your own error messages as normal. 263 | 264 | When validating a single form field: 265 | 266 | ```html 267 | 268 | ``` 269 | 270 | ```php 271 | $attributes = request()->validate([ 272 | 'slug' => 'unique_translation:posts', 273 | ], [ 274 | 'slug.unique_translation' => 'Your custom :attribute error.', 275 | ]); 276 | ``` 277 | 278 | In your view you can then get the error with `$errors->first('slug')`. 279 | 280 | Or when validation an array: 281 | 282 | ```html 283 | 284 | ``` 285 | 286 | ```php 287 | $attributes = request()->validate([ 288 | 'slug.*' => 'unique_translation:posts', 289 | ], [ 290 | 'slug.*.unique_translation' => 'Your custom :attribute error.', 291 | ]); 292 | ``` 293 | 294 | In your view you can then get the error with `$errors->first('slug.en')` (`en` being your array key). 295 | 296 | ## 🚧 Testing 297 | 298 | ``` 299 | vendor/bin/phpunit 300 | ``` 301 | 302 | ## ☕️ Credits 303 | 304 | - [Ivan Vermeyen](https://byterider.io) 305 | - [All contributors](../../contributors) 306 | 307 | ## 🔓 Security 308 | 309 | If you discover any security related issues, please [e-mail me](mailto:ivan@codezero.be) instead of using the issue tracker. 310 | 311 | ## 📑 Changelog 312 | 313 | A complete list of all notable changes to this package can be found on the 314 | [releases page](https://github.com/codezero-be/laravel-unique-translation/releases). 315 | 316 | ## 📜 License 317 | 318 | The MIT License (MIT). Please see [License File](https://github.com/codezero-be/laravel-unique-translation/blob/master/LICENSE.md) for more information. 319 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codezero/laravel-unique-translation", 3 | "description": "Check if a translated value in a JSON column is unique in the database.", 4 | "keywords": [ 5 | "translation", 6 | "json", 7 | "mysql", 8 | "php", 9 | "laravel", 10 | "validation", 11 | "validator", 12 | "unique", 13 | "rule", 14 | "language", 15 | "database" 16 | ], 17 | "license": "MIT", 18 | "authors": [ 19 | { 20 | "name": "Ivan Vermeyen", 21 | "email": "ivan@codezero.be" 22 | } 23 | ], 24 | "require": { 25 | "php": "^7.2|^8.0" 26 | }, 27 | "require-dev": { 28 | "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0", 29 | "phpunit/phpunit": "^8.0|^9.0|^10.0", 30 | "spatie/laravel-translatable": "^4.4|^5.0|^6.0" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "CodeZero\\UniqueTranslation\\": "src" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "CodeZero\\UniqueTranslation\\Tests\\": "tests" 40 | } 41 | }, 42 | "extra": { 43 | "laravel": { 44 | "providers": [ 45 | "CodeZero\\UniqueTranslation\\UniqueTranslationServiceProvider" 46 | ] 47 | } 48 | }, 49 | "config": { 50 | "preferred-install": "dist", 51 | "sort-packages": true, 52 | "optimize-autoloader": true 53 | }, 54 | "minimum-stability": "dev", 55 | "prefer-stable": true 56 | } 57 | -------------------------------------------------------------------------------- /src/UniqueTranslationRule.php: -------------------------------------------------------------------------------- 1 | table = $table; 54 | $this->column = $column; 55 | } 56 | 57 | /** 58 | * Ignore any record that has a column with the given value. 59 | * 60 | * @param mixed $value 61 | * @param string $column 62 | * 63 | * @return $this 64 | */ 65 | public function ignore($value, $column = 'id') 66 | { 67 | $this->ignoreValue = $value; 68 | $this->ignoreColumn = $column; 69 | 70 | return $this; 71 | } 72 | 73 | /** 74 | * Generate a string representation of the validation rule. 75 | * 76 | * @return string 77 | */ 78 | public function __toString() 79 | { 80 | return rtrim(sprintf( 81 | '%s:%s,%s,%s,%s,%s', 82 | $this->rule, 83 | $this->table, 84 | $this->column ?: 'NULL', 85 | $this->ignoreValue ?: 'NULL', 86 | $this->ignoreColumn ?: 'NULL', 87 | $this->formatWheres() 88 | ), ','); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/UniqueTranslationServiceProvider.php: -------------------------------------------------------------------------------- 1 | isNovaTranslation($attribute) 26 | ? $this->getNovaAttributeNameAndLocale($attribute) 27 | : ( 28 | $this->isFilamentTranslation($attribute) 29 | ? $this->getFilamentAttributeNameAndLocale($attribute, $validator) 30 | : $this->getArrayAttributeNameAndLocale($attribute) 31 | ); 32 | 33 | if ($this->isUnique($value, $name, $locale, $parameters)) { 34 | return true; 35 | } 36 | 37 | $this->setMissingErrorMessages($validator, $name, $locale); 38 | 39 | return false; 40 | } 41 | 42 | /** 43 | * Set any missing (custom) error messages for our validation rule. 44 | * 45 | * @param \Illuminate\Validation\Validator $validator 46 | * @param string $name 47 | * @param string $locale 48 | * 49 | * @return void 50 | */ 51 | protected function setMissingErrorMessages($validator, $name, $locale) 52 | { 53 | $rule = 'unique_translation'; 54 | 55 | $keys = [ 56 | "{$name}.{$rule}", 57 | "{$name}.*.{$rule}", 58 | "{$name}.{$locale}.{$rule}", 59 | "translations_{$name}_{$locale}.{$rule}", 60 | ]; 61 | 62 | foreach ($keys as $key) { 63 | if ( ! array_key_exists($key, $validator->customMessages)) { 64 | $validator->customMessages[$key] = trans('validation.unique'); 65 | } 66 | } 67 | } 68 | 69 | /** 70 | * Check if the attribute is a Nova translation field name. 71 | * 72 | * @param string $attribute 73 | * 74 | * @return bool 75 | */ 76 | protected function isNovaTranslation($attribute) 77 | { 78 | return strpos($attribute, '.') === false && strpos($attribute, 'translations_') === 0; 79 | } 80 | 81 | /** 82 | * Get the attribute name and locale of a Filament translation field. 83 | * 84 | * @param string $attribute 85 | * 86 | * @return array 87 | */ 88 | protected function getNovaAttributeNameAndLocale($attribute) 89 | { 90 | $attribute = str_replace('translations_', '', $attribute); 91 | 92 | return $this->getAttributeNameAndLocale($attribute, '_'); 93 | } 94 | 95 | /** 96 | * Check if the attribute is a Filament translation field name. 97 | * 98 | * @param string $attribute 99 | * 100 | * @return bool 101 | */ 102 | protected function isFilamentTranslation($attribute) 103 | { 104 | return strpos($attribute, 'data.') === 0; 105 | } 106 | 107 | /** 108 | * Get the attribute name and locale of a Filament translation field. 109 | * 110 | * @param string $attribute 111 | * 112 | * @return array 113 | */ 114 | protected function getFilamentAttributeNameAndLocale($attribute, $validator) 115 | { 116 | $attribute = str_replace('data.', '', $attribute); 117 | 118 | $dataValidator = $validator->getData(); 119 | 120 | @list($name, $locale) = @explode('.', $attribute); 121 | 122 | if ($locale === null && Arr::exists($dataValidator, 'activeLocale')) { 123 | $locale = $dataValidator['activeLocale']; 124 | } 125 | 126 | return [$name, $locale]; 127 | } 128 | 129 | /** 130 | * Get the attribute name and locale of an array field. 131 | * 132 | * @param string $attribute 133 | * 134 | * @return array 135 | */ 136 | protected function getArrayAttributeNameAndLocale($attribute) 137 | { 138 | return $this->getAttributeNameAndLocale($attribute, '.'); 139 | } 140 | 141 | /** 142 | * Get the attribute name and locale. 143 | * 144 | * @param string $attribute 145 | * @param string $delimiter 146 | * 147 | * @return array 148 | */ 149 | protected function getAttributeNameAndLocale($attribute, $delimiter) 150 | { 151 | $locale = $this->getAttributeLocale($attribute, $delimiter); 152 | $name = $this->getAttributeName($attribute, $locale, $delimiter); 153 | 154 | return [$name, $locale ?: App::getLocale()]; 155 | } 156 | 157 | /** 158 | * Get the locale from the attribute name. 159 | * 160 | * @param string $attribute 161 | * @param string $delimiter 162 | * 163 | * @return string|null 164 | */ 165 | protected function getAttributeLocale($attribute, $delimiter) 166 | { 167 | $pos = strrpos($attribute, $delimiter); 168 | 169 | return $pos > 0 ? substr($attribute, $pos + 1) : null; 170 | } 171 | 172 | /** 173 | * Get the attribute name without the locale. 174 | * 175 | * @param string $attribute 176 | * @param string|null $locale 177 | * @param string $delimiter 178 | * 179 | * @return string 180 | */ 181 | protected function getAttributeName($attribute, $locale, $delimiter) 182 | { 183 | return $locale ? str_replace("{$delimiter}{$locale}", '', $attribute) : $attribute; 184 | } 185 | 186 | /** 187 | * Get the database connection and table name. 188 | * 189 | * @param array $parameters 190 | * 191 | * @return array 192 | */ 193 | protected function getConnectionAndTable($parameters) 194 | { 195 | $parts = explode('.', $this->getParameter($parameters, 0)); 196 | 197 | $connection = isset($parts[1]) 198 | ? $parts[0] 199 | : Config::get('database.default'); 200 | 201 | $table = $parts[1] ?? $parts[0]; 202 | 203 | return [$connection, $table]; 204 | } 205 | 206 | /** 207 | * Get the parameter value at the given index. 208 | * 209 | * @param array $parameters 210 | * @param int $index 211 | * 212 | * @return string|null 213 | */ 214 | protected function getParameter($parameters, $index) 215 | { 216 | return $this->convertNullValue($parameters[$index] ?? null); 217 | } 218 | 219 | /** 220 | * Convert any 'NULL' string value to null. 221 | * 222 | * @param string $value 223 | * 224 | * @return string|null 225 | */ 226 | protected function convertNullValue($value) 227 | { 228 | return strtoupper($value) === 'NULL' ? null : $value; 229 | } 230 | 231 | /** 232 | * Check if a translation is unique. 233 | * 234 | * @param mixed $value 235 | * @param string $name 236 | * @param string $locale 237 | * @param array $parameters 238 | * 239 | * @return bool 240 | */ 241 | protected function isUnique($value, $name, $locale, $parameters) 242 | { 243 | list ($connection, $table) = $this->getConnectionAndTable($parameters); 244 | 245 | $column = $this->getParameter($parameters, 1) ?? $name; 246 | $ignoreValue = $this->getParameter($parameters, 2); 247 | $ignoreColumn = $this->getParameter($parameters, 3); 248 | 249 | $query = $this->findTranslation($connection, $table, $column, $locale, $value); 250 | $query = $this->ignore($query, $ignoreColumn, $ignoreValue); 251 | $query = $this->addConditions($query, $this->getUniqueExtra($parameters)); 252 | 253 | $isUnique = $query->count() === 0; 254 | 255 | return $isUnique; 256 | } 257 | 258 | /** 259 | * Find the given translated value in the database. 260 | * 261 | * @param string $connection 262 | * @param string $table 263 | * @param string $column 264 | * @param string $locale 265 | * @param mixed $value 266 | * 267 | * @return \Illuminate\Database\Query\Builder 268 | */ 269 | protected function findTranslation($connection, $table, $column, $locale, $value) 270 | { 271 | // Properly escape backslashes to work with LIKE queries... 272 | // See: https://stackoverflow.com/questions/14926386/how-to-search-for-slash-in-mysql-and-why-escaping-not-required-for-wher 273 | $escaped = DB::getDriverName() === 'sqlite' ? '\\\\' : '\\\\\\\\'; 274 | $value = str_replace('\\', $escaped, $value); 275 | 276 | // Support PostgreSQL case insensitive queries with ILIKE 277 | $operator = DB::getDriverName() === 'pgsql' ? 'ILIKE' : 'LIKE'; 278 | 279 | return DB::connection($connection)->table($table) 280 | ->where(function ($query) use ($column, $operator, $locale, $value) { 281 | $query->where($column, $operator, "%\"{$locale}\": \"{$value}\"%") 282 | ->orWhere($column, $operator, "%\"{$locale}\":\"{$value}\"%"); 283 | }); 284 | } 285 | 286 | /** 287 | * Ignore the column with the given value. 288 | * 289 | * @param \Illuminate\Database\Query\Builder $query 290 | * @param string|null $column 291 | * @param mixed $value 292 | * 293 | * @return \Illuminate\Database\Query\Builder 294 | */ 295 | protected function ignore($query, $column = null, $value = null) 296 | { 297 | if ($value !== null && $column === null) { 298 | $column = 'id'; 299 | } 300 | 301 | if ($column !== null) { 302 | $query = $query->where($column, '!=', $value); 303 | } 304 | 305 | return $query; 306 | } 307 | 308 | /** 309 | * Get the extra conditions for a unique rule. 310 | * Taken From: \Illuminate\Validation\Concerns\ValidatesAttributes 311 | * 312 | * @param array $parameters 313 | * 314 | * @return array 315 | */ 316 | protected function getUniqueExtra($parameters) 317 | { 318 | if (isset($parameters[4])) { 319 | return $this->getExtraConditions(array_slice($parameters, 4)); 320 | } 321 | 322 | return []; 323 | } 324 | 325 | /** 326 | * Get the extra conditions for a unique / exists rule. 327 | * Taken from: \Illuminate\Validation\Concerns\ValidatesAttributes 328 | * 329 | * @param array $segments 330 | * 331 | * @return array 332 | */ 333 | protected function getExtraConditions(array $segments) 334 | { 335 | $extra = []; 336 | 337 | $count = count($segments); 338 | 339 | for ($i = 0; $i < $count; $i += 2) { 340 | $extra[$segments[$i]] = $segments[$i + 1]; 341 | } 342 | 343 | return $extra; 344 | } 345 | 346 | /** 347 | * Add the given conditions to the query. 348 | * Adapted from: \Illuminate\Validation\DatabasePresenceVerifier 349 | * 350 | * @param \Illuminate\Database\Query\Builder $query 351 | * @param array $conditions 352 | * 353 | * @return \Illuminate\Database\Query\Builder 354 | */ 355 | protected function addConditions($query, $conditions) 356 | { 357 | foreach ($conditions as $key => $value) { 358 | $this->addWhere($query, $key, $value); 359 | } 360 | 361 | return $query; 362 | } 363 | 364 | /** 365 | * Add a "where" clause to the given query. 366 | * Taken from: \Illuminate\Validation\DatabasePresenceVerifier 367 | * 368 | * @param \Illuminate\Database\Query\Builder $query 369 | * @param string $key 370 | * @param string $extraValue 371 | * 372 | * @return \Illuminate\Database\Query\Builder 373 | */ 374 | protected function addWhere($query, $key, $extraValue) 375 | { 376 | if ($extraValue === 'NULL') { 377 | return $query->whereNull($key); 378 | } 379 | 380 | if ($extraValue === 'NOT_NULL') { 381 | return $query->whereNotNull($key); 382 | } 383 | 384 | $isNegative = Str::startsWith($extraValue, '!'); 385 | $operator = $isNegative ? '!=' : '='; 386 | $value = $isNegative ? mb_substr($extraValue, 1) : $extraValue; 387 | 388 | return $query->where($key, $operator, $value); 389 | } 390 | } 391 | --------------------------------------------------------------------------------