1
users                   transactions                    tasks
+----+--------+         +----+---------------+          +----+--------+
| id |  name  |         | id |     name      |          | id |  name  |
+----+--------+         +----+---------------+          +----+--------+
|  1 | User 1 |         |  1 | Transaction 1 |          |  1 | Task 1 |
|  2 | User 2 |         |  2 | Transaction 2 |          |  2 | Task 2 |
+----+--------+         +----+---------------+          +----+--------+


templates                   transaction_user                    task_transaction                  
+----+---------------+      +---------+----------------+        +---------+----------------+      
| id |     name      |      | user_id | transaction_id |        | task_id | transaction_id |
+----+---------------+      +---------+----------------+        +---------+----------------+
|  1 | Template 1    |      |       1 |              1 |        |       1 |              1 |
|  2 | Template 2    |      |       2 |              2 |        +---------+----------------+
+----+---------------+      +---------+----------------+            


task_template
+---------+-------------+
| task_id | template_id |
+---------+-------------+
|       2 |           2 |
+---------+-------------+

Motive: If there is a logged in user, say user with the ID 1, and he/she wants to see a task (say task with the ID 1) then i want to make sure that the task with ID 1 Belongs to the user before i let him view it. Also i need someway to show user all tasks that belong to him. Task is just one model.. i need to handle this for all models. I have shared my code below, am i trying too hard?

I may have omitted some details here so please feel free to ask questions. Thanks.

Code

<?php namespace SomeProject\Repositories;

use User;
use Account;
use Task;
use Document;
use Transaction;
use Property;
use DB;
use Respond;

abstract class DbRepository
{

/**
 * The many to many relationships are handeled using pivot tables
 * We will use this array to figure out relationships and then get
 * a particular resource's owner / account
 */
public $pivot_models = array(

    'Task'          => array(
                        'Transaction'   => 'task_transaction'
                    ),

    'Transaction'   => array(
                        'User'  => 'transaction_user'
                    ),

    'Document'      => array(
                        'Property'      => 'document_property',
                        'Task'          => 'document_task',
                        'Message'       => 'document_message'
                    )
);

public $entity_ids;


public function getOwnersByEntity(array $ids, $entity)
    {
        $this->entity_ids = [];

        $user_ids = [];


        $entity = ucfirst(strtolower($entity)); // arrays keys are case sensitive

        if( $this->getPivotIds($ids, $entity) )
        {
            foreach ($this->entity_ids as $entity_name => $entity_ids_arr)
            {
                $entity_name_lowercase = strtolower($entity_name);

                if($entity_name_lowercase != 'user')
                {
                    $user_ids_from_entity = $entity_name::whereIn('id', $entity_ids_arr)
                                                ->lists('user_id');
                }
                else
                {
                    // We already have the IDs if the entity is User
                    $user_ids_from_entity = $entity_ids_arr;
                }

                array_push($user_ids, $user_ids_from_entity);

            }

            $merged_user_ids = call_user_func_array('array_merge', $user_ids);

            return array_unique($merged_user_ids);
        }
        else
        {
            return $entity::whereIn('id', $ids)->lists('user_id');
        }
    }


