├── config └── config.php ├── src ├── Mixins │ ├── QueryBuilderExtraMethods.php │ ├── QueryRelationshipExistence.php │ ├── JoinRelationship.php │ └── RelationshipsExtraMethods.php ├── PowerJoinsServiceProvider.php ├── StaticCache.php ├── EloquentJoins.php ├── FakeJoinCallback.php ├── JoinsHelper.php └── PowerJoinClause.php ├── CHANGELOG.md ├── .php-cs-fixer.php ├── LICENSE.md ├── AGENTS.md ├── composer.json ├── CONTRIBUTING.md ├── .php_cs ├── .stubs.php └── README.md /config/config.php: -------------------------------------------------------------------------------- 1 | groups; 11 | }; 12 | } 13 | 14 | public function getSelect() 15 | { 16 | return function () { 17 | return $this->columns; 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/PowerJoinsServiceProvider.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | public static array $powerJoinAliasesCache = []; 15 | 16 | public static function getTableOrAliasForModel(Model $model): string 17 | { 18 | return static::$powerJoinAliasesCache[spl_object_id($model)] ?? $model->getTable(); 19 | } 20 | 21 | public static function setTableAliasForModel(Model $model, $alias): void 22 | { 23 | static::$powerJoinAliasesCache[spl_object_id($model)] = $alias; 24 | } 25 | 26 | public static function clear(): void 27 | { 28 | static::$powerJoinAliasesCache = []; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `eloquent-power-joins` will be documented in this file. 4 | 5 | ## 2.2.2 - 2020-10 6 | - Fixed the ability to pass nested closures in join callbacks when using aliases; 7 | 8 | ## 2.2.1 - 2020-10 9 | - Fixed nested conditions in relationship definitions; 10 | 11 | ## 2.1.0 - 2020-09 12 | - Added the ability to include trashed models in join clauses; 13 | 14 | ## 2.0.0 - 2020-09 15 | - Introduced trait that has to be used by models; 16 | - Automatically applying extra relationship conditions; 17 | - Ability to order by using left joins; 18 | - Laravel 8 support; 19 | _ Lots of bugfixes; 20 | - Changed the method signature for sorting; 21 | - Changed the method signature for querying relationship existence; 22 | 23 | ## 1.1.0 24 | - Added the ability to use table aliases; 25 | 26 | ## 1.0.0 27 | - Initial release; 28 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | true, 5 | 'strict_param' => true, 6 | 'php_unit_method_casing' => ['case' => 'snake_case'], 7 | 'phpdoc_align' => [ 8 | 'align' => 'left' 9 | ], 10 | 'global_namespace_import' => [ 11 | 'import_classes' => true, 12 | 'import_constants' => true, 13 | 'import_functions' => true 14 | ], 15 | 'yoda_style' => [ 16 | 'equal' => false, 17 | 'identical' => false, 18 | 'less_and_greater' => false, 19 | ], 20 | ]; 21 | 22 | $finder = PhpCsFixer\Finder::create() 23 | ->in(__DIR__) 24 | ->name('*.php') 25 | ->notName('*.blade.php') 26 | ->ignoreDotFiles(true) 27 | ->ignoreVCS(true); 28 | 29 | return (new PhpCsFixer\Config()) 30 | ->setRules($rules) 31 | ->setFinder($finder) 32 | ->setRiskyAllowed(true) 33 | ->setUsingCache(true); 34 | -------------------------------------------------------------------------------- /src/EloquentJoins.php: -------------------------------------------------------------------------------- 1 | getQuery()->getGroupBy(); 13 | }; 14 | } 15 | 16 | public function getScopes() 17 | { 18 | return function () { 19 | return $this->scopes; 20 | }; 21 | } 22 | 23 | public function getSelect() 24 | { 25 | return function () { 26 | return $this->getQuery()->getSelect(); 27 | }; 28 | } 29 | 30 | protected function getRelationWithoutConstraintsProxy() 31 | { 32 | return function ($relation) { 33 | return Relation::noConstraints(function () use ($relation) { 34 | return $this->getModel()->{$relation}(); 35 | }); 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/FakeJoinCallback.php: -------------------------------------------------------------------------------- 1 | alias; 15 | } 16 | 17 | public function getJoinType(): ?string 18 | { 19 | return $this->joinType; 20 | } 21 | 22 | public function __call($name, $arguments) 23 | { 24 | if ($name === 'as') { 25 | $this->alias = $arguments[0]; 26 | } elseif ($name === 'joinType') { 27 | $this->joinType = $arguments[0]; 28 | } elseif ($name === 'left') { 29 | $this->joinType = 'leftPowerJoin'; 30 | } elseif ($name === 'right') { 31 | $this->joinType = 'rightPowerJoin'; 32 | } elseif ($name === 'inner') { 33 | $this->joinType = 'powerJoin'; 34 | } 35 | 36 | return $this; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Luis Dalmolin 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 all 13 | 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 THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Agent Guidelines for Eloquent Power Joins 2 | 3 | ## Commands 4 | - **Test**: `composer test` or `vendor/bin/phpunit` 5 | - **Single test**: `vendor/bin/phpunit tests/JoinRelationshipTest.php` or `vendor/bin/phpunit --filter test_method_name` 6 | - **Test with coverage**: `composer test-coverage` 7 | - **Lint**: `composer lint` or `vendor/bin/php-cs-fixer fix -vvv --show-progress=dots --config=.php-cs-fixer.php` 8 | 9 | ## Code Style 10 | - **PHP Version**: 8.2+ 11 | - **Framework**: Laravel 11.42+/12.0+ package 12 | - **Formatting**: Uses PHP-CS-Fixer with @Symfony rules + custom overrides 13 | - **Imports**: Use global namespace imports for classes/constants/functions, ordered alphabetically 14 | - **Arrays**: Short syntax `[]`, trailing commas in multiline 15 | - **Quotes**: Single quotes preferred 16 | - **Test methods**: snake_case naming with `@test` annotation 17 | - **Namespaces**: `Kirschbaum\PowerJoins` for src, `Kirschbaum\PowerJoins\Tests` for tests 18 | - **Type hints**: Use strict typing, compact nullable syntax `?Type` 19 | - **PHPDoc**: Left-aligned, no empty returns, ordered tags 20 | - **Variables**: No yoda conditions (`$var === 'value'` not `'value' === $var`) -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kirschbaum-development/eloquent-power-joins", 3 | "description": "The Laravel magic applied to joins.", 4 | "keywords": [ 5 | "laravel", 6 | "eloquent", 7 | "mysql", 8 | "join" 9 | ], 10 | "homepage": "https://github.com/kirschbaum-development/eloquent-power-joins", 11 | "license": "MIT", 12 | "type": "library", 13 | "authors": [ 14 | { 15 | "name": "Luis Dalmolin", 16 | "email": "luis.nh@gmail.com", 17 | "role": "Developer" 18 | } 19 | ], 20 | "require": { 21 | "php": "^8.2", 22 | "illuminate/support": "^11.42|^12.0", 23 | "illuminate/database": "^11.42|^12.0" 24 | }, 25 | "require-dev": { 26 | "friendsofphp/php-cs-fixer": "dev-master", 27 | "laravel/legacy-factories": "^1.0@dev", 28 | "orchestra/testbench": "^9.0|^10.0", 29 | "phpunit/phpunit": "^10.0|^11.0" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "Kirschbaum\\PowerJoins\\": "src" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Kirschbaum\\PowerJoins\\Tests\\": "tests" 39 | } 40 | }, 41 | "scripts": { 42 | "test": "vendor/bin/phpunit", 43 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage", 44 | "lint": "vendor/bin/php-cs-fixer fix -vvv --show-progress=dots --config=.php-cs-fixer.php" 45 | }, 46 | "config": { 47 | "sort-packages": true 48 | }, 49 | "extra": { 50 | "laravel": { 51 | "providers": [ 52 | "Kirschbaum\\PowerJoins\\PowerJoinsServiceProvider" 53 | ] 54 | } 55 | }, 56 | "minimum-stability": "stable" 57 | } 58 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | Please read and understand the contribution guide before creating an issue or pull request. 6 | 7 | ## Etiquette 8 | 9 | This project is open source, and as such, the maintainers give their free time to build and maintain the source code 10 | held within. They make the code freely available in the hope that it will be of use to other developers. It would be 11 | extremely unfair for them to suffer abuse or anger for their hard work. 12 | 13 | Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the 14 | world that developers are civilized and selfless people. 15 | 16 | It's the duty of the maintainer to ensure that all submissions to the project are of sufficient 17 | quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. 18 | 19 | ## Viability 20 | 21 | When requesting or submitting new features, first consider whether it might be useful to others. Open 22 | source projects are used by many developers, who may have entirely different needs to your own. Think about 23 | whether or not your feature is likely to be used by other users of the project. 24 | 25 | ## Procedure 26 | 27 | Before filing an issue: 28 | 29 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 30 | - Check to make sure your feature suggestion isn't already present within the project. 31 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 32 | - Check the pull requests tab to ensure that the feature isn't already in progress. 33 | 34 | Before submitting a pull request: 35 | 36 | - Check the codebase to ensure that your feature doesn't already exist. 37 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 38 | 39 | ## Requirements 40 | 41 | If the project maintainer has any additional requirements, you will find them listed here. 42 | 43 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). 44 | 45 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 46 | 47 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 48 | 49 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. 50 | 51 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 52 | 53 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 54 | 55 | **Happy coding**! 56 | -------------------------------------------------------------------------------- /.php_cs: -------------------------------------------------------------------------------- 1 | exclude('vendor') 5 | ->in(__DIR__ . '/src') 6 | ->name('*.php') 7 | ->ignoreDotFiles(true) 8 | ->ignoreVCS(true); 9 | 10 | return PhpCsFixer\Config::create() 11 | ->setFinder($finder) 12 | ->setRules([ 13 | '@PSR2' => true, 14 | 'phpdoc_no_empty_return' => false, 15 | 'phpdoc_var_annotation_correct_order' => true, 16 | 'array_syntax' => [ 17 | 'syntax' => 'short', 18 | ], 19 | 'no_singleline_whitespace_before_semicolons' => true, 20 | 'no_extra_blank_lines' => [ 21 | 'break', 'case', 'continue', 'curly_brace_block', 'default', 22 | 'extra', 'parenthesis_brace_block', 'return', 23 | 'square_brace_block', 'switch', 'throw', 'use', 'useTrait', 'use_trait', 24 | ], 25 | 'cast_spaces' => [ 26 | 'space' => 'single', 27 | ], 28 | 'single_quote' => true, 29 | 'lowercase_cast' => true, 30 | 'lowercase_static_reference' => true, 31 | 'no_empty_phpdoc' => true, 32 | 'no_empty_comment' => true, 33 | 'array_indentation' => true, 34 | 'short_scalar_cast' => true, 35 | 'no_mixed_echo_print' => [ 36 | 'use' => 'echo', 37 | ], 38 | 'ordered_imports' => [ 39 | 'sort_algorithm' => 'alpha', 40 | ], 41 | 'no_unused_imports' => true, 42 | 'binary_operator_spaces' => [ 43 | 'default' => 'single_space', 44 | ], 45 | 'no_empty_statement' => true, 46 | 'unary_operator_spaces' => true, // $number ++ becomes $number++ 47 | 'hash_to_slash_comment' => true, // # becomes // 48 | 'standardize_not_equals' => true, // <> becomes != 49 | 'native_function_casing' => true, 50 | 'ternary_operator_spaces' => true, 51 | 'ternary_to_null_coalescing' => true, 52 | 'declare_equal_normalize' => [ 53 | 'space' => 'single', 54 | ], 55 | 'function_typehint_space' => true, 56 | 'no_leading_import_slash' => true, 57 | 'blank_line_before_statement' => [ 58 | 'statements' => [ 59 | 'break', 'case', 'continue', 60 | 'declare', 'default', 'die', 61 | 'do', 'exit', 'for', 'foreach', 62 | 'goto', 'if', 'include', 63 | 'include_once', 'require', 'require_once', 64 | 'return', 'switch', 'throw', 'try', 'while', 'yield', 65 | ], 66 | ], 67 | 'combine_consecutive_unsets' => true, 68 | 'method_chaining_indentation' => true, 69 | 'no_whitespace_in_blank_line' => true, 70 | 'blank_line_after_opening_tag' => true, 71 | 'no_trailing_comma_in_list_call' => true, 72 | 'list_syntax' => ['syntax' => 'short'], 73 | // public function getTimezoneAttribute( ? Banana $value) becomes public function getTimezoneAttribute(?Banana $value) 74 | 'compact_nullable_typehint' => true, 75 | 'explicit_string_variable' => true, 76 | 'no_leading_namespace_whitespace' => true, 77 | 'trailing_comma_in_multiline_array' => true, 78 | 'not_operator_with_successor_space' => true, 79 | 'object_operator_without_whitespace' => true, 80 | 'single_blank_line_before_namespace' => true, 81 | 'no_blank_lines_after_class_opening' => true, 82 | 'no_blank_lines_after_phpdoc' => true, 83 | 'no_whitespace_before_comma_in_array' => true, 84 | 'no_trailing_comma_in_singleline_array' => true, 85 | 'multiline_whitespace_before_semicolons' => [ 86 | 'strategy' => 'no_multi_line', 87 | ], 88 | 'no_multiline_whitespace_around_double_arrow' => true, 89 | 'no_useless_return' => true, 90 | 'phpdoc_add_missing_param_annotation' => false, 91 | 'phpdoc_order' => true, 92 | 'phpdoc_scalar' => false, 93 | 'phpdoc_separation' => false, 94 | 'phpdoc_single_line_var_spacing' => false, 95 | 'single_trait_insert_per_statement' => true, 96 | 'return_type_declaration' => [ 97 | 'space_before' => 'none', 98 | ], 99 | ]) 100 | ->setLineEnding("\n"); 101 | -------------------------------------------------------------------------------- /.stubs.php: -------------------------------------------------------------------------------- 1 | =', int $count = 1, string $boolean = 'and', Closure|array|string $callback = null, ?string $morphable = null) {} 69 | 70 | /** @return self */ 71 | public function powerJoinDoesntHave(string $relation, string $boolean = 'and', Closure|array|string $callback = null) {} 72 | 73 | /** @return self */ 74 | public function powerJoinWhereHas(string $relation, Closure|array|string $callback = null, string $operator = '>=', int $count = 1) {} 75 | 76 | // PowerJoinClause methods for when a closure is being used as a callback 77 | /** @return self */ 78 | public function as(string $alias, ?string $joinedTableAlias = null) {} 79 | 80 | /** @return self */ 81 | public function on($first, $operator = null, $second = null, $boolean = 'and') {} 82 | 83 | /** @return self */ 84 | public function withGlobalScopes() {} 85 | 86 | /** @return self */ 87 | public function withTrashed() {} 88 | 89 | /** @return self */ 90 | public function onlyTrashed() {} 91 | 92 | /** @return self */ 93 | public function left() {} 94 | 95 | /** @return self */ 96 | public function right() {} 97 | 98 | /** @return self */ 99 | public function inner() {} 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/JoinsHelper.php: -------------------------------------------------------------------------------- 1 | joinRelationshipCache = new WeakMap(); 40 | } 41 | 42 | public static function make($model): static 43 | { 44 | static::$instances ??= new WeakMap(); 45 | 46 | return static::$instances[$model] ??= new self(); 47 | } 48 | 49 | /** 50 | * Cache to not join the same relationship twice. 51 | */ 52 | private WeakMap $joinRelationshipCache; 53 | 54 | /** 55 | * Join method map. 56 | */ 57 | public static $joinMethodsMap = [ 58 | 'join' => 'powerJoin', 59 | 'leftJoin' => 'leftPowerJoin', 60 | 'rightJoin' => 'rightPowerJoin', 61 | ]; 62 | 63 | /** 64 | * Ensure that any query model can only belong to 65 | * maximum one query, e.g. because of cloning. 66 | */ 67 | public static function ensureModelIsUniqueToQuery($query): void 68 | { 69 | $originalModel = $query->getModel(); 70 | 71 | $querySplObjectId = spl_object_id($query); 72 | 73 | if ( 74 | isset(static::$modelQueryDictionary[$originalModel]) 75 | && static::$modelQueryDictionary[$originalModel] !== $querySplObjectId 76 | ) { 77 | // If the model is already associated with another query, we need to clone the model. 78 | // This can happen if a certain query, *before having interacted with the library 79 | // `joinRelationship()` method*, was cloned by previous code. 80 | 81 | // Preserve the from clause (including any alias) before setModel overwrites it 82 | $originalFrom = $query->getQuery()->from; 83 | 84 | $query->setModel($model = new ($query->getModel())); 85 | $model->mergeCasts($originalModel->getCasts()); 86 | 87 | if ($originalFrom) { 88 | $query->getQuery()->from = $originalFrom; 89 | } 90 | 91 | // Link the Spl Object ID of the query to the new model... 92 | static::$modelQueryDictionary[$model] = $querySplObjectId; 93 | 94 | // If there is a `JoinsHelper` with a cache associated with the old model, 95 | // we will copy the cache over to the new fresh model clone added to it. 96 | $originalJoinsHelper = JoinsHelper::make($originalModel); 97 | $joinsHelper = JoinsHelper::make($model); 98 | 99 | foreach ($originalJoinsHelper->joinRelationshipCache[$originalModel] ?? [] as $relation => $value) { 100 | $joinsHelper->markRelationshipAsAlreadyJoined($model, $relation); 101 | } 102 | } else { 103 | static::$modelQueryDictionary[$originalModel] = $querySplObjectId; 104 | } 105 | 106 | $query->onClone(static function (Builder $query) { 107 | $originalModel = $query->getModel(); 108 | $originalJoinsHelper = JoinsHelper::make($originalModel); 109 | 110 | // Preserve the from clause (including any alias) before setModel overwrites it 111 | $originalFrom = $query->getQuery()->from; 112 | 113 | // Ensure the model of the cloned query is unique to the query. 114 | $query->setModel($model = new $originalModel()); 115 | $model->mergeCasts($originalModel->getCasts()); 116 | 117 | // Restore the original from clause if it was set 118 | if ($originalFrom) { 119 | $query->getQuery()->from = $originalFrom; 120 | } 121 | 122 | // Update any `beforeQueryCallbacks` to link to the new `$this` as Eloquent Query, 123 | // otherwise the reference to the current Eloquent query goes wrong. These query 124 | // callbacks are stored on the `QueryBuilder` instance and therefore do not get 125 | // an instance of Eloquent Builder passed, but an instance of `QueryBuilder`. 126 | foreach ($query->getQuery()->beforeQueryCallbacks as $key => $beforeQueryCallback) { 127 | /** @var Closure $beforeQueryCallback */ 128 | if (isset(static::$beforeQueryCallbacks[$beforeQueryCallback])) { 129 | static::$beforeQueryCallbacks[$query->getQuery()->beforeQueryCallbacks[$key] = $beforeQueryCallback->bindTo($query)] = true; 130 | } 131 | } 132 | 133 | $joinsHelper = JoinsHelper::make($model); 134 | 135 | foreach ($originalJoinsHelper->joinRelationshipCache[$originalModel] ?? [] as $relation => $value) { 136 | $joinsHelper->markRelationshipAsAlreadyJoined($model, $relation); 137 | } 138 | }); 139 | } 140 | 141 | public static function clearCacheBeforeQuery($query): void 142 | { 143 | $beforeQueryCallback = function () { 144 | /* @var Builder $this */ 145 | JoinsHelper::make($this->getModel())->clear(); 146 | }; 147 | 148 | $query->getQuery()->beforeQuery( 149 | $beforeQueryCallback = $beforeQueryCallback->bindTo($query) 150 | ); 151 | 152 | static::$beforeQueryCallbacks[$beforeQueryCallback] = true; 153 | } 154 | 155 | /** 156 | * Format the join callback. 157 | */ 158 | public function formatJoinCallback($callback) 159 | { 160 | if (is_string($callback)) { 161 | return function ($join) use ($callback) { 162 | $join->as($callback); 163 | }; 164 | } 165 | 166 | return $callback; 167 | } 168 | 169 | public function generateAliasForRelationship(Relation $relation, string $relationName): array|string 170 | { 171 | if ($relation instanceof BelongsToMany || $relation instanceof HasManyThrough) { 172 | return [ 173 | md5($relationName.'table1'.uniqid('', true)), 174 | md5($relationName.'table2'.uniqid('', true)), 175 | ]; 176 | } 177 | 178 | return md5($relationName.uniqid('', true)); 179 | } 180 | 181 | /** 182 | * Get the join alias name from all the different options. 183 | */ 184 | public function getAliasName(bool $useAlias, Relation $relation, string $relationName, string $tableName, $callback): string|array|null 185 | { 186 | if ($callback) { 187 | if (is_callable($callback)) { 188 | $fakeJoinCallback = new FakeJoinCallback($relation->getBaseQuery(), 'inner', $tableName); 189 | $callback($fakeJoinCallback); 190 | 191 | if ($fakeJoinCallback->getAlias()) { 192 | return $fakeJoinCallback->getAlias(); 193 | } 194 | } 195 | 196 | if (is_array($callback) && $relation instanceof HasOneOrManyThrough) { 197 | $alias = [null, null]; 198 | 199 | $throughParentTable = $relation->getThroughParent()->getTable(); 200 | if (isset($callback[$throughParentTable])) { 201 | $fakeJoinCallback = new FakeJoinCallback($relation->getBaseQuery(), 'inner', $throughParentTable); 202 | $callback[$throughParentTable]($fakeJoinCallback); 203 | 204 | if ($fakeJoinCallback->getAlias()) { 205 | $alias[0] = $fakeJoinCallback->getAlias(); 206 | } 207 | } 208 | 209 | $relatedTable = $relation->getRelated()->getTable(); 210 | if (isset($callback[$relatedTable])) { 211 | $fakeJoinCallback = new FakeJoinCallback($relation->getBaseQuery(), 'inner', $relatedTable); 212 | $callback[$relatedTable]($fakeJoinCallback); 213 | 214 | if ($fakeJoinCallback->getAlias()) { 215 | $alias[1] = $fakeJoinCallback->getAlias(); 216 | } 217 | } 218 | 219 | return $alias; 220 | } 221 | 222 | if (is_array($callback) && isset($callback[$tableName])) { 223 | $fakeJoinCallback = new FakeJoinCallback($relation->getBaseQuery(), 'inner', $tableName); 224 | $callback[$tableName]($fakeJoinCallback); 225 | 226 | if ($fakeJoinCallback->getAlias()) { 227 | return $fakeJoinCallback->getAlias(); 228 | } 229 | } 230 | } 231 | 232 | return $useAlias 233 | ? $this->generateAliasForRelationship($relation, $relationName) 234 | : null; 235 | } 236 | 237 | /** 238 | * Checks if the relationship was already joined. 239 | */ 240 | public function relationshipAlreadyJoined($model, string $relation): bool 241 | { 242 | return isset($this->joinRelationshipCache[$model][$relation]); 243 | } 244 | 245 | /** 246 | * Marks the relationship as already joined. 247 | */ 248 | public function markRelationshipAsAlreadyJoined($model, string $relation): void 249 | { 250 | $this->joinRelationshipCache[$model] ??= []; 251 | 252 | $this->joinRelationshipCache[$model][$relation] = true; 253 | } 254 | 255 | public function clear(): void 256 | { 257 | $this->joinRelationshipCache = new WeakMap(); 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/PowerJoinClause.php: -------------------------------------------------------------------------------- 1 | model = $model; 47 | $this->tableName = $table; 48 | } 49 | 50 | /** 51 | * Add an alias to the table being joined. 52 | */ 53 | public function as(string $alias, ?string $joinedTableAlias = null): self 54 | { 55 | $this->alias = $alias; 56 | $this->joinedTableAlias = $joinedTableAlias; 57 | $this->table = sprintf('%s as %s', $this->table, $alias); 58 | $this->useTableAliasInConditions(); 59 | 60 | if ($this->model) { 61 | StaticCache::setTableAliasForModel($this->model, $alias); 62 | } 63 | 64 | return $this; 65 | } 66 | 67 | public function on($first, $operator = null, $second = null, $boolean = 'and'): self 68 | { 69 | parent::on($first, $operator, $second, $boolean); 70 | $this->useTableAliasInConditions(); 71 | 72 | return $this; 73 | } 74 | 75 | public function getModel() 76 | { 77 | return $this->model; 78 | } 79 | 80 | /** 81 | * Apply the global scopes to the joined query. 82 | */ 83 | public function withGlobalScopes(): self 84 | { 85 | if (!$this->model) { 86 | return $this; 87 | } 88 | 89 | foreach ($this->model->getGlobalScopes() as $scope) { 90 | if ($scope instanceof Closure) { 91 | $scope->call($this, $this); 92 | continue; 93 | } 94 | 95 | if ($scope instanceof SoftDeletingScope) { 96 | continue; 97 | } 98 | 99 | (new $scope())->apply($this, $this->model); 100 | } 101 | 102 | return $this; 103 | } 104 | 105 | /** 106 | * Apply the table alias in the existing join conditions. 107 | */ 108 | protected function useTableAliasInConditions(): self 109 | { 110 | if (!$this->alias || !$this->model) { 111 | return $this; 112 | } 113 | 114 | $this->wheres = collect($this->wheres)->filter(function ($where) { 115 | $whereType = $where['type'] ?? ''; 116 | 117 | if (in_array($whereType, ['Column', 'Basic'], true)) { 118 | return true; 119 | } 120 | 121 | if ($whereType === 'Null' && Str::contains($where['column'], $this->getModel()->getDeletedAtColumn())) { 122 | return true; 123 | } 124 | 125 | return false; 126 | })->map(function ($where) { 127 | if ($where['type'] === 'Null') { 128 | return $where; 129 | } 130 | 131 | $key = $this->model->getKeyName(); 132 | $table = $this->tableName; 133 | $replaceMethod = sprintf('useAliasInWhere%sType', ucfirst($where['type'])); 134 | 135 | return $this->{$replaceMethod}($where); 136 | })->toArray(); 137 | 138 | return $this; 139 | } 140 | 141 | protected function useAliasInWhereColumnType(array $where): array 142 | { 143 | $key = $this->model->getKeyName(); 144 | $table = $this->tableName; 145 | 146 | // if it was already replaced, skip 147 | if (Str::startsWith($where['first'].'.', $this->alias.'.') || Str::startsWith($where['second'].'.', $this->alias.'.')) { 148 | return $where; 149 | } 150 | 151 | if (Str::contains($where['first'], $table) && Str::contains($where['second'], $table)) { 152 | // if joining the same table, only replace the correct table.key pair 153 | $where['first'] = str_replace($table.'.'.$key, $this->alias.'.'.$key, $where['first']); 154 | $where['second'] = str_replace($table.'.'.$key, $this->alias.'.'.$key, $where['second']); 155 | } else { 156 | $where['first'] = str_replace($table.'.', $this->alias.'.', $where['first']); 157 | $where['second'] = str_replace($table.'.', $this->alias.'.', $where['second']); 158 | } 159 | 160 | return $where; 161 | } 162 | 163 | protected function useAliasInWhereBasicType(array $where): array 164 | { 165 | $table = $this->tableName; 166 | 167 | if (Str::startsWith($where['column'].'.', $this->alias.'.')) { 168 | return $where; 169 | } 170 | 171 | if (Str::contains($where['column'], $table)) { 172 | // if joining the same table, only replace the correct table.key pair 173 | $where['column'] = str_replace($table.'.', $this->alias.'.', $where['column']); 174 | } else { 175 | $where['column'] = str_replace($table.'.', $this->alias.'.', $where['column']); 176 | } 177 | 178 | return $where; 179 | } 180 | 181 | public function whereNull($columns, $boolean = 'and', $not = false) 182 | { 183 | if ($this->alias && Str::contains($columns, $this->tableName)) { 184 | $columns = str_replace("{$this->tableName}.", "{$this->alias}.", $columns); 185 | } 186 | 187 | return parent::whereNull($columns, $boolean, $not); 188 | } 189 | 190 | public function newQuery(): self 191 | { 192 | return new static($this->newParentQuery(), $this->type, $this->table, $this->model); // <-- The model param is needed 193 | } 194 | 195 | public function where($column, $operator = null, $value = null, $boolean = 'and'): self 196 | { 197 | if ($this->alias && is_string($column) && Str::contains($column, $this->tableName)) { 198 | $column = str_replace("{$this->tableName}.", "{$this->alias}.", $column); 199 | } elseif ($this->alias && !is_callable($column)) { 200 | $column = $this->alias.'.'.$column; 201 | } 202 | 203 | if (is_callable($column)) { 204 | $query = new self($this, $this->type, $this->table, $this->model); 205 | $column($query); 206 | 207 | return $this->addNestedWhereQuery($query); 208 | } else { 209 | return parent::where($column, $operator, $value, $boolean); 210 | } 211 | } 212 | 213 | /** 214 | * Remove the soft delete condition in case the model implements soft deletes. 215 | */ 216 | public function withTrashed(): self 217 | { 218 | if (!$this->getModel() || !in_array(SoftDeletes::class, class_uses_recursive($this->getModel()), true)) { 219 | return $this; 220 | } 221 | 222 | $this->wheres = array_filter($this->wheres, function ($where) { 223 | if ($where['type'] === 'Null' && Str::contains($where['column'], $this->getModel()->getDeletedAtColumn())) { 224 | return false; 225 | } 226 | 227 | return true; 228 | }); 229 | 230 | return $this; 231 | } 232 | 233 | /** 234 | * Remove the soft delete condition in case the model implements soft deletes. 235 | */ 236 | public function onlyTrashed(): self 237 | { 238 | if (!$this->getModel() 239 | || !in_array(SoftDeletes::class, class_uses_recursive($this->getModel()), true) 240 | ) { 241 | return $this; 242 | } 243 | 244 | $hasCondition = null; 245 | 246 | $this->wheres = array_map(function ($where) use (&$hasCondition) { 247 | if ($where['type'] === 'Null' && Str::contains($where['column'], $this->getModel()->getDeletedAtColumn())) { 248 | $where['type'] = 'NotNull'; 249 | $hasCondition = true; 250 | } 251 | 252 | return $where; 253 | }, $this->wheres); 254 | 255 | if (!$hasCondition) { 256 | $this->whereNotNull($this->getModel()->getQualifiedDeletedAtColumn()); 257 | } 258 | 259 | return $this; 260 | } 261 | 262 | public function left(): self 263 | { 264 | return $this->joinType('left'); 265 | } 266 | 267 | public function right(): self 268 | { 269 | return $this->joinType('right'); 270 | } 271 | 272 | public function inner(): self 273 | { 274 | return $this->joinType('inner'); 275 | } 276 | 277 | public function joinType(string $joinType): self 278 | { 279 | $this->type = $joinType; 280 | 281 | return $this; 282 | } 283 | 284 | public function __call($name, $arguments) 285 | { 286 | if (!$this->getModel()) { 287 | return; 288 | } 289 | 290 | if (method_exists($this->getModel(), 'scope'.ucfirst($name))) { 291 | $scope = 'scope'.ucfirst($name); 292 | 293 | return $this->getModel()->{$scope}($this, ...$arguments); 294 | } 295 | 296 | if ($this->hasLaravelScopeAttribute($name) && version_compare(app()->version(), '12.0.0', '>=')) { 297 | return $this->getModel()->callNamedScope($name, array_merge([$this], $arguments)); 298 | } 299 | 300 | if (static::hasMacro($name)) { 301 | return $this->macroCall($name, $arguments); 302 | } 303 | 304 | $eloquentBuilder = $this->getModel()->newEloquentBuilder($this); 305 | if (method_exists($eloquentBuilder, $name)) { 306 | $eloquentBuilder->setModel($this->getModel()); 307 | 308 | return $eloquentBuilder->{$name}(...$arguments); 309 | } 310 | 311 | throw new InvalidArgumentException(sprintf('Method %s does not exist in PowerJoinClause class', $name)); 312 | } 313 | 314 | /** 315 | * Check if a method has the Laravel Scope attribute. 316 | */ 317 | protected function hasLaravelScopeAttribute(string $methodName): bool 318 | { 319 | if (!method_exists($this->getModel(), $methodName)) { 320 | return false; 321 | } 322 | 323 | $reflection = new ReflectionClass($this->getModel()); 324 | 325 | if (!$reflection->hasMethod($methodName)) { 326 | return false; 327 | } 328 | 329 | $method = $reflection->getMethod($methodName); 330 | $attributes = $method->getAttributes(); 331 | 332 | foreach ($attributes as $attribute) { 333 | if ($attribute->getName() === 'Illuminate\Database\Eloquent\Attributes\Scope') { 334 | return true; 335 | } 336 | } 337 | 338 | return false; 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Eloquent Power Joins](screenshots/eloquent-power-joins.jpg "Eloquent Power Joins") 2 | 3 | ![Laravel Supported Versions](https://img.shields.io/badge/laravel-10.x/11.x/12.x-green.svg) 4 | [![run-tests](https://github.com/kirschbaum-development/eloquent-power-joins/actions/workflows/ci.yaml/badge.svg)](https://github.com/kirschbaum-development/eloquent-power-joins/actions/workflows/ci.yaml) 5 | [![MIT Licensed](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 6 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/kirschbaum-development/eloquent-power-joins.svg?style=flat-square)](https://packagist.org/packages/kirschbaum-development/eloquent-power-joins) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/kirschbaum-development/eloquent-power-joins.svg?style=flat-square)](https://packagist.org/packages/kirschbaum-development/eloquent-power-joins) 8 | 9 | The Laravel magic you know, now applied to joins. 10 | 11 | Joins are very useful in a lot of ways. If you are here, you most likely know about and use them. Eloquent is very powerful, but it lacks a bit of the "Laravel way" when using joins. This package make your joins in a more Laravel way, with more readable with less code while hiding implementation details from places they don't need to be exposed. 12 | 13 | A few things we consider is missing when using joins which are very powerful Eloquent features: 14 | 15 | * Ability to use relationship definitions to make joins; 16 | * Ability to use model scopes inside different contexts; 17 | * Ability to query relationship existence using joins instead of where exists; 18 | * Ability to easily sort results based on columns or aggregations from related tables; 19 | 20 | You can read a more detailed explanation on the problems this package solves on [this blog post](https://kirschbaumdevelopment.com/insights/power-joins). 21 | 22 | ## Installation 23 | 24 | You can install the package via composer: 25 | 26 | ```bash 27 | composer require kirschbaum-development/eloquent-power-joins 28 | ``` 29 | 30 | For Laravel versions < 10, use the 3.* version. For Laravel versions < 8, use the 2.* version: 31 | 32 | ```bash 33 | composer require kirschbaum-development/eloquent-power-joins:3.* 34 | ``` 35 | 36 | ## Usage 37 | 38 | This package provides a few features. 39 | 40 | ### 1 - Join Relationship 41 | 42 | Let's say you have a `User` model with a `hasMany` relationship to the `Post` model. If you want to join the tables, you would usually write something like: 43 | 44 | ```php 45 | User::select('users.*')->join('posts', 'posts.user_id', '=', 'users.id'); 46 | ``` 47 | 48 | This package provides you with a new `joinRelationship()` method, which does the exact same thing. 49 | 50 | ```php 51 | User::joinRelationship('posts'); 52 | ``` 53 | 54 | Both options produce the same results. In terms of code, you didn't save THAT much, but you are now using the relationship between the `User` and the `Post` models to join the tables. This means that you are now hiding how this relationship works behind the scenes (implementation details). You also don't need to change the code if the relationship type changes. You now have more readable and less overwhelming code. 55 | 56 | But, **it gets better** when you need to **join nested relationships**. Let's assume you also have a `hasMany` relationship between the `Post` and `Comment` models and you need to join these tables, you can simply write: 57 | 58 | ```php 59 | User::joinRelationship('posts.comments'); 60 | ``` 61 | 62 | So much better, wouldn't you agree?! You can also `left` or `right` join the relationships as needed. 63 | 64 | ```php 65 | User::leftJoinRelationship('posts.comments'); 66 | User::rightJoinRelationship('posts.comments'); 67 | ``` 68 | 69 | #### Joining polymorphic relationships 70 | 71 | Let's imagine, you have a `Image` model that is a polymorphic relationship (`Post -> morphMany -> Image`). Besides the regular join, you would also need to apply the `where imageable_type = Post::class` condition, otherwise you could get messy results. 72 | 73 | Turns out, if you join a polymorphic relationship, Eloquent Power Joins automatically applies this condition for you. You simply need to call the same method. 74 | 75 | ```php 76 | Post::joinRelationship('images'); 77 | ``` 78 | 79 | You can also join MorphTo relationships. 80 | 81 | ```php 82 | Image::joinRelationship('imageable', morphable: Post::class); 83 | ``` 84 | 85 | Note: Querying morph to relationships only supports one morphable type at a time. 86 | 87 | **Applying conditions & callbacks to the joins** 88 | 89 | Now, let's say you want to apply a condition to the join you are making. You simply need to pass a callback as the second parameter to the `joinRelationship` method. 90 | 91 | ```php 92 | User::joinRelationship('posts', fn ($join) => $join->where('posts.approved', true))->toSql(); 93 | ``` 94 | 95 | You can also specify the type of join you want to make in the callback: 96 | 97 | ```php 98 | User::joinRelationship('posts', fn ($join) => $join->left()); 99 | ``` 100 | 101 | For **nested calls**, you simply need to pass an array referencing the relationship names. 102 | 103 | ```php 104 | User::joinRelationship('posts.comments', [ 105 | 'posts' => fn ($join) => $join->where('posts.published', true), 106 | 'comments' => fn ($join) => $join->where('comments.approved', true), 107 | ]); 108 | ``` 109 | 110 | For **belongs to many** calls, you need to pass an array with the relationship, and then an array with the table names. 111 | 112 | ```php 113 | User::joinRelationship('groups', [ 114 | 'groups' => [ 115 | 'groups' => function ($join) { 116 | // ... 117 | }, 118 | // group_members is the intermediary table here 119 | 'group_members' => fn ($join) => $join->where('group_members.active', true), 120 | ] 121 | ]); 122 | ``` 123 | 124 | #### Using model scopes inside the join callbacks 🤯 125 | 126 | We consider this one of the most useful features of this package. Let's say, you have a `published` scope on your `Post` model: 127 | 128 | ```php 129 | public function scopePublished($query) 130 | { 131 | $query->where('published', true); 132 | } 133 | ``` 134 | 135 | When joining relationships, you **can** use the scopes defined in the model being joined. How cool is this? 136 | 137 | ```php 138 | User::joinRelationship('posts', function ($join) { 139 | // the $join instance here can access any of the scopes defined in Post 🤯 140 | $join->published(); 141 | }); 142 | ``` 143 | 144 | When using model scopes inside a join clause, you **can't** type hint the `$query` parameter in your scope. Also, keep in mind you are inside a join, so you are limited to use only conditions supported by joins. 145 | 146 | #### Using aliases 147 | 148 | Sometimes, you are going to need to use table aliases on your joins because you are joining the same table more than once. One option to accomplish this is to use the `joinRelationshipUsingAlias` method. 149 | 150 | ```php 151 | Post::joinRelationshipUsingAlias('category.parent')->get(); 152 | ``` 153 | 154 | In case you need to specify the name of the alias which is going to be used, you can do in two different ways: 155 | 156 | 1. Passing a string as the second parameter (this won't work for nested joins): 157 | 158 | ```php 159 | Post::joinRelationshipUsingAlias('category', 'category_alias')->get(); 160 | ``` 161 | 162 | 2. Calling the `as` function inside the join callback. 163 | 164 | ```php 165 | Post::joinRelationship('category.parent', [ 166 | 'category' => fn ($join) => $join->as('category_alias'), 167 | 'parent' => fn ($join) => $join->as('category_parent'), 168 | ])->get() 169 | ``` 170 | 171 | For *belongs to many* or *has many through* calls, you need to pass an array with the relationship, and then an array with the table names. 172 | 173 | ```php 174 | Group::joinRelationship('posts.user', [ 175 | 'posts' => [ 176 | 'posts' => fn ($join) => $join->as('posts_alias'), 177 | 'post_groups' => fn ($join) => $join->as('post_groups_alias'), 178 | ], 179 | ])->toSql(); 180 | ``` 181 | 182 | #### Select * from table 183 | 184 | When making joins, using `select * from ...` can be dangerous as fields with the same name between the parent and the joined tables could conflict. Thinking on that, if you call the `joinRelationship` method without previously selecting any specific columns, Eloquent Power Joins will automatically include that for you. For instance, take a look at the following examples: 185 | 186 | ```php 187 | User::joinRelationship('posts')->toSql(); 188 | // select users.* from users inner join posts on posts.user_id = users.id 189 | ``` 190 | 191 | And, if you specify the select statement: 192 | 193 | ```php 194 | User::select('users.id')->joinRelationship('posts')->toSql(); 195 | // select users.id from users inner join posts on posts.user_id = users.id 196 | ``` 197 | 198 | #### Soft deletes 199 | 200 | When joining any models which uses the `SoftDeletes` trait, the following condition will be also automatically applied to all your joins: 201 | 202 | ```sql 203 | and "users"."deleted_at" is null 204 | ``` 205 | 206 | In case you want to include trashed models, you can call the `->withTrashed()` method in the join callback. 207 | 208 | ```php 209 | UserProfile::joinRelationship('users', fn ($join) => $join->withTrashed()); 210 | ``` 211 | 212 | You can also call the `onlyTrashed` model as well: 213 | 214 | ```php 215 | UserProfile::joinRelationship('users', ($join) => $join->onlyTrashed()); 216 | ``` 217 | 218 | #### Extra conditions defined in relationships 219 | 220 | If you have extra conditions in your relationship definitions, they will get automatically applied for you. 221 | 222 | ```php 223 | class User extends Model 224 | { 225 | public function publishedPosts() 226 | { 227 | return $this->hasMany(Post::class)->published(); 228 | } 229 | } 230 | ``` 231 | 232 | If you call `User::joinRelationship('publishedPosts')->get()`, it will also apply the additional published scope to the join clause. It would produce an SQL more or less like this: 233 | 234 | ```sql 235 | select users.* from users inner join posts on posts.user_id = posts.id and posts.published = 1 236 | ``` 237 | 238 | #### Global Scopes 239 | 240 | If your model have global scopes applied to it, you can enable the global scopes by calling the `withGlobalScopes` method in your join clause, like this: 241 | 242 | ```php 243 | UserProfile::joinRelationship('users', fn ($join) => $join->withGlobalScopes()); 244 | ``` 245 | 246 | There's, though, a gotcha here. Your global scope **cannot** type-hint the `Eloquent\Builder` class in the first parameter of the `apply` method, otherwise you will get errors. 247 | 248 | ### 2 - Querying relationship existence (Using Joins) 249 | 250 | [Querying relationship existence](https://laravel.com/docs/7.x/eloquent-relationships#querying-relationship-existence) is a very powerful and convenient feature of Eloquent. However, it uses the `where exists` syntax which is not always the best and may not be the more performant choice, depending on how many records you have or the structure of your tables. 251 | 252 | This packages implements the same functionality, but instead of using the `where exists` syntax, it uses **joins**. Below, you can see the methods this package implements and also the Laravel equivalent. 253 | 254 | Please note that although the methods are similar, you will not always get the same results when using joins, depending on the context of your query. You should be aware of the differences between querying the data with `where exists` vs `joins`. 255 | 256 | **Laravel Native Methods** 257 | 258 | ``` php 259 | User::has('posts'); 260 | User::has('posts.comments'); 261 | User::has('posts', '>', 3); 262 | User::whereHas('posts', fn ($query) => $query->where('posts.published', true)); 263 | User::whereHas('posts.comments', ['posts' => fn ($query) => $query->where('posts.published', true)); 264 | User::doesntHave('posts'); 265 | ``` 266 | 267 | **Package equivalent, but using joins** 268 | 269 | ```php 270 | User::powerJoinHas('posts'); 271 | User::powerJoinHas('posts.comments'); 272 | User::powerJoinHas('posts.comments', '>', 3); 273 | User::powerJoinWhereHas('posts', function ($join) { 274 | $join->where('posts.published', true); 275 | }); 276 | User::powerJoinDoesntHave('posts'); 277 | ``` 278 | 279 | When using the `powerJoinWhereHas` method with relationships that involves more than 1 table (One to Many, Many to Many, etc.), use the array syntax to pass the callback: 280 | 281 | ```php 282 | User::powerJoinWhereHas('commentsThroughPosts', [ 283 | 'comments' => fn ($query) => $query->where('body', 'a') 284 | ])->get()); 285 | ``` 286 | 287 | ### 3 - Order by 288 | 289 | You can also sort your query results using a column from another table using the `orderByPowerJoins` method. 290 | 291 | ```php 292 | User::orderByPowerJoins('profile.city'); 293 | ``` 294 | 295 | If you need to pass some raw values for the order by function, you can do like this: 296 | 297 | ```php 298 | User::orderByPowerJoins(['profile', DB::raw('concat(city, ", ", state)')]); 299 | ``` 300 | 301 | This query will sort the results based on the `city` column on the `user_profiles` table. You can also sort your results by aggregations (`COUNT`, `SUM`, `AVG`, `MIN` or `MAX`). 302 | 303 | For instance, to sort users with the highest number of posts, you can do this: 304 | 305 | ```php 306 | $users = User::orderByPowerJoinsCount('posts.id', 'desc')->get(); 307 | ``` 308 | 309 | Or, to get the list of posts where the comments contain the highest average of votes. 310 | 311 | ```php 312 | $posts = Post::orderByPowerJoinsAvg('comments.votes', 'desc')->get(); 313 | ``` 314 | 315 | You also have methods for `SUM`, `MIN` and `MAX`: 316 | 317 | ```php 318 | Post::orderByPowerJoinsSum('comments.votes'); 319 | Post::orderByPowerJoinsMin('comments.votes'); 320 | Post::orderByPowerJoinsMax('comments.votes'); 321 | ``` 322 | 323 | In case you want to use left joins in sorting, you also can: 324 | 325 | ```php 326 | Post::orderByLeftPowerJoinsCount('comments.votes'); 327 | Post::orderByLeftPowerJoinsAvg('comments.votes'); 328 | Post::orderByLeftPowerJoinsSum('comments.votes'); 329 | Post::orderByLeftPowerJoinsMin('comments.votes'); 330 | Post::orderByLeftPowerJoinsMax('comments.votes'); 331 | ``` 332 | 333 | *** 334 | 335 | ## Contributing 336 | 337 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 338 | 339 | ### Security 340 | 341 | If you discover any security related issues, please email security@kirschbaumdevelopment.com instead of using the issue tracker. 342 | 343 | ## Credits 344 | 345 | - [Luis Dalmolin](https://github.com/luisdalmolin) 346 | 347 | ## Sponsorship 348 | 349 | Development of this package is sponsored by Kirschbaum Development Group, a developer driven company focused on problem solving, team building, and community. Learn more [about us](https://kirschbaumdevelopment.com) or [join us](https://careers.kirschbaumdevelopment.com)! 350 | 351 | ## License 352 | 353 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 354 | -------------------------------------------------------------------------------- /src/Mixins/JoinRelationship.php: -------------------------------------------------------------------------------- 1 | newPowerJoinClause($this->query, $type, $table, $model); 35 | 36 | // If the first "column" of the join is really a Closure instance the developer 37 | // is trying to build a join with a complex "on" clause containing more than 38 | // one condition, so we'll add the join and call a Closure with the query. 39 | if ($first instanceof Closure) { 40 | $first($join); 41 | 42 | $this->query->joins[] = $join; 43 | 44 | $this->query->addBinding($join->getBindings(), 'join'); 45 | } 46 | 47 | // If the column is simply a string, we can assume the join simply has a basic 48 | // "on" clause with a single condition. So we will just build the join with 49 | // this simple join clauses attached to it. There is not a join callback. 50 | else { 51 | $method = $where ? 'where' : 'on'; 52 | 53 | $this->query->joins[] = $join->$method($first, $operator, $second); 54 | 55 | $this->query->addBinding($join->getBindings(), 'join'); 56 | } 57 | 58 | return $this; 59 | }; 60 | } 61 | 62 | /** 63 | * New clause for making joins, where we pass the model to the joiner class. 64 | */ 65 | public function leftPowerJoin(): Closure 66 | { 67 | return function ($table, $first, $operator = null, $second = null) { 68 | return $this->powerJoin($table, $first, $operator, $second, 'left'); 69 | }; 70 | } 71 | 72 | /** 73 | * New clause for making joins, where we pass the model to the joiner class. 74 | */ 75 | public function rightPowerJoin(): Closure 76 | { 77 | return function ($table, $first, $operator = null, $second = null) { 78 | return $this->powerJoin($table, $first, $operator, $second, 'right'); 79 | }; 80 | } 81 | 82 | public function newPowerJoinClause(): Closure 83 | { 84 | return function (QueryBuilder $parentQuery, string $type, string $table, ?Model $model = null) { 85 | return new PowerJoinClause($parentQuery, $type, $table, $model); 86 | }; 87 | } 88 | 89 | /** 90 | * Join the relationship(s). 91 | */ 92 | public function joinRelationship(): Closure 93 | { 94 | return function ( 95 | string $relationName, 96 | Closure|array|string|null $callback = null, 97 | string $joinType = 'join', 98 | bool $useAlias = false, 99 | bool $disableExtraConditions = false, 100 | ?string $morphable = null, 101 | ) { 102 | $joinType = JoinsHelper::$joinMethodsMap[$joinType] ?? $joinType; 103 | $useAlias = is_string($callback) ? false : $useAlias; 104 | $joinHelper = JoinsHelper::make($this->getModel()); 105 | $callback = $joinHelper->formatJoinCallback($callback); 106 | 107 | JoinsHelper::ensureModelIsUniqueToQuery($this); 108 | JoinsHelper::clearCacheBeforeQuery($this); 109 | 110 | // Check if the main table has an alias (e.g., "posts as p") and set it as the main table or alias if it does. 111 | $fromClause = $this->getQuery()->from; 112 | $mainTableOrAlias = $this->getModel()->getTable(); 113 | if ($fromClause && is_string($fromClause) && preg_match('/^.+\s+as\s+["\'\`]?(.+?)["\'\`]?$/i', $fromClause, $matches)) { 114 | // Register the alias for the main model so joins use it 115 | $mainTableOrAlias = $matches[1]; 116 | StaticCache::setTableAliasForModel($this->getModel(), $mainTableOrAlias); 117 | } 118 | 119 | if (is_null($this->getSelect())) { 120 | $this->select(sprintf('%s.*', $mainTableOrAlias)); 121 | } 122 | 123 | if (Str::contains($relationName, '.')) { 124 | $this->joinNestedRelationship($relationName, $callback, $joinType, $useAlias, $disableExtraConditions, $morphable); 125 | 126 | return $this; 127 | } 128 | 129 | $relationCallback = $callback; 130 | if ($callback && is_array($callback) && isset($callback[$relationName]) && is_array($callback[$relationName])) { 131 | $relationCallback = $callback[$relationName]; 132 | } 133 | 134 | $relation = $this->getModel()->{$relationName}(); 135 | $relationQuery = $relation->getQuery(); 136 | $alias = $joinHelper->getAliasName( 137 | $useAlias, 138 | $relation, 139 | $relationName, 140 | $relationQuery->getModel()->getTable(), 141 | $relationCallback 142 | ); 143 | 144 | if ($relation instanceof BelongsToMany && !is_array($alias)) { 145 | $extraAlias = $joinHelper->getAliasName( 146 | $useAlias, 147 | $relation, 148 | $relationName, 149 | $relation->getTable(), 150 | $relationCallback 151 | ); 152 | $alias = [$extraAlias, $alias]; 153 | } 154 | 155 | $aliasString = is_array($alias) ? implode('.', $alias) : $alias; 156 | $useAlias = $alias ? true : $useAlias; 157 | 158 | $relationJoinCache = $alias 159 | ? "{$aliasString}.{$relationQuery->getModel()->getTable()}.{$relationName}" 160 | : "{$relationQuery->getModel()->getTable()}.{$relationName}"; 161 | 162 | if ($joinHelper->relationshipAlreadyJoined($this->getModel(), $relationJoinCache)) { 163 | return $this; 164 | } 165 | 166 | if ($useAlias) { 167 | StaticCache::setTableAliasForModel($relation->getModel(), $alias); 168 | } 169 | 170 | $joinHelper->markRelationshipAsAlreadyJoined($this->getModel(), $relationJoinCache); 171 | 172 | $relation->performJoinForEloquentPowerJoins( 173 | builder: $this, 174 | joinType: $joinType, 175 | callback: $relationCallback, 176 | alias: $alias, 177 | disableExtraConditions: $disableExtraConditions, 178 | morphable: $morphable, 179 | ); 180 | 181 | // Clear only the related model's alias from cache after join is performed 182 | if ($useAlias) { 183 | unset(StaticCache::$powerJoinAliasesCache[spl_object_id($relation->getModel())]); 184 | } 185 | 186 | return $this; 187 | }; 188 | } 189 | 190 | /** 191 | * Join the relationship(s) using table aliases. 192 | */ 193 | public function joinRelationshipUsingAlias(): Closure 194 | { 195 | return function (string $relationName, Closure|array|string|null $callback = null, bool $disableExtraConditions = false, ?string $morphable = null) { 196 | return $this->joinRelationship($relationName, $callback, 'join', true, $disableExtraConditions, morphable: $morphable); 197 | }; 198 | } 199 | 200 | /** 201 | * Left join the relationship(s) using table aliases. 202 | */ 203 | public function leftJoinRelationshipUsingAlias(): Closure 204 | { 205 | return function (string $relationName, Closure|array|string|null $callback = null, bool $disableExtraConditions = false, ?string $morphable = null) { 206 | return $this->joinRelationship($relationName, $callback, 'leftJoin', true, $disableExtraConditions, morphable: $morphable); 207 | }; 208 | } 209 | 210 | /** 211 | * Right join the relationship(s) using table aliases. 212 | */ 213 | public function rightJoinRelationshipUsingAlias(): Closure 214 | { 215 | return function (string $relationName, Closure|array|string|null $callback = null, bool $disableExtraConditions = false, ?string $morphable = null) { 216 | return $this->joinRelationship($relationName, $callback, 'rightJoin', true, $disableExtraConditions, morphable: $morphable); 217 | }; 218 | } 219 | 220 | public function joinRelation(): Closure 221 | { 222 | return function ( 223 | string $relationName, 224 | Closure|array|string|null $callback = null, 225 | string $joinType = 'join', 226 | bool $useAlias = false, 227 | bool $disableExtraConditions = false, 228 | ?string $morphable = null, 229 | ) { 230 | return $this->joinRelationship($relationName, $callback, $joinType, $useAlias, $disableExtraConditions, morphable: $morphable); 231 | }; 232 | } 233 | 234 | public function leftJoinRelationship(): Closure 235 | { 236 | return function (string $relationName, Closure|array|string|null $callback = null, bool $useAlias = false, bool $disableExtraConditions = false, ?string $morphable = null) { 237 | return $this->joinRelationship($relationName, $callback, 'leftJoin', $useAlias, $disableExtraConditions, morphable: $morphable); 238 | }; 239 | } 240 | 241 | public function leftJoinRelation(): Closure 242 | { 243 | return function (string $relation, Closure|array|string|null $callback = null, bool $useAlias = false, bool $disableExtraConditions = false, ?string $morphable = null) { 244 | return $this->joinRelationship($relation, $callback, 'leftJoin', $useAlias, $disableExtraConditions, morphable: $morphable); 245 | }; 246 | } 247 | 248 | public function rightJoinRelationship(): Closure 249 | { 250 | return function (string $relation, Closure|array|string|null $callback = null, bool $useAlias = false, bool $disableExtraConditions = false, ?string $morphable = null) { 251 | return $this->joinRelationship($relation, $callback, 'rightJoin', $useAlias, $disableExtraConditions, morphable: $morphable); 252 | }; 253 | } 254 | 255 | public function rightJoinRelation(): Closure 256 | { 257 | return function (string $relation, Closure|array|string|null $callback = null, bool $useAlias = false, bool $disableExtraConditions = false, ?string $morphable = null) { 258 | return $this->joinRelationship($relation, $callback, 'rightJoin', $useAlias, $disableExtraConditions, morphable: $morphable); 259 | }; 260 | } 261 | 262 | /** 263 | * Join nested relationships. 264 | */ 265 | public function joinNestedRelationship(): Closure 266 | { 267 | return function ( 268 | string $relationships, 269 | Closure|array|string|null $callback = null, 270 | string $joinType = 'join', 271 | bool $useAlias = false, 272 | bool $disableExtraConditions = false, 273 | ?string $morphable = null, 274 | ) { 275 | $relations = explode('.', $relationships); 276 | $joinHelper = JoinsHelper::make($this->getModel()); 277 | /** @var Relation */ 278 | $latestRelation = null; 279 | 280 | $part = []; 281 | foreach ($relations as $relationName) { 282 | $part[] = $relationName; 283 | $fullRelationName = join('.', $part); 284 | 285 | $currentModel = $latestRelation ? $latestRelation->getModel() : $this->getModel(); 286 | $relation = $currentModel->{$relationName}(); 287 | $relationCallback = null; 288 | 289 | if ($callback && is_array($callback) && isset($callback[$relationName])) { 290 | $relationCallback = $callback[$relationName]; 291 | } 292 | 293 | if ($callback && is_array($callback) && isset($callback[$fullRelationName])) { 294 | $relationCallback = $callback[$fullRelationName]; 295 | } 296 | 297 | $alias = $joinHelper->getAliasName( 298 | $useAlias, 299 | $relation, 300 | $relationName, 301 | $relation->getQuery()->getModel()->getTable(), 302 | $relationCallback 303 | ); 304 | 305 | if ($alias && $relation instanceof BelongsToMany && !is_array($alias)) { 306 | $extraAlias = $joinHelper->getAliasName( 307 | $useAlias, 308 | $relation, 309 | $relationName, 310 | $relation->getTable(), 311 | $relationCallback 312 | ); 313 | 314 | $alias = [$extraAlias, $alias]; 315 | } 316 | 317 | $aliasString = is_array($alias) ? implode('.', $alias) : $alias; 318 | $useAlias = $alias ? true : $useAlias; 319 | 320 | if ($alias) { 321 | $relationJoinCache = $latestRelation 322 | ? "{$aliasString}.{$relation->getQuery()->getModel()->getTable()}.{$latestRelation->getModel()->getTable()}.{$relationName}" 323 | : "{$aliasString}.{$relation->getQuery()->getModel()->getTable()}.{$relationName}"; 324 | } else { 325 | $relationJoinCache = $latestRelation 326 | ? "{$relation->getQuery()->getModel()->getTable()}.{$latestRelation->getModel()->getTable()}.{$relationName}" 327 | : "{$relation->getQuery()->getModel()->getTable()}.{$relationName}"; 328 | } 329 | 330 | if ($useAlias) { 331 | StaticCache::setTableAliasForModel($relation->getModel(), $alias); 332 | } 333 | 334 | if ($joinHelper->relationshipAlreadyJoined($this->getModel(), $relationJoinCache)) { 335 | $latestRelation = $relation; 336 | 337 | continue; 338 | } 339 | 340 | $relation->performJoinForEloquentPowerJoins( 341 | $this, 342 | $joinType, 343 | $relationCallback, 344 | $alias, 345 | $disableExtraConditions, 346 | $morphable 347 | ); 348 | 349 | $latestRelation = $relation; 350 | $joinHelper->markRelationshipAsAlreadyJoined($this->getModel(), $relationJoinCache); 351 | } 352 | 353 | StaticCache::clear(); 354 | 355 | return $this; 356 | }; 357 | } 358 | 359 | /** 360 | * Order by a field in the defined relationship. 361 | */ 362 | public function orderByPowerJoins(): Closure 363 | { 364 | return function (string|array $sort, string $direction = 'asc', ?string $aggregation = null, string $joinType = 'join', $aliases = null) { 365 | if (is_array($sort)) { 366 | $relationships = explode('.', $sort[0]); 367 | $column = $sort[1]; 368 | $latestRelationshipName = $relationships[count($relationships) - 1]; 369 | } else { 370 | $relationships = explode('.', $sort); 371 | $column = array_pop($relationships); 372 | $latestRelationshipName = $relationships[count($relationships) - 1]; 373 | } 374 | 375 | $this->joinRelationship(relationName: implode('.', $relationships), callback: $aliases, joinType: $joinType); 376 | 377 | $latestRelationshipModel = array_reduce($relationships, function ($model, $relationshipName) { 378 | return $model->$relationshipName()->getModel(); 379 | }, $this->getModel()); 380 | 381 | $table = $latestRelationshipModel->getTable(); 382 | 383 | if ($aliases) { 384 | if (is_string($aliases)) { 385 | $table = $aliases; 386 | } 387 | 388 | if (is_array($aliases) && array_key_exists($latestRelationshipName, $aliases)) { 389 | $alias = $aliases[$latestRelationshipName]; 390 | 391 | if (is_callable($alias)) { 392 | $join = collect($this->query->joins) 393 | ->whereInstanceOf(PowerJoinClause::class) 394 | ->firstWhere('tableName', $table); 395 | 396 | $table = $join->alias; 397 | } 398 | } 399 | } 400 | 401 | if ($aggregation) { 402 | $aliasName = sprintf( 403 | '%s_%s_%s', 404 | $table, 405 | $column, 406 | $aggregation 407 | ); 408 | 409 | $this->selectRaw( 410 | sprintf( 411 | '%s(%s.%s) as %s', 412 | $aggregation, 413 | $table, 414 | $column, 415 | $aliasName 416 | ) 417 | ) 418 | ->groupBy(sprintf('%s.%s', $this->getModel()->getTable(), $this->getModel()->getKeyName())) 419 | ->orderBy(DB::raw(sprintf('%s', $aliasName)), $direction); 420 | } else { 421 | if ($column instanceof Expression) { 422 | $this->orderBy($column, $direction); 423 | } else { 424 | $this->orderBy( 425 | sprintf('%s.%s', $table, $column), 426 | $direction 427 | ); 428 | } 429 | } 430 | 431 | return $this; 432 | }; 433 | } 434 | 435 | public function orderByLeftPowerJoins(): Closure 436 | { 437 | return function (string|array $sort, string $direction = 'asc') { 438 | return $this->orderByPowerJoins(sort: $sort, direction: $direction, joinType: 'leftJoin'); 439 | }; 440 | } 441 | 442 | /** 443 | * Order by the COUNT aggregation using joins. 444 | */ 445 | public function orderByPowerJoinsCount(): Closure 446 | { 447 | return function (string|array $sort, string $direction = 'asc') { 448 | return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'COUNT'); 449 | }; 450 | } 451 | 452 | public function orderByLeftPowerJoinsCount(): Closure 453 | { 454 | return function (string|array $sort, string $direction = 'asc') { 455 | return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'COUNT', joinType: 'leftJoin'); 456 | }; 457 | } 458 | 459 | /** 460 | * Order by the SUM aggregation using joins. 461 | */ 462 | public function orderByPowerJoinsSum(): Closure 463 | { 464 | return function (string|array $sort, string $direction = 'asc') { 465 | return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'SUM'); 466 | }; 467 | } 468 | 469 | public function orderByLeftPowerJoinsSum(): Closure 470 | { 471 | return function (string|array $sort, string $direction = 'asc') { 472 | return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'SUM', joinType: 'leftJoin'); 473 | }; 474 | } 475 | 476 | /** 477 | * Order by the AVG aggregation using joins. 478 | */ 479 | public function orderByPowerJoinsAvg(): Closure 480 | { 481 | return function (string|array $sort, string $direction = 'asc') { 482 | return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'AVG'); 483 | }; 484 | } 485 | 486 | public function orderByLeftPowerJoinsAvg(): Closure 487 | { 488 | return function (string|array $sort, string $direction = 'asc') { 489 | return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'AVG', joinType: 'leftJoin'); 490 | }; 491 | } 492 | 493 | /** 494 | * Order by the MIN aggregation using joins. 495 | */ 496 | public function orderByPowerJoinsMin(): Closure 497 | { 498 | return function (string|array $sort, string $direction = 'asc') { 499 | return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'MIN'); 500 | }; 501 | } 502 | 503 | public function orderByLeftPowerJoinsMin(): Closure 504 | { 505 | return function (string|array $sort, string $direction = 'asc') { 506 | return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'MIN', joinType: 'leftJoin'); 507 | }; 508 | } 509 | 510 | /** 511 | * Order by the MAX aggregation using joins. 512 | */ 513 | public function orderByPowerJoinsMax(): Closure 514 | { 515 | return function (string|array $sort, string $direction = 'asc') { 516 | return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'MAX'); 517 | }; 518 | } 519 | 520 | public function orderByLeftPowerJoinsMax(): Closure 521 | { 522 | return function (string|array $sort, string $direction = 'asc') { 523 | return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'MAX', joinType: 'leftJoin'); 524 | }; 525 | } 526 | 527 | /** 528 | * Same as Laravel 'has`, but using joins instead of where exists. 529 | */ 530 | public function powerJoinHas(): Closure 531 | { 532 | return function (string $relation, string $operator = '>=', int $count = 1, $boolean = 'and', Closure|array|string|null $callback = null, ?string $morphable = null): static { 533 | if (is_null($this->getSelect())) { 534 | $this->select(sprintf('%s.*', $this->getModel()->getTable())); 535 | } 536 | 537 | if (is_null($this->getGroupBy())) { 538 | $this->groupBy($this->getModel()->getQualifiedKeyName()); 539 | } 540 | 541 | if (is_string($relation)) { 542 | if (Str::contains($relation, '.')) { 543 | $this->hasNestedUsingJoins($relation, $operator, $count, 'and', $callback); 544 | 545 | return $this; 546 | } 547 | 548 | $relation = $this->getRelationWithoutConstraintsProxy($relation); 549 | } 550 | 551 | $relation->performJoinForEloquentPowerJoins($this, 'leftPowerJoin', $callback, morphable: $morphable, hasCheck: true); 552 | $relation->performHavingForEloquentPowerJoins($this, $operator, $count, morphable: $morphable); 553 | 554 | return $this; 555 | }; 556 | } 557 | 558 | public function hasNestedUsingJoins(): Closure 559 | { 560 | return function (string $relations, string $operator = '>=', int $count = 1, string $boolean = 'and', Closure|array|string|null $callback = null): static { 561 | $relations = explode('.', $relations); 562 | 563 | /** @var Relation */ 564 | $latestRelation = null; 565 | 566 | foreach ($relations as $index => $relation) { 567 | $relationName = $relation; 568 | 569 | if (!$latestRelation) { 570 | $relation = $this->getRelationWithoutConstraintsProxy($relation); 571 | } else { 572 | $relation = $latestRelation->getModel()->query()->getRelationWithoutConstraintsProxy($relation); 573 | } 574 | 575 | $relation->performJoinForEloquentPowerJoins($this, 'leftPowerJoin', is_callable($callback) ? $callback : $callback[$relationName] ?? null); 576 | 577 | if (count($relations) === ($index + 1)) { 578 | $relation->performHavingForEloquentPowerJoins($this, $operator, $count); 579 | } 580 | 581 | $latestRelation = $relation; 582 | } 583 | 584 | return $this; 585 | }; 586 | } 587 | 588 | public function powerJoinDoesntHave(): Closure 589 | { 590 | return function ($relation, $boolean = 'and', ?Closure $callback = null) { 591 | return $this->powerJoinHas($relation, '<', 1, $boolean, $callback); 592 | }; 593 | } 594 | 595 | public function powerJoinWhereHas(): Closure 596 | { 597 | return function ($relation, $callback = null, $operator = '>=', $count = 1) { 598 | return $this->powerJoinHas($relation, $operator, $count, 'and', $callback); 599 | }; 600 | } 601 | } 602 | -------------------------------------------------------------------------------- /src/Mixins/RelationshipsExtraMethods.php: -------------------------------------------------------------------------------- 1 | $this->performJoinForEloquentPowerJoinsForMorphToMany($builder, $joinType, $callback, $alias, $disableExtraConditions), 63 | $this instanceof BelongsToMany => $this->performJoinForEloquentPowerJoinsForBelongsToMany($builder, $joinType, $callback, $alias, $disableExtraConditions), 64 | $this instanceof MorphOneOrMany => $this->performJoinForEloquentPowerJoinsForMorph($builder, $joinType, $callback, $alias, $disableExtraConditions), 65 | $this instanceof HasMany || $this instanceof HasOne => $this->performJoinForEloquentPowerJoinsForHasMany($builder, $joinType, $callback, $alias, $disableExtraConditions, $hasCheck), 66 | $this instanceof HasManyThrough || $this instanceof HasOneThrough => $this->performJoinForEloquentPowerJoinsForHasManyThrough($builder, $joinType, $callback, $alias, $disableExtraConditions), 67 | $this instanceof MorphTo => $this->performJoinForEloquentPowerJoinsForMorphTo($builder, $joinType, $callback, $alias, $disableExtraConditions, $morphable), 68 | default => $this->performJoinForEloquentPowerJoinsForBelongsTo($builder, $joinType, $callback, $alias, $disableExtraConditions), 69 | }; 70 | }; 71 | } 72 | 73 | /** 74 | * Perform the JOIN clause for the BelongsTo (or similar) relationships. 75 | */ 76 | protected function performJoinForEloquentPowerJoinsForBelongsTo() 77 | { 78 | return function ($query, $joinType, $callback = null, $alias = null, bool $disableExtraConditions = false) { 79 | $joinedTable = $this->query->getModel()->getTable(); 80 | $parentTable = StaticCache::getTableOrAliasForModel($this->parent); 81 | 82 | $query->{$joinType}($joinedTable, function ($join) use ($callback, $joinedTable, $parentTable, $alias, $disableExtraConditions) { 83 | if ($alias) { 84 | $join->as($alias); 85 | } 86 | 87 | $join->on( 88 | "{$parentTable}.{$this->foreignKey}", 89 | '=', 90 | "{$joinedTable}.{$this->ownerKey}" 91 | ); 92 | 93 | if ($disableExtraConditions === false && $this->usesSoftDeletes($this->query->getScopes())) { 94 | $join->whereNull("{$joinedTable}.{$this->query->getModel()->getDeletedAtColumn()}"); 95 | } 96 | 97 | if ($disableExtraConditions === false) { 98 | $this->applyExtraConditions($join); 99 | } 100 | 101 | if ($callback && is_callable($callback)) { 102 | $callback($join); 103 | } 104 | }, $this->query->getModel()); 105 | }; 106 | } 107 | 108 | /** 109 | * Perform the JOIN clause for the BelongsToMany (or similar) relationships. 110 | */ 111 | protected function performJoinForEloquentPowerJoinsForBelongsToMany() 112 | { 113 | return function ($builder, $joinType, $callback = null, $alias = null, bool $disableExtraConditions = false) { 114 | [$alias1, $alias2] = $alias; 115 | 116 | $joinedTable = $alias1 ?: $this->getTable(); 117 | $parentTable = StaticCache::getTableOrAliasForModel($this->parent); 118 | 119 | $builder->{$joinType}($this->getTable(), function ($join) use ($callback, $joinedTable, $parentTable, $alias1) { 120 | if ($alias1) { 121 | $join->as($alias1); 122 | } 123 | 124 | $join->on( 125 | "{$joinedTable}.{$this->getForeignPivotKeyName()}", 126 | '=', 127 | "{$parentTable}.{$this->parentKey}" 128 | ); 129 | 130 | if (is_array($callback) && isset($callback[$this->getTable()])) { 131 | $callback[$this->getTable()]($join); 132 | } 133 | }); 134 | 135 | $builder->{$joinType}($this->getModel()->getTable(), function ($join) use ($callback, $joinedTable, $alias2, $disableExtraConditions) { 136 | if ($alias2) { 137 | $join->as($alias2); 138 | } 139 | 140 | $join->on( 141 | "{$this->getModel()->getTable()}.{$this->getRelatedKeyName()}", 142 | '=', 143 | "{$joinedTable}.{$this->getRelatedPivotKeyName()}" 144 | ); 145 | 146 | if ($disableExtraConditions === false && $this->usesSoftDeletes($this->query->getScopes())) { 147 | $join->whereNull($this->query->getModel()->getQualifiedDeletedAtColumn()); 148 | } 149 | 150 | // applying any extra conditions to the belongs to many relationship 151 | if ($disableExtraConditions === false) { 152 | $this->applyExtraConditions($join); 153 | } 154 | 155 | if (is_array($callback) && isset($callback[$this->getModel()->getTable()])) { 156 | $callback[$this->getModel()->getTable()]($join); 157 | } 158 | }, $this->getModel()); 159 | 160 | return $this; 161 | }; 162 | } 163 | 164 | /** 165 | * Perform the JOIN clause for the MorphToMany (or similar) relationships. 166 | */ 167 | protected function performJoinForEloquentPowerJoinsForMorphToMany() 168 | { 169 | return function ($builder, $joinType, $callback = null, $alias = null, bool $disableExtraConditions = false) { 170 | [$alias1, $alias2] = $alias; 171 | 172 | $joinedTable = $alias1 ?: $this->getTable(); 173 | $parentTable = StaticCache::getTableOrAliasForModel($this->parent); 174 | 175 | $builder->{$joinType}($this->getTable(), function ($join) use ($callback, $joinedTable, $parentTable, $alias1, $disableExtraConditions) { 176 | if ($alias1) { 177 | $join->as($alias1); 178 | } 179 | 180 | $join->on( 181 | "{$joinedTable}.{$this->getForeignPivotKeyName()}", 182 | '=', 183 | "{$parentTable}.{$this->parentKey}" 184 | ); 185 | 186 | // applying any extra conditions to the belongs to many relationship 187 | if ($disableExtraConditions === false) { 188 | $this->applyExtraConditions($join); 189 | } 190 | 191 | if (is_array($callback) && isset($callback[$this->getTable()])) { 192 | $callback[$this->getTable()]($join); 193 | } 194 | }); 195 | 196 | $builder->{$joinType}($this->getModel()->getTable(), function ($join) use ($callback, $joinedTable, $alias2, $disableExtraConditions) { 197 | if ($alias2) { 198 | $join->as($alias2); 199 | } 200 | 201 | $join->on( 202 | "{$this->getModel()->getTable()}.{$this->getModel()->getKeyName()}", 203 | '=', 204 | "{$joinedTable}.{$this->getRelatedPivotKeyName()}" 205 | ); 206 | 207 | if ($disableExtraConditions === false && $this->usesSoftDeletes($this->query->getScopes())) { 208 | $join->whereNull($this->query->getModel()->getQualifiedDeletedAtColumn()); 209 | } 210 | 211 | if (is_array($callback) && isset($callback[$this->getModel()->getTable()])) { 212 | $callback[$this->getModel()->getTable()]($join); 213 | } 214 | }, $this->getModel()); 215 | 216 | return $this; 217 | }; 218 | } 219 | 220 | /** 221 | * Perform the JOIN clause for the Morph (or similar) relationships. 222 | */ 223 | protected function performJoinForEloquentPowerJoinsForMorph() 224 | { 225 | return function ($builder, $joinType, $callback = null, $alias = null, bool $disableExtraConditions = false) { 226 | $builder->{$joinType}($this->getModel()->getTable(), function ($join) use ($callback, $disableExtraConditions, $alias) { 227 | if ($alias) { 228 | $join->as($alias); 229 | } 230 | 231 | $join->on( 232 | "{$this->getModel()->getTable()}.{$this->getForeignKeyName()}", 233 | '=', 234 | "{$this->parent->getTable()}.{$this->localKey}" 235 | )->where("{$this->getModel()->getTable()}.{$this->getMorphType()}", '=', $this->getMorphClass()); 236 | 237 | if ($disableExtraConditions === false && $this->usesSoftDeletes($this->query->getScopes())) { 238 | $join->whereNull($this->query->getModel()->getQualifiedDeletedAtColumn()); 239 | } 240 | 241 | if ($disableExtraConditions === false) { 242 | $this->applyExtraConditions($join); 243 | } 244 | 245 | if ($callback && is_callable($callback)) { 246 | $callback($join); 247 | } 248 | }, $this->getModel()); 249 | 250 | return $this; 251 | }; 252 | } 253 | 254 | /** 255 | * Perform the JOIN clause for when calling the morphTo method from the morphable class. 256 | */ 257 | protected function performJoinForEloquentPowerJoinsForMorphTo() 258 | { 259 | return function ($builder, $joinType, $callback = null, $alias = null, bool $disableExtraConditions = false, ?string $morphable = null) { 260 | /** @var Model */ 261 | $modelInstance = new $morphable(); 262 | 263 | $builder->{$joinType}($modelInstance->getTable(), function ($join) use ($modelInstance, $callback, $disableExtraConditions) { 264 | $join->on( 265 | "{$this->getModel()->getTable()}.{$this->getForeignKeyName()}", 266 | '=', 267 | "{$modelInstance->getTable()}.{$modelInstance->getKeyName()}" 268 | )->where("{$this->getModel()->getTable()}.{$this->getMorphType()}", '=', $modelInstance->getMorphClass()); 269 | 270 | if ($disableExtraConditions === false && $this->usesSoftDeletes($modelInstance->getScopes())) { 271 | $join->whereNull($modelInstance->getQualifiedDeletedAtColumn()); 272 | } 273 | 274 | if ($disableExtraConditions === false) { 275 | $this->applyExtraConditions($join); 276 | } 277 | 278 | if ($callback && is_callable($callback)) { 279 | $callback($join); 280 | } 281 | }, $modelInstance); 282 | 283 | return $this; 284 | }; 285 | } 286 | 287 | /** 288 | * Perform the JOIN clause for the HasMany (or similar) relationships. 289 | */ 290 | protected function performJoinForEloquentPowerJoinsForHasMany() 291 | { 292 | return function ($builder, $joinType, $callback = null, $alias = null, bool $disableExtraConditions = false, bool $hasCheck = false) { 293 | $joinedModel = $this->query->getModel(); 294 | $joinedTable = $alias ?: $joinedModel->getTable(); 295 | $parentTable = StaticCache::getTableOrAliasForModel($this->parent); 296 | $isOneOfMany = method_exists($this, 'isOneOfMany') ? $this->isOneOfMany() : false; 297 | 298 | if ($isOneOfMany && !$hasCheck) { 299 | $column = $this->getOneOfManySubQuery()->getQuery()->columns[0]; 300 | $fkColumn = $this->getOneOfManySubQuery()->getQuery()->columns[1]; 301 | $localKey = $this->localKey; 302 | 303 | $builder->where(function ($query) use ($column, $joinType, $joinedModel, $builder, $fkColumn, $parentTable, $localKey) { 304 | $query->whereIn($joinedModel->getQualifiedKeyName(), function ($query) use ($column, $joinedModel, $builder, $fkColumn, $parentTable, $localKey) { 305 | $columnValue = $column->getValue($builder->getGrammar()); 306 | $direction = Str::contains($columnValue, 'min(') ? 'asc' : 'desc'; 307 | 308 | $columnName = Str::of($columnValue)->after('(')->before(')')->__toString(); 309 | $columnName = Str::replace(['"', "'", '`'], '', $columnName); 310 | 311 | if ($builder->getConnection() instanceof MySqlConnection) { 312 | $query->select('*')->from(function ($query) use ($joinedModel, $columnName, $fkColumn, $direction, $parentTable, $localKey) { 313 | $query 314 | ->select($joinedModel->getQualifiedKeyName()) 315 | ->from($joinedModel->getTable()) 316 | ->whereColumn($fkColumn, "{$parentTable}.{$localKey}") 317 | ->orderBy($columnName, $direction) 318 | ->take(1); 319 | }); 320 | } else { 321 | $query 322 | ->select($joinedModel->getQualifiedKeyName()) 323 | ->distinct($columnName) 324 | ->from($joinedModel->getTable()) 325 | ->whereColumn($fkColumn, "{$parentTable}.{$localKey}") 326 | ->orderBy($columnName, $direction) 327 | ->take(1); 328 | } 329 | }); 330 | 331 | if ($joinType === 'leftPowerJoin') { 332 | $query->orWhereRaw('1 = 1'); 333 | } 334 | }); 335 | } 336 | 337 | $builder->{$joinType}($this->query->getModel()->getTable(), function ($join) use ($callback, $joinedTable, $parentTable, $alias, $disableExtraConditions) { 338 | if ($alias) { 339 | $join->as($alias); 340 | } 341 | 342 | $join->on( 343 | $this->foreignKey, 344 | '=', 345 | "{$parentTable}.{$this->localKey}" 346 | ); 347 | 348 | if ($disableExtraConditions === false && $this->usesSoftDeletes($this->query->getScopes())) { 349 | $join->whereNull( 350 | "{$joinedTable}.{$this->query->getModel()->getDeletedAtColumn()}" 351 | ); 352 | } 353 | 354 | if ($disableExtraConditions === false) { 355 | $this->applyExtraConditions($join); 356 | } 357 | 358 | if ($callback && is_callable($callback)) { 359 | $callback($join); 360 | } 361 | }, $this->query->getModel()); 362 | }; 363 | } 364 | 365 | /** 366 | * Perform the JOIN clause for the HasManyThrough relationships. 367 | */ 368 | protected function performJoinForEloquentPowerJoinsForHasManyThrough() 369 | { 370 | return function ($builder, $joinType, $callback = null, $alias = null, bool $disableExtraConditions = false) { 371 | [$alias1, $alias2] = $alias; 372 | $throughTable = $alias1 ?: $this->getThroughParent()->getTable(); 373 | $farTable = $alias2 ?: $this->getModel()->getTable(); 374 | 375 | $builder->{$joinType}($this->getThroughParent()->getTable(), function (PowerJoinClause $join) use ($callback, $throughTable, $alias1, $disableExtraConditions) { 376 | if ($alias1) { 377 | $join->as($alias1); 378 | } 379 | 380 | $farParentTable = StaticCache::getTableOrAliasForModel($this->getFarParent()); 381 | $join->on( 382 | "{$throughTable}.{$this->getFirstKeyName()}", 383 | '=', 384 | "{$farParentTable}.{$this->localKey}" 385 | ); 386 | 387 | if ($disableExtraConditions === false && $this->usesSoftDeletes($this->getThroughParent())) { 388 | $join->whereNull($this->getThroughParent()->getQualifiedDeletedAtColumn()); 389 | } 390 | 391 | if ($disableExtraConditions === false) { 392 | $this->applyExtraConditions($join); 393 | } 394 | 395 | if (is_array($callback) && isset($callback[$this->getThroughParent()->getTable()])) { 396 | $callback[$this->getThroughParent()->getTable()]($join); 397 | } 398 | 399 | if ($callback && is_callable($callback)) { 400 | $callback($join); 401 | } 402 | }, $this->getThroughParent()); 403 | 404 | $builder->{$joinType}($this->getModel()->getTable(), function (PowerJoinClause $join) use ($callback, $throughTable, $farTable, $alias2) { 405 | if ($alias2) { 406 | $join->as($alias2); 407 | } 408 | 409 | $join->on( 410 | "{$farTable}.{$this->secondKey}", 411 | '=', 412 | "{$throughTable}.{$this->secondLocalKey}" 413 | ); 414 | 415 | if ($this->usesSoftDeletes($this->getScopes())) { 416 | $join->whereNull("{$farTable}.{$this->getModel()->getDeletedAtColumn()}"); 417 | } 418 | 419 | if (is_array($callback) && isset($callback[$this->getModel()->getTable()])) { 420 | $callback[$this->getModel()->getTable()]($join); 421 | } 422 | }, $this->getModel()); 423 | 424 | return $this; 425 | }; 426 | } 427 | 428 | /** 429 | * Perform the "HAVING" clause for eloquent power joins. 430 | */ 431 | public function performHavingForEloquentPowerJoins() 432 | { 433 | return function ($builder, $operator, $count, ?string $morphable = null) { 434 | if ($morphable) { 435 | $modelInstance = new $morphable(); 436 | 437 | $builder 438 | ->selectRaw(sprintf('count(%s) as %s_count', $modelInstance->getQualifiedKeyName(), Str::replace('.', '_', $modelInstance->getTable()))) 439 | ->havingRaw(sprintf('count(%s) %s %d', $modelInstance->getQualifiedKeyName(), $operator, $count)); 440 | } else { 441 | $builder 442 | ->selectRaw(sprintf('count(%s) as %s_count', $this->query->getModel()->getQualifiedKeyName(), Str::replace('.', '_', $this->query->getModel()->getTable()))) 443 | ->havingRaw(sprintf('count(%s) %s %d', $this->query->getModel()->getQualifiedKeyName(), $operator, $count)); 444 | } 445 | }; 446 | } 447 | 448 | /** 449 | * Checks if the relationship model uses soft deletes. 450 | */ 451 | public function usesSoftDeletes() 452 | { 453 | /* 454 | * @param \Illuminate\Database\Eloquent\Model|array $model 455 | */ 456 | return function ($model) { 457 | if ($model instanceof Model) { 458 | return in_array(SoftDeletes::class, class_uses_recursive($model), true); 459 | } 460 | 461 | return array_key_exists(SoftDeletingScope::class, $model); 462 | }; 463 | } 464 | 465 | /** 466 | * Get the throughParent for the HasManyThrough relationship. 467 | */ 468 | public function getThroughParent() 469 | { 470 | return function () { 471 | return $this->throughParent; 472 | }; 473 | } 474 | 475 | /** 476 | * Get the farParent for the HasManyThrough relationship. 477 | */ 478 | public function getFarParent() 479 | { 480 | return function () { 481 | return $this->farParent; 482 | }; 483 | } 484 | 485 | public function applyExtraConditions() 486 | { 487 | return function (PowerJoinClause $join) { 488 | foreach ($this->getQuery()->getQuery()->wheres as $condition) { 489 | if ($this->shouldNotApplyExtraCondition($condition)) { 490 | continue; 491 | } 492 | 493 | if (!in_array($condition['type'], ['Basic', 'Null', 'NotNull', 'Nested'], true)) { 494 | continue; 495 | } 496 | 497 | $method = "apply{$condition['type']}Condition"; 498 | $this->$method($join, $condition); 499 | } 500 | }; 501 | } 502 | 503 | public function applyBasicCondition() 504 | { 505 | return function ($join, $condition) { 506 | $join->where($condition['column'], $condition['operator'], $condition['value'], $condition['boolean']); 507 | }; 508 | } 509 | 510 | public function applyNullCondition() 511 | { 512 | return function ($join, $condition) { 513 | $join->whereNull($condition['column'], $condition['boolean']); 514 | }; 515 | } 516 | 517 | public function applyNotNullCondition() 518 | { 519 | return function ($join, $condition) { 520 | $join->whereNotNull($condition['column'], $condition['boolean']); 521 | }; 522 | } 523 | 524 | public function applyNestedCondition() 525 | { 526 | return function ($join, $condition) { 527 | $join->where(function ($q) use ($condition) { 528 | foreach ($condition['query']->wheres as $condition) { 529 | $method = "apply{$condition['type']}Condition"; 530 | $this->$method($q, $condition); 531 | } 532 | }); 533 | }; 534 | } 535 | 536 | public function shouldNotApplyExtraCondition() 537 | { 538 | return function ($condition) { 539 | if (isset($condition['column']) && ($condition['column'] === '' || Str::endsWith($condition['column'], '.'))) { 540 | return true; 541 | } 542 | 543 | if (!$key = $this->getPowerJoinExistenceCompareKey()) { 544 | return true; 545 | } 546 | 547 | if (isset($condition['query'])) { 548 | return false; 549 | } 550 | 551 | if (is_array($key)) { 552 | return in_array($condition['column'], $key, true); 553 | } 554 | 555 | return $condition['column'] === $key; 556 | }; 557 | } 558 | 559 | public function getPowerJoinExistenceCompareKey() 560 | { 561 | return function () { 562 | if ($this instanceof MorphTo) { 563 | return [$this->getMorphType(), $this->getForeignKeyName()]; 564 | } 565 | 566 | if ($this instanceof BelongsTo) { 567 | return $this->getQualifiedOwnerKeyName(); 568 | } 569 | 570 | if ($this instanceof HasMany || $this instanceof HasOne) { 571 | return $this->getExistenceCompareKey(); 572 | } 573 | 574 | if ($this instanceof HasManyThrough || $this instanceof HasOneThrough) { 575 | return $this->getQualifiedFirstKeyName(); 576 | } 577 | 578 | if ($this instanceof BelongsToMany) { 579 | return $this->getExistenceCompareKey(); 580 | } 581 | 582 | if ($this instanceof MorphOneOrMany) { 583 | return [$this->getQualifiedMorphType(), $this->getExistenceCompareKey()]; 584 | } 585 | }; 586 | } 587 | } 588 | --------------------------------------------------------------------------------