9

I'm attempting to write a plugin to extend tags by adding a count property that'd be useful identifying popular tags, making a tag cloud, and anything else where a count would be useful.

The Goal

What I'd like is to neatly extend TagModel so that interacting with these new count tallies would be straightforward and as similar as possible to working with tags. For example, pretend superTags is my count-appended set and I'm getting the top ten tags for a site across sections:

{% for tag in superTags.limit(10).order('count desc') %}
    {{ tag.name }} has been used {{ tag.count }} times.
{% endfor %}

...or getting the top 10 tags by a blog author:

{% for tag in superTags.channel('blog').relatedTo(author).limit(10).order('count desc') %}
    {{ tag.name }} was born in {{ tag.dateCreated | date('Y') }}.
{% endfor %}

The Problem

I humbly admit that I'm struggling to see the big picture. For example, this works fine (my plugin's called Taggregate):

{% set tags = craft.tags.find() %}
{% set superTags = craft.taggregate.appendCounts(tags) %}
{% set superTags = craft.taggregate.sortByCount(superTags) %}

{% for tag in superTags %}
    {{ tag.name }} has been used {{ tag.count }} times.<br>
{% endfor %}

I've got a TaggregateModel that extends TagModel only to merge in a count Number to defineAttributes(). My Service takes the tags array, then spits out a new one with TaggregateModel objects that include the count property. (Each count is just a tally from craft_relations with no regard for other parameters like section or author—later I intend to use an aggregate table as Brad suggested, even though that's irrelevant here.)

TaggregateService

...

public function appendCounts($tags)
{
    $superTags = array(); 

    new TaggregateModel($tags);

    for ($i=0; $i < count($tags); $i++)
    { 
        $superTags[$i] = new TaggregateModel($tags[$i]);
        $superTags[$i]->count = $this->getTagCount($superTags[$i]->id);
    }

    return $superTags;
}

public function getTagCount($tagId)
{
    $q = craft()->db->createCommand('SELECT count(*) as count FROM craft_relations WHERE targetId = '.$tagId)->queryAll();

    // TODO: get stored aggregate if it exists, otherwise count and store

    return $q[0]['count'];
}

public function sortByCount($tags)
{
    usort($tags, function($a, $b)
        {
            return $a->count < $b->count;
        }
    );

    return $tags;
}

...

While this works, it's clearly bad design:

  1. The Twig interface is specific to the plugin's weird version of non-native methods.
  2. This superTag array isn't smart enough to do any of the cool things that TagModel can.

The Question

Is there a more sensible way to build this so I can hit my ideal usage outlined in "The Goal" above? I'm not looking for somebody to write this for me, just some big-picture guidance since I suspect I'm making this harder than it should be.

...or is my seemingly-simple objective actually complicated?

5
+50

Before worrying about how templating should work, let’s just get one thing out of the way: Computing this data every time it’s requested is horribly inefficient, and could easily take a site down. You’ll absolutely need to find a way to store the tag counts in a more permanent location, and calculate it when entries are saved rather than when the template requests it. (Even if you wrap the template in a {% cache %} tag, you’re still forcing a couple people to experience slow page load times for no good reason.)

You have multiple options on how to store the data, but giving it its own table is probably the most obvious/reliable. I’d call it something like tagcounts, and give it tagId and count columns. tagId should be a FK to the tags table, set to cascade delete.

Then you’d want to listen to the entries.onSaveEntry event. When it fires, loop through all the entry’s Tags fields, and each of their selected tags. For each one, get the total count of rows in the relations table where the targetId column is set to the tag’s ID. Use DbCommand::insertOrUpdate() to save the value in your table.

craft()->db->createCommand()->insertOrUpdate('tagcounts', array(
    'tagId' => $tag->id
), array(
    'count' => $count
));

You’ll also need to loop through all existing tags and populate this table when the plugin is first installed.

With the tag count data in place, next up is templating. Unfortunately I don’t have a good answer for you there. It’s a complicated problem.

I think what I would do is, create a Variable class which accepts an ElementCriteriaModel, passes that off to craft()->elements->buildElementsQuery() which will take most of the parameters into account, then make some additional modifications to the query – join in the tagcounts table and set the ORDER BY.

Next up you would basically have to copy a ton of code from ElementsService::findElements(), which is totally lame. I’m going to see what I can do to make it much easier on you going forward. Ideally at this point you would be able to just do something like:

return craft()->elements->findElementsUsingQuery($query);
| improve this answer | |
  • 5
    Oh sure... now that there's a bounty you answer. – Brad Bell Jul 16 '14 at 23:57
  • Thanks for breaking this down, Brandon! The last two paragraphs are where I locked up, and it sounds like there isn't a nice clean way of getting findElements() functionality without just copying it. At least I know I didn't miss something obvious this time. – Matt Stein Jul 17 '14 at 0:08

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.