1
votes

If I have two tables, say foos and bars, in a many-to-many relationship (via a bars_foos join table, in this case), what is the best way in CakePHP 3.0 to include an array of the IDs of Bars associated with each Foo in the JSON returned by the RequestHandler through the _serialize property?

Specifically, I would like to be able to visit site/foos.json and see something like this:

{
  "foos": [
    {
      "id": 1,
      "name": "A foo",
      ...
      "bar_ids": [1, 3, 5]
    },
    {
      "id": 2,
      "name": "Foo too",
      ...
      "bar_ids": [2]
  ]
}

As a bonus, this should still work if each Foo also belongsToMany Bazzes, so that I end up getting, for example,

      ...
      "bar_ids": [1, 3, 5],
      "baz_ids": [37, 42]
      ...

Is there a straightforward way to achieve this that's easy to apply to many models and doesn't result in an excessive number of database queries?


What I've tried so far:

I have managed to roughly accomplish this in two ways, but neither seems ripe for quickly and easily generalizing to many models. I'm wary of the efficiency of adding new queries or query fields to get information that is likely already sitting somewhere in CakePHP, if I could only find the right syntax to bring it out.

Virtual field

One method is to create a a virtual field though a _getBarIds() function and setting protected $_virtual = ['bar_ids'] in the Foo Entity. The function uses the TableRegistry to find and query the bars_foos join table for the Bar entities associated to the current Foo, and return them as a PHP array. The key functionality is roughly like this:

// In src/Model/Entity/Foo.php, protected function _getBarIds:
$bars_foos = TableRegistry::get('Bookmarks_Tags');
$these_bars = $bars_foos->find()
    ->select(['bar_id'])
    ->where(['foo_id' => $this->id]);

This works reasonably well, but manually adding these _get<Model>Ids() functions for every association in my database isn't appealing, and performing one or more new database hits for every row I want to retrieve is certainly not ideal.

Aggregation in the controller query

Another method is to add joins to the index query in the Foos Controller to aggregate over the join tables. The key component here looks like this:

// In src/Controller/FoosController.php, public function index:
$query = $this->Foos->find('all')
    ->select(['Foos.id', 'Foos.name',
      'bar_ids' => "'[' || string_agg(BarsFoos.bar_id::text, ',') || ']'"
    ])
    ->autofields(false) // don't include other fields from bars_foos
    ->join([
      'table' => 'bars_foos',
      'alias' => 'BarsFoos',
      'type' => 'LEFT',
      'conditions' => 'BarsFoos.foo_id = Foos.id'
    ])
    ->group(['Foos.id', 'Foos.name']);
$this->set('foos', $query);
$this->set('_serialize', ['foos']);

You can wrap a coalesce(..., '') around the string_agg(...) to return "[]" instead of NULL when there aren't any associated Bars, and throw a distinct at the start of the first argument to string_agg if multiple joins are returning duplicates in the bar_id column, but this is the basic idea.

This approach is somewhat more appealing to me because it gets everything in a single query to the database, but it also feels a little too manual, and has the added disadvantage of returning the array as a string so that in the JSON it appears as "bar_ids": "[1, 3, 5]", with quotes around what should be an array instead of a string (in my current implementation with PostgreSQL's string_agg, anyway).

This doesn't seem like a particularly crazy feature---have I just missed something obvious that gives a much simpler way to accomplish the task in general?

1

1 Answers

5
votes

Result formatters

I would probably use containments and result formatters. Containing the association will require only a single additional query per association to retrieve the associated data, which you can use then to create whatever additional properties you like.

Here's a basic example, which should be pretty much self-explantory, it simply iterates over all the retrieved rows, and adds a new property containing the IDs of the associated records.

$query = $this->Foos
    ->find()
    ->contain(['Bars'])
    ->formatResults(
        function ($results) {
            /* @var $results \Cake\Datasource\ResultSetInterface|\Cake\Collection\CollectionInterface */
            return $results->map(function ($row) {
                /* @var $row array|\Cake\DataSource\EntityInterface */
                $ids = [];
                foreach ($row['bars'] as $barRow) {
                    $ids[] = $barRow['id'];
                }
                $row['bar_ids'] = $ids;
                return $row;
            });
        }
    );      

Reusable formatters and custom finders

To keep things dry you can make your table provide the formatter, and even wrap up all this in a custom finder.

public function formatWhateverResults($results) {
    // ...
}

public function findWhatever(Query $query, array $options)
{
    $query
        ->contain(['Bars'])
        ->formatResults([$this, 'formatWhateverResults']);
    return $query;
}
$query = $this->Foos->find('whatever');

Further automatization

Also you can of course automate this further, for example by inspecting the tables associations and process the IDs of all contained belongsToMany ones, something like

/**
 * @param \Cake\Datasource\ResultSetInterface|\Cake\Collection\CollectionInterface $results
 */
public function formatWhateverResults($results) {
    $associations = $this->associations()->type('BelongsToMany');
    return $results->map(function ($row) use ($associations) {
        /* @var $row array|\Cake\DataSource\EntityInterface */
        foreach ($associations as $assoc) {
            /* @var $assoc \Cake\ORM\Association */
            $property = $assoc->property();
            if (isset($row[$property])) {
                $ids = [];
                foreach ($row[$property] as $assocRow) {
                    $ids[] = $assocRow['id'];
                }
                $row[Inflector::singularize($property) . '_ids'] = $ids;
            }
        }
        return $row;
    });
}

See also