    public function getPivotIds(array $ids, $entity)
    {
        $entity_lowercase = strtolower($entity);

        if( array_key_exists($entity, $this->pivot_models) )
        {
            // Its a pivot model

            foreach ($this->pivot_models[$entity] as $related_model => $table) // Transaction, Template
            {
                $related_model_lowercase = strtolower($related_model);

                $this->entity_ids[$related_model] = DB::table($table)
                                                        ->whereIn($entity_lowercase . '_id', $ids)
                                                        ->lists($related_model_lowercase . '_id');

                if( $this->getPivotIds($this->entity_ids[$related_model], $related_model) )
                {
                    unset($this->entity_ids[$related_model]);
                }
            }

            return true;
        }

        return false;
    }
}
Lucky Soni
  • 6,811
  • 3
  • 38
  • 57
  • Just to be sure... are these REAL many-to-many relations, thus deserving "pivot" tables? I mean, can a single transaction belong to more than one user? Or a single task belong to more than one transaction? I'm saying this because, when I think about "nested" resources, I usually refer to one-to-many relations, so less tables, less complication... – matpop Jun 30 '14 at 21:14
  • @matpop Yes, a single transaction can belong to more than one user. Actually it will always have multiple users associated with it. And yes a single Task can belong to more than one Transaction. These are real Many to Many relationships.. – Lucky Soni Jun 30 '14 at 21:58
  • According to my experience, if you don't focus on SQL JOINs in such cases, you likely get to complicated/inefficient code. I know it's ugly, but you have to reverse it: "what would I do in SQL? Join transaction_user with task_transaction where task_id and user_id equal the given ids, and count results". Ok, do THAT in Laravel, with query builder/Eloquent if you find it convenient. That's even more evident in the "show all user's tasks" case: a Laravel's "eager load" would be indeed inconvenient. Sometimes high level concepts fit just hardly. – matpop Jun 30 '14 at 22:53
  • Don't let the framework compromise SQL power!! Sorry, I could not resist :) – matpop Jun 30 '14 at 23:05
  • Also, do you really need to manage all cases through a single interface? Why do you feel the urge to standardize here? I'd rather not over-abstract – matpop Jun 30 '14 at 23:17
  • PS: did you also consider Laravel's "[Has Many Through](http://laravel.com/docs/eloquent#has-many-through)" relations? – matpop Jul 01 '14 at 10:25
  • 1
    @matpop Unfortunately `Has Many Through` does not work with pivot tables. Why i need a single method to encapsulate this functionality? Well, i think this is pretty much doable as it follows a pattern and will save me a lot of keystrokes as the project i am building is massive in size and has around 40 entities. Writing individual logic for each of them is a lot of work and repetition of code. I don't know the best practice in this particular case. I am sure that developers like myself face this exact situation in their everyday project so i just want to know how others are dealing with this. – Lucky Soni Jul 01 '14 at 12:58
  • In fact I hadn't tried hasManyThrough with pivot tables but hoped they were supported as well; too bad. 40 entities related in many-to-many? I wouldn't quite call it everyday situation! But that clearly justifies your aim. At this point, I'd likely EXTEND Eloquent's Model class and use reflection to "navigate" Eloquent relations which I'd still define in Laravel-style. Then I'd implement the intended operations on "nested resources" (abstract Models) through SQL JOINs generated with Laravel query builder. Not an easy job but if I find some time I'll try to post something. – matpop Jul 01 '14 at 13:38
  • 1
    @matpop 40 Entites - not all are many to many. But yes i do have a decent number of Many to many relationships. I dont know much about PHP's reflection class but i know laravel uses it internally when we try to use its IOC container.. i will read about it and see if i can put it to use. I will love to see something from you, thanks for your time. – Lucky Soni Jul 01 '14 at 14:14
  • Some more thoughts: with reflection you can inspect only the name of the relation methods, so unless you use predictable structured names for relations methods, you'd likely find more convenient just to define some protected class variables inside the extended Model class (very Laravel style indeed). – matpop Jul 01 '14 at 14:47
  • Are you retrieving every owner of a resource only to see if a given user is an owner? – FuzzyTree Jul 02 '14 at 11:32
  • @FuzzyTree Yes, as per the above code i am doing that. – Lucky Soni Jul 02 '14 at 15:42

3 Answers3

6

To check if given model is related to another one, which is what you want if I get you right, all you need is this tiny method making the most of Eloquent:

(Implement it in BaseModel, Entity or a scope, whatever suits you)

// usage
$task->isRelatedTo('transactions.users', $id);
// or
$template->isRelatedTo('tasks.transactions.users', Auth::user());

// or any kind of relation:
// imagine this: User m-m Transaction 1-m Item m-1 Group
$group->isRelatedTo('items.transaction.users', $id);

The magic happens here:

/**
 * Check if it is related to any given model through dot nested relations
 * 
 * @param  string  $relations
 * @param  int|\Illuminate\Database\Eloquent\Model  $id
 * @return boolean
 */
public function isRelatedTo($relations, $id)
{
    $relations = explode('.', $relations);

    if ($id instanceof Model)
    {
        $related = $id;
        $id = $related->getKey();
    }
    else
    {
        $related = $this->getNestedRelated($relations);
    }

    // recursive closure
    $callback = function ($q) use (&$callback, &$relations, $related, $id) 
    {
        if (count($relations))
        {
            $q->whereHas(array_shift($relations), $callback);
        }
        else
        {
            $q->where($related->getQualifiedKeyName(), $id);
        }
    };

    return (bool) $this->whereHas(array_shift($relations), $callback)->find($this->getKey());
}

protected function getNestedRelated(array $relations)
{
    $models = [];

    foreach ($relations as $key => $relation)
    {
        $parent = ($key) ? $models[$key-1] : $this;
        $models[] = $parent->{$relation}()->getRelated();
    }

    return end($models);
}

Hey, but what's going on there?

isRelatedTo() works like this:

  1. check if passed $id is a model or just an id, and prepares $related model and its $id for use in the callback. If you don't pass an object then Eloquent needs to instantiate all the related models on the $relations (relation1.relation2.relation3...) chain to get the one we are interested in - that's what happens in getNestedRelated(), pretty straightforward.

  2. then we need to do something like this:

    // assuming relations 'relation1.relation2.relation3'
    $this->whereHas('relation1', function ($q) use ($id) {
       $q->whereHas('relation2', function ($q) use ($id) {
          $q->whereHas('relation3', function ($q) use ($id) {
             $q->where('id', $id);
          });
       });
    })->find($this->getKey()); 
    // returns new instance of current model or null, thus cast to (bool)
    
  3. since we don't know how deeply the relation is nested, we need to use recurrency. However we pass a Closure to the whereHas, so we need to use little trick in order to call itself inside its body (in fact we don't call it, but rather pass it as $callback to the whereHas method, since the latter expects a Closure as 2nd param) - this might be useful for those unfamiliar Anonymous recursive PHP functions:

    // save it to the variable and pass it by reference
    $callback = function () use (&$callback) {
      if (...) // call the $callback again
      else // finish;
    }
    
  4. we also pass to the closure $relations (as an array now) by reference in order to unshift its elements, and when we got them all (meaning we nested whereHas), we finally put the where clause instead of another whereHas, to search for our $related model.

  5. finally let's return bool

