Skip to content

Instantly share code, notes, and snippets.

@mortenscheel
Created April 27, 2026 10:17
Show Gist options
  • Select an option

  • Save mortenscheel/eab2468b9ad93b9e5d8688386a4a9816 to your computer and use it in GitHub Desktop.

Select an option

Save mortenscheel/eab2468b9ad93b9e5d8688386a4a9816 to your computer and use it in GitHub Desktop.
A one-way BelongsToMany CSV based relationship for Laravel
<?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