Mira915
Mira915

Reputation: 43

Sort query results by nested association in cakephp 3

The Problem:

I have a cakephp 3.x query object with two nested associations, Organizations.Positions.Skills, that is being set to a view variable, $Organizations. I'm trying to sort the query's resulting top level array by a column in the first nested association. That is, I want to sort $Organizations by a column in Positions, specifically Positions.Ended).

public function index()
{
    $this->loadModel('Organizations');
    $this->set('Organizations',
            $this->Organizations->find('all')
            ->contain([ //eager loading
            'Positions.Skills'
             ])
    );
}

Model Info

Organizations has many Positions

Positions has many Skills

Research I've Done

order option

According to the cookbook find() has an order option: find()->order(['field']); or find('all', ['order'=>'field DESC']); However, this only applies to fields in the table find() is being called upon. In this case, Organizations. For example, this is how it's typically used.

//This works.
//Sorts Organizations by the name of the organization in DESC.
$this->loadModel('Organizations');
$this->set('Organizations',
        $this->Organizations->find('all')
        ->contain([ //eager loading
            'Positions.Skills'
         ])
        ->order(['organization DESC'])
);

but, trying to use it for nested associations doesn't work:

$this->set('Organizations',
  this->Organizations->find(...)
    ->contain(...)
    ->order(['Organizations.Positions.ended DESC'])
);

Error: SQLSTATE[42S22]: Column not found: 1054 Unknown column 'Organizations.Positions.ended' in 'order clause'

and altering it to refer to the field that'll be nested doesn't work either:

//also doesn't work.
$this->set('Organizations',
  $this->Organizations->find(...)
    ->contain(...)
    ->order([Positions.ended DESC])
);

Error: SQLSTATE[42S22]: Column not found: 1054 Unknown column 'Positions.ended' in 'order clause'

In both cases, the sql error is created when cakephp executes the PDO statement generated by the query.

sort option

Similarly, according to the cookbook, eager loading / associations has the 'sort' option: $this->loadModel('Organizations');

$this->set('Organizations',
    $this->Organizations->find('all')
    ->contain([ //eager loading
        'Positions.Skills',
        'Positions' => [
            'sort' => ['Positions.ended'=>'DESC']
        ]
    ])
);

But, this only sorts the nested association.Specifically, it sorts the associations that are nested. It does not sort the entire resulting set by the nested association (ergo, a multidimensional sort).

For example:

The Organization, Organization C (org id 1), has two positions:

  1. Position 5. Ended 2012
  2. Position 4. Ended 2014

And the Organization, Organization B (org id 2), has two positions:

  1. Position 3 Ended 2013
  2. Position 2 Ended 2015

The above code and data results in the following array being generated when the query is evaluated:

Organizations => [
  0 => [
    'organization' => 'Organization A',
    positions => [
      0 => [
        'position' => 'Position 1',
        'ended' => '2016'
      ]
    ]
  ],
  1 => [
    'organization' => 'Organization C',
    'positions' => [
      0 => [
        'position' => 'Position 4',
        'ended' => '2014'
      ],
      1 => [
        'position' => 'Position 5',
        'ended' => '2012'
      ]
    ]
  ],
  2 => [
    'organization' => 'Organization B',
    'positions' => [
      0 => [
        'position' => 'Position 2',
        'ended' => '2015'
      ],
      1 => [
        'position' => 'Position 3',
        'ended' => '2013'
      ]
    ]
  ]
]

other research

Likewise the following stackoverflow questions came up in my research:

http://stackoverflow.com/questions/26859700/cakephp-order-not-working

http://stackoverflow.com/questions/17670986/order-by-doesnt-work-with-custom-model-find-while-paginating

http://stackoverflow.com/questions/18958410/cakephp-paginate-and-sort-2nd-level-association

http://stackoverflow.com/questions/34705257/cakephp-paginate-and-sort-hasmany-association 

Furthermore, I do know that PHP has its own sorting functions like sort() and multisort(); but, those can only be called once the query has been evaluated (by foreach). Alternatively, there's calling $organization->toArray() then using multisort; but, this would have to be done in the view, would break the MVC convention of separations of concerns (data and queries are manipulated by the controller and model, not the view!), and would be quite inefficient as it'll be called while the page is loading.

