├── .php_cs ├── .scrutinizer.yml ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml └── src ├── AttributeCleaner └── Observer.php ├── BaseServiceProvider.php ├── Builder.php ├── Contracts ├── Attribute.php ├── AttributeBag.php ├── CleansAttributes.php ├── Mutator.php ├── Relations │ ├── Joiner.php │ └── JoinerFactory.php ├── Searchable │ ├── Parser.php │ ├── ParserFactory.php │ └── Searchable.php └── Validable.php ├── Eloquence.php ├── Query └── Builder.php ├── Relations ├── Joiner.php └── JoinerFactory.php ├── Searchable ├── Column.php ├── ColumnCollection.php ├── Parser.php ├── ParserFactory.php └── Subquery.php ├── Subquery.php └── helpers.php /.php_cs: -------------------------------------------------------------------------------- 1 | in(__DIR__ . '/src/'); 6 | 7 | return PhpCsFixer\Config::create() 8 | ->setFinder($finder) 9 | ->setRules([ 10 | '@PSR2' => true, 11 | 'array_syntax' => ['syntax' => 'short'], 12 | 'combine_consecutive_unsets' => true, 13 | 'method_separation' => true, 14 | 'no_multiline_whitespace_before_semicolons' => true, 15 | 'single_quote' => true, 16 | 'blank_line_before_return' => true, 17 | 'combine_consecutive_issets' => true, 18 | 'compact_nullable_typehint' => true, 19 | 'concat_space' => array('spacing' => 'one'), 20 | 'declare_equal_normalize' => true, 21 | 'function_typehint_space' => true, 22 | 'hash_to_slash_comment' => true, 23 | 'include' => true, 24 | 'lowercase_cast' => true, 25 | 'no_multiline_whitespace_around_double_arrow' => true, 26 | 'no_spaces_around_offset' => true, 27 | 'no_unused_imports' => true, 28 | 'no_whitespace_before_comma_in_array' => true, 29 | 'no_whitespace_in_blank_line' => true, 30 | 'object_operator_without_whitespace' => true, 31 | 'php_unit_fqcn_annotation' => true, 32 | 'phpdoc_no_package' => true, 33 | 'phpdoc_scalar' => true, 34 | 'phpdoc_single_line_var_spacing' => true, 35 | 'protected_to_private' => true, 36 | 'return_assignment' => true, 37 | 'single_blank_line_before_namespace' => true, 38 | 'single_line_after_imports' => true, 39 | 'single_class_element_per_statement' => true, 40 | 'ternary_operator_spaces' => true, 41 | 'trailing_comma_in_multiline_array' => true, 42 | 'trim_array_spaces' => true, 43 | 'unary_operator_spaces' => true, 44 | 'whitespace_after_comma_in_array' => true, 45 | 'no_null_property_initialization' => true, 46 | 'binary_operator_spaces' => [ 47 | 'default' => 'single_space', 48 | ], 49 | 'class_attributes_separation' => [ 50 | 'elements' => ['method'], 51 | ], 52 | 'method_argument_space' => [ 53 | 'keep_multiple_spaces_after_comma' => true, 54 | ], 55 | 'braces' => [ 56 | 'allow_single_line_closure' => false, 57 | 'position_after_anonymous_constructs' => 'same', 58 | 'position_after_control_structures' => 'same', 59 | 'position_after_functions_and_oop_constructs' => 'next', 60 | ], 61 | 'no_extra_consecutive_blank_lines' => [ 62 | 'curly_brace_block', 63 | 'extra', 64 | 'parenthesis_brace_block', 65 | 'square_brace_block', 66 | 'throw', 67 | 'use', 68 | ], 69 | ]) 70 | ->setLineEnding("\n") 71 | ; 72 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | tools: 2 | php_sim: true 3 | php_pdepend: true 4 | php_analyzer: true 5 | filter: 6 | excluded_paths: 7 | - 'tests/*' 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2017 Jarek Tkaczyk 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 | # Sofa/Eloquence 2 | 3 | [![GitHub Tests Action Status](https://github.com/jarektkaczyk/eloquence-base/workflows/Tests/badge.svg)](https://github.com/jarektkaczyk/eloquence-base/actions?query=workflow%3Atests+branch%3Amaster) [![Coverage Status](https://coveralls.io/repos/jarektkaczyk/eloquence-base/badge.svg)](https://coveralls.io/r/jarektkaczyk/eloquence-base) [![Code Quality](https://scrutinizer-ci.com/g/jarektkaczyk/eloquence-base/badges/quality-score.png)](https://scrutinizer-ci.com/g/jarektkaczyk/eloquence-base) [![Downloads](https://poser.pugx.org/sofa/eloquence-base/downloads)](https://packagist.org/packages/sofa/eloquence-base) [![stable](https://poser.pugx.org/sofa/eloquence-base/v/stable.svg)](https://packagist.org/packages/sofa/eloquence-base) 4 | 5 | Easy and flexible extensions for the [Eloquent ORM](https://laravel.com/docs/eloquent). 6 | 7 | Currently available extensions: 8 | 9 | 1. [Base - Searchable](https://github.com/jarektkaczyk/eloquence-base) query - crazy-simple fulltext search through any related model 10 | 1. [Validable](https://github.com/jarektkaczyk/eloquence-validable) - self-validating models 11 | 2. [Mappable](https://github.com/jarektkaczyk/eloquence-mappable) -map attributes to table fields and/or related models 12 | 3. [Metable](https://github.com/jarektkaczyk/eloquence-metable) - meta attributes made easy 13 | 4. [Mutable](https://github.com/jarektkaczyk/eloquence-mutable) - flexible attribute get/set mutators with quick setup 14 | 5. [Mutator](https://github.com/jarektkaczyk/eloquence-mutable) - pipe-based mutating 15 | 16 | ## Installation 17 | 18 | ```bash 19 | composer require sofa/eloquence-base 20 | ``` 21 | 22 | **Check the [documentation](https://github.com/jarektkaczyk/eloquence/wiki) for installation and usage info, [website](http://softonsofa.com/tag/eloquence/) for examples and [API reference](http://jarektkaczyk.github.io/eloquence-api)** 23 | 24 | ## Contribution 25 | 26 | Shout out to all the Contributors! 27 | 28 | All contributions are welcome, PRs must be **tested** and **PSR-2 compliant**. 29 | 30 | To validate your builds before committing use the following composer command: 31 | ```bash 32 | composer test 33 | ``` 34 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sofa/eloquence-base", 3 | "description": "Flexible Searchable, Mappable, Metable, Validation and more extensions for Laravel Eloquent ORM.", 4 | "license": "MIT", 5 | "support": { 6 | "issues": "https://github.com/jarektkaczyk/eloquence-base/issues", 7 | "source": "https://github.com/jarektkaczyk/eloquence-base" 8 | }, 9 | "keywords": [ 10 | "laravel", 11 | "eloquent", 12 | "metable", 13 | "searchable", 14 | "mappable", 15 | "mutable" 16 | ], 17 | "authors": [ 18 | { 19 | "name": "Jarek Tkaczyk", 20 | "email": "jarek@softonsofa.com", 21 | "homepage": "https://softonsofa.com/", 22 | "role": "Developer" 23 | } 24 | ], 25 | "require": { 26 | "php": ">=7.1", 27 | "sofa/hookable": ">=5.5", 28 | "illuminate/database": ">=5.5" 29 | }, 30 | "require-dev": { 31 | "phpunit/phpunit": "^9.5", 32 | "squizlabs/php_codesniffer": "2.3.3", 33 | "mockery/mockery": "^1.0", 34 | "friendsofphp/php-cs-fixer": "^2.0" 35 | }, 36 | "autoload": { 37 | "psr-4": { 38 | "Sofa\\Eloquence\\": "src" 39 | }, 40 | "files": [ 41 | "src/helpers.php" 42 | ] 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "Sofa\\Eloquence\\Tests\\": "tests" 47 | } 48 | }, 49 | "extra": { 50 | "laravel": { 51 | "providers": [ 52 | "Sofa\\Eloquence\\BaseServiceProvider" 53 | ] 54 | } 55 | }, 56 | "minimum-stability": "stable", 57 | "scripts": { 58 | "test": "phpunit && ./vendor/bin/phpcs src --standard=psr2 --report=diff --colors", 59 | "phpcs": "./vendor/bin/phpcs src --standard=psr2 --report=diff --colors" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | ./tests/ 13 | 14 | 15 | 16 | 19 | 20 | src 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/AttributeCleaner/Observer.php: -------------------------------------------------------------------------------- 1 | cleanAttributes($model); 19 | } 20 | } 21 | 22 | /** 23 | * Get rid of attributes that are not correct columns on this model's table. 24 | * 25 | * @param CleansAttributes $model 26 | * @return void 27 | */ 28 | protected function cleanAttributes(CleansAttributes $model) 29 | { 30 | $dirty = array_keys($model->getDirty()); 31 | 32 | $invalidColumns = array_diff($dirty, $model->getColumnListing()); 33 | 34 | foreach ($invalidColumns as $column) { 35 | unset($model->{$column}); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/BaseServiceProvider.php: -------------------------------------------------------------------------------- 1 | registerJoiner(); 29 | $this->registerParser(); 30 | } 31 | 32 | /** 33 | * Register relation joiner factory. 34 | * 35 | * @return void 36 | */ 37 | protected function registerJoiner() 38 | { 39 | $this->app->singleton('eloquence.joiner', function () { 40 | return new JoinerFactory; 41 | }); 42 | 43 | $this->app->alias('eloquence.joiner', 'Sofa\Eloquence\Contracts\Relations\JoinerFactory'); 44 | } 45 | 46 | /** 47 | * Register serachable parser factory. 48 | * 49 | * @return void 50 | */ 51 | protected function registerParser() 52 | { 53 | $this->app->singleton('eloquence.parser', function () { 54 | return new ParserFactory; 55 | }); 56 | 57 | $this->app->alias('eloquence.parser', 'Sofa\Eloquence\Contracts\Relations\ParserFactory'); 58 | } 59 | 60 | /** 61 | * Get the services provided by the provider. 62 | * 63 | * @return array 64 | */ 65 | public function provides() 66 | { 67 | return ['eloquence.joiner', 'eloquence.parser']; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Builder.php: -------------------------------------------------------------------------------- 1 | query->from instanceof Subquery) { 58 | $this->wheresToSubquery($this->query->from); 59 | } 60 | 61 | return parent::get($columns); 62 | } 63 | 64 | /** 65 | * Search through any columns on current table or any defined relations 66 | * and return results ordered by search relevance. 67 | * 68 | * @param array|string $query 69 | * @param array $columns 70 | * @param bool $fulltext 71 | * @param float $threshold 72 | * @return $this 73 | */ 74 | public function search($query, $columns = null, $fulltext = true, $threshold = null) 75 | { 76 | if (is_bool($columns)) { 77 | list($fulltext, $columns) = [$columns, []]; 78 | } 79 | 80 | $parser = static::$parser->make(); 81 | 82 | $words = is_array($query) ? $query : $parser->parseQuery($query, $fulltext); 83 | 84 | $columns = $parser->parseWeights($columns ?: $this->model->getSearchableColumns()); 85 | 86 | if (count($words) && count($columns)) { 87 | $this->query->from($this->buildSubquery($words, $columns, $threshold)); 88 | } 89 | 90 | return $this; 91 | } 92 | 93 | /** 94 | * Build the search subquery. 95 | * 96 | * @param array $words 97 | * @param array $mappings 98 | * @param float $threshold 99 | * @return SearchableSubquery 100 | */ 101 | protected function buildSubquery(array $words, array $mappings, $threshold) 102 | { 103 | $subquery = new SearchableSubquery($this->query->newQuery(), $this->model->getTable()); 104 | 105 | $columns = $this->joinForSearch($mappings, $subquery); 106 | 107 | $threshold = (is_null($threshold)) 108 | ? array_sum($columns->getWeights()) / 4 109 | : (float) $threshold; 110 | 111 | $subquery->select($this->model->getTable() . '.*') 112 | ->from($this->model->getTable()) 113 | ->groupBy($this->model->getQualifiedKeyName()); 114 | 115 | $this->addSearchClauses($subquery, $columns, $words, $threshold); 116 | 117 | return $subquery; 118 | } 119 | 120 | /** 121 | * Add select and where clauses on the subquery. 122 | * 123 | * @param SearchableSubquery $subquery 124 | * @param ColumnCollection $columns 125 | * @param array $words 126 | * @param float $threshold 127 | * @return void 128 | */ 129 | protected function addSearchClauses( 130 | SearchableSubquery $subquery, 131 | ColumnCollection $columns, 132 | array $words, 133 | $threshold 134 | ) { 135 | $whereBindings = $this->searchSelect($subquery, $columns, $words, $threshold); 136 | 137 | // For morphOne/morphMany support we need to port the bindings from JoinClauses. 138 | $joinBindings = collect($subquery->getQuery()->joins)->flatMap(function ($join) { 139 | return $join->getBindings(); 140 | })->all(); 141 | 142 | $this->addBinding($joinBindings, 'select'); 143 | 144 | // Developer may want to skip the score threshold filtering by passing zero 145 | // value as threshold in order to simply order full result by relevance. 146 | // Otherwise we are going to add where clauses for speed improvement. 147 | if ($threshold > 0) { 148 | $this->searchWhere($subquery, $columns, $words, $whereBindings); 149 | } 150 | 151 | $this->query->where('relevance', '>=', new Expression(number_format($threshold, 2))); 152 | 153 | $this->query->orders = array_merge( 154 | [['column' => 'relevance', 'direction' => 'desc']], 155 | (array) $this->query->orders 156 | ); 157 | } 158 | 159 | /** 160 | * Apply relevance select on the subquery. 161 | * 162 | * @param SearchableSubquery $subquery 163 | * @param ColumnCollection $columns 164 | * @param array $words 165 | * @return array 166 | */ 167 | protected function searchSelect(SearchableSubquery $subquery, ColumnCollection $columns, array $words) 168 | { 169 | $cases = $bindings = []; 170 | 171 | foreach ($columns as $column) { 172 | list($cases[], $binding) = $this->buildCase($column, $words); 173 | 174 | $bindings = array_merge_recursive($bindings, $binding); 175 | } 176 | 177 | $select = implode(' + ', $cases); 178 | 179 | $subquery->selectRaw("max({$select}) as relevance"); 180 | 181 | $this->addBinding($bindings['select'], 'select'); 182 | 183 | return $bindings['where']; 184 | } 185 | 186 | /** 187 | * Apply where clauses on the subquery. 188 | * 189 | * @param SearchableSubquery $subquery 190 | * @param ColumnCollection $columns 191 | * @param array $words 192 | * @return void 193 | */ 194 | protected function searchWhere( 195 | SearchableSubquery $subquery, 196 | ColumnCollection $columns, 197 | array $words, 198 | array $bindings 199 | ) { 200 | $operator = $this->getLikeOperator(); 201 | 202 | $wheres = []; 203 | 204 | foreach ($columns as $column) { 205 | $wheres[] = implode( 206 | ' or ', 207 | array_fill(0, count($words), sprintf('%s %s ?', $column->getWrapped(), $operator)) 208 | ); 209 | } 210 | 211 | $where = implode(' or ', $wheres); 212 | 213 | $subquery->whereRaw("({$where})"); 214 | 215 | $this->addBinding($bindings, 'select'); 216 | } 217 | 218 | /** 219 | * Move where clauses to subquery to improve performance. 220 | * 221 | * @param SearchableSubquery $subquery 222 | * @return void 223 | */ 224 | protected function wheresToSubquery(SearchableSubquery $subquery) 225 | { 226 | $bindingKey = 0; 227 | 228 | $typesToMove = [ 229 | 'basic', 'in', 'notin', 'between', 'null', 230 | 'notnull', 'date', 'day', 'month', 'year', 231 | ]; 232 | 233 | // Here we are going to move all the where clauses that we might apply 234 | // on the subquery in order to improve performance, since this way 235 | // we can drastically reduce number of joined rows on subquery. 236 | foreach ((array) $this->query->wheres as $key => $where) { 237 | $type = strtolower($where['type']); 238 | 239 | $bindingsCount = $this->countBindings($where, $type); 240 | 241 | if (in_array($type, $typesToMove) && $this->model->hasColumn($where['column'])) { 242 | unset($this->query->wheres[$key]); 243 | 244 | $where['column'] = $this->model->getTable() . '.' . $where['column']; 245 | 246 | $subquery->getQuery()->wheres[] = $where; 247 | 248 | $whereBindings = $this->query->getRawBindings()['where']; 249 | 250 | $bindings = array_splice($whereBindings, $bindingKey, $bindingsCount); 251 | 252 | $this->query->setBindings($whereBindings, 'where'); 253 | 254 | $this->query->addBinding($bindings, 'select'); 255 | 256 | // if where is not to be moved onto the subquery, let's increment 257 | // binding key appropriately, so we can reliably move binding 258 | // for the next where clauses in the loop that is running. 259 | } else { 260 | $bindingKey += $bindingsCount; 261 | } 262 | } 263 | } 264 | 265 | /** 266 | * Get number of bindings provided for a where clause. 267 | * 268 | * @param array $where 269 | * @param string $type 270 | * @return int 271 | */ 272 | protected function countBindings(array $where, $type) 273 | { 274 | if ($this->isHasWhere($where, $type)) { 275 | return substr_count($where['column'] . $where['value'], '?'); 276 | } elseif ($type === 'basic') { 277 | return (int) !$where['value'] instanceof Expression; 278 | } elseif (in_array($type, ['basic', 'date', 'year', 'month', 'day'])) { 279 | return (int) !$where['value'] instanceof Expression; 280 | } elseif (in_array($type, ['null', 'notnull'])) { 281 | return 0; 282 | } elseif ($type === 'between') { 283 | return 2; 284 | } elseif (in_array($type, ['in', 'notin'])) { 285 | return count($where['values']); 286 | } elseif ($type === 'raw') { 287 | return substr_count($where['sql'], '?'); 288 | } elseif (in_array($type, ['nested', 'sub', 'exists', 'notexists', 'insub', 'notinsub'])) { 289 | return count($where['query']->getBindings()); 290 | } 291 | } 292 | 293 | /** 294 | * Determine whether where clause is eloquent has subquery. 295 | * 296 | * @param array $where 297 | * @param string $type 298 | * @return bool 299 | */ 300 | protected function isHasWhere($where, $type) 301 | { 302 | return $type === 'basic' 303 | && $where['column'] instanceof Expression 304 | && $where['value'] instanceof Expression; 305 | } 306 | 307 | /** 308 | * Build case clause from all words for a single column. 309 | * 310 | * @param Column $column 311 | * @param array $words 312 | * @return array 313 | */ 314 | protected function buildCase(Column $column, array $words) 315 | { 316 | // THIS IS BAD 317 | // @todo refactor 318 | 319 | $operator = $this->getLikeOperator(); 320 | 321 | $bindings['select'] = $bindings['where'] = array_map(function ($word) { 322 | return $this->caseBinding($word); 323 | }, $words); 324 | 325 | $case = $this->buildEqualsCase($column, $words); 326 | 327 | if (strpos(implode('', $words), '*') !== false) { 328 | $leftMatching = []; 329 | 330 | foreach ($words as $key => $word) { 331 | if ($this->isLeftMatching($word)) { 332 | $leftMatching[] = sprintf('%s %s ?', $column->getWrapped(), $operator); 333 | $bindings['select'][] = $bindings['where'][$key] = $this->caseBinding($word) . '%'; 334 | } 335 | } 336 | 337 | if (count($leftMatching)) { 338 | $leftMatching = implode(' or ', $leftMatching); 339 | $score = 5 * $column->getWeight(); 340 | $case .= " + case when {$leftMatching} then {$score} else 0 end"; 341 | } 342 | 343 | $wildcards = []; 344 | 345 | foreach ($words as $key => $word) { 346 | if ($this->isWildcard($word)) { 347 | $wildcards[] = sprintf('%s %s ?', $column->getWrapped(), $operator); 348 | $bindings['select'][] = $bindings['where'][$key] = '%' . $this->caseBinding($word) . '%'; 349 | } 350 | } 351 | 352 | if (count($wildcards)) { 353 | $wildcards = implode(' or ', $wildcards); 354 | $score = 1 * $column->getWeight(); 355 | $case .= " + case when {$wildcards} then {$score} else 0 end"; 356 | } 357 | } 358 | 359 | return [$case, $bindings]; 360 | } 361 | 362 | /** 363 | * Replace '?' with single character SQL wildcards. 364 | * 365 | * @param string $word 366 | * @return string 367 | */ 368 | protected function caseBinding($word) 369 | { 370 | $parser = static::$parser->make(); 371 | 372 | return str_replace('?', '_', $parser->stripWildcards($word)); 373 | } 374 | 375 | /** 376 | * Build basic search case for 'equals' comparison. 377 | * 378 | * @param Column $column 379 | * @param array $words 380 | * @return string 381 | */ 382 | protected function buildEqualsCase(Column $column, array $words) 383 | { 384 | $equals = implode(' or ', array_fill(0, count($words), sprintf('%s = ?', $column->getWrapped()))); 385 | 386 | $score = 15 * $column->getWeight(); 387 | 388 | return "case when {$equals} then {$score} else 0 end"; 389 | } 390 | 391 | /** 392 | * Determine whether word ends with wildcard. 393 | * 394 | * @param string $word 395 | * @return bool 396 | */ 397 | protected function isLeftMatching($word) 398 | { 399 | return Str::endsWith($word, '*'); 400 | } 401 | 402 | /** 403 | * Determine whether word starts and ends with wildcards. 404 | * 405 | * @param string $word 406 | * @return bool 407 | */ 408 | protected function isWildcard($word) 409 | { 410 | return Str::endsWith($word, '*') && Str::startsWith($word, '*'); 411 | } 412 | 413 | /** 414 | * Get driver-specific case insensitive like operator. 415 | * 416 | * @return string 417 | */ 418 | public function getLikeOperator() 419 | { 420 | $grammar = $this->query->getGrammar(); 421 | 422 | if ($grammar instanceof PostgresGrammar) { 423 | return 'ilike'; 424 | } 425 | 426 | return 'like'; 427 | } 428 | 429 | /** 430 | * Join related tables on the search subquery. 431 | * 432 | * @param array $mappings 433 | * @param SearchableSubquery $subquery 434 | * @return ColumnCollection 435 | */ 436 | protected function joinForSearch($mappings, $subquery) 437 | { 438 | $mappings = is_array($mappings) ? $mappings : (array) $mappings; 439 | 440 | $columns = new ColumnCollection; 441 | 442 | $grammar = $this->query->getGrammar(); 443 | 444 | $joiner = static::$joinerFactory->make($subquery->getQuery(), $this->model); 445 | 446 | // Here we loop through the search mappings in order to join related tables 447 | // appropriately and build a searchable column collection, which we will 448 | // use to build select and where clauses with correct table prefixes. 449 | foreach ($mappings as $mapping => $weight) { 450 | if (strpos($mapping, '.') !== false) { 451 | list($relation, $column) = $this->model->parseMappedColumn($mapping); 452 | 453 | $related = $joiner->leftJoin($relation); 454 | 455 | $columns->add( 456 | new Column($grammar, $related->getTable(), $column, $mapping, $weight) 457 | ); 458 | } else { 459 | $columns->add( 460 | new Column($grammar, $this->model->getTable(), $mapping, $mapping, $weight) 461 | ); 462 | } 463 | } 464 | 465 | return $columns; 466 | } 467 | 468 | /** 469 | * Prefix selected columns with table name in order to avoid collisions. 470 | * 471 | * @return $this 472 | */ 473 | public function prefixColumnsForJoin() 474 | { 475 | if (!$columns = $this->query->columns) { 476 | return $this->select($this->model->getTable() . '.*'); 477 | } 478 | 479 | foreach ($columns as $key => $column) { 480 | if ($this->model->hasColumn($column)) { 481 | $columns[$key] = $this->model->getTable() . '.' . $column; 482 | } 483 | } 484 | 485 | $this->query->columns = $columns; 486 | 487 | return $this; 488 | } 489 | 490 | /** 491 | * Join related tables. 492 | * 493 | * @param array|string $relations 494 | * @param string $type 495 | * @return $this 496 | */ 497 | public function joinRelations($relations, $type = 'inner') 498 | { 499 | if (is_null($this->joiner)) { 500 | $this->joiner = static::$joinerFactory->make($this); 501 | } 502 | 503 | if (!is_array($relations)) { 504 | list($relations, $type) = [func_get_args(), 'inner']; 505 | } 506 | 507 | foreach ($relations as $relation) { 508 | $this->joiner->join($relation, $type); 509 | } 510 | 511 | return $this; 512 | } 513 | 514 | /** 515 | * Left join related tables. 516 | * 517 | * @param array|string $relations 518 | * @return $this 519 | */ 520 | public function leftJoinRelations($relations) 521 | { 522 | $relations = is_array($relations) ? $relations : func_get_args(); 523 | 524 | return $this->joinRelations($relations, 'left'); 525 | } 526 | 527 | /** 528 | * Right join related tables. 529 | * 530 | * @param array|string $relations 531 | * @return $this 532 | */ 533 | public function rightJoinRelations($relations) 534 | { 535 | $relations = is_array($relations) ? $relations : func_get_args(); 536 | 537 | return $this->joinRelations($relations, 'right'); 538 | } 539 | 540 | /** 541 | * Set search query parser factory instance. 542 | * 543 | * @param ParserFactory $factory 544 | */ 545 | public static function setParserFactory(ParserFactory $factory) 546 | { 547 | static::$parser = $factory; 548 | } 549 | 550 | /** 551 | * Set the relations joiner factory instance. 552 | * 553 | * @param JoinerFactory $factory 554 | */ 555 | public static function setJoinerFactory(JoinerFactory $factory) 556 | { 557 | static::$joinerFactory = $factory; 558 | } 559 | } 560 | -------------------------------------------------------------------------------- /src/Contracts/Attribute.php: -------------------------------------------------------------------------------- 1 | isWhereNullByArgs($args); 56 | } 57 | 58 | /** 59 | * Determine whether where is a whereNull by the arguments passed to where method. 60 | * 61 | * @param ArgumentBag $args 62 | * @return bool 63 | */ 64 | protected function isWhereNullByArgs(ArgumentBag $args) 65 | { 66 | return is_null($args->get('operator')) 67 | || is_null($args->get('value')) && !in_array($args->get('operator'), ['<>', '!=']); 68 | } 69 | 70 | /** 71 | * Extract real name and alias from the sql select clause. 72 | * 73 | * @param string $column 74 | * @return array 75 | */ 76 | protected function extractColumnAlias($column) 77 | { 78 | $alias = $column; 79 | 80 | if (strpos($column, ' as ') !== false) { 81 | list($column, $alias) = explode(' as ', $column); 82 | } 83 | 84 | return [$column, $alias]; 85 | } 86 | 87 | /** 88 | * Get the target relation and column from the mapping. 89 | * 90 | * @param string $mapping 91 | * @return array 92 | */ 93 | public function parseMappedColumn($mapping) 94 | { 95 | $segments = explode('.', $mapping); 96 | 97 | $column = array_pop($segments); 98 | 99 | $target = implode('.', $segments); 100 | 101 | return [$target, $column]; 102 | } 103 | 104 | /** 105 | * Determine whether the key is meta attribute or actual table field. 106 | * 107 | * @param string $key 108 | * @return bool 109 | */ 110 | public static function hasColumn($key) 111 | { 112 | static::loadColumnListing(); 113 | 114 | return in_array((string) $key, static::$columnListing); 115 | } 116 | 117 | /** 118 | * Get searchable columns defined on the model. 119 | * 120 | * @return array 121 | */ 122 | public function getSearchableColumns() 123 | { 124 | return (property_exists($this, 'searchableColumns')) ? $this->searchableColumns : []; 125 | } 126 | 127 | /** 128 | * Get model table columns. 129 | * 130 | * @return array 131 | */ 132 | public static function getColumnListing() 133 | { 134 | static::loadColumnListing(); 135 | 136 | return static::$columnListing; 137 | } 138 | 139 | /** 140 | * Fetch model table columns. 141 | * 142 | * @return void 143 | */ 144 | protected static function loadColumnListing() 145 | { 146 | if (empty(static::$columnListing)) { 147 | $instance = new static; 148 | 149 | static::$columnListing = $instance->getConnection() 150 | ->getSchemaBuilder() 151 | ->getColumnListing($instance->getTable()); 152 | } 153 | } 154 | 155 | /** 156 | * Create new Eloquence query builder for the instance. 157 | * 158 | * @param QueryBuilder $query 159 | * @return Builder 160 | */ 161 | public function newEloquentBuilder($query) 162 | { 163 | return new Builder($query); 164 | } 165 | 166 | /** 167 | * Get a new query builder instance for the connection. 168 | * 169 | * @return QueryBuilder 170 | */ 171 | protected function newBaseQueryBuilder() 172 | { 173 | $conn = $this->getConnection(); 174 | 175 | $grammar = $conn->getQueryGrammar(); 176 | 177 | return new QueryBuilder($conn, $grammar, $conn->getPostProcessor()); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/Query/Builder.php: -------------------------------------------------------------------------------- 1 | aggregate = compact('function', 'columns'); 19 | 20 | $previousColumns = $this->columns; 21 | 22 | if (!$this->from instanceof Subquery) { 23 | // We will also back up the select bindings since the select clause will be 24 | // removed when performing the aggregate function. Once the query is run 25 | // we will add the bindings back onto this query so they can get used. 26 | $previousSelectBindings = $this->bindings['select']; 27 | 28 | $this->bindings['select'] = []; 29 | } 30 | 31 | $results = $this->get($columns); 32 | 33 | // Once we have executed the query, we will reset the aggregate property so 34 | // that more select queries can be executed against the database without 35 | // the aggregate value getting in the way when the grammar builds it. 36 | $this->aggregate = null; 37 | 38 | $this->columns = $previousColumns; 39 | 40 | if (!$this->from instanceof Subquery) { 41 | $this->bindings['select'] = $previousSelectBindings; 42 | } 43 | 44 | if (isset($results[0])) { 45 | $result = array_change_key_case((array) $results[0]); 46 | 47 | return $result['aggregate']; 48 | } 49 | } 50 | 51 | /** 52 | * Backup some fields for the pagination count. 53 | * 54 | * @return void 55 | */ 56 | protected function backupFieldsForCount() 57 | { 58 | foreach (['orders', 'limit', 'offset', 'columns'] as $field) { 59 | $this->backups[$field] = $this->{$field}; 60 | 61 | $this->{$field} = null; 62 | } 63 | 64 | $bindings = ($this->from instanceof Subquery) ? ['order'] : ['order', 'select']; 65 | 66 | foreach ($bindings as $key) { 67 | $this->bindingBackups[$key] = $this->bindings[$key]; 68 | 69 | $this->bindings[$key] = []; 70 | } 71 | } 72 | 73 | /** 74 | * Restore some fields after the pagination count. 75 | * 76 | * @return void 77 | */ 78 | protected function restoreFieldsForCount() 79 | { 80 | foreach ($this->backups as $field => $value) { 81 | $this->{$field} = $value; 82 | } 83 | 84 | foreach ($this->bindingBackups as $key => $value) { 85 | $this->bindings[$key] = $value; 86 | } 87 | 88 | $this->backups = $this->bindingBackups = []; 89 | } 90 | 91 | /** 92 | * Run a pagination count query. 93 | * 94 | * @param array $columns 95 | * @return array 96 | */ 97 | protected function runPaginationCountQuery($columns = ['*']) 98 | { 99 | $bindings = $this->from instanceof Subquery ? ['order'] : ['select', 'order']; 100 | 101 | return $this->cloneWithout(['columns', 'orders', 'limit', 'offset']) 102 | ->cloneWithoutBindings($bindings) 103 | ->setAggregate('count', $this->withoutSelectAliases($columns)) 104 | ->get()->all(); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Relations/Joiner.php: -------------------------------------------------------------------------------- 1 | query = $query; 46 | $this->model = $model; 47 | } 48 | 49 | /** 50 | * Join related tables. 51 | * 52 | * @param string $target 53 | * @param string $type 54 | * @return Model 55 | */ 56 | public function join($target, $type = 'inner') 57 | { 58 | $related = $this->model; 59 | 60 | foreach (explode('.', $target) as $segment) { 61 | $related = $this->joinSegment($related, $segment, $type); 62 | } 63 | 64 | return $related; 65 | } 66 | 67 | /** 68 | * Left join related tables. 69 | * 70 | * @param string $target 71 | * @return Model 72 | */ 73 | public function leftJoin($target) 74 | { 75 | return $this->join($target, 'left'); 76 | } 77 | 78 | /** 79 | * Right join related tables. 80 | * 81 | * @param string $target 82 | * @return Model 83 | */ 84 | public function rightJoin($target) 85 | { 86 | return $this->join($target, 'right'); 87 | } 88 | 89 | /** 90 | * Join relation's table accordingly. 91 | * 92 | * @param Model $parent 93 | * @param string $segment 94 | * @param string $type 95 | * @return Model 96 | */ 97 | protected function joinSegment(Model $parent, $segment, $type) 98 | { 99 | $relation = $parent->{$segment}(); 100 | $related = $relation->getRelated(); 101 | $table = $related->getTable(); 102 | 103 | if ($relation instanceof BelongsToMany || $relation instanceof HasManyThrough) { 104 | $this->joinIntermediate($parent, $relation, $type); 105 | } 106 | 107 | if (!$this->alreadyJoined($join = $this->getJoinClause($parent, $relation, $table, $type))) { 108 | $this->query->joins[] = $join; 109 | } 110 | 111 | return $related; 112 | } 113 | 114 | /** 115 | * Determine whether the related table has been already joined. 116 | * 117 | * @param Join $join 118 | * @return bool 119 | */ 120 | protected function alreadyJoined(Join $join) 121 | { 122 | return in_array($join, (array) $this->query->joins); 123 | } 124 | 125 | /** 126 | * Get the join clause for related table. 127 | * 128 | * @param Model $parent 129 | * @param Relation $relation 130 | * @param string $type 131 | * @param string $table 132 | * @return Join 133 | */ 134 | protected function getJoinClause(Model $parent, Relation $relation, $table, $type) 135 | { 136 | [$fk, $pk] = $this->getJoinKeys($relation); 137 | 138 | $join = (new Join($this->query, $type, $table))->on($fk, '=', $pk); 139 | 140 | /** @var Model|SoftDeletes $related */ 141 | $related = $relation->getRelated(); 142 | if (method_exists($related, 'getQualifiedDeletedAtColumn')) { 143 | $join->whereNull($related->getQualifiedDeletedAtColumn()); 144 | } 145 | 146 | if ($relation instanceof MorphOneOrMany) { 147 | $join->where($relation->getQualifiedMorphType(), '=', $parent->getMorphClass()); 148 | } elseif ($relation instanceof MorphToMany || $relation instanceof MorphMany) { 149 | $join->where($relation->getMorphType(), '=', $parent->getMorphClass()); 150 | } 151 | 152 | return $join; 153 | } 154 | 155 | /** 156 | * Join pivot or 'through' table. 157 | * 158 | * @param Model $parent 159 | * @param Relation $relation 160 | * @param string $type 161 | * @return void 162 | */ 163 | protected function joinIntermediate(Model $parent, Relation $relation, $type) 164 | { 165 | if ($relation instanceof BelongsToMany) { 166 | $table = $relation->getTable(); 167 | $fk = $relation->getQualifiedForeignPivotKeyName(); 168 | } else { 169 | $table = $relation->getParent()->getTable(); 170 | $fk = $relation->getQualifiedFirstKeyName(); 171 | } 172 | 173 | $pk = $parent->getQualifiedKeyName(); 174 | 175 | if (!$this->alreadyJoined($join = (new Join($this->query, $type, $table))->on($fk, '=', $pk))) { 176 | $this->query->joins[] = $join; 177 | } 178 | } 179 | 180 | /** 181 | * Get pair of the keys from relation in order to join the table. 182 | * 183 | * @param Relation $relation 184 | * @return array 185 | * 186 | * @throws LogicException 187 | */ 188 | protected function getJoinKeys(Relation $relation) 189 | { 190 | if ($relation instanceof MorphTo) { 191 | throw new LogicException('MorphTo relation cannot be joined.'); 192 | } 193 | 194 | if ($relation instanceof HasOneOrMany) { 195 | return [$relation->getQualifiedForeignKeyName(), $relation->getQualifiedParentKeyName()]; 196 | } 197 | 198 | if ($relation instanceof BelongsTo) { 199 | return [$relation->getQualifiedForeignKeyName(), $relation->getQualifiedOwnerKeyName()]; 200 | } 201 | 202 | if ($relation instanceof BelongsToMany) { 203 | return [$relation->getQualifiedRelatedPivotKeyName(), $relation->getRelated()->getQualifiedKeyName()]; 204 | } 205 | 206 | if ($relation instanceof HasManyThrough) { 207 | $fk = $relation->getQualifiedFarKeyName(); 208 | 209 | return [$fk, $relation->getQualifiedParentKeyName()]; 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/Relations/JoinerFactory.php: -------------------------------------------------------------------------------- 1 | getModel(); 23 | $query = $query->getQuery(); 24 | } 25 | 26 | return new Joiner($query, $model); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Searchable/Column.php: -------------------------------------------------------------------------------- 1 | grammar = $grammar; 35 | $this->table = $table; 36 | $this->name = $name; 37 | $this->mapping = $mapping; 38 | $this->weight = $weight; 39 | } 40 | 41 | /** 42 | * Get qualified name wrapped by the grammar. 43 | * 44 | * @return string 45 | */ 46 | public function getWrapped() 47 | { 48 | return $this->grammar->wrap($this->getQualifiedName()); 49 | } 50 | 51 | /** 52 | * Get column name with table prefix. 53 | * 54 | * @return string 55 | */ 56 | public function getQualifiedName() 57 | { 58 | return $this->getTable() . '.' . $this->getName(); 59 | } 60 | 61 | /** 62 | * @return string 63 | */ 64 | public function getTable() 65 | { 66 | return $this->table; 67 | } 68 | 69 | /** 70 | * @return string 71 | */ 72 | public function getName() 73 | { 74 | return $this->name; 75 | } 76 | 77 | /** 78 | * @return string 79 | */ 80 | public function getMapping() 81 | { 82 | return $this->mapping; 83 | } 84 | 85 | /** 86 | * @return int 87 | */ 88 | public function getWeight() 89 | { 90 | return $this->weight; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Searchable/ColumnCollection.php: -------------------------------------------------------------------------------- 1 | add($column); 23 | } 24 | } 25 | 26 | /** 27 | * Get columns as plain array. 28 | * 29 | * @return array 30 | */ 31 | public function getColumns() 32 | { 33 | return $this->columns; 34 | } 35 | 36 | /** 37 | * Add column to the collection. 38 | * 39 | * @param Column $column 40 | */ 41 | public function add(Column $column) 42 | { 43 | $this->columns[$column->getMapping()] = $column; 44 | } 45 | 46 | /** 47 | * Get array of qualified columns names. 48 | * 49 | * @return array 50 | */ 51 | public function getQualifiedNames() 52 | { 53 | return array_map(function ($column) { 54 | return $column->getQualifiedName(); 55 | }, $this->columns); 56 | } 57 | 58 | /** 59 | * Get array of tables names. 60 | * 61 | * @return array 62 | */ 63 | public function getTables() 64 | { 65 | return array_unique(array_map(function ($column) { 66 | return $column->getTable(); 67 | }, $this->columns)); 68 | } 69 | 70 | /** 71 | * Get array of columns mappings and weights. 72 | * 73 | * @return array 74 | */ 75 | public function getWeights() 76 | { 77 | $weights = []; 78 | 79 | foreach ($this->columns as $column) { 80 | $weights[$column->getMapping()] = $column->getWeight(); 81 | } 82 | 83 | return $weights; 84 | } 85 | 86 | /** 87 | * Get array of columns mappings. 88 | * 89 | * @return array 90 | */ 91 | public function getMappings() 92 | { 93 | return array_map(function ($column) { 94 | return $column->getMapping(); 95 | }, $this->columns); 96 | } 97 | 98 | /** 99 | * Check if element exists at given offset. 100 | * 101 | * @param string $key 102 | * @return bool 103 | */ 104 | public function offsetExists($key) 105 | { 106 | return array_key_exists($key, $this->columns); 107 | } 108 | 109 | /** 110 | * Get element at given offset. 111 | * 112 | * @param string $key 113 | * @return Column 114 | */ 115 | public function offsetGet($key) 116 | { 117 | return $this->columns[$key]; 118 | } 119 | 120 | /** 121 | * Set element at given offset. 122 | * 123 | * @param string $key [description] 124 | * @param Column $column 125 | * @return void 126 | */ 127 | public function offsetSet($key, $column) 128 | { 129 | $this->add($column); 130 | } 131 | 132 | /** 133 | * Unset element at given offset. 134 | * 135 | * @param string $key 136 | * @return Column 137 | */ 138 | public function offsetUnset($key) 139 | { 140 | unset($this->columns[$key]); 141 | } 142 | 143 | /** 144 | * Get an iterator for the columns. 145 | * 146 | * @return ArrayIterator 147 | */ 148 | public function getIterator() 149 | { 150 | return new ArrayIterator($this->columns); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/Searchable/Parser.php: -------------------------------------------------------------------------------- 1 | weight = $weight; 31 | $this->wildcard = $wildcard; 32 | } 33 | 34 | /** 35 | * Parse searchable columns. 36 | * 37 | * @param array|string $columns 38 | * @return array 39 | */ 40 | public function parseWeights($columns) 41 | { 42 | if (is_string($columns)) { 43 | $columns = func_get_args(); 44 | } 45 | 46 | return $this->addMissingWeights($columns); 47 | } 48 | 49 | /** 50 | * Add search weight to the columns if missing. 51 | * 52 | * @param array $columns 53 | */ 54 | protected function addMissingWeights(array $columns) 55 | { 56 | $parsed = []; 57 | 58 | foreach ($columns as $column => $weight) { 59 | if (is_numeric($column)) { 60 | list($column, $weight) = [$weight, $this->weight]; 61 | } 62 | 63 | $parsed[$column] = $weight; 64 | } 65 | 66 | return $parsed; 67 | } 68 | 69 | /** 70 | * Strip wildcard tokens from the word. 71 | * 72 | * @param string $word 73 | * @return string 74 | */ 75 | public function stripWildcards($word) 76 | { 77 | return str_replace($this->wildcard, '%', trim($word, $this->wildcard)); 78 | } 79 | 80 | /** 81 | * Parse query string into separate words with wildcards if applicable. 82 | * 83 | * @param string $query 84 | * @param bool $fulltext 85 | * @return array 86 | */ 87 | public function parseQuery($query, $fulltext = true) 88 | { 89 | $words = $this->splitString($query); 90 | 91 | if ($fulltext) { 92 | $words = $this->addWildcards($words); 93 | } 94 | 95 | return $words; 96 | } 97 | 98 | /** 99 | * Split query string into words/phrases to be searched. 100 | * 101 | * @param string $query 102 | * @return array 103 | */ 104 | protected function splitString($query) 105 | { 106 | preg_match_all('/(?<=")[\w ][^"]+(?=")|(?<=\s|^)[^\s"]+(?=\s|$)/u', $query, $matches); 107 | 108 | return reset($matches); 109 | } 110 | 111 | /** 112 | * Add wildcard tokens to the words. 113 | * 114 | * @param array $words 115 | */ 116 | protected function addWildcards(array $words) 117 | { 118 | $token = $this->wildcard; 119 | 120 | return array_map(function ($word) use ($token) { 121 | return preg_replace('/\*+/', '*', "{$token}{$word}{$token}"); 122 | }, $words); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Searchable/ParserFactory.php: -------------------------------------------------------------------------------- 1 | getQuery(); 38 | } 39 | 40 | $this->setQuery($query); 41 | 42 | $this->alias = $alias; 43 | } 44 | 45 | /** 46 | * Set underlying query builder. 47 | * 48 | * @param QueryBuilder $query 49 | */ 50 | public function setQuery(QueryBuilder $query) 51 | { 52 | $this->query = $query; 53 | } 54 | 55 | /** 56 | * Get underlying query builder. 57 | * 58 | * @return QueryBuilder 59 | */ 60 | public function getQuery() 61 | { 62 | return $this->query; 63 | } 64 | 65 | /** 66 | * Evaluate query as string. 67 | * 68 | * @return string 69 | */ 70 | public function getValue() 71 | { 72 | $sql = '(' . $this->query->toSql() . ')'; 73 | 74 | if ($this->alias) { 75 | $alias = $this->query->getGrammar()->wrapTable($this->alias); 76 | 77 | $sql .= ' as ' . $alias; 78 | } 79 | 80 | return $sql; 81 | } 82 | 83 | /** 84 | * Get subquery alias. 85 | * 86 | * @return string 87 | */ 88 | public function getAlias() 89 | { 90 | return $this->alias; 91 | } 92 | 93 | /** 94 | * Set subquery alias. 95 | * 96 | * @param string $alias 97 | * @return $this 98 | */ 99 | public function setAlias($alias) 100 | { 101 | $this->alias = $alias; 102 | 103 | return $this; 104 | } 105 | 106 | /** 107 | * Pass property calls to the underlying builder. 108 | * 109 | * @param string $property 110 | * @param mixed $value 111 | * @return mixed 112 | */ 113 | public function __set($property, $value) 114 | { 115 | return $this->query->{$property} = $value; 116 | } 117 | 118 | /** 119 | * Pass property calls to the underlying builder. 120 | * 121 | * @param string $property 122 | * @return mixed 123 | */ 124 | public function __get($property) 125 | { 126 | return $this->query->{$property}; 127 | } 128 | 129 | /** 130 | * Pass method calls to the underlying builder. 131 | * 132 | * @param string $method 133 | * @param array $params 134 | * @return mixed 135 | */ 136 | public function __call($method, $params) 137 | { 138 | return call_user_func_array([$this->query, $method], $params); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | 7 | use Illuminate\Database\Eloquent\Model; 8 | 9 | if (!function_exists('rules_for_update')) { 10 | /** 11 | * Adjust unique rules for update so it doesn't treat updated model's row as duplicate. 12 | * 13 | * @link http://laravel.com/docs/5.0/validation#rule-unique 14 | * 15 | * @param array $rules 16 | * @param Model|int|string $id 17 | * @param string $primaryKey 18 | * @return array 19 | */ 20 | function rules_for_update(array $rules, $id, $primaryKey = 'id') 21 | { 22 | if ($id instanceof Model) { 23 | list($primaryKey, $id) = [$id->getKeyName(), $id->getKey()]; 24 | } 25 | 26 | // We want to update each unique rule so it ignores this model's row 27 | // during unique check in order to avoid faulty non-unique errors 28 | // in accordance to the linked Laravel Validator documentation. 29 | array_walk($rules, function (&$fieldRules, $field) use ($id, $primaryKey) { 30 | if (is_string($fieldRules)) { 31 | $fieldRules = explode('|', $fieldRules); 32 | } 33 | 34 | array_walk($fieldRules, function (&$rule) use ($field, $id, $primaryKey) { 35 | if (strpos($rule, 'unique') === false) { 36 | return; 37 | } 38 | 39 | list(, $argsString) = explode(':', $rule); 40 | 41 | $args = explode(',', $argsString); 42 | 43 | $args[1] = isset($args[1]) ? $args[1] : $field; 44 | $args[2] = $id; 45 | $args[3] = $primaryKey; 46 | 47 | $rule = 'unique:' . implode(',', $args); 48 | }); 49 | }); 50 | 51 | return $rules; 52 | } 53 | } 54 | --------------------------------------------------------------------------------