aboutsummaryrefslogtreecommitdiffstats
path: root/activerecord/lib/active_record/integration.rb
blob: 67a63cd2d15015e6999d7963f93e6967bc327e81 (plain) (blame)
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
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
# frozen_string_literal: true

require "active_support/core_ext/string/filters"

module ActiveRecord
  module Integration
    extend ActiveSupport::Concern

    included do
      ##
      # :singleton-method:
      # Indicates the format used to generate the timestamp in the cache key, if
      # versioning is off. Accepts any of the symbols in <tt>Time::DATE_FORMATS</tt>.
      #
      # This is +:usec+, by default.
      class_attribute :cache_timestamp_format, instance_writer: false, default: :usec

      ##
      # :singleton-method:
      # Indicates whether to use a stable #cache_key method that is accompanied
      # by a changing version in the #cache_version method.
      #
      # This is +true+, by default on Rails 5.2 and above.
      class_attribute :cache_versioning, instance_writer: false, default: false
    end

    # Returns a +String+, which Action Pack uses for constructing a URL to this
    # object. The default implementation returns this record's id as a +String+,
    # or +nil+ if this record's unsaved.
    #
    # For example, suppose that you have a User model, and that you have a
    # <tt>resources :users</tt> route. Normally, +user_path+ will
    # construct a path with the user object's 'id' in it:
    #
    #   user = User.find_by(name: 'Phusion')
    #   user_path(user)  # => "/users/1"
    #
    # You can override +to_param+ in your model to make +user_path+ construct
    # a path using the user's name instead of the user's id:
    #
    #   class User < ActiveRecord::Base
    #     def to_param  # overridden
    #       name
    #     end
    #   end
    #
    #   user = User.find_by(name: 'Phusion')
    #   user_path(user)  # => "/users/Phusion"
    def to_param
      # We can't use alias_method here, because method 'id' optimizes itself on the fly.
      id && id.to_s # Be sure to stringify the id for routes
    end

    # Returns a stable cache key that can be used to identify this record.
    #
    #   Product.new.cache_key     # => "products/new"
    #   Product.find(5).cache_key # => "products/5"
    #
    # If ActiveRecord::Base.cache_versioning is turned off, as it was in Rails 5.1 and earlier,
    # the cache key will also include a version.
    #
    #   Product.cache_versioning = false
    #   Product.find(5).cache_key  # => "products/5-20071224150000" (updated_at available)
    def cache_key
      if new_record?
        "#{model_name.cache_key}/new"
      else
        if cache_version
          "#{model_name.cache_key}/#{id}"
        else
          timestamp = max_updated_column_timestamp

          if timestamp
            timestamp = timestamp.utc.to_s(cache_timestamp_format)
            "#{model_name.cache_key}/#{id}-#{timestamp}"
          else
            "#{model_name.cache_key}/#{id}"
          end
        end
      end
    end

    # Returns a cache version that can be used together with the cache key to form
    # a recyclable caching scheme. By default, the #updated_at column is used for the
    # cache_version, but this method can be overwritten to return something else.
    #
    # Note, this method will return nil if ActiveRecord::Base.cache_versioning is set to
    # +false+ (which it is by default until Rails 6.0).
    def cache_version
      return unless cache_versioning

      if has_attribute?("updated_at")
        timestamp = updated_at_before_type_cast
        if can_use_fast_cache_version?(timestamp)
          raw_timestamp_to_cache_version(timestamp)
        elsif timestamp = updated_at
          timestamp.utc.to_s(cache_timestamp_format)
        end
      else
        if self.class.has_attribute?("updated_at")
          raise ActiveModel::MissingAttributeError, "missing attribute: updated_at"
        end
      end
    end

    # Returns a cache key along with the version.
    def cache_key_with_version
      if version = cache_version
        "#{cache_key}-#{version}"
      else
        cache_key
      end
    end

    module ClassMethods
      # Defines your model's +to_param+ method to generate "pretty" URLs
      # using +method_name+, which can be any attribute or method that
      # responds to +to_s+.
      #
      #   class User < ActiveRecord::Base
      #     to_param :name
      #   end
      #
      #   user = User.find_by(name: 'Fancy Pants')
      #   user.id         # => 123
      #   user_path(user) # => "/users/123-fancy-pants"
      #
      # Values longer than 20 characters will be truncated. The value
      # is truncated word by word.
      #
      #   user = User.find_by(name: 'David Heinemeier Hansson')
      #   user.id         # => 125
      #   user_path(user) # => "/users/125-david-heinemeier"
      #
      # Because the generated param begins with the record's +id+, it is
      # suitable for passing to +find+. In a controller, for example:
      #
      #   params[:id]               # => "123-fancy-pants"
      #   User.find(params[:id]).id # => 123
      def to_param(method_name = nil)
        if method_name.nil?
          super()
        else
          define_method :to_param do
            if (default = super()) &&
                 (result = send(method_name).to_s).present? &&
                   (param = result.squish.parameterize.truncate(20, separator: /-/, omission: "")).present?
              "#{default}-#{param}"
            else
              default
            end
          end
        end
      end
    end

    private
      # Detects if the value before type cast
      # can be used to generate a cache_version.
      #
      # The fast cache version only works with a
      # string value directly from the database.
      #
      # We also must check if the timestamp format has been changed
      # or if the timezone is not set to UTC then
      # we cannot apply our transformations correctly.
      def can_use_fast_cache_version?(timestamp)
        timestamp.is_a?(String) &&
          cache_timestamp_format == :usec &&
          default_timezone == :utc &&
          !updated_at_came_from_user?
      end

      # Converts a raw database string to `:usec`
      # format.
      #
      # Example:
      #
      #   timestamp = "2018-10-15 20:02:15.266505"
      #   raw_timestamp_to_cache_version(timestamp)
      #   # => "20181015200215266505"
      #
      # PostgreSQL truncates trailing zeros,
      # https://github.com/postgres/postgres/commit/3e1beda2cde3495f41290e1ece5d544525810214
      # to account for this we pad the output with zeros
      def raw_timestamp_to_cache_version(timestamp)
        key = timestamp.delete("- :.")
        if key.length < 20
          key.ljust(20, "0")
        else
          key
        end
      end
  end
end