🆙 Add cms i using 🆙

This commit is contained in:
Remco
2025-11-25 22:42:56 +01:00
parent 94704e0925
commit d44196149e
35591 changed files with 3601123 additions and 0 deletions
@@ -0,0 +1,33 @@
<?php
$rules = [
'@Symfony' => true,
'strict_param' => true,
'php_unit_method_casing' => ['case' => 'snake_case'],
'phpdoc_align' => [
'align' => 'left'
],
'global_namespace_import' => [
'import_classes' => true,
'import_constants' => true,
'import_functions' => true
],
'yoda_style' => [
'equal' => false,
'identical' => false,
'less_and_greater' => false,
],
];
$finder = PhpCsFixer\Finder::create()
->in(__DIR__)
->name('*.php')
->notName('*.blade.php')
->ignoreDotFiles(true)
->ignoreVCS(true);
return (new PhpCsFixer\Config())
->setRules($rules)
->setFinder($finder)
->setRiskyAllowed(true)
->setUsingCache(true);
@@ -0,0 +1,100 @@
<?php
$finder = PhpCsFixer\Finder::create()
->exclude('vendor')
->in(__DIR__ . '/src')
->name('*.php')
->ignoreDotFiles(true)
->ignoreVCS(true);
return PhpCsFixer\Config::create()
->setFinder($finder)
->setRules([
'@PSR2' => true,
'phpdoc_no_empty_return' => false,
'phpdoc_var_annotation_correct_order' => true,
'array_syntax' => [
'syntax' => 'short',
],
'no_singleline_whitespace_before_semicolons' => true,
'no_extra_blank_lines' => [
'break', 'case', 'continue', 'curly_brace_block', 'default',
'extra', 'parenthesis_brace_block', 'return',
'square_brace_block', 'switch', 'throw', 'use', 'useTrait', 'use_trait',
],
'cast_spaces' => [
'space' => 'single',
],
'single_quote' => true,
'lowercase_cast' => true,
'lowercase_static_reference' => true,
'no_empty_phpdoc' => true,
'no_empty_comment' => true,
'array_indentation' => true,
'short_scalar_cast' => true,
'no_mixed_echo_print' => [
'use' => 'echo',
],
'ordered_imports' => [
'sort_algorithm' => 'alpha',
],
'no_unused_imports' => true,
'binary_operator_spaces' => [
'default' => 'single_space',
],
'no_empty_statement' => true,
'unary_operator_spaces' => true, // $number ++ becomes $number++
'hash_to_slash_comment' => true, // # becomes //
'standardize_not_equals' => true, // <> becomes !=
'native_function_casing' => true,
'ternary_operator_spaces' => true,
'ternary_to_null_coalescing' => true,
'declare_equal_normalize' => [
'space' => 'single',
],
'function_typehint_space' => true,
'no_leading_import_slash' => true,
'blank_line_before_statement' => [
'statements' => [
'break', 'case', 'continue',
'declare', 'default', 'die',
'do', 'exit', 'for', 'foreach',
'goto', 'if', 'include',
'include_once', 'require', 'require_once',
'return', 'switch', 'throw', 'try', 'while', 'yield',
],
],
'combine_consecutive_unsets' => true,
'method_chaining_indentation' => true,
'no_whitespace_in_blank_line' => true,
'blank_line_after_opening_tag' => true,
'no_trailing_comma_in_list_call' => true,
'list_syntax' => ['syntax' => 'short'],
// public function getTimezoneAttribute( ? Banana $value) becomes public function getTimezoneAttribute(?Banana $value)
'compact_nullable_typehint' => true,
'explicit_string_variable' => true,
'no_leading_namespace_whitespace' => true,
'trailing_comma_in_multiline_array' => true,
'not_operator_with_successor_space' => true,
'object_operator_without_whitespace' => true,
'single_blank_line_before_namespace' => true,
'no_blank_lines_after_class_opening' => true,
'no_blank_lines_after_phpdoc' => true,
'no_whitespace_before_comma_in_array' => true,
'no_trailing_comma_in_singleline_array' => true,
'multiline_whitespace_before_semicolons' => [
'strategy' => 'no_multi_line',
],
'no_multiline_whitespace_around_double_arrow' => true,
'no_useless_return' => true,
'phpdoc_add_missing_param_annotation' => false,
'phpdoc_order' => true,
'phpdoc_scalar' => false,
'phpdoc_separation' => false,
'phpdoc_single_line_var_spacing' => false,
'single_trait_insert_per_statement' => true,
'return_type_declaration' => [
'space_before' => 'none',
],
])
->setLineEnding("\n");
@@ -0,0 +1,101 @@
<?php
namespace Illuminate\Database\Eloquent {
use Closure;
class Builder
{
// join relationship methods
/** @return self */
public function joinRelationship(string $relationName, Closure|array|string $callback = null, string $joinType = 'join', bool $useAlias = false, bool $disableExtraConditions = false, string $morphable = null) {}
/** @return self */
public function leftJoinRelationship(string $relationName, Closure|array|string $callback = null, string $joinType = 'join', bool $useAlias = false, bool $disableExtraConditions = false, string $morphable = null) {}
/** @return self */
public function rightJoinRelationship(string $relationName, Closure|array|string $callback = null, string $joinType = 'join', bool $useAlias = false, bool $disableExtraConditions = false, string $morphable = null) {}
/** @return self */
public function joinRelationshipUsingAlias(string $relationName, Closure|array|string $callback = null, bool $disableExtraConditions = false, string $morphable = null) {}
/** @return self */
public function leftJoinRelationshipUsingAlias(string $relationName, Closure|array|string $callback = null, bool $disableExtraConditions = false, string $morphable = null) {}
/** @return self */
public function rightJoinRelationshipUsingAlias(string $relationName, Closure|array|string $callback = null, bool $disableExtraConditions = false, string $morphable = null) {}
/** @return self */
public function joinNestedRelationship(string $relationships, Closure|array|string $callback = null, string $joinType = 'join', bool $useAlias = false, bool $disableExtraConditions = false, ?string $morphable = null) {}
/** @return self */
public function orderByPowerJoins(string|array $sort, string $direction = 'asc', ?string $aggregation = null, $joinType = 'join', $aliases = null) {}
/** @return self */
public function orderByLeftPowerJoins(string|array $sort, string $direction = 'asc') {}
/** @return self */
public function orderByPowerJoinsCount(string|array $sort, string $direction = 'asc') {}
/** @return self */
public function orderByLeftPowerJoinsCount(string|array $sort, string $direction = 'asc') {}
/** @return self */
public function orderByPowerJoinsSum(string|array $sort, string $direction = 'asc') {}
/** @return self */
public function orderByLeftPowerJoinsSum(string|array $sort, string $direction = 'asc') {}
/** @return self */
public function orderByPowerJoinsAvg(string|array $sort, string $direction = 'asc') {}
/** @return self */
public function orderByLeftPowerJoinsAvg(string|array $sort, $direction = 'asc') {}
/** @return self */
public function orderByPowerJoinsMin(string|array $sort, string $direction = 'asc') {}
/** @return self */
public function orderByLeftPowerJoinsMin(string|array $sort, string $direction = 'asc') {}
/** @return self */
public function orderByPowerJoinsMax(string|array $sort, string $direction = 'asc') {}
/** @return self */
public function orderByLeftPowerJoinsMax(string|array $sort, string $direction = 'asc') {}
/** @return self */
public function powerJoinHas(string $relation, string $operator = '>=', int $count = 1, string $boolean = 'and', Closure|array|string $callback = null, ?string $morphable = null) {}
/** @return self */
public function powerJoinDoesntHave(string $relation, string $boolean = 'and', Closure|array|string $callback = null) {}
/** @return self */
public function powerJoinWhereHas(string $relation, Closure|array|string $callback = null, string $operator = '>=', int $count = 1) {}
// PowerJoinClause methods for when a closure is being used as a callback
/** @return self */
public function as(string $alias, ?string $joinedTableAlias = null) {}
/** @return self */
public function on($first, $operator = null, $second = null, $boolean = 'and') {}
/** @return self */
public function withGlobalScopes() {}
/** @return self */
public function withTrashed() {}
/** @return self */
public function onlyTrashed() {}
/** @return self */
public function left() {}
/** @return self */
public function right() {}
/** @return self */
public function inner() {}
}
}
@@ -0,0 +1,20 @@
# Agent Guidelines for Eloquent Power Joins
## Commands
- **Test**: `composer test` or `vendor/bin/phpunit`
- **Single test**: `vendor/bin/phpunit tests/JoinRelationshipTest.php` or `vendor/bin/phpunit --filter test_method_name`
- **Test with coverage**: `composer test-coverage`
- **Lint**: `composer lint` or `vendor/bin/php-cs-fixer fix -vvv --show-progress=dots --config=.php-cs-fixer.php`
## Code Style
- **PHP Version**: 8.2+
- **Framework**: Laravel 11.42+/12.0+ package
- **Formatting**: Uses PHP-CS-Fixer with @Symfony rules + custom overrides
- **Imports**: Use global namespace imports for classes/constants/functions, ordered alphabetically
- **Arrays**: Short syntax `[]`, trailing commas in multiline
- **Quotes**: Single quotes preferred
- **Test methods**: snake_case naming with `@test` annotation
- **Namespaces**: `Kirschbaum\PowerJoins` for src, `Kirschbaum\PowerJoins\Tests` for tests
- **Type hints**: Use strict typing, compact nullable syntax `?Type`
- **PHPDoc**: Left-aligned, no empty returns, ordered tags
- **Variables**: No yoda conditions (`$var === 'value'` not `'value' === $var`)
@@ -0,0 +1,27 @@
# Changelog
All notable changes to `eloquent-power-joins` will be documented in this file.
## 2.2.2 - 2020-10
- Fixed the ability to pass nested closures in join callbacks when using aliases;
## 2.2.1 - 2020-10
- Fixed nested conditions in relationship definitions;
## 2.1.0 - 2020-09
- Added the ability to include trashed models in join clauses;
## 2.0.0 - 2020-09
- Introduced trait that has to be used by models;
- Automatically applying extra relationship conditions;
- Ability to order by using left joins;
- Laravel 8 support;
_ Lots of bugfixes;
- Changed the method signature for sorting;
- Changed the method signature for querying relationship existence;
## 1.1.0
- Added the ability to use table aliases;
## 1.0.0
- Initial release;
@@ -0,0 +1,55 @@
# Contributing
Contributions are **welcome** and will be fully **credited**.
Please read and understand the contribution guide before creating an issue or pull request.
## Etiquette
This project is open source, and as such, the maintainers give their free time to build and maintain the source code
held within. They make the code freely available in the hope that it will be of use to other developers. It would be
extremely unfair for them to suffer abuse or anger for their hard work.
Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the
world that developers are civilized and selfless people.
It's the duty of the maintainer to ensure that all submissions to the project are of sufficient
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.
## Viability
When requesting or submitting new features, first consider whether it might be useful to others. Open
source projects are used by many developers, who may have entirely different needs to your own. Think about
whether or not your feature is likely to be used by other users of the project.
## Procedure
Before filing an issue:
- Attempt to replicate the problem, to ensure that it wasn't a coincidental incident.
- Check to make sure your feature suggestion isn't already present within the project.
- Check the pull requests tab to ensure that the bug doesn't have a fix in progress.
- Check the pull requests tab to ensure that the feature isn't already in progress.
Before submitting a pull request:
- Check the codebase to ensure that your feature doesn't already exist.
- Check the pull requests to ensure that another person hasn't already submitted the feature or fix.
## Requirements
If the project maintainer has any additional requirements, you will find them listed here.
- **[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).
- **Add tests!** - Your patch won't be accepted if it doesn't have tests.
- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date.
- **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option.
- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests.
- **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.
**Happy coding**!
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Luis Dalmolin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,353 @@
![Eloquent Power Joins](screenshots/eloquent-power-joins.jpg "Eloquent Power Joins")
![Laravel Supported Versions](https://img.shields.io/badge/laravel-10.x/11.x/12.x-green.svg)
[![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)
[![MIT Licensed](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md)
[![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)
[![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)
The Laravel magic you know, now applied to joins.
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.
A few things we consider is missing when using joins which are very powerful Eloquent features:
* Ability to use relationship definitions to make joins;
* Ability to use model scopes inside different contexts;
* Ability to query relationship existence using joins instead of where exists;
* Ability to easily sort results based on columns or aggregations from related tables;
You can read a more detailed explanation on the problems this package solves on [this blog post](https://kirschbaumdevelopment.com/insights/power-joins).
## Installation
You can install the package via composer:
```bash
composer require kirschbaum-development/eloquent-power-joins
```
For Laravel versions < 10, use the 3.* version. For Laravel versions < 8, use the 2.* version:
```bash
composer require kirschbaum-development/eloquent-power-joins:3.*
```
## Usage
This package provides a few features.
### 1 - Join Relationship
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:
```php
User::select('users.*')->join('posts', 'posts.user_id', '=', 'users.id');
```
This package provides you with a new `joinRelationship()` method, which does the exact same thing.
```php
User::joinRelationship('posts');
```
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.
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:
```php
User::joinRelationship('posts.comments');
```
So much better, wouldn't you agree?! You can also `left` or `right` join the relationships as needed.
```php
User::leftJoinRelationship('posts.comments');
User::rightJoinRelationship('posts.comments');
```
#### Joining polymorphic relationships
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.
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.
```php
Post::joinRelationship('images');
```
You can also join MorphTo relationships.
```php
Image::joinRelationship('imageable', morphable: Post::class);
```
Note: Querying morph to relationships only supports one morphable type at a time.
**Applying conditions & callbacks to the joins**
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.
```php
User::joinRelationship('posts', fn ($join) => $join->where('posts.approved', true))->toSql();
```
You can also specify the type of join you want to make in the callback:
```php
User::joinRelationship('posts', fn ($join) => $join->left());
```
For **nested calls**, you simply need to pass an array referencing the relationship names.
```php
User::joinRelationship('posts.comments', [
'posts' => fn ($join) => $join->where('posts.published', true),
'comments' => fn ($join) => $join->where('comments.approved', true),
]);
```
For **belongs to many** calls, you need to pass an array with the relationship, and then an array with the table names.
```php
User::joinRelationship('groups', [
'groups' => [
'groups' => function ($join) {
// ...
},
// group_members is the intermediary table here
'group_members' => fn ($join) => $join->where('group_members.active', true),
]
]);
```
#### Using model scopes inside the join callbacks 🤯
We consider this one of the most useful features of this package. Let's say, you have a `published` scope on your `Post` model:
```php
public function scopePublished($query)
{
$query->where('published', true);
}
```
When joining relationships, you **can** use the scopes defined in the model being joined. How cool is this?
```php
User::joinRelationship('posts', function ($join) {
// the $join instance here can access any of the scopes defined in Post 🤯
$join->published();
});
```
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.
#### Using aliases
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.
```php
Post::joinRelationshipUsingAlias('category.parent')->get();
```
In case you need to specify the name of the alias which is going to be used, you can do in two different ways:
1. Passing a string as the second parameter (this won't work for nested joins):
```php
Post::joinRelationshipUsingAlias('category', 'category_alias')->get();
```
2. Calling the `as` function inside the join callback.
```php
Post::joinRelationship('category.parent', [
'category' => fn ($join) => $join->as('category_alias'),
'parent' => fn ($join) => $join->as('category_parent'),
])->get()
```
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.
```php
Group::joinRelationship('posts.user', [
'posts' => [
'posts' => fn ($join) => $join->as('posts_alias'),
'post_groups' => fn ($join) => $join->as('post_groups_alias'),
],
])->toSql();
```
#### Select * from table
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:
```php
User::joinRelationship('posts')->toSql();
// select users.* from users inner join posts on posts.user_id = users.id
```
And, if you specify the select statement:
```php
User::select('users.id')->joinRelationship('posts')->toSql();
// select users.id from users inner join posts on posts.user_id = users.id
```
#### Soft deletes
When joining any models which uses the `SoftDeletes` trait, the following condition will be also automatically applied to all your joins:
```sql
and "users"."deleted_at" is null
```
In case you want to include trashed models, you can call the `->withTrashed()` method in the join callback.
```php
UserProfile::joinRelationship('users', fn ($join) => $join->withTrashed());
```
You can also call the `onlyTrashed` model as well:
```php
UserProfile::joinRelationship('users', ($join) => $join->onlyTrashed());
```
#### Extra conditions defined in relationships
If you have extra conditions in your relationship definitions, they will get automatically applied for you.
```php
class User extends Model
{
public function publishedPosts()
{
return $this->hasMany(Post::class)->published();
}
}
```
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:
```sql
select users.* from users inner join posts on posts.user_id = posts.id and posts.published = 1
```
#### Global Scopes
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:
```php
UserProfile::joinRelationship('users', fn ($join) => $join->withGlobalScopes());
```
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.
### 2 - Querying relationship existence (Using Joins)
[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.
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.
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`.
**Laravel Native Methods**
``` php
User::has('posts');
User::has('posts.comments');
User::has('posts', '>', 3);
User::whereHas('posts', fn ($query) => $query->where('posts.published', true));
User::whereHas('posts.comments', ['posts' => fn ($query) => $query->where('posts.published', true));
User::doesntHave('posts');
```
**Package equivalent, but using joins**
```php
User::powerJoinHas('posts');
User::powerJoinHas('posts.comments');
User::powerJoinHas('posts.comments', '>', 3);
User::powerJoinWhereHas('posts', function ($join) {
$join->where('posts.published', true);
});
User::powerJoinDoesntHave('posts');
```
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:
```php
User::powerJoinWhereHas('commentsThroughPosts', [
'comments' => fn ($query) => $query->where('body', 'a')
])->get());
```
### 3 - Order by
You can also sort your query results using a column from another table using the `orderByPowerJoins` method.
```php
User::orderByPowerJoins('profile.city');
```
If you need to pass some raw values for the order by function, you can do like this:
```php
User::orderByPowerJoins(['profile', DB::raw('concat(city, ", ", state)')]);
```
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`).
For instance, to sort users with the highest number of posts, you can do this:
```php
$users = User::orderByPowerJoinsCount('posts.id', 'desc')->get();
```
Or, to get the list of posts where the comments contain the highest average of votes.
```php
$posts = Post::orderByPowerJoinsAvg('comments.votes', 'desc')->get();
```
You also have methods for `SUM`, `MIN` and `MAX`:
```php
Post::orderByPowerJoinsSum('comments.votes');
Post::orderByPowerJoinsMin('comments.votes');
Post::orderByPowerJoinsMax('comments.votes');
```
In case you want to use left joins in sorting, you also can:
```php
Post::orderByLeftPowerJoinsCount('comments.votes');
Post::orderByLeftPowerJoinsAvg('comments.votes');
Post::orderByLeftPowerJoinsSum('comments.votes');
Post::orderByLeftPowerJoinsMin('comments.votes');
Post::orderByLeftPowerJoinsMax('comments.votes');
```
***
## Contributing
Please see [CONTRIBUTING](CONTRIBUTING.md) for details.
### Security
If you discover any security related issues, please email security@kirschbaumdevelopment.com instead of using the issue tracker.
## Credits
- [Luis Dalmolin](https://github.com/luisdalmolin)
## Sponsorship
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)!
## License
The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
@@ -0,0 +1,57 @@
{
"name": "kirschbaum-development/eloquent-power-joins",
"description": "The Laravel magic applied to joins.",
"keywords": [
"laravel",
"eloquent",
"mysql",
"join"
],
"homepage": "https://github.com/kirschbaum-development/eloquent-power-joins",
"license": "MIT",
"type": "library",
"authors": [
{
"name": "Luis Dalmolin",
"email": "luis.nh@gmail.com",
"role": "Developer"
}
],
"require": {
"php": "^8.2",
"illuminate/support": "^11.42|^12.0",
"illuminate/database": "^11.42|^12.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "dev-master",
"laravel/legacy-factories": "^1.0@dev",
"orchestra/testbench": "^9.0|^10.0",
"phpunit/phpunit": "^10.0|^11.0"
},
"autoload": {
"psr-4": {
"Kirschbaum\\PowerJoins\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"Kirschbaum\\PowerJoins\\Tests\\": "tests"
}
},
"scripts": {
"test": "vendor/bin/phpunit",
"test-coverage": "vendor/bin/phpunit --coverage-html coverage",
"lint": "vendor/bin/php-cs-fixer fix -vvv --show-progress=dots --config=.php-cs-fixer.php"
},
"config": {
"sort-packages": true
},
"extra": {
"laravel": {
"providers": [
"Kirschbaum\\PowerJoins\\PowerJoinsServiceProvider"
]
}
},
"minimum-stability": "stable"
}
@@ -0,0 +1,7 @@
<?php
/*
* You can place your custom package configuration in here.
*/
return [
];
@@ -0,0 +1,26 @@
<?php
namespace Kirschbaum\PowerJoins;
use Illuminate\Database\Eloquent\Builder as EloquentQueryBuilder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Kirschbaum\PowerJoins\Mixins\JoinRelationship;
use Kirschbaum\PowerJoins\Mixins\QueryBuilderExtraMethods;
use Kirschbaum\PowerJoins\Mixins\QueryRelationshipExistence;
use Kirschbaum\PowerJoins\Mixins\RelationshipsExtraMethods;
class EloquentJoins
{
/**
* Register macros with Eloquent.
*/
public static function registerEloquentMacros()
{
EloquentQueryBuilder::mixin(new JoinRelationship());
EloquentQueryBuilder::mixin(new QueryRelationshipExistence());
QueryBuilder::mixin(new QueryBuilderExtraMethods());
Relation::mixin(new RelationshipsExtraMethods());
}
}
@@ -0,0 +1,38 @@
<?php
namespace Kirschbaum\PowerJoins;
/**
* @method static as(string $alias)
*/
class FakeJoinCallback extends PowerJoinClause
{
protected ?string $joinType = null;
public function getAlias(): ?string
{
return $this->alias;
}
public function getJoinType(): ?string
{
return $this->joinType;
}
public function __call($name, $arguments)
{
if ($name === 'as') {
$this->alias = $arguments[0];
} elseif ($name === 'joinType') {
$this->joinType = $arguments[0];
} elseif ($name === 'left') {
$this->joinType = 'leftPowerJoin';
} elseif ($name === 'right') {
$this->joinType = 'rightPowerJoin';
} elseif ($name === 'inner') {
$this->joinType = 'powerJoin';
}
return $this;
}
}
@@ -0,0 +1,259 @@
<?php
namespace Kirschbaum\PowerJoins;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOneOrManyThrough;
use Illuminate\Database\Eloquent\Relations\Relation;
use WeakMap;
class JoinsHelper
{
public static WeakMap $instances;
/**
* Cache to determine which query model belongs to which query.
* This is used to determine if a query is a clone of another
* query and therefore if we should refresh the model in it.
*
* The keys are the model objects, and the value is the spl
* object ID of the associated Eloquent builder instance.
*/
public static WeakMap $modelQueryDictionary;
/**
* An array of `beforeQuery` callbacks that
* are registered by the library.
*/
public static WeakMap $beforeQueryCallbacks;
protected function __construct()
{
static::$instances ??= new WeakMap();
static::$modelQueryDictionary ??= new WeakMap();
static::$beforeQueryCallbacks ??= new WeakMap();
$this->joinRelationshipCache = new WeakMap();
}
public static function make($model): static
{
static::$instances ??= new WeakMap();
return static::$instances[$model] ??= new self();
}
/**
* Cache to not join the same relationship twice.
*/
private WeakMap $joinRelationshipCache;
/**
* Join method map.
*/
public static $joinMethodsMap = [
'join' => 'powerJoin',
'leftJoin' => 'leftPowerJoin',
'rightJoin' => 'rightPowerJoin',
];
/**
* Ensure that any query model can only belong to
* maximum one query, e.g. because of cloning.
*/
public static function ensureModelIsUniqueToQuery($query): void
{
$originalModel = $query->getModel();
$querySplObjectId = spl_object_id($query);
if (
isset(static::$modelQueryDictionary[$originalModel])
&& static::$modelQueryDictionary[$originalModel] !== $querySplObjectId
) {
// If the model is already associated with another query, we need to clone the model.
// This can happen if a certain query, *before having interacted with the library
// `joinRelationship()` method*, was cloned by previous code.
// Preserve the from clause (including any alias) before setModel overwrites it
$originalFrom = $query->getQuery()->from;
$query->setModel($model = new ($query->getModel()));
$model->mergeCasts($originalModel->getCasts());
if ($originalFrom) {
$query->getQuery()->from = $originalFrom;
}
// Link the Spl Object ID of the query to the new model...
static::$modelQueryDictionary[$model] = $querySplObjectId;
// If there is a `JoinsHelper` with a cache associated with the old model,
// we will copy the cache over to the new fresh model clone added to it.
$originalJoinsHelper = JoinsHelper::make($originalModel);
$joinsHelper = JoinsHelper::make($model);
foreach ($originalJoinsHelper->joinRelationshipCache[$originalModel] ?? [] as $relation => $value) {
$joinsHelper->markRelationshipAsAlreadyJoined($model, $relation);
}
} else {
static::$modelQueryDictionary[$originalModel] = $querySplObjectId;
}
$query->onClone(static function (Builder $query) {
$originalModel = $query->getModel();
$originalJoinsHelper = JoinsHelper::make($originalModel);
// Preserve the from clause (including any alias) before setModel overwrites it
$originalFrom = $query->getQuery()->from;
// Ensure the model of the cloned query is unique to the query.
$query->setModel($model = new $originalModel());
$model->mergeCasts($originalModel->getCasts());
// Restore the original from clause if it was set
if ($originalFrom) {
$query->getQuery()->from = $originalFrom;
}
// Update any `beforeQueryCallbacks` to link to the new `$this` as Eloquent Query,
// otherwise the reference to the current Eloquent query goes wrong. These query
// callbacks are stored on the `QueryBuilder` instance and therefore do not get
// an instance of Eloquent Builder passed, but an instance of `QueryBuilder`.
foreach ($query->getQuery()->beforeQueryCallbacks as $key => $beforeQueryCallback) {
/** @var Closure $beforeQueryCallback */
if (isset(static::$beforeQueryCallbacks[$beforeQueryCallback])) {
static::$beforeQueryCallbacks[$query->getQuery()->beforeQueryCallbacks[$key] = $beforeQueryCallback->bindTo($query)] = true;
}
}
$joinsHelper = JoinsHelper::make($model);
foreach ($originalJoinsHelper->joinRelationshipCache[$originalModel] ?? [] as $relation => $value) {
$joinsHelper->markRelationshipAsAlreadyJoined($model, $relation);
}
});
}
public static function clearCacheBeforeQuery($query): void
{
$beforeQueryCallback = function () {
/* @var Builder $this */
JoinsHelper::make($this->getModel())->clear();
};
$query->getQuery()->beforeQuery(
$beforeQueryCallback = $beforeQueryCallback->bindTo($query)
);
static::$beforeQueryCallbacks[$beforeQueryCallback] = true;
}
/**
* Format the join callback.
*/
public function formatJoinCallback($callback)
{
if (is_string($callback)) {
return function ($join) use ($callback) {
$join->as($callback);
};
}
return $callback;
}
public function generateAliasForRelationship(Relation $relation, string $relationName): array|string
{
if ($relation instanceof BelongsToMany || $relation instanceof HasManyThrough) {
return [
md5($relationName.'table1'.uniqid('', true)),
md5($relationName.'table2'.uniqid('', true)),
];
}
return md5($relationName.uniqid('', true));
}
/**
* Get the join alias name from all the different options.
*/
public function getAliasName(bool $useAlias, Relation $relation, string $relationName, string $tableName, $callback): string|array|null
{
if ($callback) {
if (is_callable($callback)) {
$fakeJoinCallback = new FakeJoinCallback($relation->getBaseQuery(), 'inner', $tableName);
$callback($fakeJoinCallback);
if ($fakeJoinCallback->getAlias()) {
return $fakeJoinCallback->getAlias();
}
}
if (is_array($callback) && $relation instanceof HasOneOrManyThrough) {
$alias = [null, null];
$throughParentTable = $relation->getThroughParent()->getTable();
if (isset($callback[$throughParentTable])) {
$fakeJoinCallback = new FakeJoinCallback($relation->getBaseQuery(), 'inner', $throughParentTable);
$callback[$throughParentTable]($fakeJoinCallback);
if ($fakeJoinCallback->getAlias()) {
$alias[0] = $fakeJoinCallback->getAlias();
}
}
$relatedTable = $relation->getRelated()->getTable();
if (isset($callback[$relatedTable])) {
$fakeJoinCallback = new FakeJoinCallback($relation->getBaseQuery(), 'inner', $relatedTable);
$callback[$relatedTable]($fakeJoinCallback);
if ($fakeJoinCallback->getAlias()) {
$alias[1] = $fakeJoinCallback->getAlias();
}
}
return $alias;
}
if (is_array($callback) && isset($callback[$tableName])) {
$fakeJoinCallback = new FakeJoinCallback($relation->getBaseQuery(), 'inner', $tableName);
$callback[$tableName]($fakeJoinCallback);
if ($fakeJoinCallback->getAlias()) {
return $fakeJoinCallback->getAlias();
}
}
}
return $useAlias
? $this->generateAliasForRelationship($relation, $relationName)
: null;
}
/**
* Checks if the relationship was already joined.
*/
public function relationshipAlreadyJoined($model, string $relation): bool
{
return isset($this->joinRelationshipCache[$model][$relation]);
}
/**
* Marks the relationship as already joined.
*/
public function markRelationshipAsAlreadyJoined($model, string $relation): void
{
$this->joinRelationshipCache[$model] ??= [];
$this->joinRelationshipCache[$model][$relation] = true;
}
public function clear(): void
{
$this->joinRelationshipCache = new WeakMap();
}
}
@@ -0,0 +1,601 @@
<?php
namespace Kirschbaum\PowerJoins\Mixins;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Query\Expression;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Kirschbaum\PowerJoins\JoinsHelper;
use Kirschbaum\PowerJoins\PowerJoinClause;
use Kirschbaum\PowerJoins\StaticCache;
/**
* @mixin Builder
*
* @method \Illuminate\Database\Eloquent\Model getModel()
*
* @property Builder $query
*/
class JoinRelationship
{
/**
* New clause for making joins, where we pass the model to the joiner class.
*/
public function powerJoin(): Closure
{
return function ($table, $first, $operator = null, $second = null, $type = 'inner', $where = false): static {
$model = $operator instanceof Model ? $operator : null;
$join = $this->newPowerJoinClause($this->query, $type, $table, $model);
// If the first "column" of the join is really a Closure instance the developer
// is trying to build a join with a complex "on" clause containing more than
// one condition, so we'll add the join and call a Closure with the query.
if ($first instanceof Closure) {
$first($join);
$this->query->joins[] = $join;
$this->query->addBinding($join->getBindings(), 'join');
}
// If the column is simply a string, we can assume the join simply has a basic
// "on" clause with a single condition. So we will just build the join with
// this simple join clauses attached to it. There is not a join callback.
else {
$method = $where ? 'where' : 'on';
$this->query->joins[] = $join->$method($first, $operator, $second);
$this->query->addBinding($join->getBindings(), 'join');
}
return $this;
};
}
/**
* New clause for making joins, where we pass the model to the joiner class.
*/
public function leftPowerJoin(): Closure
{
return function ($table, $first, $operator = null, $second = null) {
return $this->powerJoin($table, $first, $operator, $second, 'left');
};
}
/**
* New clause for making joins, where we pass the model to the joiner class.
*/
public function rightPowerJoin(): Closure
{
return function ($table, $first, $operator = null, $second = null) {
return $this->powerJoin($table, $first, $operator, $second, 'right');
};
}
public function newPowerJoinClause(): Closure
{
return function (QueryBuilder $parentQuery, string $type, string $table, ?Model $model = null) {
return new PowerJoinClause($parentQuery, $type, $table, $model);
};
}
/**
* Join the relationship(s).
*/
public function joinRelationship(): Closure
{
return function (
string $relationName,
Closure|array|string|null $callback = null,
string $joinType = 'join',
bool $useAlias = false,
bool $disableExtraConditions = false,
?string $morphable = null,
) {
$joinType = JoinsHelper::$joinMethodsMap[$joinType] ?? $joinType;
$useAlias = is_string($callback) ? false : $useAlias;
$joinHelper = JoinsHelper::make($this->getModel());
$callback = $joinHelper->formatJoinCallback($callback);
JoinsHelper::ensureModelIsUniqueToQuery($this);
JoinsHelper::clearCacheBeforeQuery($this);
// 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.
$fromClause = $this->getQuery()->from;
$mainTableOrAlias = $this->getModel()->getTable();
if ($fromClause && preg_match('/^.+\s+as\s+["\'\`]?(.+?)["\'\`]?$/i', $fromClause, $matches)) {
// Register the alias for the main model so joins use it
$mainTableOrAlias = $matches[1];
StaticCache::setTableAliasForModel($this->getModel(), $mainTableOrAlias);
}
if (is_null($this->getSelect())) {
$this->select(sprintf('%s.*', $mainTableOrAlias));
}
if (Str::contains($relationName, '.')) {
$this->joinNestedRelationship($relationName, $callback, $joinType, $useAlias, $disableExtraConditions, $morphable);
return $this;
}
$relationCallback = $callback;
if ($callback && is_array($callback) && isset($callback[$relationName]) && is_array($callback[$relationName])) {
$relationCallback = $callback[$relationName];
}
$relation = $this->getModel()->{$relationName}();
$relationQuery = $relation->getQuery();
$alias = $joinHelper->getAliasName(
$useAlias,
$relation,
$relationName,
$relationQuery->getModel()->getTable(),
$relationCallback
);
if ($relation instanceof BelongsToMany && !is_array($alias)) {
$extraAlias = $joinHelper->getAliasName(
$useAlias,
$relation,
$relationName,
$relation->getTable(),
$relationCallback
);
$alias = [$extraAlias, $alias];
}
$aliasString = is_array($alias) ? implode('.', $alias) : $alias;
$useAlias = $alias ? true : $useAlias;
$relationJoinCache = $alias
? "{$aliasString}.{$relationQuery->getModel()->getTable()}.{$relationName}"
: "{$relationQuery->getModel()->getTable()}.{$relationName}";
if ($joinHelper->relationshipAlreadyJoined($this->getModel(), $relationJoinCache)) {
return $this;
}
if ($useAlias) {
StaticCache::setTableAliasForModel($relation->getModel(), $alias);
}
$joinHelper->markRelationshipAsAlreadyJoined($this->getModel(), $relationJoinCache);
$relation->performJoinForEloquentPowerJoins(
builder: $this,
joinType: $joinType,
callback: $relationCallback,
alias: $alias,
disableExtraConditions: $disableExtraConditions,
morphable: $morphable,
);
// Clear only the related model's alias from cache after join is performed
if ($useAlias) {
unset(StaticCache::$powerJoinAliasesCache[spl_object_id($relation->getModel())]);
}
return $this;
};
}
/**
* Join the relationship(s) using table aliases.
*/
public function joinRelationshipUsingAlias(): Closure
{
return function (string $relationName, Closure|array|string|null $callback = null, bool $disableExtraConditions = false, ?string $morphable = null) {
return $this->joinRelationship($relationName, $callback, 'join', true, $disableExtraConditions, morphable: $morphable);
};
}
/**
* Left join the relationship(s) using table aliases.
*/
public function leftJoinRelationshipUsingAlias(): Closure
{
return function (string $relationName, Closure|array|string|null $callback = null, bool $disableExtraConditions = false, ?string $morphable = null) {
return $this->joinRelationship($relationName, $callback, 'leftJoin', true, $disableExtraConditions, morphable: $morphable);
};
}
/**
* Right join the relationship(s) using table aliases.
*/
public function rightJoinRelationshipUsingAlias(): Closure
{
return function (string $relationName, Closure|array|string|null $callback = null, bool $disableExtraConditions = false, ?string $morphable = null) {
return $this->joinRelationship($relationName, $callback, 'rightJoin', true, $disableExtraConditions, morphable: $morphable);
};
}
public function joinRelation(): Closure
{
return function (
string $relationName,
Closure|array|string|null $callback = null,
string $joinType = 'join',
bool $useAlias = false,
bool $disableExtraConditions = false,
?string $morphable = null,
) {
return $this->joinRelationship($relationName, $callback, $joinType, $useAlias, $disableExtraConditions, morphable: $morphable);
};
}
public function leftJoinRelationship(): Closure
{
return function (string $relationName, Closure|array|string|null $callback = null, bool $useAlias = false, bool $disableExtraConditions = false, ?string $morphable = null) {
return $this->joinRelationship($relationName, $callback, 'leftJoin', $useAlias, $disableExtraConditions, morphable: $morphable);
};
}
public function leftJoinRelation(): Closure
{
return function (string $relation, Closure|array|string|null $callback = null, bool $useAlias = false, bool $disableExtraConditions = false, ?string $morphable = null) {
return $this->joinRelationship($relation, $callback, 'leftJoin', $useAlias, $disableExtraConditions, morphable: $morphable);
};
}
public function rightJoinRelationship(): Closure
{
return function (string $relation, Closure|array|string|null $callback = null, bool $useAlias = false, bool $disableExtraConditions = false, ?string $morphable = null) {
return $this->joinRelationship($relation, $callback, 'rightJoin', $useAlias, $disableExtraConditions, morphable: $morphable);
};
}
public function rightJoinRelation(): Closure
{
return function (string $relation, Closure|array|string|null $callback = null, bool $useAlias = false, bool $disableExtraConditions = false, ?string $morphable = null) {
return $this->joinRelationship($relation, $callback, 'rightJoin', $useAlias, $disableExtraConditions, morphable: $morphable);
};
}
/**
* Join nested relationships.
*/
public function joinNestedRelationship(): Closure
{
return function (
string $relationships,
Closure|array|string|null $callback = null,
string $joinType = 'join',
bool $useAlias = false,
bool $disableExtraConditions = false,
?string $morphable = null,
) {
$relations = explode('.', $relationships);
$joinHelper = JoinsHelper::make($this->getModel());
/** @var Relation */
$latestRelation = null;
$part = [];
foreach ($relations as $relationName) {
$part[] = $relationName;
$fullRelationName = join('.', $part);
$currentModel = $latestRelation ? $latestRelation->getModel() : $this->getModel();
$relation = $currentModel->{$relationName}();
$relationCallback = null;
if ($callback && is_array($callback) && isset($callback[$relationName])) {
$relationCallback = $callback[$relationName];
}
if ($callback && is_array($callback) && isset($callback[$fullRelationName])) {
$relationCallback = $callback[$fullRelationName];
}
$alias = $joinHelper->getAliasName(
$useAlias,
$relation,
$relationName,
$relation->getQuery()->getModel()->getTable(),
$relationCallback
);
if ($alias && $relation instanceof BelongsToMany && !is_array($alias)) {
$extraAlias = $joinHelper->getAliasName(
$useAlias,
$relation,
$relationName,
$relation->getTable(),
$relationCallback
);
$alias = [$extraAlias, $alias];
}
$aliasString = is_array($alias) ? implode('.', $alias) : $alias;
$useAlias = $alias ? true : $useAlias;
if ($alias) {
$relationJoinCache = $latestRelation
? "{$aliasString}.{$relation->getQuery()->getModel()->getTable()}.{$latestRelation->getModel()->getTable()}.{$relationName}"
: "{$aliasString}.{$relation->getQuery()->getModel()->getTable()}.{$relationName}";
} else {
$relationJoinCache = $latestRelation
? "{$relation->getQuery()->getModel()->getTable()}.{$latestRelation->getModel()->getTable()}.{$relationName}"
: "{$relation->getQuery()->getModel()->getTable()}.{$relationName}";
}
if ($useAlias) {
StaticCache::setTableAliasForModel($relation->getModel(), $alias);
}
if ($joinHelper->relationshipAlreadyJoined($this->getModel(), $relationJoinCache)) {
$latestRelation = $relation;
continue;
}
$relation->performJoinForEloquentPowerJoins(
$this,
$joinType,
$relationCallback,
$alias,
$disableExtraConditions,
$morphable
);
$latestRelation = $relation;
$joinHelper->markRelationshipAsAlreadyJoined($this->getModel(), $relationJoinCache);
}
StaticCache::clear();
return $this;
};
}
/**
* Order by a field in the defined relationship.
*/
public function orderByPowerJoins(): Closure
{
return function (string|array $sort, string $direction = 'asc', ?string $aggregation = null, string $joinType = 'join', $aliases = null) {
if (is_array($sort)) {
$relationships = explode('.', $sort[0]);
$column = $sort[1];
$latestRelationshipName = $relationships[count($relationships) - 1];
} else {
$relationships = explode('.', $sort);
$column = array_pop($relationships);
$latestRelationshipName = $relationships[count($relationships) - 1];
}
$this->joinRelationship(relationName: implode('.', $relationships), callback: $aliases, joinType: $joinType);
$latestRelationshipModel = array_reduce($relationships, function ($model, $relationshipName) {
return $model->$relationshipName()->getModel();
}, $this->getModel());
$table = $latestRelationshipModel->getTable();
if ($aliases) {
if (is_string($aliases)) {
$table = $aliases;
}
if (is_array($aliases) && array_key_exists($latestRelationshipName, $aliases)) {
$alias = $aliases[$latestRelationshipName];
if (is_callable($alias)) {
$join = collect($this->query->joins)
->whereInstanceOf(PowerJoinClause::class)
->firstWhere('tableName', $table);
$table = $join->alias;
}
}
}
if ($aggregation) {
$aliasName = sprintf(
'%s_%s_%s',
$table,
$column,
$aggregation
);
$this->selectRaw(
sprintf(
'%s(%s.%s) as %s',
$aggregation,
$table,
$column,
$aliasName
)
)
->groupBy(sprintf('%s.%s', $this->getModel()->getTable(), $this->getModel()->getKeyName()))
->orderBy(DB::raw(sprintf('%s', $aliasName)), $direction);
} else {
if ($column instanceof Expression) {
$this->orderBy($column, $direction);
} else {
$this->orderBy(
sprintf('%s.%s', $table, $column),
$direction
);
}
}
return $this;
};
}
public function orderByLeftPowerJoins(): Closure
{
return function (string|array $sort, string $direction = 'asc') {
return $this->orderByPowerJoins(sort: $sort, direction: $direction, joinType: 'leftJoin');
};
}
/**
* Order by the COUNT aggregation using joins.
*/
public function orderByPowerJoinsCount(): Closure
{
return function (string|array $sort, string $direction = 'asc') {
return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'COUNT');
};
}
public function orderByLeftPowerJoinsCount(): Closure
{
return function (string|array $sort, string $direction = 'asc') {
return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'COUNT', joinType: 'leftJoin');
};
}
/**
* Order by the SUM aggregation using joins.
*/
public function orderByPowerJoinsSum(): Closure
{
return function (string|array $sort, string $direction = 'asc') {
return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'SUM');
};
}
public function orderByLeftPowerJoinsSum(): Closure
{
return function (string|array $sort, string $direction = 'asc') {
return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'SUM', joinType: 'leftJoin');
};
}
/**
* Order by the AVG aggregation using joins.
*/
public function orderByPowerJoinsAvg(): Closure
{
return function (string|array $sort, string $direction = 'asc') {
return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'AVG');
};
}
public function orderByLeftPowerJoinsAvg(): Closure
{
return function (string|array $sort, string $direction = 'asc') {
return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'AVG', joinType: 'leftJoin');
};
}
/**
* Order by the MIN aggregation using joins.
*/
public function orderByPowerJoinsMin(): Closure
{
return function (string|array $sort, string $direction = 'asc') {
return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'MIN');
};
}
public function orderByLeftPowerJoinsMin(): Closure
{
return function (string|array $sort, string $direction = 'asc') {
return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'MIN', joinType: 'leftJoin');
};
}
/**
* Order by the MAX aggregation using joins.
*/
public function orderByPowerJoinsMax(): Closure
{
return function (string|array $sort, string $direction = 'asc') {
return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'MAX');
};
}
public function orderByLeftPowerJoinsMax(): Closure
{
return function (string|array $sort, string $direction = 'asc') {
return $this->orderByPowerJoins(sort: $sort, direction: $direction, aggregation: 'MAX', joinType: 'leftJoin');
};
}
/**
* Same as Laravel 'has`, but using joins instead of where exists.
*/
public function powerJoinHas(): Closure
{
return function (string $relation, string $operator = '>=', int $count = 1, $boolean = 'and', Closure|array|string|null $callback = null, ?string $morphable = null): static {
if (is_null($this->getSelect())) {
$this->select(sprintf('%s.*', $this->getModel()->getTable()));
}
if (is_null($this->getGroupBy())) {
$this->groupBy($this->getModel()->getQualifiedKeyName());
}
if (is_string($relation)) {
if (Str::contains($relation, '.')) {
$this->hasNestedUsingJoins($relation, $operator, $count, 'and', $callback);
return $this;
}
$relation = $this->getRelationWithoutConstraintsProxy($relation);
}
$relation->performJoinForEloquentPowerJoins($this, 'leftPowerJoin', $callback, morphable: $morphable, hasCheck: true);
$relation->performHavingForEloquentPowerJoins($this, $operator, $count, morphable: $morphable);
return $this;
};
}
public function hasNestedUsingJoins(): Closure
{
return function (string $relations, string $operator = '>=', int $count = 1, string $boolean = 'and', Closure|array|string|null $callback = null): static {
$relations = explode('.', $relations);
/** @var Relation */
$latestRelation = null;
foreach ($relations as $index => $relation) {
$relationName = $relation;
if (!$latestRelation) {
$relation = $this->getRelationWithoutConstraintsProxy($relation);
} else {
$relation = $latestRelation->getModel()->query()->getRelationWithoutConstraintsProxy($relation);
}
$relation->performJoinForEloquentPowerJoins($this, 'leftPowerJoin', is_callable($callback) ? $callback : $callback[$relationName] ?? null);
if (count($relations) === ($index + 1)) {
$relation->performHavingForEloquentPowerJoins($this, $operator, $count);
}
$latestRelation = $relation;
}
return $this;
};
}
public function powerJoinDoesntHave(): Closure
{
return function ($relation, $boolean = 'and', ?Closure $callback = null) {
return $this->powerJoinHas($relation, '<', 1, $boolean, $callback);
};
}
public function powerJoinWhereHas(): Closure
{
return function ($relation, $callback = null, $operator = '>=', $count = 1) {
return $this->powerJoinHas($relation, $operator, $count, 'and', $callback);
};
}
}
@@ -0,0 +1,20 @@
<?php
namespace Kirschbaum\PowerJoins\Mixins;
class QueryBuilderExtraMethods
{
public function getGroupBy()
{
return function () {
return $this->groups;
};
}
public function getSelect()
{
return function () {
return $this->columns;
};
}
}
@@ -0,0 +1,38 @@
<?php
namespace Kirschbaum\PowerJoins\Mixins;
use Illuminate\Database\Eloquent\Relations\Relation;
class QueryRelationshipExistence
{
public function getGroupBy()
{
return function () {
return $this->getQuery()->getGroupBy();
};
}
public function getScopes()
{
return function () {
return $this->scopes;
};
}
public function getSelect()
{
return function () {
return $this->getQuery()->getSelect();
};
}
protected function getRelationWithoutConstraintsProxy()
{
return function ($relation) {
return Relation::noConstraints(function () use ($relation) {
return $this->getModel()->{$relation}();
});
};
}
}
@@ -0,0 +1,587 @@
<?php
namespace Kirschbaum\PowerJoins\Mixins;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
use Illuminate\Database\Eloquent\Relations\MorphOneOrMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Database\MySqlConnection;
use Illuminate\Support\Str;
use Kirschbaum\PowerJoins\PowerJoinClause;
use Kirschbaum\PowerJoins\StaticCache;
/**
* @method \Illuminate\Database\Eloquent\Model getModel()
* @method string getTable()
* @method string getForeignPivotKeyName()
* @method string getRelatedPivotKeyName()
* @method bool isOneOfMany()
* @method \Illuminate\Database\Eloquent\Builder|void getOneOfManySubQuery()
* @method \Illuminate\Database\Eloquent\Builder getQuery()
* @method \Illuminate\Database\Eloquent\Model getThroughParent()
* @method string getForeignKeyName()
* @method string getMorphType()
* @method string getMorphClass()
* @method string getFirstKeyName()
* @method string getQualifiedLocalKeyName()
* @method string getExistenceCompareKey()
*
* @mixin \Illuminate\Database\Eloquent\Relations\Relation
* @mixin \Illuminate\Database\Eloquent\Relations\HasOneOrMany
* @mixin \Illuminate\Database\Eloquent\Relations\BelongsToMany
*
* @property \Illuminate\Database\Eloquent\Builder $query
* @property Model $parent
* @property Model $throughParent
* @property string $foreignKey
* @property string $parentKey
* @property string $ownerKey
* @property string $localKey
* @property string $secondKey
* @property string $secondLocalKey
* @property Model $farParent
*/
class RelationshipsExtraMethods
{
/**
* Perform the JOIN clause for eloquent power joins.
*/
public function performJoinForEloquentPowerJoins()
{
return function ($builder, $joinType = 'leftJoin', $callback = null, $alias = null, bool $disableExtraConditions = false, ?string $morphable = null, bool $hasCheck = false) {
return match (true) {
$this instanceof MorphToMany => $this->performJoinForEloquentPowerJoinsForMorphToMany($builder, $joinType, $callback, $alias, $disableExtraConditions),
$this instanceof BelongsToMany => $this->performJoinForEloquentPowerJoinsForBelongsToMany($builder, $joinType, $callback, $alias, $disableExtraConditions),
$this instanceof MorphOneOrMany => $this->performJoinForEloquentPowerJoinsForMorph($builder, $joinType, $callback, $alias, $disableExtraConditions),
$this instanceof HasMany || $this instanceof HasOne => $this->performJoinForEloquentPowerJoinsForHasMany($builder, $joinType, $callback, $alias, $disableExtraConditions, $hasCheck),
$this instanceof HasManyThrough || $this instanceof HasOneThrough => $this->performJoinForEloquentPowerJoinsForHasManyThrough($builder, $joinType, $callback, $alias, $disableExtraConditions),
$this instanceof MorphTo => $this->performJoinForEloquentPowerJoinsForMorphTo($builder, $joinType, $callback, $alias, $disableExtraConditions, $morphable),
default => $this->performJoinForEloquentPowerJoinsForBelongsTo($builder, $joinType, $callback, $alias, $disableExtraConditions),
};
};
}
/**
* Perform the JOIN clause for the BelongsTo (or similar) relationships.
*/
protected function performJoinForEloquentPowerJoinsForBelongsTo()
{
return function ($query, $joinType, $callback = null, $alias = null, bool $disableExtraConditions = false) {
$joinedTable = $this->query->getModel()->getTable();
$parentTable = StaticCache::getTableOrAliasForModel($this->parent);
$query->{$joinType}($joinedTable, function ($join) use ($callback, $joinedTable, $parentTable, $alias, $disableExtraConditions) {
if ($alias) {
$join->as($alias);
}
$join->on(
"{$parentTable}.{$this->foreignKey}",
'=',
"{$joinedTable}.{$this->ownerKey}"
);
if ($disableExtraConditions === false && $this->usesSoftDeletes($this->query->getScopes())) {
$join->whereNull("{$joinedTable}.{$this->query->getModel()->getDeletedAtColumn()}");
}
if ($disableExtraConditions === false) {
$this->applyExtraConditions($join);
}
if ($callback && is_callable($callback)) {
$callback($join);
}
}, $this->query->getModel());
};
}
/**
* Perform the JOIN clause for the BelongsToMany (or similar) relationships.
*/
protected function performJoinForEloquentPowerJoinsForBelongsToMany()
{
return function ($builder, $joinType, $callback = null, $alias = null, bool $disableExtraConditions = false) {
[$alias1, $alias2] = $alias;
$joinedTable = $alias1 ?: $this->getTable();
$parentTable = StaticCache::getTableOrAliasForModel($this->parent);
$builder->{$joinType}($this->getTable(), function ($join) use ($callback, $joinedTable, $parentTable, $alias1) {
if ($alias1) {
$join->as($alias1);
}
$join->on(
"{$joinedTable}.{$this->getForeignPivotKeyName()}",
'=',
"{$parentTable}.{$this->parentKey}"
);
if (is_array($callback) && isset($callback[$this->getTable()])) {
$callback[$this->getTable()]($join);
}
});
$builder->{$joinType}($this->getModel()->getTable(), function ($join) use ($callback, $joinedTable, $alias2, $disableExtraConditions) {
if ($alias2) {
$join->as($alias2);
}
$join->on(
"{$this->getModel()->getTable()}.{$this->getRelatedKeyName()}",
'=',
"{$joinedTable}.{$this->getRelatedPivotKeyName()}"
);
if ($disableExtraConditions === false && $this->usesSoftDeletes($this->query->getScopes())) {
$join->whereNull($this->query->getModel()->getQualifiedDeletedAtColumn());
}
// applying any extra conditions to the belongs to many relationship
if ($disableExtraConditions === false) {
$this->applyExtraConditions($join);
}
if (is_array($callback) && isset($callback[$this->getModel()->getTable()])) {
$callback[$this->getModel()->getTable()]($join);
}
}, $this->getModel());
return $this;
};
}
/**
* Perform the JOIN clause for the MorphToMany (or similar) relationships.
*/
protected function performJoinForEloquentPowerJoinsForMorphToMany()
{
return function ($builder, $joinType, $callback = null, $alias = null, bool $disableExtraConditions = false) {
[$alias1, $alias2] = $alias;
$joinedTable = $alias1 ?: $this->getTable();
$parentTable = StaticCache::getTableOrAliasForModel($this->parent);
$builder->{$joinType}($this->getTable(), function ($join) use ($callback, $joinedTable, $parentTable, $alias1, $disableExtraConditions) {
if ($alias1) {
$join->as($alias1);
}
$join->on(
"{$joinedTable}.{$this->getForeignPivotKeyName()}",
'=',
"{$parentTable}.{$this->parentKey}"
);
// applying any extra conditions to the belongs to many relationship
if ($disableExtraConditions === false) {
$this->applyExtraConditions($join);
}
if (is_array($callback) && isset($callback[$this->getTable()])) {
$callback[$this->getTable()]($join);
}
});
$builder->{$joinType}($this->getModel()->getTable(), function ($join) use ($callback, $joinedTable, $alias2, $disableExtraConditions) {
if ($alias2) {
$join->as($alias2);
}
$join->on(
"{$this->getModel()->getTable()}.{$this->getModel()->getKeyName()}",
'=',
"{$joinedTable}.{$this->getRelatedPivotKeyName()}"
);
if ($disableExtraConditions === false && $this->usesSoftDeletes($this->query->getScopes())) {
$join->whereNull($this->query->getModel()->getQualifiedDeletedAtColumn());
}
if (is_array($callback) && isset($callback[$this->getModel()->getTable()])) {
$callback[$this->getModel()->getTable()]($join);
}
}, $this->getModel());
return $this;
};
}
/**
* Perform the JOIN clause for the Morph (or similar) relationships.
*/
protected function performJoinForEloquentPowerJoinsForMorph()
{
return function ($builder, $joinType, $callback = null, $alias = null, bool $disableExtraConditions = false) {
$builder->{$joinType}($this->getModel()->getTable(), function ($join) use ($callback, $disableExtraConditions, $alias) {
if ($alias) {
$join->as($alias);
}
$join->on(
"{$this->getModel()->getTable()}.{$this->getForeignKeyName()}",
'=',
"{$this->parent->getTable()}.{$this->localKey}"
)->where("{$this->getModel()->getTable()}.{$this->getMorphType()}", '=', $this->getMorphClass());
if ($disableExtraConditions === false && $this->usesSoftDeletes($this->query->getScopes())) {
$join->whereNull($this->query->getModel()->getQualifiedDeletedAtColumn());
}
if ($disableExtraConditions === false) {
$this->applyExtraConditions($join);
}
if ($callback && is_callable($callback)) {
$callback($join);
}
}, $this->getModel());
return $this;
};
}
/**
* Perform the JOIN clause for when calling the morphTo method from the morphable class.
*/
protected function performJoinForEloquentPowerJoinsForMorphTo()
{
return function ($builder, $joinType, $callback = null, $alias = null, bool $disableExtraConditions = false, ?string $morphable = null) {
/** @var Model */
$modelInstance = new $morphable();
$builder->{$joinType}($modelInstance->getTable(), function ($join) use ($modelInstance, $callback, $disableExtraConditions) {
$join->on(
"{$this->getModel()->getTable()}.{$this->getForeignKeyName()}",
'=',
"{$modelInstance->getTable()}.{$modelInstance->getKeyName()}"
)->where("{$this->getModel()->getTable()}.{$this->getMorphType()}", '=', $modelInstance->getMorphClass());
if ($disableExtraConditions === false && $this->usesSoftDeletes($modelInstance->getScopes())) {
$join->whereNull($modelInstance->getQualifiedDeletedAtColumn());
}
if ($disableExtraConditions === false) {
$this->applyExtraConditions($join);
}
if ($callback && is_callable($callback)) {
$callback($join);
}
}, $modelInstance);
return $this;
};
}
/**
* Perform the JOIN clause for the HasMany (or similar) relationships.
*/
protected function performJoinForEloquentPowerJoinsForHasMany()
{
return function ($builder, $joinType, $callback = null, $alias = null, bool $disableExtraConditions = false, bool $hasCheck = false) {
$joinedModel = $this->query->getModel();
$joinedTable = $alias ?: $joinedModel->getTable();
$parentTable = StaticCache::getTableOrAliasForModel($this->parent);
$isOneOfMany = method_exists($this, 'isOneOfMany') ? $this->isOneOfMany() : false;
if ($isOneOfMany && !$hasCheck) {
$column = $this->getOneOfManySubQuery()->getQuery()->columns[0];
$fkColumn = $this->getOneOfManySubQuery()->getQuery()->columns[1];
$localKey = $this->localKey;
$builder->where(function ($query) use ($column, $joinType, $joinedModel, $builder, $fkColumn, $parentTable, $localKey) {
$query->whereIn($joinedModel->getQualifiedKeyName(), function ($query) use ($column, $joinedModel, $builder, $fkColumn, $parentTable, $localKey) {
$columnValue = $column->getValue($builder->getGrammar());
$direction = Str::contains($columnValue, 'min(') ? 'asc' : 'desc';
$columnName = Str::of($columnValue)->after('(')->before(')')->__toString();
$columnName = Str::replace(['"', "'", '`'], '', $columnName);
if ($builder->getConnection() instanceof MySqlConnection) {
$query->select('*')->from(function ($query) use ($joinedModel, $columnName, $fkColumn, $direction, $parentTable, $localKey) {
$query
->select($joinedModel->getQualifiedKeyName())
->from($joinedModel->getTable())
->whereColumn($fkColumn, "{$parentTable}.{$localKey}")
->orderBy($columnName, $direction)
->take(1);
});
} else {
$query
->select($joinedModel->getQualifiedKeyName())
->distinct($columnName)
->from($joinedModel->getTable())
->whereColumn($fkColumn, "{$parentTable}.{$localKey}")
->orderBy($columnName, $direction)
->take(1);
}
});
if ($joinType === 'leftPowerJoin') {
$query->orWhereRaw('1 = 1');
}
});
}
$builder->{$joinType}($this->query->getModel()->getTable(), function ($join) use ($callback, $joinedTable, $parentTable, $alias, $disableExtraConditions) {
if ($alias) {
$join->as($alias);
}
$join->on(
$this->foreignKey,
'=',
"{$parentTable}.{$this->localKey}"
);
if ($disableExtraConditions === false && $this->usesSoftDeletes($this->query->getScopes())) {
$join->whereNull(
"{$joinedTable}.{$this->query->getModel()->getDeletedAtColumn()}"
);
}
if ($disableExtraConditions === false) {
$this->applyExtraConditions($join);
}
if ($callback && is_callable($callback)) {
$callback($join);
}
}, $this->query->getModel());
};
}
/**
* Perform the JOIN clause for the HasManyThrough relationships.
*/
protected function performJoinForEloquentPowerJoinsForHasManyThrough()
{
return function ($builder, $joinType, $callback = null, $alias = null, bool $disableExtraConditions = false) {
[$alias1, $alias2] = $alias;
$throughTable = $alias1 ?: $this->getThroughParent()->getTable();
$farTable = $alias2 ?: $this->getModel()->getTable();
$builder->{$joinType}($this->getThroughParent()->getTable(), function (PowerJoinClause $join) use ($callback, $throughTable, $alias1, $disableExtraConditions) {
if ($alias1) {
$join->as($alias1);
}
$farParentTable = StaticCache::getTableOrAliasForModel($this->getFarParent());
$join->on(
"{$throughTable}.{$this->getFirstKeyName()}",
'=',
"{$farParentTable}.{$this->localKey}"
);
if ($disableExtraConditions === false && $this->usesSoftDeletes($this->getThroughParent())) {
$join->whereNull($this->getThroughParent()->getQualifiedDeletedAtColumn());
}
if ($disableExtraConditions === false) {
$this->applyExtraConditions($join);
}
if (is_array($callback) && isset($callback[$this->getThroughParent()->getTable()])) {
$callback[$this->getThroughParent()->getTable()]($join);
}
if ($callback && is_callable($callback)) {
$callback($join);
}
}, $this->getThroughParent());
$builder->{$joinType}($this->getModel()->getTable(), function (PowerJoinClause $join) use ($callback, $throughTable, $farTable, $alias2) {
if ($alias2) {
$join->as($alias2);
}
$join->on(
"{$farTable}.{$this->secondKey}",
'=',
"{$throughTable}.{$this->secondLocalKey}"
);
if ($this->usesSoftDeletes($this->getScopes())) {
$join->whereNull("{$farTable}.{$this->getModel()->getDeletedAtColumn()}");
}
if (is_array($callback) && isset($callback[$this->getModel()->getTable()])) {
$callback[$this->getModel()->getTable()]($join);
}
}, $this->getModel());
return $this;
};
}
/**
* Perform the "HAVING" clause for eloquent power joins.
*/
public function performHavingForEloquentPowerJoins()
{
return function ($builder, $operator, $count, ?string $morphable = null) {
if ($morphable) {
$modelInstance = new $morphable();
$builder
->selectRaw(sprintf('count(%s) as %s_count', $modelInstance->getQualifiedKeyName(), Str::replace('.', '_', $modelInstance->getTable())))
->havingRaw(sprintf('count(%s) %s %d', $modelInstance->getQualifiedKeyName(), $operator, $count));
} else {
$builder
->selectRaw(sprintf('count(%s) as %s_count', $this->query->getModel()->getQualifiedKeyName(), Str::replace('.', '_', $this->query->getModel()->getTable())))
->havingRaw(sprintf('count(%s) %s %d', $this->query->getModel()->getQualifiedKeyName(), $operator, $count));
}
};
}
/**
* Checks if the relationship model uses soft deletes.
*/
public function usesSoftDeletes()
{
/*
* @param \Illuminate\Database\Eloquent\Model|array $model
*/
return function ($model) {
if ($model instanceof Model) {
return in_array(SoftDeletes::class, class_uses_recursive($model), true);
}
return array_key_exists(SoftDeletingScope::class, $model);
};
}
/**
* Get the throughParent for the HasManyThrough relationship.
*/
public function getThroughParent()
{
return function () {
return $this->throughParent;
};
}
/**
* Get the farParent for the HasManyThrough relationship.
*/
public function getFarParent()
{
return function () {
return $this->farParent;
};
}
public function applyExtraConditions()
{
return function (PowerJoinClause $join) {
foreach ($this->getQuery()->getQuery()->wheres as $condition) {
if ($this->shouldNotApplyExtraCondition($condition)) {
continue;
}
if (!in_array($condition['type'], ['Basic', 'Null', 'NotNull', 'Nested'], true)) {
continue;
}
$method = "apply{$condition['type']}Condition";
$this->$method($join, $condition);
}
};
}
public function applyBasicCondition()
{
return function ($join, $condition) {
$join->where($condition['column'], $condition['operator'], $condition['value'], $condition['boolean']);
};
}
public function applyNullCondition()
{
return function ($join, $condition) {
$join->whereNull($condition['column'], $condition['boolean']);
};
}
public function applyNotNullCondition()
{
return function ($join, $condition) {
$join->whereNotNull($condition['column'], $condition['boolean']);
};
}
public function applyNestedCondition()
{
return function ($join, $condition) {
$join->where(function ($q) use ($condition) {
foreach ($condition['query']->wheres as $condition) {
$method = "apply{$condition['type']}Condition";
$this->$method($q, $condition);
}
});
};
}
public function shouldNotApplyExtraCondition()
{
return function ($condition) {
if (isset($condition['column']) && ($condition['column'] === '' || Str::endsWith($condition['column'], '.'))) {
return true;
}
if (!$key = $this->getPowerJoinExistenceCompareKey()) {
return true;
}
if (isset($condition['query'])) {
return false;
}
if (is_array($key)) {
return in_array($condition['column'], $key, true);
}
return $condition['column'] === $key;
};
}
public function getPowerJoinExistenceCompareKey()
{
return function () {
if ($this instanceof MorphTo) {
return [$this->getMorphType(), $this->getForeignKeyName()];
}
if ($this instanceof BelongsTo) {
return $this->getQualifiedOwnerKeyName();
}
if ($this instanceof HasMany || $this instanceof HasOne) {
return $this->getExistenceCompareKey();
}
if ($this instanceof HasManyThrough || $this instanceof HasOneThrough) {
return $this->getQualifiedFirstKeyName();
}
if ($this instanceof BelongsToMany) {
return $this->getExistenceCompareKey();
}
if ($this instanceof MorphOneOrMany) {
return [$this->getQualifiedMorphType(), $this->getExistenceCompareKey()];
}
};
}
}
@@ -0,0 +1,340 @@
<?php
namespace Kirschbaum\PowerJoins;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Str;
use InvalidArgumentException;
use ReflectionClass;
class PowerJoinClause extends JoinClause
{
/**
* @var Model
*/
public $model;
/**
* Table name backup in case an alias is being used.
*
* @var string
*/
public $tableName;
/**
* Alias name.
*/
public ?string $alias = null;
/**
* Joined table alias name (mostly for belongs to many aliases).
*/
public ?string $joinedTableAlias = null;
/**
* Create a new join clause instance.
*/
public function __construct(Builder $parentQuery, $type, string $table, ?Model $model = null)
{
parent::__construct($parentQuery, $type, $table);
$this->model = $model;
$this->tableName = $table;
}
/**
* Add an alias to the table being joined.
*/
public function as(string $alias, ?string $joinedTableAlias = null): self
{
$this->alias = $alias;
$this->joinedTableAlias = $joinedTableAlias;
$this->table = sprintf('%s as %s', $this->table, $alias);
$this->useTableAliasInConditions();
if ($this->model) {
StaticCache::setTableAliasForModel($this->model, $alias);
}
return $this;
}
public function on($first, $operator = null, $second = null, $boolean = 'and'): self
{
parent::on($first, $operator, $second, $boolean);
$this->useTableAliasInConditions();
return $this;
}
public function getModel()
{
return $this->model;
}
/**
* Apply the global scopes to the joined query.
*/
public function withGlobalScopes(): self
{
if (!$this->model) {
return $this;
}
foreach ($this->model->getGlobalScopes() as $scope) {
if ($scope instanceof Closure) {
$scope->call($this, $this);
continue;
}
if ($scope instanceof SoftDeletingScope) {
continue;
}
(new $scope())->apply($this, $this->model);
}
return $this;
}
/**
* Apply the table alias in the existing join conditions.
*/
protected function useTableAliasInConditions(): self
{
if (!$this->alias || !$this->model) {
return $this;
}
$this->wheres = collect($this->wheres)->filter(function ($where) {
$whereType = $where['type'] ?? '';
if (in_array($whereType, ['Column', 'Basic'], true)) {
return true;
}
if ($whereType === 'Null' && Str::contains($where['column'], $this->getModel()->getDeletedAtColumn())) {
return true;
}
return false;
})->map(function ($where) {
if ($where['type'] === 'Null') {
return $where;
}
$key = $this->model->getKeyName();
$table = $this->tableName;
$replaceMethod = sprintf('useAliasInWhere%sType', ucfirst($where['type']));
return $this->{$replaceMethod}($where);
})->toArray();
return $this;
}
protected function useAliasInWhereColumnType(array $where): array
{
$key = $this->model->getKeyName();
$table = $this->tableName;
// if it was already replaced, skip
if (Str::startsWith($where['first'].'.', $this->alias.'.') || Str::startsWith($where['second'].'.', $this->alias.'.')) {
return $where;
}
if (Str::contains($where['first'], $table) && Str::contains($where['second'], $table)) {
// if joining the same table, only replace the correct table.key pair
$where['first'] = str_replace($table.'.'.$key, $this->alias.'.'.$key, $where['first']);
$where['second'] = str_replace($table.'.'.$key, $this->alias.'.'.$key, $where['second']);
} else {
$where['first'] = str_replace($table.'.', $this->alias.'.', $where['first']);
$where['second'] = str_replace($table.'.', $this->alias.'.', $where['second']);
}
return $where;
}
protected function useAliasInWhereBasicType(array $where): array
{
$table = $this->tableName;
if (Str::startsWith($where['column'].'.', $this->alias.'.')) {
return $where;
}
if (Str::contains($where['column'], $table)) {
// if joining the same table, only replace the correct table.key pair
$where['column'] = str_replace($table.'.', $this->alias.'.', $where['column']);
} else {
$where['column'] = str_replace($table.'.', $this->alias.'.', $where['column']);
}
return $where;
}
public function whereNull($columns, $boolean = 'and', $not = false)
{
if ($this->alias && Str::contains($columns, $this->tableName)) {
$columns = str_replace("{$this->tableName}.", "{$this->alias}.", $columns);
}
return parent::whereNull($columns, $boolean, $not);
}
public function newQuery(): self
{
return new static($this->newParentQuery(), $this->type, $this->table, $this->model); // <-- The model param is needed
}
public function where($column, $operator = null, $value = null, $boolean = 'and'): self
{
if ($this->alias && is_string($column) && Str::contains($column, $this->tableName)) {
$column = str_replace("{$this->tableName}.", "{$this->alias}.", $column);
} elseif ($this->alias && !is_callable($column)) {
$column = $this->alias.'.'.$column;
}
if (is_callable($column)) {
$query = new self($this, $this->type, $this->table, $this->model);
$column($query);
return $this->addNestedWhereQuery($query);
} else {
return parent::where($column, $operator, $value, $boolean);
}
}
/**
* Remove the soft delete condition in case the model implements soft deletes.
*/
public function withTrashed(): self
{
if (!$this->getModel() || !in_array(SoftDeletes::class, class_uses_recursive($this->getModel()), true)) {
return $this;
}
$this->wheres = array_filter($this->wheres, function ($where) {
if ($where['type'] === 'Null' && Str::contains($where['column'], $this->getModel()->getDeletedAtColumn())) {
return false;
}
return true;
});
return $this;
}
/**
* Remove the soft delete condition in case the model implements soft deletes.
*/
public function onlyTrashed(): self
{
if (!$this->getModel()
|| !in_array(SoftDeletes::class, class_uses_recursive($this->getModel()), true)
) {
return $this;
}
$hasCondition = null;
$this->wheres = array_map(function ($where) use (&$hasCondition) {
if ($where['type'] === 'Null' && Str::contains($where['column'], $this->getModel()->getDeletedAtColumn())) {
$where['type'] = 'NotNull';
$hasCondition = true;
}
return $where;
}, $this->wheres);
if (!$hasCondition) {
$this->whereNotNull($this->getModel()->getQualifiedDeletedAtColumn());
}
return $this;
}
public function left(): self
{
return $this->joinType('left');
}
public function right(): self
{
return $this->joinType('right');
}
public function inner(): self
{
return $this->joinType('inner');
}
public function joinType(string $joinType): self
{
$this->type = $joinType;
return $this;
}
public function __call($name, $arguments)
{
if (!$this->getModel()) {
return;
}
if (method_exists($this->getModel(), 'scope'.ucfirst($name))) {
$scope = 'scope'.ucfirst($name);
return $this->getModel()->{$scope}($this, ...$arguments);
}
if ($this->hasLaravelScopeAttribute($name) && version_compare(app()->version(), '12.0.0', '>=')) {
return $this->getModel()->callNamedScope($name, array_merge([$this], $arguments));
}
if (static::hasMacro($name)) {
return $this->macroCall($name, $arguments);
}
$eloquentBuilder = $this->getModel()->newEloquentBuilder($this);
if (method_exists($eloquentBuilder, $name)) {
$eloquentBuilder->setModel($this->getModel());
return $eloquentBuilder->{$name}(...$arguments);
}
throw new InvalidArgumentException(sprintf('Method %s does not exist in PowerJoinClause class', $name));
}
/**
* Check if a method has the Laravel Scope attribute.
*/
protected function hasLaravelScopeAttribute(string $methodName): bool
{
if (!method_exists($this->getModel(), $methodName)) {
return false;
}
$reflection = new ReflectionClass($this->getModel());
if (!$reflection->hasMethod($methodName)) {
return false;
}
$method = $reflection->getMethod($methodName);
$attributes = $method->getAttributes();
foreach ($attributes as $attribute) {
if ($attribute->getName() === 'Illuminate\Database\Eloquent\Attributes\Scope') {
return true;
}
}
return false;
}
}
@@ -0,0 +1,23 @@
<?php
namespace Kirschbaum\PowerJoins;
use Illuminate\Support\ServiceProvider;
class PowerJoinsServiceProvider extends ServiceProvider
{
/**
* Bootstrap the application services.
*/
public function boot()
{
}
/**
* Register the application services.
*/
public function register()
{
EloquentJoins::registerEloquentMacros();
}
}
@@ -0,0 +1,30 @@
<?php
namespace Kirschbaum\PowerJoins;
use Illuminate\Database\Eloquent\Model;
class StaticCache
{
/**
* Cache to not join the same relationship twice.
*
* @var array<int, string>
*/
public static array $powerJoinAliasesCache = [];
public static function getTableOrAliasForModel(Model $model): string
{
return static::$powerJoinAliasesCache[spl_object_id($model)] ?? $model->getTable();
}
public static function setTableAliasForModel(Model $model, $alias): void
{
static::$powerJoinAliasesCache[spl_object_id($model)] = $alias;
}
public static function clear(): void
{
static::$powerJoinAliasesCache = [];
}
}