Class: MakeTaggable::Tag
- Inherits:
-
ActiveRecord::Base
- Object
- ActiveRecord::Base
- MakeTaggable::Tag
- Defined in:
- lib/make_taggable/tag.rb
Overview
A tag name, shared by every record tagged with it.
Tags are found and created through the class methods here rather than directly, so that the configured case sensitivity is applied consistently. Subclass it to keep a separate vocabulary for one context, and point at the subclass from MakeTaggable::Taggable::Core#find_or_create_tags_from_list_with_context.
Instance Attribute Summary collapse
-
#name ⇒ String
The tag itself.
-
#taggings_count ⇒ Integer
How many taggings reference this tag, maintained as a counter cache.
Class Method Summary collapse
-
.find_or_create_all_with_like_by_name(*list) ⇒ Array<MakeTaggable::Tag>
Finds every tag in a list by name, creating those that do not exist yet.
-
.find_or_create_with_like_by_name(name) ⇒ MakeTaggable::Tag
Finds a tag by name, creating it when it does not exist yet.
-
.for_context(context) ⇒ ActiveRecord::Relation
Tags used in a given context, whatever the record they were applied to.
-
.named(name) ⇒ ActiveRecord::Relation
Tags matching a name exactly, honouring the configured case sensitivity.
-
.named_any(list) ⇒ ActiveRecord::Relation
Tags matching any of the given names exactly.
-
.named_like(name) ⇒ ActiveRecord::Relation
Tags whose name contains the given fragment.
-
.named_like_any(list) ⇒ ActiveRecord::Relation
Tags whose name contains any of the given fragments.
-
.tag_type_column? ⇒ TrueClass, FalseClass
private
Whether the tags table carries a
typecolumn, and so is being used for single table inheritance.
Instance Method Summary collapse
-
#==(other) ⇒ TrueClass, FalseClass
Compares tags by name, so a saved tag and an unsaved one with the same name are equal.
-
#count ⇒ Integer
How many times this tag matched, on relations that select a count alongside the tag columns.
-
#to_s ⇒ String
The tag's name, so a tag renders as itself in a view or a string.
-
#validates_name_uniqueness? ⇒ TrueClass, FalseClass
Whether the uniqueness validation on
nameruns.
Instance Attribute Details
#name ⇒ String
The tag itself.
19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 |
# File 'lib/make_taggable/tag.rb', line 19 class Tag < ::ActiveRecord::Base self.table_name = MakeTaggable. ### ASSOCIATIONS: has_many :taggings, dependent: :destroy, class_name: "::MakeTaggable::Tagging" ### VALIDATIONS: validates_presence_of :name # Two declarations, one of which runs. A tags table with a `type` column is # being used for single table inheritance -- a Tag subclass per vocabulary -- # and there a name is expected to repeat across subclasses: "energy" as a # Market and as a Genre. Scoping the check keeps it meaningful within a # subclass rather than making the whole thing something to switch off. # # The column is looked up per validation rather than when this class loads, # because the class can load before the migration that adds it has run. validates_uniqueness_of :name, if: -> { validates_name_uniqueness? && !self.class.tag_type_column? }, case_sensitive: true validates_uniqueness_of :name, scope: :type, if: -> { validates_name_uniqueness? && self.class.tag_type_column? }, case_sensitive: true validates_length_of :name, maximum: 255 ## # Whether the uniqueness validation on `name` runs. # # Override this in a subclass to allow tag names to repeat. # # @return [TrueClass, FalseClass] always `true` here # def validates_name_uniqueness? true end ## # Whether the tags table carries a `type` column, and so is being used for single table # inheritance. # # @return [TrueClass, FalseClass] # # @api private # def self.tag_type_column? column_names.include?("type") end ### SCOPES: scope :most_used, ->(limit = 20) { order("taggings_count desc").limit(limit) } scope :least_used, ->(limit = 20) { order("taggings_count asc").limit(limit) } ## # Tags matching a name exactly, honouring the configured case sensitivity. # # @param name [String] the name to match # @return [ActiveRecord::Relation] # def self.named(name) if MakeTaggable.strict_case_match where(["name = #{binary}?", name.to_s]) else where(["LOWER(name) = LOWER(?)", name.to_s.downcase]) end end ## # Tags matching any of the given names exactly. # # @param list [Array<String>] the names to match # @return [ActiveRecord::Relation] # def self.named_any(list) clause = list.map { |tag| sanitize_sql_for_named_any(tag) }.join(" OR ") where(clause) end ## # Tags whose name contains the given fragment. # # Case insensitive on PostgreSQL, which uses `ILIKE`; otherwise it follows the column's # collation. # # @param name [String] the fragment to look for # @return [ActiveRecord::Relation] # def self.named_like(name) clause = ["name #{MakeTaggable::Utils.like_operator} ? ESCAPE '!'", "%#{MakeTaggable::Utils.escape_like(name)}%"] where(clause) end ## # Tags whose name contains any of the given fragments. # # @param list [Array<String>] the fragments to look for # @return [ActiveRecord::Relation] # def self.named_like_any(list) clause = list.map { |tag| sanitize_sql(["name #{MakeTaggable::Utils.like_operator} ? ESCAPE '!'", "%#{MakeTaggable::Utils.escape_like(tag.to_s)}%"]) }.join(" OR ") where(clause) end ## # Tags used in a given context, whatever the record they were applied to. # # @param context [String, Symbol] the tagging context # @return [ActiveRecord::Relation] # # @example # MakeTaggable::Tag.for_context(:skills) # def self.for_context(context) joins(:taggings) .where(["#{MakeTaggable.taggings_table}.context = ?", context]) .select("DISTINCT #{MakeTaggable.}.*") end ### CLASS METHODS: ## # Finds a tag by name, creating it when it does not exist yet. # # The name is matched in full. Honours the configured case sensitivity. # # @param name [String] the tag name # @return [MakeTaggable::Tag] # # @example # MakeTaggable::Tag.find_or_create_with_like_by_name("ruby") # def self.find_or_create_with_like_by_name(name) if MakeTaggable.strict_case_match find_or_create_all_with_like_by_name([name]).first else # Matching has to happen in Ruby's terms rather than the column's: the # MySQL migration collates tag names as utf8mb4_bin, which would make a # LIKE comparison case sensitive whatever strict_case_match says. named(name).first || create(name: name) end end ## # Finds every tag in a list by name, creating those that do not exist yet. # # A competing write that takes a name first is retried up to three times before giving up. # Each insert runs in a savepoint of its own, so a name lost to a race unwinds that insert # alone -- an enclosing transaction the caller opened is left untouched, along with everything # written into it. # # @param list [Array<String>] the tag names # @return [Array<MakeTaggable::Tag>] in the order the names were given # @raise [MakeTaggable::DuplicateTagError] when a name stays taken after three attempts # # @example # MakeTaggable::Tag.find_or_create_all_with_like_by_name(%w[ruby rails]) # def self.find_or_create_all_with_like_by_name(*list) list = Array(list).flatten return [] if list.empty? = named_any(list).to_a list.map do |tag_name| tries ||= 3 comparable_tag_name = comparable_name(tag_name) existing_tag = .find { |tag| comparable_name(tag.name) == comparable_tag_name } next existing_tag if existing_tag # Tags created earlier in this call have to stay visible to the names # that follow, or a list holding both "Ruby" and "ruby" resolves to two # rows even though the two names compare equal. # # The insert gets a savepoint of its own so that a RecordNotUnique # unwinds only the failed insert. Without one the caller's transaction # is left in an aborted state and everything it had done is lost. transaction(requires_new: true) { create(name: tag_name) }.tap { |tag| << tag } # A deadlock counts as losing the race, the same as a duplicate key. # MySQL reports one or the other depending on how two inserts of the # same name interleave on the unique index, and both mean the work # should be re-read and retried rather than abandoned. rescue ActiveRecord::RecordNotUnique, ActiveRecord::Deadlocked if (tries -= 1).positive? = named_any(list).to_a retry end raise DuplicateTagError.new("'#{tag_name}' has already been taken") end end ### INSTANCE METHODS: ## # Compares tags by name, so a saved tag and an unsaved one with the same name are equal. # # @param other [Object] the object to compare against # @return [TrueClass, FalseClass] # def ==(other) super || (other.is_a?(Tag) && name == other.name) end ## # The tag's name, so a tag renders as itself in a view or a string. # # @return [String] # def to_s name end ## # How many times this tag matched, on relations that select a count alongside the tag columns. # # Zero on a tag loaded without one. # # @return [Integer] # def count read_attribute(:count).to_i end class << self private def comparable_name(str) if MakeTaggable.strict_case_match str else str.to_s.downcase end end def binary MakeTaggable::Utils.using_mysql? ? "BINARY " : nil end def sanitize_sql_for_named_any(tag) if MakeTaggable.strict_case_match sanitize_sql(["name = #{binary}?", tag.to_s]) else sanitize_sql(["LOWER(name) = LOWER(?)", tag.to_s.downcase]) end end end end |
#taggings_count ⇒ Integer
How many taggings reference this tag, maintained as a counter cache.
19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 |
# File 'lib/make_taggable/tag.rb', line 19 class Tag < ::ActiveRecord::Base self.table_name = MakeTaggable. ### ASSOCIATIONS: has_many :taggings, dependent: :destroy, class_name: "::MakeTaggable::Tagging" ### VALIDATIONS: validates_presence_of :name # Two declarations, one of which runs. A tags table with a `type` column is # being used for single table inheritance -- a Tag subclass per vocabulary -- # and there a name is expected to repeat across subclasses: "energy" as a # Market and as a Genre. Scoping the check keeps it meaningful within a # subclass rather than making the whole thing something to switch off. # # The column is looked up per validation rather than when this class loads, # because the class can load before the migration that adds it has run. validates_uniqueness_of :name, if: -> { validates_name_uniqueness? && !self.class.tag_type_column? }, case_sensitive: true validates_uniqueness_of :name, scope: :type, if: -> { validates_name_uniqueness? && self.class.tag_type_column? }, case_sensitive: true validates_length_of :name, maximum: 255 ## # Whether the uniqueness validation on `name` runs. # # Override this in a subclass to allow tag names to repeat. # # @return [TrueClass, FalseClass] always `true` here # def validates_name_uniqueness? true end ## # Whether the tags table carries a `type` column, and so is being used for single table # inheritance. # # @return [TrueClass, FalseClass] # # @api private # def self.tag_type_column? column_names.include?("type") end ### SCOPES: scope :most_used, ->(limit = 20) { order("taggings_count desc").limit(limit) } scope :least_used, ->(limit = 20) { order("taggings_count asc").limit(limit) } ## # Tags matching a name exactly, honouring the configured case sensitivity. # # @param name [String] the name to match # @return [ActiveRecord::Relation] # def self.named(name) if MakeTaggable.strict_case_match where(["name = #{binary}?", name.to_s]) else where(["LOWER(name) = LOWER(?)", name.to_s.downcase]) end end ## # Tags matching any of the given names exactly. # # @param list [Array<String>] the names to match # @return [ActiveRecord::Relation] # def self.named_any(list) clause = list.map { |tag| sanitize_sql_for_named_any(tag) }.join(" OR ") where(clause) end ## # Tags whose name contains the given fragment. # # Case insensitive on PostgreSQL, which uses `ILIKE`; otherwise it follows the column's # collation. # # @param name [String] the fragment to look for # @return [ActiveRecord::Relation] # def self.named_like(name) clause = ["name #{MakeTaggable::Utils.like_operator} ? ESCAPE '!'", "%#{MakeTaggable::Utils.escape_like(name)}%"] where(clause) end ## # Tags whose name contains any of the given fragments. # # @param list [Array<String>] the fragments to look for # @return [ActiveRecord::Relation] # def self.named_like_any(list) clause = list.map { |tag| sanitize_sql(["name #{MakeTaggable::Utils.like_operator} ? ESCAPE '!'", "%#{MakeTaggable::Utils.escape_like(tag.to_s)}%"]) }.join(" OR ") where(clause) end ## # Tags used in a given context, whatever the record they were applied to. # # @param context [String, Symbol] the tagging context # @return [ActiveRecord::Relation] # # @example # MakeTaggable::Tag.for_context(:skills) # def self.for_context(context) joins(:taggings) .where(["#{MakeTaggable.taggings_table}.context = ?", context]) .select("DISTINCT #{MakeTaggable.}.*") end ### CLASS METHODS: ## # Finds a tag by name, creating it when it does not exist yet. # # The name is matched in full. Honours the configured case sensitivity. # # @param name [String] the tag name # @return [MakeTaggable::Tag] # # @example # MakeTaggable::Tag.find_or_create_with_like_by_name("ruby") # def self.find_or_create_with_like_by_name(name) if MakeTaggable.strict_case_match find_or_create_all_with_like_by_name([name]).first else # Matching has to happen in Ruby's terms rather than the column's: the # MySQL migration collates tag names as utf8mb4_bin, which would make a # LIKE comparison case sensitive whatever strict_case_match says. named(name).first || create(name: name) end end ## # Finds every tag in a list by name, creating those that do not exist yet. # # A competing write that takes a name first is retried up to three times before giving up. # Each insert runs in a savepoint of its own, so a name lost to a race unwinds that insert # alone -- an enclosing transaction the caller opened is left untouched, along with everything # written into it. # # @param list [Array<String>] the tag names # @return [Array<MakeTaggable::Tag>] in the order the names were given # @raise [MakeTaggable::DuplicateTagError] when a name stays taken after three attempts # # @example # MakeTaggable::Tag.find_or_create_all_with_like_by_name(%w[ruby rails]) # def self.find_or_create_all_with_like_by_name(*list) list = Array(list).flatten return [] if list.empty? = named_any(list).to_a list.map do |tag_name| tries ||= 3 comparable_tag_name = comparable_name(tag_name) existing_tag = .find { |tag| comparable_name(tag.name) == comparable_tag_name } next existing_tag if existing_tag # Tags created earlier in this call have to stay visible to the names # that follow, or a list holding both "Ruby" and "ruby" resolves to two # rows even though the two names compare equal. # # The insert gets a savepoint of its own so that a RecordNotUnique # unwinds only the failed insert. Without one the caller's transaction # is left in an aborted state and everything it had done is lost. transaction(requires_new: true) { create(name: tag_name) }.tap { |tag| << tag } # A deadlock counts as losing the race, the same as a duplicate key. # MySQL reports one or the other depending on how two inserts of the # same name interleave on the unique index, and both mean the work # should be re-read and retried rather than abandoned. rescue ActiveRecord::RecordNotUnique, ActiveRecord::Deadlocked if (tries -= 1).positive? = named_any(list).to_a retry end raise DuplicateTagError.new("'#{tag_name}' has already been taken") end end ### INSTANCE METHODS: ## # Compares tags by name, so a saved tag and an unsaved one with the same name are equal. # # @param other [Object] the object to compare against # @return [TrueClass, FalseClass] # def ==(other) super || (other.is_a?(Tag) && name == other.name) end ## # The tag's name, so a tag renders as itself in a view or a string. # # @return [String] # def to_s name end ## # How many times this tag matched, on relations that select a count alongside the tag columns. # # Zero on a tag loaded without one. # # @return [Integer] # def count read_attribute(:count).to_i end class << self private def comparable_name(str) if MakeTaggable.strict_case_match str else str.to_s.downcase end end def binary MakeTaggable::Utils.using_mysql? ? "BINARY " : nil end def sanitize_sql_for_named_any(tag) if MakeTaggable.strict_case_match sanitize_sql(["name = #{binary}?", tag.to_s]) else sanitize_sql(["LOWER(name) = LOWER(?)", tag.to_s.downcase]) end end end end |
Class Method Details
.find_or_create_all_with_like_by_name(*list) ⇒ Array<MakeTaggable::Tag>
Finds every tag in a list by name, creating those that do not exist yet.
A competing write that takes a name first is retried up to three times before giving up. Each insert runs in a savepoint of its own, so a name lost to a race unwinds that insert alone -- an enclosing transaction the caller opened is left untouched, along with everything written into it.
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
# File 'lib/make_taggable/tag.rb', line 180 def self.find_or_create_all_with_like_by_name(*list) list = Array(list).flatten return [] if list.empty? = named_any(list).to_a list.map do |tag_name| tries ||= 3 comparable_tag_name = comparable_name(tag_name) existing_tag = .find { |tag| comparable_name(tag.name) == comparable_tag_name } next existing_tag if existing_tag # Tags created earlier in this call have to stay visible to the names # that follow, or a list holding both "Ruby" and "ruby" resolves to two # rows even though the two names compare equal. # # The insert gets a savepoint of its own so that a RecordNotUnique # unwinds only the failed insert. Without one the caller's transaction # is left in an aborted state and everything it had done is lost. transaction(requires_new: true) { create(name: tag_name) }.tap { |tag| << tag } # A deadlock counts as losing the race, the same as a duplicate key. # MySQL reports one or the other depending on how two inserts of the # same name interleave on the unique index, and both mean the work # should be re-read and retried rather than abandoned. rescue ActiveRecord::RecordNotUnique, ActiveRecord::Deadlocked if (tries -= 1).positive? = named_any(list).to_a retry end raise DuplicateTagError.new("'#{tag_name}' has already been taken") end end |
.find_or_create_with_like_by_name(name) ⇒ MakeTaggable::Tag
Finds a tag by name, creating it when it does not exist yet.
The name is matched in full. Honours the configured case sensitivity.
154 155 156 157 158 159 160 161 162 163 |
# File 'lib/make_taggable/tag.rb', line 154 def self.find_or_create_with_like_by_name(name) if MakeTaggable.strict_case_match find_or_create_all_with_like_by_name([name]).first else # Matching has to happen in Ruby's terms rather than the column's: the # MySQL migration collates tag names as utf8mb4_bin, which would make a # LIKE comparison case sensitive whatever strict_case_match says. named(name).first || create(name: name) end end |
.for_context(context) ⇒ ActiveRecord::Relation
Tags used in a given context, whatever the record they were applied to.
135 136 137 138 139 |
# File 'lib/make_taggable/tag.rb', line 135 def self.for_context(context) joins(:taggings) .where(["#{MakeTaggable.taggings_table}.context = ?", context]) .select("DISTINCT #{MakeTaggable.}.*") end |
.named(name) ⇒ ActiveRecord::Relation
Tags matching a name exactly, honouring the configured case sensitivity.
78 79 80 81 82 83 84 |
# File 'lib/make_taggable/tag.rb', line 78 def self.named(name) if MakeTaggable.strict_case_match where(["name = #{binary}?", name.to_s]) else where(["LOWER(name) = LOWER(?)", name.to_s.downcase]) end end |
.named_any(list) ⇒ ActiveRecord::Relation
Tags matching any of the given names exactly.
92 93 94 95 96 97 |
# File 'lib/make_taggable/tag.rb', line 92 def self.named_any(list) clause = list.map { |tag| sanitize_sql_for_named_any(tag) }.join(" OR ") where(clause) end |
.named_like(name) ⇒ ActiveRecord::Relation
Tags whose name contains the given fragment.
Case insensitive on PostgreSQL, which uses ILIKE; otherwise it follows the column's
collation.
108 109 110 111 |
# File 'lib/make_taggable/tag.rb', line 108 def self.named_like(name) clause = ["name #{MakeTaggable::Utils.like_operator} ? ESCAPE '!'", "%#{MakeTaggable::Utils.escape_like(name)}%"] where(clause) end |
.named_like_any(list) ⇒ ActiveRecord::Relation
Tags whose name contains any of the given fragments.
119 120 121 122 123 124 |
# File 'lib/make_taggable/tag.rb', line 119 def self.named_like_any(list) clause = list.map { |tag| sanitize_sql(["name #{MakeTaggable::Utils.like_operator} ? ESCAPE '!'", "%#{MakeTaggable::Utils.escape_like(tag.to_s)}%"]) }.join(" OR ") where(clause) end |
.tag_type_column? ⇒ TrueClass, FalseClass
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Whether the tags table carries a type column, and so is being used for single table
inheritance.
64 65 66 |
# File 'lib/make_taggable/tag.rb', line 64 def self.tag_type_column? column_names.include?("type") end |
Instance Method Details
#==(other) ⇒ TrueClass, FalseClass
Compares tags by name, so a saved tag and an unsaved one with the same name are equal.
222 223 224 |
# File 'lib/make_taggable/tag.rb', line 222 def ==(other) super || (other.is_a?(Tag) && name == other.name) end |
#count ⇒ Integer
How many times this tag matched, on relations that select a count alongside the tag columns.
Zero on a tag loaded without one.
242 243 244 |
# File 'lib/make_taggable/tag.rb', line 242 def count read_attribute(:count).to_i end |
#to_s ⇒ String
The tag's name, so a tag renders as itself in a view or a string.
231 232 233 |
# File 'lib/make_taggable/tag.rb', line 231 def to_s name end |
#validates_name_uniqueness? ⇒ TrueClass, FalseClass
Whether the uniqueness validation on name runs.
Override this in a subclass to allow tag names to repeat.
52 53 54 |
# File 'lib/make_taggable/tag.rb', line 52 def validates_name_uniqueness? true end |