Arth
Arth

Reputation: 13110

Yii2 eager load aggregation through junction table

I have the following tables:

Where a piece of content has_many categories, and a category is also a piece of content.

In content I have the following code:

public function getCategories()
{
    return $this
        ->hasMany(Category::className(), ['id' => 'category_id'])
        ->viaTable('content_category', ['content_id' => 'id']);
}

public function getCategoriesCsv(){
   ...
}

For my grid view in the backend, I'd like to display a comma separated list of categories for each piece of content.

I'm aware that I could select this information separately, but I would like to do it as part of the find query and using the existing relation if possible.

Upvotes: 0

Views: 442

Answers (2)

Arth
Arth

Reputation: 13110

WITH CATEGORIES LOADED

Originally I implemented it as:

public function getCategoriesCsv(){
    $categoryTitles = [];
    foreach ($this->categories as $category){
        $categoryTitles[] = $category->title;
    }
    return implode(', ', $categoryTitles);       
}

Thanks to @IStranger, I neatened this to:

public function getCategoriesCsv()
{
    $titles = ArrayHelper::getColumn($this->categories, 'title');
    return implode(', ', $titles);
}

WITHOUT CATEGORIES LOADED

I have now managed to avoid loading all the category models by adding a separate CategoryCsv ActiveRecord:

class CategoryCsv extends ActiveRecord
{
  public static function tableName(){
    return '{{%content_category}}';
  }

  public function attributes(){
    return ['content_id', 'value'];
  }

  public static function find(){
    return parent::find()
      ->select([
        'content_id',
        'GROUP_CONCAT(
           categoryCsv.title 
           ORDER BY categoryCsv.title 
           SEPARATOR ", "
         ) value'
       ])
     ->innerJoin('content categoryCsv','category_id = categoryCsv.id')
     ->groupBy('content_id');
  }
}

Then in the Content ActiveRecord:

public function getCategoriesCsv(){
  return $this->hasOne(CategoryCsv::className(), ['content_id' => 'id']);
}

Thus I can access the value like so:

$contents = Content::find()->with('categoryCsv')->all();
foreach($contents as $content){
   echo $content->categoryCsv->value;
}

Upvotes: 0

IStranger
IStranger

Reputation: 2041

Using defined relation (more simple, less efficient).

This approach typical way and it works with related Category models. Therefore it requires a lot of memory.

class Content extends \yii\db\ActiveRecord
{
    /**
     * Returns comma separated list of category titles using specified separator.
     *
     * @param string $separator
     *
     * @return string
     */
    public function getCategoriesCsv($separator = ', ')
    {
        $titles = \yii\helpers\ArrayHelper::getColumn($this->categories, 'title');

        return implode($separator, $titles);
    }

    // ...
}

Should be used with eager loading:

Content::find()
    ->with('categories')
    ->all();

Using subquery (more efficient, less convenient)

This approach uses subqueries and don't use relations and related models. Therefore this way more fast and keeps a lot of memory.

class Content extends \yii\db\ActiveRecord
{

    const ATTR_CATEGORIES_CSV = 'categoriesCsv';

    /**
     * @var string Comma separated list of category titles.
     */
    public $categoriesCsv;

    /**
     * Returns DB expression for retrieving related category titles.
     *
     * @return \yii\db\Expression
     */
    public function prepareRelatedCategoriesExpression()
    {
        // Build subquery that selects all category records related with current content row.
        $queryRelatedCategories = Category::find()
            ->leftJoin('{{%content_category}}', '{{%content_category}}.[[category_id]] = {{%category}}.[[id]]')
            ->andWhere(new \yii\db\Expression('{{%content_category}}.[[content_id]] = {{%content}}.[[id]]'));

        // Prepare subquery for retrieving only comma-separated titles
        $queryRelatedCategories
            ->select(new \yii\db\Expression('GROUP_CONCAT( {{%category}}.[[title]] )'));

        // Prepare expression with scalar value from subquery
        $sqlRelatedCategories = $queryRelatedCategories->createCommand()->getRawSql();

        return new \yii\db\Expression('(' . $sqlRelatedCategories . ')');
    }

    // ...
}

When alias of additional column equals to some model property, it will be populated by all() method:

$contentModels = Content::find()
    ->andSelect([
        '*',
        Content::ATTR_CATEGORIES_CSV => Content::prepareRelatedCategoriesExpression(),
    ])
    ->all();


foreach ($contentModels as $contentModel) {
    $contentModel->id;
    $contentModel->categoriesCsv;   // it will be also populated by ->all() method.
    // ...
}

ps: I not tested this code, probably should be fixed query for retrieving categories.

Moreover, in this example it written using base simple syntax, but it may be optimized to more cute state using various helpers, junction models etc.

Upvotes: 1

Related Questions