├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── art ├── socialcard-dark.png └── socialcard-light.png ├── composer.json ├── config └── searchable.php ├── database ├── factories │ ├── TeamFactory.php │ └── UserFactory.php └── migrations │ ├── create_teams_table.php.stub │ └── create_users_table.php.stub ├── phpstan-baseline.neon ├── phpstan.neon.dist └── src ├── Concerns ├── AddsSearchTermToQuery.php └── JoinsRelationshipsToQuery.php ├── HasSearch.php ├── SearchBuilder.php ├── SearchableAttribute.php ├── SearchableServiceProvider.php └── Utils ├── AttributeUtil.php ├── Joiner.php └── Parser.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-searchable` will be documented in this file. 4 | 5 | ## 4.0.0 - 2024-03-27 6 | 7 | ### What's Changed 8 | 9 | * Updated cover images and new MAIZE company references by @frestifo in https://github.com/maize-tech/laravel-searchable/pull/20 10 | * UPDATE php8.3 by @enricodelazzari in https://github.com/maize-tech/laravel-searchable/pull/24 11 | * ADD Laravel 11 compatibility by @riccardodallavia in https://github.com/maize-tech/laravel-searchable/pull/26 12 | 13 | ### New Contributors 14 | 15 | * @frestifo made their first contribution in https://github.com/maize-tech/laravel-searchable/pull/20 16 | * @enricodelazzari made their first contribution in https://github.com/maize-tech/laravel-searchable/pull/24 17 | 18 | **Full Changelog**: https://github.com/maize-tech/laravel-searchable/compare/3.2.0...4.0.0 19 | 20 | ## 3.2.0 - 2023-02-13 21 | 22 | ### What's Changed 23 | 24 | - Add support to Laravel 10.x 25 | 26 | ## 3.1.1 - 2023-01-10 27 | 28 | - FIX json_extract param 29 | 30 | ## 3.1.0 - 2022-04-08 31 | 32 | ADD query optimization for models with integer id 33 | 34 | ## 3.0.0 - 2022-02-15 35 | 36 | - add laravel 9 support 37 | - drop support to older laravel versions 38 | 39 | ## 2.0.0 - 2021-10-12 40 | 41 | - UPDATE package namespace 42 | 43 | ## 1.0.0 - 2021-05-05 44 | 45 | - first release 🚀 46 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 MAIZE SRL 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 |

2 | 3 | 4 | 5 | Social Card of Laravel Searchable 6 | 7 |

8 | 9 | # Laravel Searchable 🔍 10 | 11 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/maize-tech/laravel-searchable.svg?style=flat-square)](https://packagist.org/packages/maize-tech/laravel-searchable) 12 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/maize-tech/laravel-searchable/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/maize-tech/laravel-searchable/actions?query=workflow%3Arun-tests+branch%3Amain) 13 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/maize-tech/laravel-searchable/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/maize-tech/laravel-searchable/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) 14 | [![Total Downloads](https://img.shields.io/packagist/dt/maize-tech/laravel-searchable.svg?style=flat-square)](https://packagist.org/packages/maize-tech/laravel-searchable) 15 | 16 | Easily add weighted searches through model attributes and relationships. 17 | 18 | This package currently supports `MySQL` and `PostgreSQL`. 19 | 20 | ## Installation 21 | 22 | You can install the package via composer: 23 | 24 | ```bash 25 | composer require maize-tech/laravel-searchable 26 | ``` 27 | 28 | You can publish the config file with: 29 | ```bash 30 | php artisan vendor:publish --provider="Maize\Searchable\SearchableServiceProvider" --tag="searchable-config" 31 | ``` 32 | 33 | This is the content of the published config file: 34 | 35 | ```php 36 | return [ 37 | /* 38 | |-------------------------------------------------------------------------- 39 | | Default match weight 40 | |-------------------------------------------------------------------------- 41 | | 42 | | The weight of all searched words which match at least one of the 43 | | list of searchable attributes. 44 | | Defaults to 1. 45 | | 46 | */ 47 | 48 | 'default_match_weight' => 1, 49 | ]; 50 | ``` 51 | 52 | ## Usage 53 | 54 | To use the package, add the `Maize\Searchable\HasSearch` trait to each model you want to make searchable. 55 | 56 | Once done, you can implement the `getSearchableAttributes` abstract method by returning the list of attributes (or relationships' attributes) you want to search for. 57 | 58 | You can also define the weight of each searchable attribute. If no weight is specified then `default_match_weight` will be taken from `config/searchable.php`. 59 | 60 | Here's an example model including the `HasSearch` trait: 61 | 62 | ``` php 63 | 'array', 85 | ]; 86 | 87 | /** 88 | * Get the model's searchable attributes. 89 | * 90 | * @return array 91 | */ 92 | public function getSearchableAttributes(): array 93 | { 94 | return [ 95 | 'title' => 5, // Model attribute 96 | 'body.en' => 2, // Single json key of a model attribute 97 | 'tags.name', // Relationship attribute 98 | 'tags.description.*', // All json keys of a relationship attribute 99 | DB::raw("CONCAT(creator_name, ' ', creator_surname)"), // Raw expressions are supported too 100 | ]; 101 | } 102 | 103 | /** 104 | * Allows fetching the tags bound to current article instance 105 | * 106 | * @return BelongsToMany 107 | */ 108 | public function tags(): BelongsToMany 109 | { 110 | return $this->belongsToMany(Tag::class)->withTimestamps(); 111 | } 112 | } 113 | ``` 114 | 115 | Now you can just search for a given term using the `scopeSearch` scope method: 116 | 117 | ``` php 118 | use App\Models\Article; 119 | 120 | $searchTerm = 'the search string'; 121 | 122 | Article::query() 123 | ->search($searchTerm) 124 | ->where('column', '=', 'something') 125 | ->get(); 126 | ``` 127 | 128 | That's all! 129 | 130 | The package generates an SQL query with an 'or' condition for each search term and each searchable fields. 131 | The given query returns all models matching the search terms. 132 | Furthermore, search results are weighted, which means the query will be ordered by the most matching models. 133 | 134 | If you don't want to order the search results by its match weight, you can set the `orderByWeight` flag to false: 135 | 136 | ``` php 137 | use App\Models\Article; 138 | 139 | $searchTerm = 'the search string'; 140 | 141 | Article::query() 142 | ->search($searchTerm, false) 143 | ->where('column', '=', 'something') 144 | ->get(); 145 | ``` 146 | 147 | ## Testing 148 | 149 | ```bash 150 | composer test 151 | ``` 152 | 153 | ## Changelog 154 | 155 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 156 | 157 | ## Contributing 158 | 159 | Please see [CONTRIBUTING](https://github.com/maize-tech/.github/blob/main/CONTRIBUTING.md) for details. 160 | 161 | ## Security Vulnerabilities 162 | 163 | Please review [our security policy](https://github.com/maize-tech/.github/security/policy) on how to report security vulnerabilities. 164 | 165 | ## Credits 166 | 167 | - [Riccardo Dalla Via](https://github.com/riccardodallavia) 168 | - [All Contributors](../../contributors) 169 | 170 | ## License 171 | 172 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 173 | -------------------------------------------------------------------------------- /art/socialcard-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maize-tech/laravel-searchable/14bf889e191290629a08aeeeec7c95b94703516a/art/socialcard-dark.png -------------------------------------------------------------------------------- /art/socialcard-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maize-tech/laravel-searchable/14bf889e191290629a08aeeeec7c95b94703516a/art/socialcard-light.png -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "maize-tech/laravel-searchable", 3 | "description": "Laravel Searchable", 4 | "keywords": [ 5 | "maize-tech", 6 | "laravel", 7 | "searchable" 8 | ], 9 | "homepage": "https://github.com/maize-tech/laravel-searchable", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Riccardo Dalla Via", 14 | "email": "riccardo.dallavia@maize.io", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.1", 20 | "illuminate/database": "^10.0|^11.0", 21 | "illuminate/support": "^10.0|^11.0", 22 | "spatie/laravel-package-tools": "^1.14.1" 23 | }, 24 | "require-dev": { 25 | "larastan/larastan": "^2.9", 26 | "laravel/pint": "^1.14", 27 | "orchestra/testbench": "^8.0|^9.0", 28 | "pestphp/pest": "^2.34", 29 | "pestphp/pest-plugin-arch": "^2.7", 30 | "pestphp/pest-plugin-laravel": "^2.3", 31 | "phpstan/extension-installer": "^1.3", 32 | "phpstan/phpstan-deprecation-rules": "^1.1", 33 | "phpstan/phpstan-phpunit": "^1.3" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Maize\\Searchable\\": "src", 38 | "Maize\\Searchable\\Database\\Factories\\": "database/factories" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "Maize\\Searchable\\Tests\\": "tests" 44 | } 45 | }, 46 | "scripts": { 47 | "analyse": "vendor/bin/phpstan analyse", 48 | "format": "vendor/bin/pint", 49 | "test": "vendor/bin/pest", 50 | "test-coverage": "vendor/bin/pest --coverage" 51 | }, 52 | "config": { 53 | "sort-packages": true, 54 | "allow-plugins": { 55 | "pestphp/pest-plugin": true, 56 | "phpstan/extension-installer": true 57 | } 58 | }, 59 | "extra": { 60 | "laravel": { 61 | "providers": [ 62 | "Maize\\Searchable\\SearchableServiceProvider" 63 | ] 64 | } 65 | }, 66 | "minimum-stability": "dev", 67 | "prefer-stable": true 68 | } 69 | -------------------------------------------------------------------------------- /config/searchable.php: -------------------------------------------------------------------------------- 1 | 1, 16 | ]; 17 | -------------------------------------------------------------------------------- /database/factories/TeamFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->uuid(), 16 | 'name' => 'maize-tech', 17 | ]; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | 'Name', 17 | 'last_name' => 'Surname', 18 | 'email' => 'name.surname@example.com', 19 | 'description' => '{ "en": "Just a random guy" }', 20 | 'team_id' => Team::factory()->create(), 21 | ]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /database/migrations/create_teams_table.php.stub: -------------------------------------------------------------------------------- 1 | uuid('id'); 13 | $table->string('name'); 14 | $table->timestamps(); 15 | }); 16 | } 17 | 18 | public function down() 19 | { 20 | Schema::dropIfExists('teams'); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /database/migrations/create_users_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->foreignUuid('team_id'); 14 | $table->string('first_name'); 15 | $table->string('last_name'); 16 | $table->string('email'); 17 | $table->json('description'); 18 | $table->timestamps(); 19 | }); 20 | } 21 | 22 | public function down() 23 | { 24 | Schema::dropIfExists('users'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maize-tech/laravel-searchable/14bf889e191290629a08aeeeec7c95b94703516a/phpstan-baseline.neon -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.neon 3 | 4 | parameters: 5 | level: 5 6 | paths: 7 | - src 8 | - config 9 | - database 10 | tmpDir: build/phpstan 11 | checkOctaneCompatibility: true 12 | checkModelProperties: true 13 | checkMissingIterableValueType: false 14 | -------------------------------------------------------------------------------- /src/Concerns/AddsSearchTermToQuery.php: -------------------------------------------------------------------------------- 1 | getModel(), $attribute); 19 | 20 | $this->querySearchTerm($query, $attributeField, $searchTerm, $weight); 21 | } 22 | 23 | /** 24 | * Queries the given search term against the given attribute. 25 | */ 26 | protected function querySearchTerm(Builder $query, string $attributeField, string $term, float $weight): void 27 | { 28 | $sql = "LOWER($attributeField) LIKE ?"; 29 | 30 | $query->orWhereRaw($sql, $term); 31 | 32 | $this->addSearchWeight($sql, $weight, $term); 33 | } 34 | 35 | /** 36 | * Add the given search term to the weights list. 37 | */ 38 | protected function addSearchWeight(string $sql, float $weight, string $searchTerm): void 39 | { 40 | $this->searchWeights->add([ 41 | 'query' => "(CASE WHEN ($sql) THEN $weight ELSE 0 END)", 42 | 'value' => $searchTerm, 43 | ]); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Concerns/JoinsRelationshipsToQuery.php: -------------------------------------------------------------------------------- 1 | getModel(), $this->getRelationships(), $as); 18 | 19 | return $this; 20 | } 21 | 22 | /** 23 | * Retrieves the list of relationships with at least one searchable attribute. 24 | */ 25 | protected function getRelationships(): array 26 | { 27 | return $this->searchableAttributes 28 | ->map 29 | ->getAttribute() 30 | ->filter(fn ($attribute) => AttributeUtil::isRelationship($this->getModel(), $attribute)) 31 | ->map(fn ($attribute) => Arr::first(explode('.', $attribute))) 32 | ->unique() 33 | ->toArray(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/HasSearch.php: -------------------------------------------------------------------------------- 1 | getModel()->getKeyType(); 23 | $keyName = $query->getModel()->getQualifiedKeyName(); 24 | $keys = $this->applySearch($query, $search, $keyName); 25 | 26 | if (in_array($keyType, ['int', 'integer'])) { 27 | $query->whereIntegerInRaw($keyName, $keys); 28 | } else { 29 | $query->whereIn($keyName, $keys); 30 | } 31 | 32 | $query->when( 33 | $keys->isNotEmpty() && $orderByWeight, 34 | fn ($query) => $query->orderByRaw( 35 | $this->formatOrderQuery($keys, $keyName), 36 | $keys 37 | ) 38 | ); 39 | } 40 | 41 | /** 42 | * Retrieves the model keys matching 43 | * the given search query string. 44 | */ 45 | protected function applySearch(Builder $query, string $search, string $keyName): Collection 46 | { 47 | return SearchBuilder::for($query) 48 | ->withSearchableAttributes($this->getSearchableAttributes()) 49 | ->search($search) 50 | ->pluck($keyName); 51 | } 52 | 53 | /** 54 | * Formats the order by operator to 55 | * order the query with the given keys. 56 | */ 57 | protected function formatOrderQuery(Collection $keys, string $keyName): ?string 58 | { 59 | return $keys 60 | ->map(fn ($key, $order) => "WHEN $keyName=? THEN $order") 61 | ->prepend('CASE') 62 | ->add('END') 63 | ->join(' '); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/SearchBuilder.php: -------------------------------------------------------------------------------- 1 | getQuery()->newQuery()); 31 | 32 | $this->initializeFromBuilder($builder); 33 | 34 | $this->searchableAttributes = collect(); 35 | $this->searchWeights = collect(); 36 | 37 | $this->defaultMatchWeight = config('searchable.default_match_weight', 1); 38 | } 39 | 40 | /** 41 | * Creates a new SearchBuilder instance. 42 | */ 43 | public static function for(Builder $builder): self 44 | { 45 | return new self($builder); 46 | } 47 | 48 | /** 49 | * Adds the given attributes to the searchable attributes list. 50 | */ 51 | public function withSearchableAttributes(array $attributes): self 52 | { 53 | foreach ($attributes as $key => $value) { 54 | if (is_numeric($key)) { 55 | $this->searchableAttributes->push(new SearchableAttribute($value, $this->defaultMatchWeight)); 56 | } else { 57 | $this->searchableAttributes->push(new SearchableAttribute($key, $value)); 58 | } 59 | } 60 | 61 | return $this; 62 | } 63 | 64 | /** 65 | * Searches through the searchable attributes the given search string. 66 | */ 67 | public function search(string $search): self 68 | { 69 | $searchTerms = Parser::parseQuery($search); 70 | 71 | if (empty($searchTerms) || $this->searchableAttributes->isEmpty()) { 72 | return $this; 73 | } 74 | 75 | return $this 76 | ->joinRelationships($this->query) 77 | ->querySearchTerms($searchTerms) 78 | ->orderSearchQuery(); 79 | } 80 | 81 | /** 82 | * Queries all terms within the related attribute. 83 | */ 84 | protected function querySearchTerms(array $searchTerms): self 85 | { 86 | return $this->where(function (Builder $query) use ($searchTerms) { 87 | foreach ($this->searchableAttributes as $searchableAttribute) { 88 | foreach ($searchTerms as $searchTerm) { 89 | $this->searchTerm( 90 | $query, 91 | $searchableAttribute->getAttribute(), 92 | $searchableAttribute->getWeight(), 93 | $searchTerm 94 | ); 95 | } 96 | } 97 | }); 98 | } 99 | 100 | /** 101 | * Orders the query results with the sum of all weights 102 | * of each term matched against a single entry. 103 | */ 104 | protected function orderSearchQuery(): self 105 | { 106 | return $this->orderBy(function ($query) { 107 | $tableName = $this->getModel()->getTable(); 108 | $tableKey = $this->getModel()->getKeyName(); 109 | $select = $this->searchWeights->pluck('query')->implode('+'); 110 | $bindings = $this->searchWeights->pluck('value')->toArray(); 111 | 112 | $this->joinRelationships($query, 'sw'); 113 | 114 | $query->selectRaw($select, $bindings) 115 | ->from($tableName, 'sw') 116 | ->whereColumn("sw.$tableKey", "$tableName.$tableKey") 117 | ->limit(1); 118 | }, 'desc'); 119 | } 120 | 121 | /** 122 | * Add the model, scopes, eager loaded relationships, local macro's and onDelete callback 123 | * from the $builder to this query builder. 124 | */ 125 | protected function initializeFromBuilder(Builder $builder): void 126 | { 127 | $this 128 | ->setModel($builder->getModel()) 129 | ->setEagerLoads($builder->getEagerLoads()); 130 | 131 | $builder->macro('getProtected', function (Builder $builder, string $property) { 132 | return $builder->{$property}; 133 | }); 134 | 135 | /* @phpstan-ignore-next-line */ 136 | $this->scopes = $builder->getProtected('scopes'); 137 | /* @phpstan-ignore-next-line */ 138 | $this->localMacros = $builder->getProtected('localMacros'); 139 | /* @phpstan-ignore-next-line */ 140 | $this->onDelete = $builder->getProtected('onDelete'); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/SearchableAttribute.php: -------------------------------------------------------------------------------- 1 | attribute = $attribute; 16 | $this->weight = $weight; 17 | } 18 | 19 | public function getAttribute(): \Illuminate\Database\Query\Expression|string 20 | { 21 | return $this->attribute; 22 | } 23 | 24 | public function getWeight(): float 25 | { 26 | return $this->weight; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/SearchableServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-searchable') 14 | ->hasConfigFile(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Utils/AttributeUtil.php: -------------------------------------------------------------------------------- 1 | getTable(); 27 | 28 | return Schema::hasColumn( 29 | $tableName, 30 | explode('.', $attribute)[0] 31 | ); 32 | } 33 | 34 | /** 35 | * Checks whether the given field is a relationship or not. 36 | * 37 | * @param string|Expression $attribute 38 | */ 39 | public static function isRelationship(Model $model, $attribute): bool 40 | { 41 | if ($attribute instanceof Expression) { 42 | return false; 43 | } 44 | 45 | $relationship = explode('.', $attribute)[0]; 46 | 47 | return method_exists($model, $relationship); 48 | } 49 | 50 | /** 51 | * Checks whether the given field is a json 52 | * attribute or not. 53 | * 54 | * @param string|Expression $attribute 55 | */ 56 | public static function isJsonAttribute(Model $model, $attribute): bool 57 | { 58 | if ($attribute instanceof Expression) { 59 | return false; 60 | } 61 | 62 | $count = self::isRelationship($model, $attribute) ? 2 : 1; 63 | 64 | return count(explode('.', $attribute)) > $count; 65 | } 66 | 67 | /** 68 | * Prepares the given attribute and returns the 69 | * associated query string. 70 | * 71 | * @param string|Expression $attribute 72 | */ 73 | public static function formatAttribute(Model $model, $attribute): string 74 | { 75 | if ($attribute instanceof Expression) { 76 | $grammar = $model::query()->getQuery()->getGrammar(); 77 | 78 | return strval($attribute->getValue($grammar)); 79 | } 80 | 81 | $attributeName = self::formatAttributeName($model, $attribute); 82 | 83 | if (self::isJsonAttribute($model, $attribute)) { 84 | $jsonKey = Arr::last(explode('.', $attribute)); 85 | $jsonOperator = self::formatJsonOperator($model, $attributeName, $jsonKey); 86 | 87 | return "COALESCE($jsonOperator,'')"; 88 | } 89 | 90 | return $attributeName; 91 | } 92 | 93 | /** 94 | * Formats the driver-specific json operator 95 | * to extract the given json key. 96 | */ 97 | public static function formatJsonOperator(Model $model, string $attributeName, string $jsonKey): string 98 | { 99 | $grammar = $model::query()->getQuery()->getGrammar(); 100 | 101 | if ($grammar instanceof PostgresGrammar) { 102 | if ($jsonKey === self::ALL_ATTRIBUTES_SELECTOR) { 103 | return "$attributeName::TEXT"; 104 | } 105 | 106 | return "$attributeName->>'$jsonKey'"; 107 | } 108 | 109 | return "JSON_UNQUOTE(JSON_EXTRACT($attributeName, '$.\"$jsonKey\"'))"; 110 | } 111 | 112 | /** 113 | * Prepares the given attribute and returns the 114 | * associated field name. 115 | */ 116 | protected static function formatAttributeName(Model $model, string $attribute): string 117 | { 118 | if (self::isRelationship($model, $attribute)) { 119 | [$relationship, $field] = explode('.', $attribute); 120 | $relationTable = $model->{$relationship}()->getRelated()->getTable(); 121 | 122 | return "$relationTable.$field"; 123 | } 124 | 125 | $tableName = $model->getTable(); 126 | $field = Arr::first(explode('.', $attribute)); 127 | 128 | return "$tableName.$field"; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Utils/Joiner.php: -------------------------------------------------------------------------------- 1 | query = $query; 48 | $this->model = $model; 49 | $this->tableName = $as ?? $model->getTable(); 50 | } 51 | 52 | /** 53 | * Join related tables. 54 | */ 55 | public static function joinAll(Builder $query, Model $model, array $targets, ?string $as = null, string $type = 'inner'): void 56 | { 57 | $joiner = new self($query, $model, $as); 58 | 59 | foreach ($targets as $target) { 60 | $joiner->join($target, $type); 61 | } 62 | } 63 | 64 | /** 65 | * Left join related tables. 66 | */ 67 | public static function leftJoinAll(Builder $query, Model $model, array $targets, ?string $as = null): void 68 | { 69 | $joiner = new self($query, $model, $as); 70 | 71 | foreach ($targets as $target) { 72 | $joiner->leftJoin($target); 73 | } 74 | } 75 | 76 | /** 77 | * Left join related tables. 78 | */ 79 | public static function rightJoinAll(Builder $query, Model $model, array $targets, ?string $as = null): void 80 | { 81 | $joiner = new self($query, $model, $as); 82 | 83 | foreach ($targets as $target) { 84 | $joiner->rightJoin($target); 85 | } 86 | } 87 | 88 | /** 89 | * Join related tables. 90 | */ 91 | public function join(string $target, string $type = 'inner'): Model 92 | { 93 | $related = $this->model; 94 | 95 | foreach (explode('.', $target) as $segment) { 96 | $related = $this->joinSegment($related, $segment, $type); 97 | } 98 | 99 | return $related; 100 | } 101 | 102 | /** 103 | * Left join related tables. 104 | */ 105 | public function leftJoin(string $target): Model 106 | { 107 | return $this->join($target, 'left'); 108 | } 109 | 110 | /** 111 | * Right join related tables. 112 | */ 113 | public function rightJoin(string $target): Model 114 | { 115 | return $this->join($target, 'right'); 116 | } 117 | 118 | /** 119 | * Join relation's table accordingly. 120 | */ 121 | protected function joinSegment(Model $parent, string $segment, string $type): Model 122 | { 123 | $relation = $parent->{$segment}(); 124 | $related = $relation->getRelated(); 125 | $table = $related->getTable(); 126 | 127 | if ($relation instanceof BelongsToMany || $relation instanceof HasManyThrough) { 128 | $this->joinIntermediate($parent, $relation, $type); 129 | } 130 | 131 | if (! $this->alreadyJoined($join = $this->getJoinClause($parent, $relation, $table, $type))) { 132 | $this->query->joins[] = $join; 133 | } 134 | 135 | return $related; 136 | } 137 | 138 | /** 139 | * Determine whether the related table has been already joined. 140 | */ 141 | protected function alreadyJoined(Join $join): bool 142 | { 143 | return in_array($join, (array) $this->query->joins); 144 | } 145 | 146 | /** 147 | * Get the join clause for related table. 148 | */ 149 | protected function getJoinClause(Model $parent, Relation $relation, string $table, string $type): Join 150 | { 151 | [$fk, $pk] = $this->getJoinKeys($relation); 152 | 153 | $join = (new Join($parent::query()->getQuery(), $type, $table))->on($fk, '=', $pk); 154 | 155 | if (in_array(SoftDeletes::class, class_uses_recursive($relation->getRelated()))) { 156 | /* @phpstan-ignore-next-line */ 157 | $join->whereNull($relation->getRelated()->getQualifiedDeletedAtColumn()); 158 | } 159 | 160 | if ($relation instanceof MorphOneOrMany) { 161 | $join->where($relation->getQualifiedMorphType(), '=', $parent->getMorphClass()); 162 | } elseif ($relation instanceof MorphToMany) { 163 | $join->where($relation->getMorphType(), '=', $parent->getMorphClass()); 164 | } 165 | 166 | return $join; 167 | } 168 | 169 | /** 170 | * Join pivot or 'through' table. 171 | */ 172 | protected function joinIntermediate(Model $parent, BelongsToMany|HasManyThrough $relation, string $type): void 173 | { 174 | $table = match (true) { 175 | $relation instanceof BelongsToMany => $relation->getTable(), 176 | $relation instanceof HasManyThrough => $relation->getParent()->getTable(), 177 | }; 178 | 179 | $fk = match (true) { 180 | $relation instanceof BelongsToMany => $relation->getQualifiedForeignPivotKeyName(), 181 | $relation instanceof HasManyThrough => $relation->getQualifiedFirstKeyName(), 182 | }; 183 | 184 | $pk = "{$this->tableName}.{$parent->getKeyName()}"; 185 | 186 | if (! $this->alreadyJoined($join = (new Join($this->query, $type, $table))->on($fk, '=', $pk))) { 187 | $this->query->joins[] = $join; 188 | } 189 | } 190 | 191 | /** 192 | * Get pair of the keys from relation in order to join the table. 193 | * 194 | * @throws LogicException 195 | */ 196 | protected function getJoinKeys(Relation $relation): array 197 | { 198 | if ($relation instanceof MorphTo) { 199 | throw new LogicException('MorphTo relation cannot be joined.'); 200 | } 201 | 202 | $isSelfParent = $relation->getParent()->getMorphClass() === $this->model->getMorphClass(); 203 | 204 | if ($relation instanceof HasOneOrMany) { 205 | return $isSelfParent 206 | ? [$relation->getQualifiedForeignKeyName(), "{$this->tableName}.{$relation->getLocalKeyName()}"] 207 | : [$relation->getQualifiedForeignKeyName(), $relation->getQualifiedParentKeyName()]; 208 | } 209 | 210 | if ($relation instanceof BelongsTo) { 211 | return $isSelfParent 212 | ? ["{$this->tableName}.{$relation->getForeignKeyName()}", $relation->getQualifiedOwnerKeyName()] 213 | : [$relation->getQualifiedForeignKeyName(), $relation->getQualifiedOwnerKeyName()]; 214 | } 215 | 216 | if ($relation instanceof BelongsToMany) { 217 | return [$relation->getQualifiedRelatedPivotKeyName(), $relation->getRelated()->getQualifiedKeyName()]; 218 | } 219 | 220 | if ($relation instanceof HasManyThrough) { 221 | return [$relation->getQualifiedFarKeyName(), $relation->getQualifiedParentKeyName()]; 222 | } 223 | 224 | throw new LogicException('Unknown relation type.'); 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/Utils/Parser.php: -------------------------------------------------------------------------------- 1 |