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>
Tag records attached to this model, resolved through its taggings.
-
#taggings ⇒ ActiveRecord::Relation<::Tagging>
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) ⇒ Array<String>
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) ⇒ Tagging?
Add a single tag.
-
#has_tag?(tag_name) ⇒ Boolean
Check if record has a specific tag.
-
#remove_tag(tag_name) ⇒ Integer?
Remove a single tag.
-
#tag_list ⇒ Array<String>
Alias for tags (backward compatibility).
-
#tag_list=(value) ⇒ void
Alias for tags= (backward compatibility).
-
#taggable_type_for_tagging ⇒ String
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) OPTIMIZATION: Uses preloaded taggings association when available to avoid N+1 queries.
-
#tags=(value) ⇒ void
Set tags from an array of strings or Tag objects.
Class Method Details
.all_tags(exclude_tags: []) ⇒ Array<String>
Get all unique tag names used by this model
113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'app/concerns/models/taggable.rb', line 113 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) ⇒ Array<String>
Helper to normalize tag names for queries
104 105 106 |
# File 'app/concerns/models/taggable.rb', line 104 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].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)
132 133 134 135 136 137 138 139 |
# File 'app/concerns/models/taggable.rb', line 132 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].in(tag_names)) .select('1') .group(:taggable_id) .having(Arel.sql("COUNT(DISTINCT 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].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) ⇒ Tagging?
Add a single tag
219 220 221 222 223 224 225 226 227 |
# File 'app/concerns/models/taggable.rb', line 219 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
246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 |
# File 'app/concerns/models/taggable.rb', line 246 def has_tag?(tag_name) return false if tag_name.blank? normalized = Tag.canonicalize_input(tag_name) 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?(tags: { name: normalized }) end else Tag.joins(:taggings) .where(taggings: { taggable_id: id, taggable_type: taggable_type_for_tagging }) .exists?(tags: { name: normalized }) end end |
#remove_tag(tag_name) ⇒ Integer?
Remove a single tag
232 233 234 235 236 237 238 239 240 241 |
# File 'app/concerns/models/taggable.rb', line 232 def remove_tag(tag_name) return if tag_name.blank? return unless persisted? normalized = Tag.canonicalize_input(tag_name) tag = Tag.find_by(name: normalized) return unless tag Tagging.delete_by(tag: tag, taggable_id: id, taggable_type: taggable_type_for_tagging) end |
#tag_list ⇒ Array<String>
Alias for tags (backward compatibility)
205 206 207 |
# File 'app/concerns/models/taggable.rb', line 205 def tag_list end |
#tag_list=(value) ⇒ void
This method returns an undefined value.
Alias for tags= (backward compatibility)
212 213 214 |
# File 'app/concerns/models/taggable.rb', line 212 def tag_list=(value) self. = value end |
#tag_records ⇒ ActiveRecord::Relation<::Tag>
Tag records attached to this model, resolved through its taggings.
29 |
# File 'app/concerns/models/taggable.rb', line 29 has_many :tag_records, through: :taggings, source: :tag, class_name: '::Tag' |
#taggable_type_for_tagging ⇒ String
Returns the correct taggable_type for STI models
For Post (which inherits from Article), this returns 'Post' not 'Article'
174 175 176 |
# File 'app/concerns/models/taggable.rb', line 174 def taggable_type_for_tagging self.class.name end |
#taggings ⇒ ActiveRecord::Relation<::Tagging>
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.
27 |
# File 'app/concerns/models/taggable.rb', line 27 has_many :taggings, as: :taggable, class_name: '::Tagging' |
#tags ⇒ Array<String>
For STI models, we query using the actual class name (not base class)
Get tag names as an array of strings (backward compatible with array column)
OPTIMIZATION: Uses preloaded taggings association when available to avoid N+1 queries.
Ensure you preload with includes(taggings: :tag) for best performance.
147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 |
# File 'app/concerns/models/taggable.rb', line 147 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) ⇒ void
For STI models, we create taggings with the actual class name
This method returns an undefined value.
Set tags from an array of strings or Tag objects
182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'app/concerns/models/taggable.rb', line 182 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.delete_by(taggable_id: id, taggable_type: taggable_type_for_tagging) # 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 |