How then, do I sort a cakephp query by its nested associations?

Or, put more simply, how do I order/sort the query to produce the following array upon evaluation:

Organizations => [
  0 => [
    'organization' => 'Organization A',
    'positions' => [
      0 => [
        'position' => 'Position 1',
        'ended' => '2016'
      ]
    ]
  ],
  0 => [
    'organization' => 'Organization B',
    'positions' => [
      0 => [
        'position' => 'Position 2',
        'ended' => '2015'
      ],
      1 => [
        'position' => 'Position 3',
        'ended' => '2013'
      ]
    ]
  ],
  1 => [
    'organization => 'Organization C',
    'positions' => [
      0 => [
        'position' => 'Position 4',
        'ended' => '2014'
       ],
      1 => [
        'position' => 'Position 5',
        'ended' => '2012'
      ]
    ]
  ]
]

Background & Context:

I'm building a [portfolio website][7] for myself with cakephp 3.2 to showcase my web dev skills and assist in my quest for a dev career. For my resume page, I'm organizing the massive amount of data with nested accordions to mimic the resume style recruiters would expect to see on an actual resume. As a result, my view does the following:

  1. Looping through the top level view variable (Organizations)
    1. Rendering the organization details
    2. Looping through that organization's positions (still inside 1)
      1. render the position details
      2. loop through the position's relevant skills
        1. render each skill w/ the appropriate link to filter by that skill.

List item

Upvotes: 4

Views: 4986

Answers (2)

ndm
ndm

Reputation: 60463

Only hasOne and belongsTo associations are being retrieved via a join on the main query. hasMany associations are being retrieved in a separate queries, hence the errors when you try to refer to a field of Positions.

What you want should be fairly easy to solve, on SQL level, as well as on PHP level.

SQL level

On SQL level you could join in Positions, and order by a computed max(Positions.ended) column, like:

$this->Organizations
    ->find('all')
    ->select(['max_ended' => $this->Organizations->query()->func()->max('Positions.ended')])
    ->select($this->Organizations)
    ->contain([
        'Positions.Skills',
        'Positions' => [
            'sort' => ['Positions.ended' => 'DESC']
        ]
    ])
    ->leftJoinWith('Positions')
    ->order([
        'max_ended' => 'DESC'
    ])
    ->group('Organizations.id');

And that's all, that should give you the results that you want. The query will look something like:

SELECT
    MAX(Positions.ended) AS max_ended,
    ... 
FROM
    organizations Organizations     
LEFT JOIN
    positions Positions 
        ON Organizations.id = (
            Positions.organization_id
        )   
GROUP BY
    Organizations.id   
ORDER BY
    max_ended DESC

PHP level

On PHP level it's also pretty easy to solve to using collections (note that queries are collections - kind of), however it would only make sense if you'd intend to retrieve all rows, or must deal with a set of unordered rows for whatever reason... something like:

$Organizations->map(function ($organization) {
    $organization['positions'] =
        collection($organization['positions'])->sortBy('ended')->toList();
    return $organization;
});
$sorted = $sorted->sortBy(function ($organization) {
    return
        isset($organization['positions'][0]['ended']) ?
            $organization['positions'][0]['ended'] : 0;
});

This could also be implemented in a result formatter, so things happen on controller or model level if you insist.

$query->formatResults(function ($results) {
    /* @var $results \Cake\Datasource\ResultSetInterface|\Cake\Collection\CollectionInterface */
    $results = $results->map(function ($row) {
        $row['positions'] =
            collection($row['positions'])->sortBy('ended')->toList();
        return $row;
    });
    return $results->sortBy(function ($row) {
        return isset($row['positions'][0]['ended']) ?
            $row['positions'][0]['ended'] : 0;
    });
});

See

Upvotes: 6

bolshas
bolshas

Reputation: 101

This worked for me:

$this->Organizations->find('all')
->contain(['Positions' => ['sort' => ['Positions.ended' => 'DESC']]])
->contain('Postions.Skills');

Upvotes: 1

Related Questions