Community
  • 1
  • 1
Jarek Tkaczyk
  • 78,987
  • 25
  • 159
  • 157
  • Though your code works but could you explain whats going on? This piece of code is something that i dont understand.. not that its not expressive but some explanation will be really helpful. Thanks! – Lucky Soni Jul 07 '14 at 10:23
  • @deczo I see that your code performs nested database queries. Though the relationships i have are maximum 4 levels deep but will this not cause a performance penalty? Also later down the road when the application user base grows will this not result in one of those slow queries? – Lucky Soni Jul 07 '14 at 20:29
  • @LuckySoni Naturally this way of resolving permissions has drawback in performance. Didn't test it on big data, but in fact it might be faster then simple `Model::with('relation')->take(20)->get();`. That being said, I don't think it will be a problem but of course it depends on the db. And when you find it being a bottleneck of your app, then probably you will think of optimization, but not before that. – Jarek Tkaczyk Jul 07 '14 at 21:06
  • @deczo okay, i'll test it and keep track of my queries. Thanks again. – Lucky Soni Jul 07 '14 at 21:30
1

There's really no easy nor canonical way, but here's a raw example of what I'd try to do.

class Entity extends Eloquent {

    public function isRelatedTo($instance, $through)
    {
        $column = $instance->joiningTable($through) . '.' . $instance->getForeignKey();
        $query = DB::table('');
        this->buildJoin($query, $instance, $through);
        return $query->where($column, '=', $instance->getKey())->exists();
    }

    public function relatesToMany($related, $through)
    {
        $that = $this;
        $related = new $related;
        return $related->whereIn($related->getKeyName(), function($query) use ($that, $related, $through) {
            $that->buildJoin($query, $related, $through);
        })->get();
    }

    protected function buildJoin($query, $related, $through)
    {
        $through = new $through;
        $this_id = $this->getForeignKey();
        $related_id = $related->getForeignKey();
        $through_id = $through->getForeignKey();
        $this_pivot = $this->joiningTable($through);
        $related_pivot = $related->joiningTable($through);
        $query->select($related_pivot . '.' . $related_id)->from($related_pivot)
            ->join($this_pivot, $related_pivot . '.' . $through_id, '=', $this_pivot . '.' . $through_id)
            ->where($this_pivot . '.' . $this_id, '=', $this->getKey());
    }

}

Then, for your use case:

class User extends Entity {

    public function isOwnerOf($task)
    {
        return $this->isRelatedTo($task, 'Transaction');
    }

    public function tasks()
    {
        return $this->relatesToMany('Task', 'Transaction');
    }

}

Disclaimer: the code has not been tested.

Note that, in this very simplified example, relatesToMany directly returns a Collection. To have more advantages, it could instead return an instance of your own extension of Eloquent's Relation class - that takes longer to implement, clearly.
It shouldn't be difficult to add support for multiple intermediate entities; you could likely expect the $through argument to possibly be an array and then build the multi-join query accordingly.

matpop
  • 1,969
  • 1
  • 19
  • 36
-2

If this is a Laravel project, then yes, you're trying far too hard.

If you're going to use Laravel, it's recommended that you use the features provided to you with Laravel, which are namely it's ORM, Eloquent, and it's bundled Schema tool. I'd recommend that you view Laravel's Getting Started page in their documentation, so that you can set your project up correctly to use Eloquent.

It would also be beneficial if you read up on the basics of how Eloquent handles relations in their models, as they do all of the work that you're trying to do.

Evan Darwin
  • 850
  • 1
  • 8
  • 29
  • I have already read the docs, not sure what i am missing.. mind giving an example/code solution to the above problem? – Lucky Soni Jun 30 '14 at 21:54
  • Specifically this part of the documentation, http://laravel.com/docs/quick#creating-a-migration and the following 'Eloquent ORM' section. You don't appear to be using either Migrations, Eloquent, or it's Models. – Evan Darwin Jun 30 '14 at 21:56
  • I am not sure how you can comment whether i am using Migrations or not (which i am using). I am also using Eloquent but i think the Query Builder is more helpful in this case. Sorry, but I think you don't understand my problem here. What do you think `$entity::whereIn('id', $ids)->lists('user_id');` is? – Lucky Soni Jun 30 '14 at 22:00
  • the solution to my problem here is not as simple as `$user->tasks` because there is no pivot table that links the user and tasks directly.. Hope this makes sense.. – Lucky Soni Jun 30 '14 at 22:12
  • @LuckySoni, It seems to me that the transaction_user table would be the pivot table, as it states a user_id and a transaction_id. In which case it'd simply be playing with the secondary and tertiary arguments for hasOne() and hasMany(). Also I mis-read your post and didn't see the fact you stated "model", it just looked like you were trying to re-implement pivot tables. – Evan Darwin Jun 30 '14 at 23:22