Add #cache_key to ActiveRecord::Relation.

This commit is contained in:
Alberto F. Capel 2015-07-14 23:47:16 +01:00
parent 6ffec3c16c
commit 476e3f552f
6 changed files with 137 additions and 0 deletions

View File

@ -1,3 +1,13 @@
* Add `cache_key` to ActiveRecord::Relation.
Example:
@users = User.where("name like ?", "%Alberto%")
@users.cache_key
=> "/users/query-5942b155a43b139f2471b872ac54251f-3-20150714212107656125000"
*Alberto Fernández-Capel*
* Fix a bug where counter_cache doesn't always work with polymorphic
relations.

View File

@ -53,6 +53,7 @@ module ActiveRecord
autoload :Persistence
autoload :QueryCache
autoload :Querying
autoload :CollectionCacheKey
autoload :ReadonlyAttributes
autoload :RecordInvalid, 'active_record/validations'
autoload :Reflection

View File

@ -280,6 +280,7 @@ module ActiveRecord #:nodoc:
extend Explain
extend Enum
extend Delegation::DelegateCache
extend CollectionCacheKey
include Core
include Persistence

View File

@ -0,0 +1,29 @@
module ActiveRecord
module CollectionCacheKey
def collection_cache_key(collection = all, timestamp_column = :updated_at) # :nodoc:
query_signature = Digest::MD5.hexdigest(collection.to_sql)
key = "#{collection.model_name.cache_key}/query-#{query_signature}"
if collection.loaded?
size = collection.size
timestamp = collection.max_by(&timestamp_column).public_send(timestamp_column)
else
column_type = type_for_attribute(timestamp_column.to_s)
column = "#{connection.quote_table_name(collection.table_name)}.#{connection.quote_column_name(timestamp_column)}"
query = collection.select("COUNT(*) AS size", "MAX(#{column}) AS timestamp")
result = connection.select_one(query)
size = result["size"]
timestamp = column_type.deserialize(result["timestamp"])
end
if timestamp
"#{key}-#{size}-#{timestamp.utc.to_s(cache_timestamp_format)}"
else
"#{key}-#{size}"
end
end
end
end

View File

@ -298,6 +298,32 @@ module ActiveRecord
limit_value ? to_a.many? : size > 1
end
# Returns a cache key that can be used to identify the records fetched by
# this query. The cache key is built with a fingerprint of the sql query,
# the number of records matched by the query and a timestamp of the last
# updated record. When a new record comes to match the query, or any of
# the existing records is updated or deleted, the cache key changes.
#
# Product.where("name like ?", "%Cosmic Encounter%").cache_key
# => "products/query-1850ab3d302391b85b8693e941286659-1-20150714212553907087000"
#
# If the collection is loaded, the method will iterate through the records
# to generate the timestamp, otherwise it will trigger one SQL query like:
#
# SELECT COUNT(*), MAX("products"."updated_at") FROM "products" WHERE (name like '%Cosmic Encounter%')
#
# You can also pass a custom timestamp column to fetch the timestamp of the
# last updated record.
#
# Product.where("name like ?", "%Game%").cache_key(:last_reviewed_at)
#
# You can customize the strategy to generate the key on a per model basis
# overriding ActiveRecord::Base#collection_cache_key.
def cache_key(timestamp_column = :updated_at)
@cache_keys ||= {}
@cache_keys[timestamp_column] ||= @klass.collection_cache_key(self, timestamp_column)
end
# Scope all queries to the current scope.
#
# Comment.where(post_id: 1).scoping do

View File

@ -0,0 +1,70 @@
require "cases/helper"
require "models/computer"
require "models/developer"
require "models/project"
require "models/topic"
require "models/post"
require "models/comment"
module ActiveRecord
class CollectionCacheKeyTest < ActiveRecord::TestCase
fixtures :developers, :projects, :developers_projects, :topics, :comments, :posts
test "collection_cache_key on model" do
assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\Z/, Developer.collection_cache_key)
end
test "cache_key for relation" do
developers = Developer.where(name: "David")
last_developer_timestamp = developers.order(updated_at: :desc).first.updated_at
assert_match /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\Z/, developers.cache_key
/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\Z/ =~ developers.cache_key
assert_equal Digest::MD5.hexdigest(developers.to_sql), $1
assert_equal developers.count.to_s, $2
assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3
end
test "it triggers at most one query" do
developers = Developer.where(name: "David")
assert_queries(1) { developers.cache_key }
assert_queries(0) { developers.cache_key }
end
test "it doesn't trigger any query if the relation is already loaded" do
developers = Developer.where(name: "David").load
assert_queries(0) { developers.cache_key }
end
test "relation cache_key changes when the sql query changes" do
developers = Developer.where(name: "David")
other_relation = Developer.where(name: "David").where("1 = 1")
assert_not_equal developers.cache_key, other_relation.cache_key
end
test "cache_key for empty relation" do
developers = Developer.where(name: "Non Existent Developer")
assert_match(/\Adevelopers\/query-(\h+)-0\Z/, developers.cache_key)
end
test "cache_key with custom timestamp column" do
topics = Topic.where("title like ?", "%Topic%")
last_topic_timestamp = topics(:fifth).written_on.utc.to_s(:nsec)
assert_match(last_topic_timestamp, topics.cache_key(:written_on))
end
test "cache_key with unknown timestamp column" do
topics = Topic.where("title like ?", "%Topic%")
assert_raises(ActiveRecord::StatementInvalid) { topics.cache_key(:published_at) }
end
test "collection proxy provides a cache_key" do
developers = projects(:active_record).developers
assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\Z/, developers.cache_key)
end
end
end