The below function allows me to retrieve related entries of a given model type that have, for instance, overlapping tags associated with them.
<?php
public function getRelated($entity, $fields = [], $relations = []) {
$related = new EloquentCollection();
// get models of same entity type that have relations in common
if (count($relations)) {
foreach ($relations as $relation) {
// check if the current model in use has any of the currently iterated relationship at all
if ($entity->{$relation}()->exists()) {
// if yes, get all the ids of the related models, for instance tags
$relationIds = $entity->{$relation}()->get()->pluck('id')->toArray();
// now, get all same type models except the current one
$relationEntities = $this->entity::whereNot('id', $entity->id)
// also except the ones we already found
->whereNotIn('id', $related->modelKeys())
// and only get those which also have current relationship (e.g. tags)
->whereHas($relation, function (Builder $query) use($relationIds, $relation) {
$query->whereIn($relation . '.id', $relationIds);
})
->get()
->sortKeysDesc()
->take(10);
$related = $related->concat($relationEntities->except($related->modelKeys()));
}
}
}
}
This works well. So I could load a blog post, for instance, and show related blog posts that have some of the same tags by calling:
$post = Post::find(1);
$related = $this->getRelated($post, [], ['tags']);
Now, I also need to respect other types than tags, hence $relations
is an array: "Show me related blog posts that have any of these tags, categories, you name it, in common."
My first problem was in the subquery. I had to hardcode the id in the relation table:
$query->whereIn('id', $relationIds);
had to be
$query->whereIn($relation . '.id', $relationIds);
So now my problem is that $relation . '.id'
doesnt work for all cases. The relationship fooBars()
would expect foo_bar.id
but looks for fooBar.id
.
Is there no automatic way of Laravel automatically knowing the column name based on the relationship I am currently querying? If not, I must, for each relatioship I am querying, define the column name (of the id) of the relationship table. Is there something I am missing?
Or can I somehow load the relationship and look at the DB scheme? Whats best practice here?
One way could be to use snake case ($query->whereIn(Str::of($relation)->snake() . '.id', $relationIds);
) but that feels vague and dirty.
All of these are polymorphic morphToMany/ morphedByMany.
Edit: For better understanding I'm adding an example of what my models look like. They all use default naming conventions. Again, I want to get related Posts that have either some Tags or FooBars in common.
<?php
class Post extends Entity
{
public function tags()
{
return $this->morphToMany(Tag::class, 'taggable');
}
public function fooBars()
{
return $this->morphToMany(FooBar::class, 'foo_barable');
}
}
class Tag extends Entity
{
public function posts()
{
return $this->morphedByMany(Post::class, 'taggable');
}
}
class FooBar extends Entity
{
public function posts()
{
return $this->morphedByMany(Post::class, 'foo_barable');
}
}
Thanks to @KGG's answer I came up with the following:
<?php
if (count($relations)) {
foreach ($relations as $relation) {
if ($entity->{$relation}()->exists()) {
$relationIds = $entity->{$relation}()->get()->modelKeys();
$relatedClass = get_class($entity->$relation()->getRelated());
$relationPrimaryKey = ($instance = new $relatedClass)->getQualifiedKeyName();
$relationEntities = $this->entity::whereNot('id', $entity->id)
->whereNotIn('id', $related->modelKeys())
->whereHas($relation, function (Builder $query) use($relationIds, $relationPrimaryKey) {
$query->whereIn($relationPrimaryKey, $relationIds);
})
->get()
->sortKeysDesc()
->take(10)
;
$related = $related->concat($relationEntities->except($related->modelKeys()));
}
}
}
CodePudding user response:
I made a mini version of your code to show concept: You can build on it to load inner relationships or do changes to relationships that are loaded.
$post = Post::first();
$related = $this->getRelated($post, ['tags', 'tags', 'tags', 'brands', 'somethingStrange']);
The getRelated function:
public function getRelated($entity, $relations = []) {
if (count($relations)) {
array_unique($relations);
$verifiedRelationships = [];
$verifiedRelationshipsPrimaryKey = [];
foreach ($relations as $relation) {
//If this method exists in the parent class continue
if(method_exists($entity, $relation)) {
//add the relation
array_push($verifiedRelationships, $relation);
//add the relation primary_id
array_push($verifiedRelationshipsPrimaryKey, Str::lower(Str::singular(Str::snake(Str::studly($relation)))).'_id');
}
}
$entity->load($verifiedRelationships);
}
}
This will automatically load all related methods that are found in that class.
Few notes:
$verifiedRelationships
will return any found methods['tags', 'brands', 'somethingStrange']
$verifiedRelationshipsPrimaryKey
will return['tag_id', 'brand_id', 'something_strange_id']
- Laravel has a function on the model that gets the key using
getKeyName();
- Laravel has a function on the model to return the qualified relationship id using
getQualifiedKeyName()
Example of $model Primary Key inside the foreach loop:
$relatedClass = get_class($entity->$relation()->getRelated());
$primaryKey = ($instance = new $relatedClass)->getKeyName();
//returns "id"
Example of the $relationship Primary key inside the foreach loop:
$relatedClass = get_class($entity->$relation()->getRelated());
$primaryKey = ($instance = new $relatedClass)->getQualifiedKeyName();
//returns "post_brands.id"
Please test this out, and if you want changes or have other cases for relationships or clarifications, I would be happy to adjust the code until it works for you, I honestly don't know the relationships you have, it would be easier if I can get an array example.