Created
April 27, 2026 10:17
-
-
Save mortenscheel/eab2468b9ad93b9e5d8688386a4a9816 to your computer and use it in GitHub Desktop.
A one-way BelongsToMany CSV based relationship for Laravel
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\General\Relations; | |
| use Illuminate\Database\Eloquent\Builder; | |
| use Illuminate\Database\Eloquent\Collection; | |
| use Illuminate\Database\Eloquent\Model; | |
| use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; | |
| use Illuminate\Database\Eloquent\Relations\Relation; | |
| /** | |
| * @template TRelatedModel of \Illuminate\Database\Eloquent\Model | |
| * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model | |
| * | |
| * @extends Relation<TRelatedModel, TDeclaringModel, Collection<int, TRelatedModel>> | |
| * | |
| * @mixin Builder<TRelatedModel> | |
| */ | |
| class BelongsToCommaSeparated extends Relation | |
| { | |
| use InteractsWithDictionary; | |
| public function __construct(Builder $query, Model $parent, private readonly string $foreignKey, ?string $ownerKey = null) | |
| { | |
| $this->ownerKey = $ownerKey ?? $query->getModel()->getKeyName(); | |
| parent::__construct($query, $parent); | |
| } | |
| private readonly string $ownerKey; | |
| public function addConstraints(): void | |
| { | |
| if (!static::$constraints) { | |
| return; | |
| } | |
| $ids = $this->getForeignKeysForModel($this->parent); | |
| if ($ids === []) { | |
| $this->query->whereRaw('0 = 1'); | |
| return; | |
| } | |
| $this->query->whereIn($this->getQualifiedOwnerKeyName(), array_values(array_unique($ids))); | |
| } | |
| public function addEagerConstraints(array $models): void | |
| { | |
| $keys = []; | |
| foreach ($models as $model) { | |
| array_push($keys, ...$this->getForeignKeysForModel($model)); | |
| } | |
| $keys = array_values(array_unique($keys)); | |
| $this->whereInEager( | |
| $this->whereInMethod($this->related, $this->ownerKey), | |
| $this->getQualifiedOwnerKeyName(), | |
| $keys, | |
| ); | |
| } | |
| public function initRelation(array $models, $relation): array | |
| { | |
| foreach ($models as $model) { | |
| $model->setRelation($relation, $this->related->newCollection()); | |
| } | |
| return $models; | |
| } | |
| public function match(array $models, Collection $results, $relation): array | |
| { | |
| /** @var array<int|string, TRelatedModel> $dictionary */ | |
| $dictionary = []; | |
| foreach ($results as $result) { | |
| $dictionaryKey = $this->getDictionaryKey($result->getAttribute($this->ownerKey)); | |
| if ($dictionaryKey !== null) { | |
| $dictionary[$dictionaryKey] = $result; | |
| } | |
| } | |
| foreach ($models as $model) { | |
| $related = []; | |
| foreach ($this->getForeignKeysForModel($model) as $id) { | |
| $dictionaryKey = $this->getDictionaryKey($id); | |
| if (!is_int($dictionaryKey) && !is_string($dictionaryKey)) { | |
| continue; | |
| } | |
| if (array_key_exists($dictionaryKey, $dictionary)) { | |
| $related[] = $dictionary[$dictionaryKey]; | |
| } | |
| } | |
| /** @var Collection<int, TRelatedModel> $relatedCollection */ | |
| $relatedCollection = $this->related->newCollection($related); | |
| $model->setRelation($relation, $relatedCollection); | |
| } | |
| return $models; | |
| } | |
| public function getResults(): Collection | |
| { | |
| $ids = $this->getForeignKeysForModel($this->parent); | |
| if ($ids === []) { | |
| return $this->related->newCollection(); | |
| } | |
| /** @var Collection<int, TRelatedModel> $results */ | |
| $results = $this->query->get(); | |
| /** @var Collection<int, TRelatedModel> $orderedResults */ | |
| $orderedResults = $this->reorderResults($results, $ids); | |
| return $orderedResults; | |
| } | |
| public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']): Builder | |
| { | |
| if ($parentQuery->getQuery()->from === $query->getQuery()->from) { | |
| return $this->getRelationExistenceQueryForSelfRelation($query, $columns); | |
| } | |
| return $query->select($columns)->whereRaw($this->compileFindInSetClause($query)); | |
| } | |
| public function getQualifiedForeignKeyName(): string | |
| { | |
| return $this->parent->qualifyColumn($this->foreignKey); | |
| } | |
| public function getQualifiedOwnerKeyName(): string | |
| { | |
| return $this->related->qualifyColumn($this->ownerKey); | |
| } | |
| /** | |
| * @param Builder<TRelatedModel> $query | |
| * @param mixed $columns | |
| * @return Builder<TRelatedModel> | |
| */ | |
| protected function getRelationExistenceQueryForSelfRelation(Builder $query, $columns = ['*']): Builder | |
| { | |
| $query->select($columns)->from( | |
| $query->getModel()->getTable() . ' as ' . $hash = $this->getRelationCountHash() | |
| ); | |
| $query->getModel()->setTable($hash); | |
| return $query->whereRaw($this->compileFindInSetClause($query)); | |
| } | |
| /** | |
| * @return array<int, int|string> | |
| */ | |
| private function getForeignKeysForModel(Model $model): array | |
| { | |
| $value = $model->getAttribute($this->foreignKey); | |
| if ($value === null || $value === '') { | |
| return []; | |
| } | |
| if (is_array($value)) { | |
| $values = $value; | |
| } elseif (is_string($value)) { | |
| $values = array_map('trim', explode(',', $value)); | |
| } else { | |
| $values = [$value]; | |
| } | |
| return array_values(array_filter($values, static fn (mixed $item): bool => $item !== null && $item !== '')); | |
| } | |
| /** | |
| * @param Collection<int, TRelatedModel> $results | |
| * @param array<int, int|string> $ids | |
| * @return Collection<int, TRelatedModel> | |
| */ | |
| private function reorderResults(Collection $results, array $ids): Collection | |
| { | |
| /** @var array<int|string, TRelatedModel> $dictionary */ | |
| $dictionary = []; | |
| foreach ($results as $result) { | |
| $dictionaryKey = $this->getDictionaryKey($result->getAttribute($this->ownerKey)); | |
| if ($dictionaryKey !== null) { | |
| $dictionary[$dictionaryKey] = $result; | |
| } | |
| } | |
| $orderedResults = []; | |
| foreach ($ids as $id) { | |
| $dictionaryKey = $this->getDictionaryKey($id); | |
| if (!is_int($dictionaryKey) && !is_string($dictionaryKey)) { | |
| continue; | |
| } | |
| if (array_key_exists($dictionaryKey, $dictionary)) { | |
| $orderedResults[] = $dictionary[$dictionaryKey]; | |
| } | |
| } | |
| /** @var Collection<int, TRelatedModel> $orderedCollection */ | |
| $orderedCollection = $this->related->newCollection($orderedResults); | |
| return $orderedCollection; | |
| } | |
| /** | |
| * @param Builder<TRelatedModel> $query | |
| */ | |
| private function compileFindInSetClause(Builder $query): string | |
| { | |
| $grammar = $query->getQuery()->getGrammar(); | |
| return 'FIND_IN_SET(CAST(' . $grammar->wrap($query->qualifyColumn($this->ownerKey)) . ' AS CHAR), ' . $grammar->wrap($this->getQualifiedForeignKeyName()) . ') > 0'; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment