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