atomic_internal_id.rb 3.82 KB
Newer Older
1 2
# frozen_string_literal: true

3 4
# Include atomic internal id generation scheme for a model
#
Andreas Brandl's avatar
Andreas Brandl committed
5
# This allows us to atomically generate internal ids that are
6 7 8 9
# unique within a given scope.
#
# For example, let's generate internal ids for Issue per Project:
# ```
10
# class Issue < ApplicationRecord
11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
#   has_internal_id :iid, scope: :project, init: ->(s) { s.project.issues.maximum(:iid) }
# end
# ```
#
# This generates unique internal ids per project for newly created issues.
# The generated internal id is saved in the `iid` attribute of `Issue`.
#
# This concern uses InternalId records to facilitate atomicity.
# In the absence of a record for the given scope, one will be created automatically.
# In this situation, the `init` block is called to calculate the initial value.
# In the example above, we calculate the maximum `iid` of all issues
# within the given project.
#
# Note that a model may have more than one internal id associated with possibly
# different scopes.
26 27 28
module AtomicInternalId
  extend ActiveSupport::Concern

29
  class_methods do
30
    def has_internal_id(column, scope:, init:, ensure_if: nil, presence: true) # rubocop:disable Naming/PredicateName
31 32 33
      # We require init here to retain the ability to recalculate in the absence of a
      # InternaLId record (we may delete records in `internal_ids` for example).
      raise "has_internal_id requires a init block, none given." unless init
34
      raise "has_internal_id needs to be defined on association." unless self.reflect_on_association(scope)
35

36 37
      before_validation :"track_#{scope}_#{column}!", on: :create
      before_validation :"ensure_#{scope}_#{column}!", on: :create, if: ensure_if
38
      validates column, presence: presence
39

40
      define_method("ensure_#{scope}_#{column}!") do
41
        scope_value = internal_id_read_scope(scope)
42 43
        value = read_attribute(column)
        return value unless scope_value
44

45
        if value.nil?
46 47
          # We don't have a value yet and use a InternalId record to generate
          # the next value.
48 49 50 51 52
          value = InternalId.generate_next(
            self,
            internal_id_scope_attrs(scope),
            internal_id_scope_usage,
            init)
53
          write_attribute(column, value)
Andreas Brandl's avatar
Andreas Brandl committed
54
        end
55

56
        value
57
      end
58

59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
      define_method("track_#{scope}_#{column}!") do
        if !@internal_id_needs_tracking
          return unless Feature.enabled?(:iid_always_track, default_enabled: true)
        end

        @internal_id_needs_tracking = false

        scope_value = internal_id_read_scope(scope)
        value = read_attribute(column)
        return unless scope_value

        if value.present?
          # The value was set externally, e.g. by the user
          # We update the InternalId record to keep track of the greatest value.
          InternalId.track_greatest(
            self,
            internal_id_scope_attrs(scope),
            internal_id_scope_usage,
            value,
            init)
        end
      end

82 83 84
      define_method("#{column}=") do |value|
        super(value).tap do |v|
          # Indicate the iid was set from externally
85
          @internal_id_needs_tracking = true
86 87 88
        end
      end

89 90
      define_method("reset_#{scope}_#{column}") do
        if value = read_attribute(column)
91 92 93 94 95
          did_reset = InternalId.reset(
            self,
            internal_id_scope_attrs(scope),
            internal_id_scope_usage,
            value)
96

97
          if did_reset
98 99 100 101 102 103
            write_attribute(column, nil)
          end
        end

        read_attribute(column)
      end
104
    end
105
  end
106 107 108 109 110 111 112 113 114 115 116 117 118 119

  def internal_id_scope_attrs(scope)
    scope_value = internal_id_read_scope(scope)

    { scope_value.class.table_name.singularize.to_sym => scope_value } if scope_value
  end

  def internal_id_scope_usage
    self.class.table_name.to_sym
  end

  def internal_id_read_scope(scope)
    association(scope).reader
  end
120
end