Module: Models::Taggable
- Extended by:
- ActiveSupport::Concern
- Included in:
- ActivityType, Article, DigitalAsset, FloorPlanDisplay, Item, LocatorRecord, ProductLine, Showcase
- Defined in:
- app/concerns/models/taggable.rb
Overview
Taggable concern for centralized tag management
This concern replaces the old has_array_scopes method and provides
backward-compatible interface for all models that use tags.
Usage:
class Article < ApplicationRecord
include Models::Taggable
end
This provides:
- Instance methods: tags, tags=, tag_list, tag_list=
- Class methods: all_tags, with_any_tags, with_all_tags, tagged_with, not_tagged_with
- Scopes for filtering by tags
Has many collapse
- #tag_records ⇒ ActiveRecord::Relation<::Tag>
-
#taggings ⇒ ActiveRecord::Relation<::Tagging>
NOTE: For STI models (like Post < Article), we need special handling.
Class Method Summary collapse
-
.all_tags(exclude_tags: []) ⇒ Array<String>
Get all unique tag names used by this model.
-
.normalize_tag_names(tag_names) ⇒ Object
Helper to normalize tag names for queries.
-
.not_tagged_with ⇒ ActiveRecord::Relation<Models::Taggable>
A relation of Models::Taggables that are not tagged with.
-
.tagged_with ⇒ ActiveRecord::Relation<Models::Taggable>
A relation of Models::Taggables that are tagged with.
-
.tags_cloud ⇒ Array<Array>
Get tag cloud (tag name with count).
-
.tags_exclude ⇒ ActiveRecord::Relation<Models::Taggable>
A relation of Models::Taggables that are tags exclude.
-
.tags_include ⇒ ActiveRecord::Relation<Models::Taggable>
A relation of Models::Taggables that are tags include.
-
.with_all_tags ⇒ ActiveRecord::Relation<Models::Taggable>
A relation of Models::Taggables that are with all tags.
-
.with_any_tags ⇒ ActiveRecord::Relation<Models::Taggable>
A relation of Models::Taggables that are with any tags.
-
.without_all_tags ⇒ ActiveRecord::Relation<Models::Taggable>
A relation of Models::Taggables that are without all tags.
-
.without_any_tags ⇒ ActiveRecord::Relation<Models::Taggable>
A relation of Models::Taggables that are without any tags.
Instance Method Summary collapse
-
#add_tag(tag_name) ⇒ Object
Add a single tag.
-
#has_tag?(tag_name) ⇒ Boolean
Check if record has a specific tag.
-
#remove_tag(tag_name) ⇒ Object
Remove a single tag.
-
#tag_list ⇒ Object
Alias for tags (backward compatibility).
-
#tag_list=(value) ⇒ Object
Alias for tags= (backward compatibility).
-
#taggable_type_for_tagging ⇒ Object
Returns the correct taggable_type for STI models For Post (which inherits from Article), this returns 'Post' not 'Article'.
-
#tags ⇒ Array<String>
Get tag names as an array of strings (backward compatible with array column) NOTE: For STI models, we query using the actual class name (not base class) OPTIMIZATION: Uses preloaded taggings association when available to avoid N+1 queries.
-
#tags=(value) ⇒ Object
Set tags from an array of strings or Tag objects NOTE: For STI models, we create taggings with the actual class name.
Class Method Details
.all_tags(exclude_tags: []) ⇒ Array<String>
Get all unique tag names used by this model
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'app/concerns/models/taggable.rb', line 111 def (exclude_tags: []) exclusion = Array().flatten.compact.map { |t| t.to_s.strip.downcase } # Use explicit join with correct taggable_type (self.name) to handle STI properly # Taggings are stored with subclass names (Video, Image, Post, Faq, etc.) tag_names = joins("INNER JOIN taggings ON taggings.taggable_id = #{table_name}.id AND taggings.taggable_type = '#{name}'") .joins('INNER JOIN tags ON tags.id = taggings.tag_id') .distinct .pluck('tags.name') .compact .map { |t| t.to_s.strip.downcase } .uniq .sort (tag_names - exclusion) end |
.normalize_tag_names(tag_names) ⇒ Object
Helper to normalize tag names for queries
102 103 104 |
# File 'app/concerns/models/taggable.rb', line 102 def self.normalize_tag_names(tag_names) Array(tag_names).flatten.filter_map(&:presence).map { |t| t.to_s.strip.downcase }.uniq end |
.not_tagged_with ⇒ ActiveRecord::Relation<Models::Taggable>
A relation of Models::Taggables that are not tagged with. Active Record Scope
82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'app/concerns/models/taggable.rb', line 82 scope :not_tagged_with, ->(*tag_names) { tag_names = normalize_tag_names(tag_names) return all if tag_names.empty? subquery = Tagging.where(Tagging[:taggable_id].eq(arel_table[:id])) .where(taggable_type: name) .joins(:tag) .where(Tag[:name].lower.in(tag_names)) .select('1') where("NOT EXISTS (#{subquery.to_sql})") } |
.tagged_with ⇒ ActiveRecord::Relation<Models::Taggable>
A relation of Models::Taggables that are tagged with. Active Record Scope
72 |
# File 'app/concerns/models/taggable.rb', line 72 scope :tagged_with, ->(*tag_names) { (*tag_names) } |
.tags_cloud ⇒ Array<Array>
Get tag cloud (tag name with count)
130 131 132 133 134 135 136 137 |
# File 'app/concerns/models/taggable.rb', line 130 def # Use explicit join with correct taggable_type (self.name) to handle STI properly joins("INNER JOIN taggings ON taggings.taggable_id = #{table_name}.id AND taggings.taggable_type = '#{name}'") .joins('INNER JOIN tags ON tags.id = taggings.tag_id') .group('tags.name') .order('tags.name') .pluck(Arel.sql('tags.name, COUNT(*) as count')) end |
.tags_exclude ⇒ ActiveRecord::Relation<Models::Taggable>
A relation of Models::Taggables that are tags exclude. Active Record Scope
79 |
# File 'app/concerns/models/taggable.rb', line 79 scope :tags_exclude, ->(*tag_names) { not_tagged_with(*tag_names) } |
.tags_include ⇒ ActiveRecord::Relation<Models::Taggable>
A relation of Models::Taggables that are tags include. Active Record Scope
76 |
# File 'app/concerns/models/taggable.rb', line 76 scope :tags_include, ->(*tag_names) { (*tag_names) } |
.with_all_tags ⇒ ActiveRecord::Relation<Models::Taggable>
A relation of Models::Taggables that are with all tags. Active Record Scope
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
# File 'app/concerns/models/taggable.rb', line 55 scope :with_all_tags, ->(*tag_names) { tag_names = normalize_tag_names(tag_names) return none if tag_names.empty? # Use a subquery to count matching tags and compare to expected count subquery = Tagging.where(Tagging[:taggable_id].eq(arel_table[:id])) .where(taggable_type: name) .joins(:tag) .where(Tag[:name].lower.in(tag_names)) .select('1') .group(:taggable_id) .having(Arel.sql("COUNT(DISTINCT LOWER(tags.name)) = #{tag_names.size}")) where("EXISTS (#{subquery.to_sql})") } |
.with_any_tags ⇒ ActiveRecord::Relation<Models::Taggable>
A relation of Models::Taggables that are with any tags. Active Record Scope
40 41 42 43 44 45 46 47 48 49 50 51 52 |
# File 'app/concerns/models/taggable.rb', line 40 scope :with_any_tags, ->(*tag_names) { tag_names = normalize_tag_names(tag_names) return none if tag_names.empty? # Use arel-helpers Model[:column] syntax for cleaner code subquery = Tagging.where(Tagging[:taggable_id].eq(arel_table[:id])) .where(taggable_type: name) .joins(:tag) .where(Tag[:name].lower.in(tag_names)) .select('1') where("EXISTS (#{subquery.to_sql})") } |
.without_all_tags ⇒ ActiveRecord::Relation<Models::Taggable>
A relation of Models::Taggables that are without all tags. Active Record Scope
99 |
# File 'app/concerns/models/taggable.rb', line 99 scope :without_all_tags, ->(*tag_names) { not_tagged_with(*tag_names) } |
.without_any_tags ⇒ ActiveRecord::Relation<Models::Taggable>
A relation of Models::Taggables that are without any tags. Active Record Scope
96 |
# File 'app/concerns/models/taggable.rb', line 96 scope :without_any_tags, ->(*tag_names) { not_tagged_with(*tag_names) } |
Instance Method Details
#add_tag(tag_name) ⇒ Object
Add a single tag
211 212 213 214 215 216 217 218 219 |
# File 'app/concerns/models/taggable.rb', line 211 def add_tag(tag_name) return if tag_name.blank? return unless persisted? tag = Tag.find_or_create_by_name(tag_name) return if has_tag?(tag_name) Tagging.create!(tag: tag, taggable_id: id, taggable_type: taggable_type_for_tagging) end |
#has_tag?(tag_name) ⇒ Boolean
Check if record has a specific tag
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 |
# File 'app/concerns/models/taggable.rb', line 237 def has_tag?(tag_name) return false if tag_name.blank? normalized = tag_name.to_s.strip.downcase if association(:taggings).loaded? filtered = taggings.select { |t| t.taggable_type == taggable_type_for_tagging } if filtered.all? { |t| t.association(:tag).loaded? } filtered.any? { |t| t.tag&.name.to_s.downcase == normalized } else Tag.joins(:taggings) .where(taggings: { taggable_id: id, taggable_type: taggable_type_for_tagging }) .exists?(['LOWER(tags.name) = ?', normalized]) end else Tag.joins(:taggings) .where(taggings: { taggable_id: id, taggable_type: taggable_type_for_tagging }) .exists?(['LOWER(tags.name) = ?', normalized]) end end |
#remove_tag(tag_name) ⇒ Object
Remove a single tag
223 224 225 226 227 228 229 230 231 232 |
# File 'app/concerns/models/taggable.rb', line 223 def remove_tag(tag_name) return if tag_name.blank? return unless persisted? normalized = tag_name.to_s.strip.downcase tag = Tag.find_by('LOWER(name) = ?', normalized) return unless tag Tagging.where(tag: tag, taggable_id: id, taggable_type: taggable_type_for_tagging).delete_all end |
#tag_list ⇒ Object
Alias for tags (backward compatibility)
200 201 202 |
# File 'app/concerns/models/taggable.rb', line 200 def tag_list end |
#tag_list=(value) ⇒ Object
Alias for tags= (backward compatibility)
205 206 207 |
# File 'app/concerns/models/taggable.rb', line 205 def tag_list=(value) self. = value end |
#tag_records ⇒ ActiveRecord::Relation<::Tag>
29 |
# File 'app/concerns/models/taggable.rb', line 29 has_many :tag_records, through: :taggings, source: :tag, class_name: '::Tag' |
#taggable_type_for_tagging ⇒ Object
Returns the correct taggable_type for STI models
For Post (which inherits from Article), this returns 'Post' not 'Article'
171 172 173 |
# File 'app/concerns/models/taggable.rb', line 171 def taggable_type_for_tagging self.class.name end |
#taggings ⇒ ActiveRecord::Relation<::Tagging>
NOTE: For STI models (like Post < Article), we need special handling.
Rails' polymorphic associations use the base class name, but our taggings
data uses the actual STI class name ('Post', not 'Article').
The tags/tags= instance methods handle this with taggable_type_for_tagging.
We don't use dependent: :destroy because it uses the wrong type for STI.
28 |
# File 'app/concerns/models/taggable.rb', line 28 has_many :taggings, as: :taggable, class_name: '::Tagging' |
#tags ⇒ Array<String>
Get tag names as an array of strings (backward compatible with array column)
NOTE: For STI models, we query using the actual class name (not base class)
OPTIMIZATION: Uses preloaded taggings association when available to avoid N+1 queries.
Ensure you preload with includes(taggings: :tag) for best performance.
145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 |
# File 'app/concerns/models/taggable.rb', line 145 def # Check if taggings association is already loaded to avoid N+1 queries if association(:taggings).loaded? # Use preloaded taggings - filter by correct taggable_type for STI models filtered_taggings = taggings.select { |t| t.taggable_type == taggable_type_for_tagging } # Batch load any tags that aren't already loaded tag_ids_to_load = filtered_taggings.reject { |t| t.association(:tag).loaded? }.map(&:tag_id).uniq if tag_ids_to_load.any? Tag.where(id: tag_ids_to_load).index_by(&:id).tap do || filtered_taggings.each { |t| t.association(:tag).target = [t.tag_id] if [t.tag_id] } end end # Now all tags should be loaded, so we can safely access them filtered_taggings.map { |t| t.tag.name } else # Fall back to direct query if not preloaded Tag.joins(:taggings) .where(taggings: { taggable_id: id, taggable_type: taggable_type_for_tagging }) .pluck(:name) end end |
#tags=(value) ⇒ Object
Set tags from an array of strings or Tag objects
NOTE: For STI models, we create taggings with the actual class name
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 |
# File 'app/concerns/models/taggable.rb', line 178 def (value) tag_names = parse_tag_value(value) # Find or create all tags = Tag.find_or_create_all_by_name(tag_names) # For new (unsaved) records, defer tagging until after save if new_record? @pending_tags = return end # Delete existing taggings with the correct taggable_type Tagging.where(taggable_id: id, taggable_type: taggable_type_for_tagging).delete_all # Create new taggings with the correct taggable_type .each do |tag| Tagging.create!(tag: tag, taggable_id: id, taggable_type: taggable_type_for_tagging) end end |