Skip to content

Instantly share code, notes, and snippets.

@fagiani
Created March 9, 2011 19:22
Show Gist options
  • Save fagiani/862783 to your computer and use it in GitHub Desktop.
Save fagiani/862783 to your computer and use it in GitHub Desktop.
This is the current refactor I am working on
# Copyright (c) 2010 Wilker Lúcio <[email protected]>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
module Mongoid
module Taggable
extend ActiveSupport::Concern
included do
class_inheritable_reader :tags_field
class_inheritable_accessor :tags_separator, :tag_aggregation,
:instance_writer => false
delegate :convert_string_tags_to_array, :to => 'self.class'
set_callback :create, :after, :aggregate_tags
set_callback :save, :after, :aggregate_tags, :if => proc { previous_changes.include?(tags_field.to_s) }
end
# Execute map/reduce operation to aggregate tag counts for document
# class
def aggregate_tags
return unless tag_aggregation
map = "function() {
if (!this.#{tags_field}) {
return;
}
for (index in this.#{tags_field}) {
emit(this.#{tags_field}[index], 1);
}
}"
reduce = "function(previous, current) {
var count = 0;
for (index in current) {
count += current[index]
}
return count;
}"
collection.master.map_reduce(map, reduce, :out => "#{collection_name}_tags_aggregation")
end
module ClassMethods
# Macro to declare a document class as taggable, specify field name
# for tags, and set options for tagging behavior.
#
# @example Define a taggable document.
#
# class Article
# include Mongoid::Document
# include Mongoid::Taggable
# taggable :keywords, :separator => ' ', :aggregation => true
# end
#
# @param [ Symbol ] field The name of the field for tags.
# @param [ Hash ] options Options for taggable behavior.
#
# @option options [ String ] :separator The tag separator to
# convert from; defaults to ','
# @option options [ true, false ] :aggregation Whether or not to
# aggregate counts of tags within the document collection using
# map/reduce; defaults to false
def taggable(*args)
options = args.extract_options!
options.reverse_merge!(
:separator => ',',
:aggregation => false
)
write_inheritable_attribute(:tags_field, args.blank? ? :tags : args.shift)
self.tags_separator = options[:separator]
self.tag_aggregation = options[:aggregation]
field tags_field, :type => Array
index tags_field
define_tag_field_accessors(tags_field)
end
# get an array with all defined tags for this model, this list returns
# an array of distinct ordered list of tags defined in all documents
# of this model
def tags
db.collection(tags_aggregation_collection).find.to_a.map{ |r| r["_id"] }
end
# retrieve the list of tags with weight(count), this is useful for
# creating tag clouds
def tags_with_weight
db.collection(tags_aggregation_collection).find.to_a.map{ |r| [r["_id"], r["value"]] }
end
# Find documents tagged with all tags passed as a parameter, given
# as an Array or a String using the configured separator.
#
# @example Find matching all tags in an Array.
# Article.tagged_with(['ruby', 'mongodb'])
# @example Find matching all tags in a String.
# Article.tagged_with('ruby, mongodb')
#
# @param [ Array<String, Symbol>, String ] _tags Tags to match.
# @return [ Criteria ] A new criteria.
def tagged_with(_tags)
_tags = convert_string_tags_to_array(_tags) if _tags.is_a? String
criteria.all_in(tags_field => _tags)
end
# Collection name for storing results of tag count aggregation
def tags_aggregation_collection
@tags_aggregation_collection ||= "#{collection_name}_tags_aggregation"
end
private
# Helper method to convert a String to an Array based on the
# configured tag separator.
def convert_string_tags_to_array(_tags)
(_tags).split(tags_separator).map(&:strip)
end
# Define modifier for the configured tag field name that overrides
# the default to transparently convert tags given as a String.
def define_tag_field_accessors(name)
define_method "#{name}_with_taggable=" do |value|
value = convert_string_tags_to_array(value) if value.is_a? String
send("#{name}_without_taggable=", value)
end
alias_method_chain "#{name}=", :taggable
end
end
end
end
# Copyright (c) 2010 Wilker Lúcio <[email protected]>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
require File.join(File.dirname(__FILE__), %w[.. spec_helper])
class MyModel
include Mongoid::Document
include Mongoid::Taggable
field :attr
taggable
end
class Article
include Mongoid::Document
include Mongoid::Taggable
taggable :keywords
end
class Editorial < Article
self.tags_separator = ' '
self.tag_aggregation = true
end
class Template
include Mongoid::Document
include Mongoid::Taggable
include Mongoid::Timestamps
taggable :aggregation => true
end
describe Mongoid::Taggable do
context "saving tags from plain text" do
let(:model) { MyModel.new }
it "should set tags array from string" do
model.tags = "some,new,tag"
model.tags.should == %w[some new tag]
end
it "should strip tags before put in array" do
model.tags = "now , with, some spaces , in places "
model.tags.should == ["now", "with", "some spaces", "in places"]
end
end
context "with customized tag field name" do
let(:article) { Article.new }
it "should set tags array from string" do
article.keywords = "some,new,tag"
article.keywords.should == %w[some new tag]
end
end
context "changing separator" do
before :all do
MyModel.tags_separator = ";"
end
after :all do
MyModel.tags_separator = ","
end
let(:model) { MyModel.new }
it "should split with custom separator" do
model.tags = "some;other;separator"
model.tags.should == %w[some other separator]
end
end
context "tag & count aggregation" do
it "should generate the aggregate collection name based on model" do
MyModel.tags_aggregation_collection.should == "my_models_tags_aggregation"
end
it "should be disabled by default" do
MyModel.create!(:tags => "sample,tags")
MyModel.tags.should == []
end
context "when enabled" do
before :all do
MyModel.tag_aggregation = true
end
after :all do
MyModel.tag_aggregation = false
end
let!(:models) do
[
MyModel.create!(:tags => "food,ant,bee"),
MyModel.create!(:tags => "juice,food,bee,zip"),
MyModel.create!(:tags => "honey,strip,food")
]
end
it "should list all saved tags distinct and ordered" do
MyModel.tags.should == %w[ant bee food honey juice strip zip]
end
it "should list all tags with their weights" do
MyModel.tags_with_weight.should == [
['ant', 1],
['bee', 2],
['food', 3],
['honey', 1],
['juice', 1],
['strip', 1],
['zip', 1]
]
end
it "should update when tags are edited" do
MyModel.should_receive(:aggregate_tags)
models.first.update_attributes(:tags => 'changed')
end
it "should not update if tags are unchanged" do
MyModel.should_not_receive(:aggregate_tags)
models.first.update_attributes(:attr => "changed")
end
end
end
context "#self.tagged_with" do
let!(:models) do
[
MyModel.create!(:tags => "tag1,tag2,tag3"),
MyModel.create!(:tags => "tag2"),
MyModel.create!(:tags => "tag1", :attr => "value")
]
end
it "should return all tags with single tag input" do
MyModel.tagged_with("tag2").sort_by{|a| a.id.to_s}.should == [models.first, models.second].sort_by{|a| a.id.to_s}
end
it "should return all tags with tags array input" do
MyModel.tagged_with(%w{tag2 tag1}).should == [models.first]
end
it "should return all tags with tags string input" do
MyModel.tagged_with("tag2,tag1").should == [models.first]
end
it "should be able to be part of methods chain" do
MyModel.tagged_with("tag1").where(:attr => "value").should == [models.last]
end
end
context "a subclass of a taggable document" do
let(:editorial) { Editorial.new }
it "can enable tag aggregation exclusively" do
Article.tag_aggregation.should == false
Editorial.tag_aggregation.should == true
end
it "can split with a different separator" do
editorial.keywords = 'opinion politics'
editorial.keywords.should == %w[opinion politics]
end
end
context "using Mongoid::Timestamps along" do
it "should list all saved tags distinct and ordered with custom tag attribute" do
Template.create!(:tags => 'food, ant, bee')
Template.tags.should == %w[ant bee food]
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment