summaryrefslogtreecommitdiff
path: root/app/models/concerns/atomic_internal_id.rb
blob: 3e9b084e784a67ccd436eb2d30f7edf28342554f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
# frozen_string_literal: true

# Include atomic internal id generation scheme for a model
#
# This allows us to atomically generate internal ids that are
# unique within a given scope.
#
# For example, let's generate internal ids for Issue per Project:
# ```
# class Issue < ApplicationRecord
#   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.
module AtomicInternalId
  extend ActiveSupport::Concern

  class_methods do
    def has_internal_id(column, scope:, init:, ensure_if: nil, track_if: nil, presence: true) # rubocop:disable Naming/PredicateName
      # 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
      raise "has_internal_id needs to be defined on association." unless self.reflect_on_association(scope)

      before_validation :"track_#{scope}_#{column}!", on: :create, if: track_if
      before_validation :"ensure_#{scope}_#{column}!", on: :create, if: ensure_if
      validates column, presence: presence

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

        if value.nil?
          # We don't have a value yet and use a InternalId record to generate
          # the next value.
          value = InternalId.generate_next(
            self,
            internal_id_scope_attrs(scope),
            internal_id_scope_usage,
            init)
          write_attribute(column, value)
        end

        value
      end

      define_method("track_#{scope}_#{column}!") do
        return unless @internal_id_needs_tracking

        scope_value = internal_id_read_scope(scope)
        return unless scope_value

        value = read_attribute(column)

        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)

          @internal_id_needs_tracking = false
        end
      end

      define_method("#{column}=") do |value|
        super(value).tap do |v|
          # Indicate the iid was set from externally
          @internal_id_needs_tracking = true
        end
      end

      define_method("reset_#{scope}_#{column}") do
        if value = read_attribute(column)
          did_reset = InternalId.reset(
            self,
            internal_id_scope_attrs(scope),
            internal_id_scope_usage,
            value)

          if did_reset
            write_attribute(column, nil)
          end
        end

        read_attribute(column)
      end
    end
  end

  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
end