module UniqueAttributes

Example: unique_attribute :code { SecureRandom.random_number(1000) }

Constants

SAVE_ATTEMPTS_LIMIT

If we try saving an object 50 times and it fails each time, raise an error. This should be high enough to handle even fairly collision-prone attribute generation algorithms.

VERSION

Public Class Methods

unique_attribute(name, block, scope: nil) click to toggle source

Indicate that a given attribute is unique and should be auto-assigned with the given block. @param name [Symbol] the name of the ActiveRecord attribute @param block [Proc] the code to use to auto-assign the attribute @param scope the scope to limit uniqueness by for the attribute; uses the

same format as Rails' `validates *, uniqueness: { scope: __ }` pattern.
# File lib/unique_attributes.rb, line 28
def self.unique_attribute(name, block, scope: nil)
  unique_attributes[name] = block # Store the proc for this attribute.

  # Use a uniqueness scope if one is passed; otherwise have global
  # uniqueness.
  uniqueness_options = scope ? { scope: scope } : true

  # Note: This is restricted to update only since the attribute value is not
  # generated until we save the model the first time. The around_save
  # callback happens after validation, but before saving, so we need to make
  # it past validation at least one time before checking for a valid code.
  validates name, uniqueness: uniqueness_options, on: :update
  validates name, presence: true, on: :update

  # Assign all unique attributes when saving.
  # Note that even if we call unique_attribute more than once within a
  # class, Rails only runs this around_save logic once per save, so we
  # therefore need the around_save logic to handle *all* unique attributes
  # (not just the one defined in this method).
  around_save :save_with_unique_attributes
end

Public Instance Methods

blank_unique_attributes() click to toggle source

@return [Hash] the subset of the unique attributes hash for which we have

no values set.
# File lib/unique_attributes.rb, line 54
def blank_unique_attributes
  self.class.unique_attributes.select { |k| send(k).nil? }
end
save_with_unique_attributes() { || ... } click to toggle source

Ensures that the attribute value exists and is unique (relying on a database-level unique index) when saving. This will set the value on the first save of the object.

# File lib/unique_attributes.rb, line 61
def save_with_unique_attributes
  blank_attrs = blank_unique_attributes

  # If we have blank unique attributes.
  if blank_attrs.size > 0
    attempts = 0
    attr_group = "(?<attr>#{blank_attrs.keys.join('|')})"
    other_fields = "(, [\\w`'\".]+)*"

    # Keep retrying until the save works.
    while !self.persisted?
      attempts += 1 # Keep track of the number of times we've tried to save.

      # Set each of the blank attributes with the given blocks.
      blank_attrs.each { |attr, block| write_attribute(attr, block.call) }

      begin
        ActiveRecord::Base.transaction(requires_new: true) do
          yield # Perform the save, and see if it works.
        end
      rescue ActiveRecord::RecordNotUnique => error
        if attempts <= SAVE_ATTEMPTS_LIMIT
          match = [
            # Postgres
            /Key \(#{attr_group}#{other_fields}\)=\([\w\s,]*\) already exists/,
            # SQLite
            /column(s)? #{attr_group}#{other_fields} (is|are) not unique/,
            /UNIQUE constraint failed: #{self.class.table_name}\.#{attr_group}#{other_fields}:/
          ].inject(nil) { |m, regex| m || regex.match(error.message) }

          # If we've managed to hit the same unique attribute of a record
          # already in the database, then we should wipe the attribute and
          # try again
          if match
            attr = match[:attr].to_sym
            blank_attrs = { attr => self.class.unique_attributes[attr] }
            write_attribute(attr, nil)
            next
          end
        end

        # If we're already at the attempts limit, or some other attribute
        # was the problem, let the error propagate.
        raise error
      end
    end
  else # If the unique values are already set, perform a regular save.
    yield
  end
end