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

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.all_tags(exclude_tags: []) ⇒ Array<String>

Get all unique tag names used by this model

Parameters:

  • exclude_tags (Array<String>) (defaults to: [])

    Tags to exclude from the result

Returns:

  • (Array<String>)

    Sorted array of unique tag names



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 all_tags(exclude_tags: [])
  exclusion = Array(exclude_tags).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_withActiveRecord::Relation<Models::Taggable>

A relation of Models::Taggables that are not tagged with. Active Record Scope

Returns:

See Also:



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_withActiveRecord::Relation<Models::Taggable>

A relation of Models::Taggables that are tagged with. Active Record Scope

Returns:

See Also:



72
# File 'app/concerns/models/taggable.rb', line 72

scope :tagged_with, ->(*tag_names) { with_any_tags(*tag_names) }

.tags_cloudArray<Array>

Get tag cloud (tag name with count)

Returns:

  • (Array<Array>)

    Array of [tag_name, count] pairs



130
131
132
133
134
135
136
137
# File 'app/concerns/models/taggable.rb', line 130

def tags_cloud
  # 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_excludeActiveRecord::Relation<Models::Taggable>

A relation of Models::Taggables that are tags exclude. Active Record Scope

Returns:

See Also:



79
# File 'app/concerns/models/taggable.rb', line 79

scope :tags_exclude, ->(*tag_names) { not_tagged_with(*tag_names) }

.tags_includeActiveRecord::Relation<Models::Taggable>

A relation of Models::Taggables that are tags include. Active Record Scope

Returns:

See Also:



76
# File 'app/concerns/models/taggable.rb', line 76

scope :tags_include, ->(*tag_names) { with_any_tags(*tag_names) }

.with_all_tagsActiveRecord::Relation<Models::Taggable>

A relation of Models::Taggables that are with all tags. Active Record Scope

Returns:

See Also:



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_tagsActiveRecord::Relation<Models::Taggable>

A relation of Models::Taggables that are with any tags. Active Record Scope

Returns:

See Also:



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_tagsActiveRecord::Relation<Models::Taggable>

A relation of Models::Taggables that are without all tags. Active Record Scope

Returns:

See Also:



99
# File 'app/concerns/models/taggable.rb', line 99

scope :without_all_tags, ->(*tag_names) { not_tagged_with(*tag_names) }

.without_any_tagsActiveRecord::Relation<Models::Taggable>

A relation of Models::Taggables that are without any tags. Active Record Scope

Returns:

See Also:



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

Parameters:

  • tag_name (String)

    The tag name to add



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

Parameters:

  • tag_name (String)

    The tag name to check

Returns:

  • (Boolean)

    True if the record has the 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

Parameters:

  • tag_name (String)

    The tag name to remove



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_listObject

Alias for tags (backward compatibility)



200
201
202
# File 'app/concerns/models/taggable.rb', line 200

def tag_list
  tags
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.tags = value
end

#tag_recordsActiveRecord::Relation<::Tag>

Returns:

  • (ActiveRecord::Relation<::Tag>)

See Also:



29
# File 'app/concerns/models/taggable.rb', line 29

has_many :tag_records, through: :taggings, source: :tag, class_name: '::Tag'

#taggable_type_for_taggingObject

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

#taggingsActiveRecord::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.

Returns:

See Also:



28
# File 'app/concerns/models/taggable.rb', line 28

has_many :taggings, as: :taggable, class_name: '::Tagging'

#tagsArray<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.

Returns:

  • (Array<String>)

    Array of tag name strings



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 tags
  # 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 |tags_by_id|
        filtered_taggings.each { |t| t.association(:tag).target = tags_by_id[t.tag_id] if tags_by_id[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

Parameters:

  • value (Array<String>, Array<Tag>, String)

    Tags to set



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 tags=(value)
  tag_names = parse_tag_value(value)

  # Find or create all tags
  new_tags = Tag.find_or_create_all_by_name(tag_names)

  # For new (unsaved) records, defer tagging until after save
  if new_record?
    @pending_tags = new_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
  new_tags.each do |tag|
    Tagging.create!(tag: tag, taggable_id: id, taggable_type: taggable_type_for_tagging)
  end
end