acts_as_taggable_on 3.0.0.rc1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +15 -0
- data/.gitignore +11 -0
- data/.rspec +2 -0
- data/.travis.yml +9 -0
- data/Appraisals +7 -0
- data/Gemfile +5 -0
- data/Guardfile +5 -0
- data/LICENSE.md +20 -0
- data/README.md +309 -0
- data/Rakefile +13 -0
- data/UPGRADING +7 -0
- data/acts_as_taggable_on.gemspec +35 -0
- data/db/migrate/1_acts_as_taggable_on_migration.rb +30 -0
- data/db/migrate/2_add_missing_unique_indices.rb +21 -0
- data/gemfiles/rails_3.gemfile +8 -0
- data/gemfiles/rails_4.gemfile +8 -0
- data/lib/acts_as_taggable_on.rb +61 -0
- data/lib/acts_as_taggable_on/acts_as_taggable_on/cache.rb +82 -0
- data/lib/acts_as_taggable_on/acts_as_taggable_on/collection.rb +187 -0
- data/lib/acts_as_taggable_on/acts_as_taggable_on/compatibility.rb +34 -0
- data/lib/acts_as_taggable_on/acts_as_taggable_on/core.rb +394 -0
- data/lib/acts_as_taggable_on/acts_as_taggable_on/dirty.rb +37 -0
- data/lib/acts_as_taggable_on/acts_as_taggable_on/ownership.rb +135 -0
- data/lib/acts_as_taggable_on/acts_as_taggable_on/related.rb +84 -0
- data/lib/acts_as_taggable_on/engine.rb +6 -0
- data/lib/acts_as_taggable_on/tag.rb +119 -0
- data/lib/acts_as_taggable_on/tag_list.rb +101 -0
- data/lib/acts_as_taggable_on/taggable.rb +105 -0
- data/lib/acts_as_taggable_on/tagger.rb +76 -0
- data/lib/acts_as_taggable_on/tagging.rb +34 -0
- data/lib/acts_as_taggable_on/tags_helper.rb +15 -0
- data/lib/acts_as_taggable_on/utils.rb +34 -0
- data/lib/acts_as_taggable_on/version.rb +4 -0
- data/spec/acts_as_taggable_on/acts_as_taggable_on_spec.rb +265 -0
- data/spec/acts_as_taggable_on/acts_as_tagger_spec.rb +114 -0
- data/spec/acts_as_taggable_on/caching_spec.rb +77 -0
- data/spec/acts_as_taggable_on/related_spec.rb +143 -0
- data/spec/acts_as_taggable_on/single_table_inheritance_spec.rb +187 -0
- data/spec/acts_as_taggable_on/tag_list_spec.rb +126 -0
- data/spec/acts_as_taggable_on/tag_spec.rb +211 -0
- data/spec/acts_as_taggable_on/taggable_spec.rb +623 -0
- data/spec/acts_as_taggable_on/tagger_spec.rb +137 -0
- data/spec/acts_as_taggable_on/tagging_spec.rb +28 -0
- data/spec/acts_as_taggable_on/tags_helper_spec.rb +44 -0
- data/spec/acts_as_taggable_on/utils_spec.rb +21 -0
- data/spec/bm.rb +52 -0
- data/spec/database.yml.sample +19 -0
- data/spec/models.rb +58 -0
- data/spec/schema.rb +65 -0
- data/spec/spec_helper.rb +87 -0
- metadata +248 -0
@@ -0,0 +1,30 @@
|
|
1
|
+
class ActsAsTaggableOnMigration < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
create_table :tags do |t|
|
4
|
+
t.string :name
|
5
|
+
end
|
6
|
+
|
7
|
+
create_table :taggings do |t|
|
8
|
+
t.references :tag
|
9
|
+
|
10
|
+
# You should make sure that the column created is
|
11
|
+
# long enough to store the required class names.
|
12
|
+
t.references :taggable, :polymorphic => true
|
13
|
+
t.references :tagger, :polymorphic => true
|
14
|
+
|
15
|
+
# Limit is created to prevent MySQL error on index
|
16
|
+
# length for MyISAM table type: http://bit.ly/vgW2Ql
|
17
|
+
t.string :context, :limit => 128
|
18
|
+
|
19
|
+
t.datetime :created_at
|
20
|
+
end
|
21
|
+
|
22
|
+
add_index :taggings, :tag_id
|
23
|
+
add_index :taggings, [:taggable_id, :taggable_type, :context]
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.down
|
27
|
+
drop_table :taggings
|
28
|
+
drop_table :tags
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
class AddMissingUniqueIndices < ActiveRecord::Migration
|
2
|
+
|
3
|
+
def self.up
|
4
|
+
add_index :tags, :name, unique: true
|
5
|
+
|
6
|
+
remove_index :taggings, :tag_id
|
7
|
+
remove_index :taggings, [:taggable_id, :taggable_type, :context]
|
8
|
+
add_index :taggings,
|
9
|
+
[:tag_id, :taggable_id, :taggable_type, :context, :tagger_id, :tagger_type],
|
10
|
+
unique: true, name: 'taggings_idx'
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.down
|
14
|
+
remove_index :tags, :name
|
15
|
+
|
16
|
+
remove_index :taggings, name: 'tagging_idx'
|
17
|
+
add_index :taggings, :tag_id
|
18
|
+
add_index :taggings, [:taggable_id, :taggable_type, :context]
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require "active_record"
|
2
|
+
require "active_record/version"
|
3
|
+
require "active_support/core_ext/module"
|
4
|
+
require "action_view"
|
5
|
+
require 'active_support/all'
|
6
|
+
|
7
|
+
require "digest/sha1"
|
8
|
+
|
9
|
+
module ActsAsTaggableOn
|
10
|
+
mattr_accessor :delimiter
|
11
|
+
@@delimiter = ','
|
12
|
+
|
13
|
+
mattr_accessor :force_lowercase
|
14
|
+
@@force_lowercase = false
|
15
|
+
|
16
|
+
mattr_accessor :force_parameterize
|
17
|
+
@@force_parameterize = false
|
18
|
+
|
19
|
+
mattr_accessor :strict_case_match
|
20
|
+
@@strict_case_match = false
|
21
|
+
|
22
|
+
mattr_accessor :remove_unused_tags
|
23
|
+
self.remove_unused_tags = false
|
24
|
+
|
25
|
+
def self.glue
|
26
|
+
delimiter = @@delimiter.kind_of?(Array) ? @@delimiter[0] : @@delimiter
|
27
|
+
delimiter.ends_with?(" ") ? delimiter : "#{delimiter} "
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.setup
|
31
|
+
yield self
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
|
36
|
+
require "acts_as_taggable_on/utils"
|
37
|
+
|
38
|
+
require "acts_as_taggable_on/taggable"
|
39
|
+
require "acts_as_taggable_on/acts_as_taggable_on/compatibility"
|
40
|
+
require "acts_as_taggable_on/acts_as_taggable_on/core"
|
41
|
+
require "acts_as_taggable_on/acts_as_taggable_on/collection"
|
42
|
+
require "acts_as_taggable_on/acts_as_taggable_on/cache"
|
43
|
+
require "acts_as_taggable_on/acts_as_taggable_on/ownership"
|
44
|
+
require "acts_as_taggable_on/acts_as_taggable_on/related"
|
45
|
+
require "acts_as_taggable_on/acts_as_taggable_on/dirty"
|
46
|
+
|
47
|
+
require "acts_as_taggable_on/tagger"
|
48
|
+
require "acts_as_taggable_on/tag"
|
49
|
+
require "acts_as_taggable_on/tag_list"
|
50
|
+
require "acts_as_taggable_on/tags_helper"
|
51
|
+
require "acts_as_taggable_on/tagging"
|
52
|
+
require 'acts_as_taggable_on/engine'
|
53
|
+
|
54
|
+
ActiveSupport.on_load(:active_record) do
|
55
|
+
extend ActsAsTaggableOn::Compatibility
|
56
|
+
extend ActsAsTaggableOn::Taggable
|
57
|
+
include ActsAsTaggableOn::Tagger
|
58
|
+
end
|
59
|
+
ActiveSupport.on_load(:action_view) do
|
60
|
+
include ActsAsTaggableOn::TagsHelper
|
61
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module ActsAsTaggableOn::Taggable
|
2
|
+
module Cache
|
3
|
+
def self.included(base)
|
4
|
+
# When included, conditionally adds tag caching methods when the model
|
5
|
+
# has any "cached_#{tag_type}_list" column
|
6
|
+
base.instance_eval do
|
7
|
+
# @private
|
8
|
+
def _has_acts_as_taggable_on_cache_columns?(db_columns)
|
9
|
+
db_column_names = db_columns.map(&:name)
|
10
|
+
tag_types.any? {|context|
|
11
|
+
db_column_names.include?("cached_#{context.to_s.singularize}_list")
|
12
|
+
}
|
13
|
+
end
|
14
|
+
|
15
|
+
# @private
|
16
|
+
def _add_acts_as_taggable_on_caching_methods
|
17
|
+
send :include, ActsAsTaggableOn::Taggable::Cache::InstanceMethods
|
18
|
+
extend ActsAsTaggableOn::Taggable::Cache::ClassMethods
|
19
|
+
|
20
|
+
before_save :save_cached_tag_list
|
21
|
+
|
22
|
+
initialize_acts_as_taggable_on_cache
|
23
|
+
end
|
24
|
+
|
25
|
+
# ActiveRecord::Base.columns makes a database connection and caches the calculated
|
26
|
+
# columns hash for the record as @columns. Since we don't want to add caching
|
27
|
+
# methods until we confirm the presence of a caching column, and we don't
|
28
|
+
# want to force opening a database connection when the class is loaded,
|
29
|
+
# here we intercept and cache the call to :columns as @acts_as_taggable_on_columns
|
30
|
+
# to mimic the underlying behavior. While processing this first call to columns,
|
31
|
+
# we do the caching column check and dynamically add the class and instance methods
|
32
|
+
def columns
|
33
|
+
@acts_as_taggable_on_columns ||= begin
|
34
|
+
db_columns = super
|
35
|
+
if _has_acts_as_taggable_on_cache_columns?(db_columns)
|
36
|
+
_add_acts_as_taggable_on_caching_methods
|
37
|
+
end
|
38
|
+
db_columns
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
module ClassMethods
|
46
|
+
def initialize_acts_as_taggable_on_cache
|
47
|
+
tag_types.map(&:to_s).each do |tag_type|
|
48
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
49
|
+
def self.caching_#{tag_type.singularize}_list?
|
50
|
+
caching_tag_list_on?("#{tag_type}")
|
51
|
+
end
|
52
|
+
RUBY
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def acts_as_taggable_on(*args)
|
57
|
+
super(*args)
|
58
|
+
initialize_acts_as_taggable_on_cache
|
59
|
+
end
|
60
|
+
|
61
|
+
def caching_tag_list_on?(context)
|
62
|
+
column_names.include?("cached_#{context.to_s.singularize}_list")
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
module InstanceMethods
|
67
|
+
def save_cached_tag_list
|
68
|
+
tag_types.map(&:to_s).each do |tag_type|
|
69
|
+
if self.class.send("caching_#{tag_type.singularize}_list?")
|
70
|
+
if tag_list_cache_set_on(tag_type)
|
71
|
+
list = tag_list_cache_on(tag_type).to_a.flatten.compact.join(', ')
|
72
|
+
self["cached_#{tag_type.singularize}_list"] = list
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
true
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,187 @@
|
|
1
|
+
module ActsAsTaggableOn::Taggable
|
2
|
+
module Collection
|
3
|
+
def self.included(base)
|
4
|
+
base.send :include, ActsAsTaggableOn::Taggable::Collection::InstanceMethods
|
5
|
+
base.extend ActsAsTaggableOn::Taggable::Collection::ClassMethods
|
6
|
+
base.initialize_acts_as_taggable_on_collection
|
7
|
+
end
|
8
|
+
|
9
|
+
module ClassMethods
|
10
|
+
def initialize_acts_as_taggable_on_collection
|
11
|
+
tag_types.map(&:to_s).each do |tag_type|
|
12
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
13
|
+
def self.#{tag_type.singularize}_counts(options={})
|
14
|
+
tag_counts_on('#{tag_type}', options)
|
15
|
+
end
|
16
|
+
|
17
|
+
def #{tag_type.singularize}_counts(options = {})
|
18
|
+
tag_counts_on('#{tag_type}', options)
|
19
|
+
end
|
20
|
+
|
21
|
+
def top_#{tag_type}(limit = 10)
|
22
|
+
tag_counts_on('#{tag_type}', :order => 'count desc', :limit => limit.to_i)
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.top_#{tag_type}(limit = 10)
|
26
|
+
tag_counts_on('#{tag_type}', :order => 'count desc', :limit => limit.to_i)
|
27
|
+
end
|
28
|
+
RUBY
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def acts_as_taggable_on(*args)
|
33
|
+
super(*args)
|
34
|
+
initialize_acts_as_taggable_on_collection
|
35
|
+
end
|
36
|
+
|
37
|
+
def tag_counts_on(context, options = {})
|
38
|
+
all_tag_counts(options.merge({:on => context.to_s}))
|
39
|
+
end
|
40
|
+
|
41
|
+
def tags_on(context, options = {})
|
42
|
+
all_tags(options.merge({:on => context.to_s}))
|
43
|
+
end
|
44
|
+
|
45
|
+
##
|
46
|
+
# Calculate the tag names.
|
47
|
+
# To be used when you don't need tag counts and want to avoid the taggable joins.
|
48
|
+
#
|
49
|
+
# @param [Hash] options Options:
|
50
|
+
# * :start_at - Restrict the tags to those created after a certain time
|
51
|
+
# * :end_at - Restrict the tags to those created before a certain time
|
52
|
+
# * :conditions - A piece of SQL conditions to add to the query. Note we don't join the taggable objects for performance reasons.
|
53
|
+
# * :limit - The maximum number of tags to return
|
54
|
+
# * :order - A piece of SQL to order by. Eg 'tags.count desc' or 'taggings.created_at desc'
|
55
|
+
# * :on - Scope the find to only include a certain context
|
56
|
+
def all_tags(options = {})
|
57
|
+
options.assert_valid_keys :start_at, :end_at, :conditions, :order, :limit, :on
|
58
|
+
|
59
|
+
## Generate conditions:
|
60
|
+
options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions]
|
61
|
+
|
62
|
+
start_at_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at]
|
63
|
+
end_at_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
|
64
|
+
|
65
|
+
taggable_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.taggable_type = ?", base_class.name])
|
66
|
+
taggable_conditions << sanitize_sql([" AND #{ActsAsTaggableOn::Tagging.table_name}.context = ?", options.delete(:on).to_s]) if options[:on]
|
67
|
+
|
68
|
+
tagging_conditions = [
|
69
|
+
taggable_conditions,
|
70
|
+
start_at_conditions,
|
71
|
+
end_at_conditions
|
72
|
+
].compact.reverse
|
73
|
+
|
74
|
+
tag_conditions = [
|
75
|
+
options[:conditions]
|
76
|
+
].compact.reverse
|
77
|
+
|
78
|
+
## Generate scope:
|
79
|
+
tagging_scope = ActsAsTaggableOn::Tagging.select("#{ActsAsTaggableOn::Tagging.table_name}.tag_id")
|
80
|
+
tag_scope = ActsAsTaggableOn::Tag.select("#{ActsAsTaggableOn::Tag.table_name}.*").order(options[:order]).limit(options[:limit])
|
81
|
+
|
82
|
+
# Joins and conditions
|
83
|
+
tagging_conditions.each { |condition| tagging_scope = tagging_scope.where(condition) }
|
84
|
+
tag_conditions.each { |condition| tag_scope = tag_scope.where(condition) }
|
85
|
+
|
86
|
+
group_columns = "#{ActsAsTaggableOn::Tagging.table_name}.tag_id"
|
87
|
+
|
88
|
+
# Append the current scope to the scope, because we can't use scope(:find) in RoR 3.0 anymore:
|
89
|
+
scoped_select = "#{table_name}.#{primary_key}"
|
90
|
+
tagging_scope = tagging_scope.where("#{ActsAsTaggableOn::Tagging.table_name}.taggable_id IN(#{safe_to_sql(select(scoped_select))})").group(group_columns)
|
91
|
+
|
92
|
+
tag_scope = tag_scope.joins("JOIN (#{safe_to_sql(tagging_scope)}) AS #{ActsAsTaggableOn::Tagging.table_name} ON #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.id")
|
93
|
+
tag_scope
|
94
|
+
end
|
95
|
+
|
96
|
+
##
|
97
|
+
# Calculate the tag counts for all tags.
|
98
|
+
#
|
99
|
+
# @param [Hash] options Options:
|
100
|
+
# * :start_at - Restrict the tags to those created after a certain time
|
101
|
+
# * :end_at - Restrict the tags to those created before a certain time
|
102
|
+
# * :conditions - A piece of SQL conditions to add to the query
|
103
|
+
# * :limit - The maximum number of tags to return
|
104
|
+
# * :order - A piece of SQL to order by. Eg 'tags.count desc' or 'taggings.created_at desc'
|
105
|
+
# * :at_least - Exclude tags with a frequency less than the given value
|
106
|
+
# * :at_most - Exclude tags with a frequency greater than the given value
|
107
|
+
# * :on - Scope the find to only include a certain context
|
108
|
+
def all_tag_counts(options = {})
|
109
|
+
options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit, :on, :id
|
110
|
+
|
111
|
+
scope = {}
|
112
|
+
|
113
|
+
## Generate conditions:
|
114
|
+
options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions]
|
115
|
+
|
116
|
+
start_at_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at]
|
117
|
+
end_at_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
|
118
|
+
|
119
|
+
taggable_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.taggable_type = ?", base_class.name])
|
120
|
+
taggable_conditions << sanitize_sql([" AND #{ActsAsTaggableOn::Tagging.table_name}.taggable_id = ?", options[:id]]) if options[:id]
|
121
|
+
taggable_conditions << sanitize_sql([" AND #{ActsAsTaggableOn::Tagging.table_name}.context = ?", options.delete(:on).to_s]) if options[:on]
|
122
|
+
|
123
|
+
tagging_conditions = [
|
124
|
+
taggable_conditions,
|
125
|
+
scope[:conditions],
|
126
|
+
start_at_conditions,
|
127
|
+
end_at_conditions
|
128
|
+
].compact.reverse
|
129
|
+
|
130
|
+
tag_conditions = [
|
131
|
+
options[:conditions]
|
132
|
+
].compact.reverse
|
133
|
+
|
134
|
+
## Generate joins:
|
135
|
+
taggable_join = "INNER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{ActsAsTaggableOn::Tagging.table_name}.taggable_id"
|
136
|
+
taggable_join << " AND #{table_name}.#{inheritance_column} = '#{name}'" unless descends_from_active_record? # Current model is STI descendant, so add type checking to the join condition
|
137
|
+
|
138
|
+
tagging_joins = [
|
139
|
+
taggable_join,
|
140
|
+
scope[:joins]
|
141
|
+
].compact
|
142
|
+
|
143
|
+
tag_joins = [
|
144
|
+
].compact
|
145
|
+
|
146
|
+
## Generate scope:
|
147
|
+
tagging_scope = ActsAsTaggableOn::Tagging.select("#{ActsAsTaggableOn::Tagging.table_name}.tag_id, COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) AS tags_count")
|
148
|
+
tag_scope = ActsAsTaggableOn::Tag.select("#{ActsAsTaggableOn::Tag.table_name}.*, #{ActsAsTaggableOn::Tagging.table_name}.tags_count AS count").order(options[:order]).limit(options[:limit])
|
149
|
+
|
150
|
+
# Joins and conditions
|
151
|
+
tagging_joins.each { |join| tagging_scope = tagging_scope.joins(join) }
|
152
|
+
tagging_conditions.each { |condition| tagging_scope = tagging_scope.where(condition) }
|
153
|
+
|
154
|
+
tag_joins.each { |join| tag_scope = tag_scope.joins(join) }
|
155
|
+
tag_conditions.each { |condition| tag_scope = tag_scope.where(condition) }
|
156
|
+
|
157
|
+
# GROUP BY and HAVING clauses:
|
158
|
+
at_least = sanitize_sql(["COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) >= ?", options.delete(:at_least)]) if options[:at_least]
|
159
|
+
at_most = sanitize_sql(["COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) <= ?", options.delete(:at_most)]) if options[:at_most]
|
160
|
+
having = ["COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) > 0", at_least, at_most].compact.join(' AND ')
|
161
|
+
|
162
|
+
group_columns = "#{ActsAsTaggableOn::Tagging.table_name}.tag_id"
|
163
|
+
|
164
|
+
unless options[:id]
|
165
|
+
# Append the current scope to the scope, because we can't use scope(:find) in RoR 3.0 anymore:
|
166
|
+
scoped_select = "#{table_name}.#{primary_key}"
|
167
|
+
tagging_scope = tagging_scope.where("#{ActsAsTaggableOn::Tagging.table_name}.taggable_id IN(#{safe_to_sql(select(scoped_select))})")
|
168
|
+
end
|
169
|
+
|
170
|
+
tagging_scope = tagging_scope.group(group_columns).having(having)
|
171
|
+
|
172
|
+
tag_scope = tag_scope.joins("JOIN (#{safe_to_sql(tagging_scope)}) AS #{ActsAsTaggableOn::Tagging.table_name} ON #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.id")
|
173
|
+
tag_scope
|
174
|
+
end
|
175
|
+
|
176
|
+
def safe_to_sql(relation)
|
177
|
+
connection.respond_to?(:unprepared_statement) ? connection.unprepared_statement{relation.to_sql} : relation.to_sql
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
module InstanceMethods
|
182
|
+
def tag_counts_on(context, options={})
|
183
|
+
self.class.tag_counts_on(context, options.merge(:id => id))
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module ActsAsTaggableOn::Compatibility
|
2
|
+
def has_many_with_compatibility(name, options = {}, &extention)
|
3
|
+
if ActiveRecord::VERSION::MAJOR >= 4
|
4
|
+
scope, opts = build_scope_and_options(options)
|
5
|
+
has_many(name, scope, opts, &extention)
|
6
|
+
else
|
7
|
+
has_many(name, options, &extention)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def build_scope_and_options(opts)
|
12
|
+
scope_opts, opts = parse_options(opts)
|
13
|
+
|
14
|
+
unless scope_opts.empty?
|
15
|
+
scope = lambda do
|
16
|
+
scope_opts.inject(self) { |result, hash| result.send *hash }
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
[defined?(scope) ? scope : nil, opts]
|
21
|
+
end
|
22
|
+
|
23
|
+
def parse_options(opts)
|
24
|
+
scope_opts = {}
|
25
|
+
[:order, :having, :select, :group, :limit, :offset, :readonly].each do |o|
|
26
|
+
scope_opts[o] = opts.delete o if opts[o]
|
27
|
+
end
|
28
|
+
scope_opts[:where] = opts.delete :conditions if opts[:conditions]
|
29
|
+
scope_opts[:joins] = opts.delete :include if opts [:include]
|
30
|
+
scope_opts[:distinct] = opts.delete :uniq if opts[:uniq]
|
31
|
+
|
32
|
+
[scope_opts, opts]
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,394 @@
|
|
1
|
+
module ActsAsTaggableOn::Taggable
|
2
|
+
module Core
|
3
|
+
def self.included(base)
|
4
|
+
base.send :include, ActsAsTaggableOn::Taggable::Core::InstanceMethods
|
5
|
+
base.extend ActsAsTaggableOn::Taggable::Core::ClassMethods
|
6
|
+
|
7
|
+
base.class_eval do
|
8
|
+
attr_writer :custom_contexts
|
9
|
+
after_save :save_tags
|
10
|
+
end
|
11
|
+
|
12
|
+
base.initialize_acts_as_taggable_on_core
|
13
|
+
end
|
14
|
+
|
15
|
+
module ClassMethods
|
16
|
+
|
17
|
+
def initialize_acts_as_taggable_on_core
|
18
|
+
include taggable_mixin
|
19
|
+
tag_types.map(&:to_s).each do |tags_type|
|
20
|
+
tag_type = tags_type.to_s.singularize
|
21
|
+
context_taggings = "#{tag_type}_taggings".to_sym
|
22
|
+
context_tags = tags_type.to_sym
|
23
|
+
taggings_order = (preserve_tag_order? ? "#{ActsAsTaggableOn::Tagging.table_name}.id" : [])
|
24
|
+
|
25
|
+
class_eval do
|
26
|
+
# when preserving tag order, include order option so that for a 'tags' context
|
27
|
+
# the associations tag_taggings & tags are always returned in created order
|
28
|
+
has_many_with_compatibility context_taggings, :as => :taggable,
|
29
|
+
:dependent => :destroy,
|
30
|
+
:class_name => "ActsAsTaggableOn::Tagging",
|
31
|
+
:order => taggings_order,
|
32
|
+
:conditions => ["#{ActsAsTaggableOn::Tagging.table_name}.context = (?)", tags_type],
|
33
|
+
:include => :tag
|
34
|
+
|
35
|
+
has_many_with_compatibility context_tags, :through => context_taggings,
|
36
|
+
:source => :tag,
|
37
|
+
:class_name => "ActsAsTaggableOn::Tag",
|
38
|
+
:order => taggings_order
|
39
|
+
|
40
|
+
end
|
41
|
+
|
42
|
+
taggable_mixin.class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
43
|
+
def #{tag_type}_list
|
44
|
+
tag_list_on('#{tags_type}')
|
45
|
+
end
|
46
|
+
|
47
|
+
def #{tag_type}_list=(new_tags)
|
48
|
+
set_tag_list_on('#{tags_type}', new_tags)
|
49
|
+
end
|
50
|
+
|
51
|
+
def all_#{tags_type}_list
|
52
|
+
all_tags_list_on('#{tags_type}')
|
53
|
+
end
|
54
|
+
RUBY
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def taggable_on(preserve_tag_order, *tag_types)
|
59
|
+
super(preserve_tag_order, *tag_types)
|
60
|
+
initialize_acts_as_taggable_on_core
|
61
|
+
end
|
62
|
+
|
63
|
+
# all column names are necessary for PostgreSQL group clause
|
64
|
+
def grouped_column_names_for(object)
|
65
|
+
object.column_names.map { |column| "#{object.table_name}.#{column}" }.join(", ")
|
66
|
+
end
|
67
|
+
|
68
|
+
##
|
69
|
+
# Return a scope of objects that are tagged with the specified tags.
|
70
|
+
#
|
71
|
+
# @param tags The tags that we want to query for
|
72
|
+
# @param [Hash] options A hash of options to alter you query:
|
73
|
+
# * <tt>:exclude</tt> - if set to true, return objects that are *NOT* tagged with the specified tags
|
74
|
+
# * <tt>:any</tt> - if set to true, return objects that are tagged with *ANY* of the specified tags
|
75
|
+
# * <tt>:match_all</tt> - if set to true, return objects that are *ONLY* tagged with the specified tags
|
76
|
+
# * <tt>:owned_by</tt> - return objects that are *ONLY* owned by the owner
|
77
|
+
#
|
78
|
+
# Example:
|
79
|
+
# User.tagged_with("awesome", "cool") # Users that are tagged with awesome and cool
|
80
|
+
# User.tagged_with("awesome", "cool", :exclude => true) # Users that are not tagged with awesome or cool
|
81
|
+
# User.tagged_with("awesome", "cool", :any => true) # Users that are tagged with awesome or cool
|
82
|
+
# User.tagged_with("awesome", "cool", :match_all => true) # Users that are tagged with just awesome and cool
|
83
|
+
# User.tagged_with("awesome", "cool", :owned_by => foo ) # Users that are tagged with just awesome and cool by 'foo'
|
84
|
+
def tagged_with(tags, options = {})
|
85
|
+
tag_list = ActsAsTaggableOn::TagList.from(tags)
|
86
|
+
empty_result = where("1 = 0")
|
87
|
+
|
88
|
+
return empty_result if tag_list.empty?
|
89
|
+
|
90
|
+
joins = []
|
91
|
+
conditions = []
|
92
|
+
having = []
|
93
|
+
select_clause = []
|
94
|
+
|
95
|
+
context = options.delete(:on)
|
96
|
+
owned_by = options.delete(:owned_by)
|
97
|
+
alias_base_name = undecorated_table_name.gsub('.','_')
|
98
|
+
quote = ActsAsTaggableOn::Tag.using_postgresql? ? '"' : ''
|
99
|
+
|
100
|
+
if options.delete(:exclude)
|
101
|
+
if options.delete(:wild)
|
102
|
+
tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name #{like_operator} ? ESCAPE '!'", "%#{escape_like(t)}%"]) }.join(" OR ")
|
103
|
+
else
|
104
|
+
tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name #{like_operator} ?", t]) }.join(" OR ")
|
105
|
+
end
|
106
|
+
|
107
|
+
conditions << "#{table_name}.#{primary_key} NOT IN (SELECT #{ActsAsTaggableOn::Tagging.table_name}.taggable_id FROM #{ActsAsTaggableOn::Tagging.table_name} JOIN #{ActsAsTaggableOn::Tag.table_name} ON #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key} AND (#{tags_conditions}) WHERE #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})"
|
108
|
+
|
109
|
+
if owned_by
|
110
|
+
joins << "JOIN #{ActsAsTaggableOn::Tagging.table_name}" +
|
111
|
+
" ON #{ActsAsTaggableOn::Tagging.table_name}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" +
|
112
|
+
" AND #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = #{quote_value(base_class.name)}" +
|
113
|
+
" AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_id = #{quote_value(owned_by.id)}" +
|
114
|
+
" AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_type = #{quote_value(owned_by.class.base_class.to_s)}"
|
115
|
+
end
|
116
|
+
|
117
|
+
elsif options.delete(:any)
|
118
|
+
# get tags, drop out if nothing returned (we need at least one)
|
119
|
+
tags = if options.delete(:wild)
|
120
|
+
ActsAsTaggableOn::Tag.named_like_any(tag_list)
|
121
|
+
else
|
122
|
+
ActsAsTaggableOn::Tag.named_any(tag_list)
|
123
|
+
end
|
124
|
+
|
125
|
+
return empty_result unless tags.length > 0
|
126
|
+
|
127
|
+
# setup taggings alias so we can chain, ex: items_locations_taggings_awesome_cool_123
|
128
|
+
# avoid ambiguous column name
|
129
|
+
taggings_context = context ? "_#{context}" : ''
|
130
|
+
|
131
|
+
taggings_alias = adjust_taggings_alias(
|
132
|
+
"#{alias_base_name[0..4]}#{taggings_context[0..6]}_taggings_#{sha_prefix(tags.map(&:name).join('_'))}"
|
133
|
+
)
|
134
|
+
|
135
|
+
tagging_join = "JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" +
|
136
|
+
" ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" +
|
137
|
+
" AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}"
|
138
|
+
tagging_join << " AND " + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context
|
139
|
+
|
140
|
+
# don't need to sanitize sql, map all ids and join with OR logic
|
141
|
+
conditions << tags.map { |t| "#{taggings_alias}.tag_id = #{quote_value(t.id)}" }.join(" OR ")
|
142
|
+
select_clause = "DISTINCT #{table_name}.*" unless context and tag_types.one?
|
143
|
+
|
144
|
+
if owned_by
|
145
|
+
tagging_join << " AND " +
|
146
|
+
sanitize_sql([
|
147
|
+
"#{taggings_alias}.tagger_id = ? AND #{taggings_alias}.tagger_type = ?",
|
148
|
+
owned_by.id,
|
149
|
+
owned_by.class.base_class.to_s
|
150
|
+
])
|
151
|
+
end
|
152
|
+
|
153
|
+
joins << tagging_join
|
154
|
+
else
|
155
|
+
tags = ActsAsTaggableOn::Tag.named_any(tag_list)
|
156
|
+
|
157
|
+
return empty_result unless tags.length == tag_list.length
|
158
|
+
|
159
|
+
tags.each do |tag|
|
160
|
+
taggings_alias = adjust_taggings_alias("#{alias_base_name[0..11]}_taggings_#{sha_prefix(tag.name)}")
|
161
|
+
tagging_join = "JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" +
|
162
|
+
" ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" +
|
163
|
+
" AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}" +
|
164
|
+
" AND #{taggings_alias}.tag_id = #{quote_value(tag.id)}"
|
165
|
+
|
166
|
+
tagging_join << " AND " + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context
|
167
|
+
|
168
|
+
if owned_by
|
169
|
+
tagging_join << " AND " +
|
170
|
+
sanitize_sql([
|
171
|
+
"#{taggings_alias}.tagger_id = ? AND #{taggings_alias}.tagger_type = ?",
|
172
|
+
owned_by.id,
|
173
|
+
owned_by.class.base_class.to_s
|
174
|
+
])
|
175
|
+
end
|
176
|
+
|
177
|
+
joins << tagging_join
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
taggings_alias, tags_alias = adjust_taggings_alias("#{alias_base_name}_taggings_group"), "#{alias_base_name}_tags_group"
|
182
|
+
|
183
|
+
if options.delete(:match_all)
|
184
|
+
joins << "LEFT OUTER JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" +
|
185
|
+
" ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" +
|
186
|
+
" AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}"
|
187
|
+
|
188
|
+
|
189
|
+
group_columns = ActsAsTaggableOn::Tag.using_postgresql? ? grouped_column_names_for(self) : "#{table_name}.#{primary_key}"
|
190
|
+
group = group_columns
|
191
|
+
having = "COUNT(#{taggings_alias}.taggable_id) = #{tags.size}"
|
192
|
+
end
|
193
|
+
|
194
|
+
select(select_clause) \
|
195
|
+
.joins(joins.join(" ")) \
|
196
|
+
.where(conditions.join(" AND ")) \
|
197
|
+
.group(group) \
|
198
|
+
.having(having) \
|
199
|
+
.order(options[:order]) \
|
200
|
+
.readonly(false)
|
201
|
+
end
|
202
|
+
|
203
|
+
def is_taggable?
|
204
|
+
true
|
205
|
+
end
|
206
|
+
|
207
|
+
def adjust_taggings_alias(taggings_alias)
|
208
|
+
if taggings_alias.size > 75
|
209
|
+
taggings_alias = 'taggings_alias_' + Digest::SHA1.hexdigest(taggings_alias)
|
210
|
+
end
|
211
|
+
taggings_alias
|
212
|
+
end
|
213
|
+
|
214
|
+
def taggable_mixin
|
215
|
+
@taggable_mixin ||= Module.new
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
module InstanceMethods
|
220
|
+
# all column names are necessary for PostgreSQL group clause
|
221
|
+
def grouped_column_names_for(object)
|
222
|
+
self.class.grouped_column_names_for(object)
|
223
|
+
end
|
224
|
+
|
225
|
+
def custom_contexts
|
226
|
+
@custom_contexts ||= []
|
227
|
+
end
|
228
|
+
|
229
|
+
def is_taggable?
|
230
|
+
self.class.is_taggable?
|
231
|
+
end
|
232
|
+
|
233
|
+
def add_custom_context(value)
|
234
|
+
custom_contexts << value.to_s unless custom_contexts.include?(value.to_s) or self.class.tag_types.map(&:to_s).include?(value.to_s)
|
235
|
+
end
|
236
|
+
|
237
|
+
def cached_tag_list_on(context)
|
238
|
+
self["cached_#{context.to_s.singularize}_list"]
|
239
|
+
end
|
240
|
+
|
241
|
+
def tag_list_cache_set_on(context)
|
242
|
+
variable_name = "@#{context.to_s.singularize}_list"
|
243
|
+
instance_variable_defined?(variable_name) && !instance_variable_get(variable_name).nil?
|
244
|
+
end
|
245
|
+
|
246
|
+
def tag_list_cache_on(context)
|
247
|
+
variable_name = "@#{context.to_s.singularize}_list"
|
248
|
+
if instance_variable_get(variable_name)
|
249
|
+
instance_variable_get(variable_name)
|
250
|
+
elsif cached_tag_list_on(context) && self.class.caching_tag_list_on?(context)
|
251
|
+
instance_variable_set(variable_name, ActsAsTaggableOn::TagList.from(cached_tag_list_on(context)))
|
252
|
+
else
|
253
|
+
instance_variable_set(variable_name, ActsAsTaggableOn::TagList.new(tags_on(context).map(&:name)))
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
def tag_list_on(context)
|
258
|
+
add_custom_context(context)
|
259
|
+
tag_list_cache_on(context)
|
260
|
+
end
|
261
|
+
|
262
|
+
def all_tags_list_on(context)
|
263
|
+
variable_name = "@all_#{context.to_s.singularize}_list"
|
264
|
+
return instance_variable_get(variable_name) if instance_variable_defined?(variable_name) && instance_variable_get(variable_name)
|
265
|
+
|
266
|
+
instance_variable_set(variable_name, ActsAsTaggableOn::TagList.new(all_tags_on(context).map(&:name)).freeze)
|
267
|
+
end
|
268
|
+
|
269
|
+
##
|
270
|
+
# Returns all tags of a given context
|
271
|
+
def all_tags_on(context)
|
272
|
+
tag_table_name = ActsAsTaggableOn::Tag.table_name
|
273
|
+
tagging_table_name = ActsAsTaggableOn::Tagging.table_name
|
274
|
+
|
275
|
+
opts = ["#{tagging_table_name}.context = ?", context.to_s]
|
276
|
+
scope = base_tags.where(opts)
|
277
|
+
|
278
|
+
if ActsAsTaggableOn::Tag.using_postgresql?
|
279
|
+
group_columns = grouped_column_names_for(ActsAsTaggableOn::Tag)
|
280
|
+
scope.order("max(#{tagging_table_name}.created_at)").group(group_columns)
|
281
|
+
else
|
282
|
+
scope.group("#{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key}")
|
283
|
+
end.to_a
|
284
|
+
end
|
285
|
+
|
286
|
+
##
|
287
|
+
# Returns all tags that are not owned of a given context
|
288
|
+
def tags_on(context)
|
289
|
+
scope = base_tags.where(["#{ActsAsTaggableOn::Tagging.table_name}.context = ? AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_id IS NULL", context.to_s])
|
290
|
+
# when preserving tag order, return tags in created order
|
291
|
+
# if we added the order to the association this would always apply
|
292
|
+
scope = scope.order("#{ActsAsTaggableOn::Tagging.table_name}.id") if self.class.preserve_tag_order?
|
293
|
+
scope
|
294
|
+
end
|
295
|
+
|
296
|
+
def set_tag_list_on(context, new_list)
|
297
|
+
add_custom_context(context)
|
298
|
+
|
299
|
+
variable_name = "@#{context.to_s.singularize}_list"
|
300
|
+
process_dirty_object(context, new_list) unless custom_contexts.include?(context.to_s)
|
301
|
+
|
302
|
+
instance_variable_set(variable_name, ActsAsTaggableOn::TagList.from(new_list))
|
303
|
+
end
|
304
|
+
|
305
|
+
def tagging_contexts
|
306
|
+
custom_contexts + self.class.tag_types.map(&:to_s)
|
307
|
+
end
|
308
|
+
|
309
|
+
def process_dirty_object(context,new_list)
|
310
|
+
value = new_list.is_a?(Array) ? new_list.join(', ') : new_list
|
311
|
+
attrib = "#{context.to_s.singularize}_list"
|
312
|
+
|
313
|
+
if changed_attributes.include?(attrib)
|
314
|
+
# The attribute already has an unsaved change.
|
315
|
+
old = changed_attributes[attrib]
|
316
|
+
changed_attributes.delete(attrib) if (old.to_s == value.to_s)
|
317
|
+
else
|
318
|
+
old = tag_list_on(context).to_s
|
319
|
+
changed_attributes[attrib] = old if (old.to_s != value.to_s)
|
320
|
+
end
|
321
|
+
end
|
322
|
+
|
323
|
+
def reload(*args)
|
324
|
+
self.class.tag_types.each do |context|
|
325
|
+
instance_variable_set("@#{context.to_s.singularize}_list", nil)
|
326
|
+
instance_variable_set("@all_#{context.to_s.singularize}_list", nil)
|
327
|
+
end
|
328
|
+
|
329
|
+
super(*args)
|
330
|
+
end
|
331
|
+
|
332
|
+
##
|
333
|
+
# Find existing tags or create non-existing tags
|
334
|
+
def load_tags(tag_list)
|
335
|
+
ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name(tag_list)
|
336
|
+
end
|
337
|
+
|
338
|
+
def save_tags
|
339
|
+
tagging_contexts.each do |context|
|
340
|
+
next unless tag_list_cache_set_on(context)
|
341
|
+
# List of currently assigned tag names
|
342
|
+
tag_list = tag_list_cache_on(context).uniq
|
343
|
+
|
344
|
+
# Find existing tags or create non-existing tags:
|
345
|
+
tags = load_tags(tag_list)
|
346
|
+
|
347
|
+
# Tag objects for currently assigned tags
|
348
|
+
current_tags = tags_on(context)
|
349
|
+
|
350
|
+
# Tag maintenance based on whether preserving the created order of tags
|
351
|
+
if self.class.preserve_tag_order?
|
352
|
+
old_tags, new_tags = current_tags - tags, tags - current_tags
|
353
|
+
|
354
|
+
shared_tags = current_tags & tags
|
355
|
+
|
356
|
+
if shared_tags.any? && tags[0...shared_tags.size] != shared_tags
|
357
|
+
index = shared_tags.each_with_index { |_, i| break i unless shared_tags[i] == tags[i] }
|
358
|
+
|
359
|
+
# Update arrays of tag objects
|
360
|
+
old_tags |= current_tags[index...current_tags.size]
|
361
|
+
new_tags |= current_tags[index...current_tags.size] & shared_tags
|
362
|
+
|
363
|
+
# Order the array of tag objects to match the tag list
|
364
|
+
new_tags = tags.map do |t|
|
365
|
+
new_tags.find { |n| n.name.downcase == t.name.downcase }
|
366
|
+
end.compact
|
367
|
+
end
|
368
|
+
else
|
369
|
+
# Delete discarded tags and create new tags
|
370
|
+
old_tags = current_tags - tags
|
371
|
+
new_tags = tags - current_tags
|
372
|
+
end
|
373
|
+
|
374
|
+
# Find taggings to remove:
|
375
|
+
if old_tags.present?
|
376
|
+
old_taggings = taggings.where(:tagger_type => nil, :tagger_id => nil, :context => context.to_s, :tag_id => old_tags)
|
377
|
+
end
|
378
|
+
|
379
|
+
# Destroy old taggings:
|
380
|
+
if old_taggings.present?
|
381
|
+
ActsAsTaggableOn::Tagging.destroy_all "#{ActsAsTaggableOn::Tagging.primary_key}".to_sym => old_taggings.map(&:id)
|
382
|
+
end
|
383
|
+
|
384
|
+
# Create new taggings:
|
385
|
+
new_tags.each do |tag|
|
386
|
+
taggings.create!(:tag_id => tag.id, :context => context.to_s, :taggable => self)
|
387
|
+
end
|
388
|
+
end
|
389
|
+
|
390
|
+
true
|
391
|
+
end
|
392
|
+
end
|
393
|
+
end
|
394
|
+
end
|