Files
2025-11-25 22:42:56 +01:00

341 lines
9.6 KiB
PHP

<?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;
}
}