[Feature Proposal] Support timestamps from joined relations in cached_version!

Hey folks, I have a query of a has_many relation, that I want to cache. The cache_key should be invalid based on max(updated_at) but also based on max(belongs_to.updated_at).

Some code for illustration:

class Article < ApplicationRecord 
  has_many :comments
end

class Comment < ApplicationRecord 
  belongs_to :article, touch: true
end

The following will work because I touch Article on every Comment update

Article.includes(:comments).all.cache_version

But

Comment.includes(:article).all.cache_version

will not get a new cache_version when article updated

I would like to propose, to

  • support multiple timestamp fields in ActiveRecord::Relation.cache_version
  • support table names in ActiveRecord::Relation.cache_version
Comment.includes(:article).cache_version('articles.updated_at','comments.updated_at')

What do you think?

I have created an extension for comput_cache_version that accepts array’s of timestamp columns like:

@slots.cache_key(%w[slots.updated_at advices.updated_at])

to generate a query like

SELECT COUNT(*) AS "size", GREATEST(MAX(slots.updated_at),MAX(advices.updated_at)) AS timestamp FROM [...]

sourcecode is

module RailsExtensions
  module ActiveRecord
    module Relation
      module CacheKeyForRelationalTables
        def compute_cache_version(timestamp_column)
          # :nodoc:
          if loaded? || distinct_value
            size = records.size
            timestamp = max_by(&timestamp_column)._read_attribute(timestamp_column) if size.positive?
          else
            collection = eager_loading? ? apply_join_dependency : self

            column = if timestamp_column.is_a?(Array)
                       column_placeholder = Array.new(timestamp_column.length, 'MAX(%s)').join(',')
                       select_values = "COUNT(*) AS #{connection.quote_column_name('size')}, " \
                                       "GREATEST(#{column_placeholder}) AS timestamp"
                       timestamp_column.map do |c|
                         connection.visitor.compile(Arel.sql(c))
                       end
                     elsif timestamp_column.is_a?(String) && timestamp_column.include?('.')
                       connection.visitor.compile(Arel.sql(timestamp_column))
                     else
                       select_values = "COUNT(*) AS #{connection.quote_column_name('size')}, " \
                                       'MAX(%s) AS timestamp'
                       connection.visitor.compile(arel_attribute(timestamp_column))
                     end

            if collection.has_limit_or_offset?
              query = collection.select("#{column} AS collection_cache_key_timestamp")
              subquery_alias = 'subquery_for_cache_key'
              subquery_column = "#{subquery_alias}.collection_cache_key_timestamp"
              arel = query.build_subquery(subquery_alias, select_values % subquery_column)
            else
              query = collection.unscope(:order)
              query.select_values = [select_values % column]
              arel = query.arel
            end

            result = connection.select_one(arel, nil)

            if result
              column_type = klass.type_for_attribute(timestamp_column)
              timestamp = column_type.deserialize(result['timestamp'])
              size = result['size']
            else
              timestamp = nil
              size = 0
            end
          end

          if timestamp
            "#{size}-#{timestamp.utc.to_s(cache_timestamp_format)}"
          else
            size.to_s
          end
        end
      end
    end
  end
end

ActiveRecord::Relation.prepend RailsExtensions::ActiveRecord::Relation::CacheKeyForRelationalTables

Any thoughts or suggestions? best