Skip to content

Commit

Permalink
Add subset_static_cache plugin for statically caching subsets of a mo…
Browse files Browse the repository at this point in the history
…del class

This is useful if the entire model class is not static, but specific
subsets of the model class are static.  It operates like the static_cache
plugin, but restricted to specific subsets.

Update the static_cache_cache plugin to handle the subset_static_cache
plugin in addition to the static_cache plugin.
  • Loading branch information
jeremyevans committed Dec 20, 2024
1 parent 14fb130 commit 42a8b30
Show file tree
Hide file tree
Showing 7 changed files with 781 additions and 21 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
=== master

* Add subset_static_cache plugin for statically caching subsets of a model class (jeremyevans)

* Allow class-level dataset methods to be overridable and call super to get the default behavior (jeremyevans)

* Support column aliases with data types on PostgreSQL, useful for selecting from functions returning records (jeremyevans)
Expand Down
52 changes: 43 additions & 9 deletions lib/sequel/plugins/static_cache_cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

module Sequel
module Plugins
# The static_cache_cache plugin allows for caching the row content for subclasses
# that use the static cache plugin (or just the current class). Using this plugin
# can avoid the need to query the database every time loading the plugin into a
# model, which can save time when you have a lot of models using the static_cache
# plugin.
# The static_cache_cache plugin allows for caching the row content for the current
# class and subclasses that use the static_cache or subset_static_cache plugins.
# Using this plugin can avoid the need to query the database every time loading
# the static_cache plugin into a model (static_cache plugin) or using the
# cache_subset method (subset_static_cache plugin).
#
# Usage:
#
Expand All @@ -27,7 +27,27 @@ module ClassMethods
# Dump the in-memory cached rows to the cache file.
def dump_static_cache_cache
static_cache_cache = {}
@static_cache_cache.sort.each do |k, v|
@static_cache_cache.sort do |a, b|
a, = a
b, = b
if a.is_a?(Array)
if b.is_a?(Array)
a_name, a_meth = a
b_name, b_meth = b
x = a_name <=> b_name
if x.zero?
x = a_meth <=> b_meth
end
x
else
1
end
elsif b.is_a?(Array)
-1
else
a <=> b
end
end.each do |k, v|
static_cache_cache[k] = v
end
File.open(@static_cache_cache_file, 'wb'){|f| f.write(Marshal.dump(static_cache_cache))}
Expand All @@ -42,12 +62,26 @@ def dump_static_cache_cache
# If not available, load the rows from the database, and
# then update the cache with the raw rows.
def load_static_cache_rows
if rows = Sequel.synchronize{@static_cache_cache[name]}
_load_static_cache_rows(dataset, name)
end

# Load the rows for the subset from the cache if available.
# If not available, load the rows from the database, and
# then update the cache with the raw rows.
def load_subset_static_cache_rows(ds, meth)
_load_static_cache_rows(ds, [name, meth].freeze)
end

# Check the cache first for the key, and return rows without a database
# query if present. Otherwise, get all records in the provided dataset,
# and update the cache with them.
def _load_static_cache_rows(ds, key)
if rows = Sequel.synchronize{@static_cache_cache[key]}
rows.map{|row| call(row)}.freeze
else
rows = dataset.all.freeze
rows = ds.all.freeze
raw_rows = rows.map(&:values)
Sequel.synchronize{@static_cache_cache[name] = raw_rows}
Sequel.synchronize{@static_cache_cache[key] = raw_rows}
rows
end
end
Expand Down
262 changes: 262 additions & 0 deletions lib/sequel/plugins/subset_static_cache.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
# frozen-string-literal: true

module Sequel
module Plugins
# The subset_static_cache plugin is designed for model subsets that are not modified at all
# in production use cases, or at least where modifications to them would usually
# coincide with an application restart. When caching a model subset, it
# retrieves all rows in the database and statically caches a ruby array and hash
# keyed on primary key containing all of the model instances. All of these cached
# instances are frozen so they won't be modified unexpectedly.
#
# With the following code:
#
# class StatusType < Sequel::Model
# dataset_module do
# where :available, hidden: false
# end
# cache_subset :available
# end
#
# The following methods will use the cache and not issue a database query:
#
# * StatusType.available.with_pk
# * StatusType.available.all
# * StatusType.available.each
# * StatusType.available.first (without block, only supporting no arguments or single integer argument)
# * StatusType.available.count (without an argument or block)
# * StatusType.available.map
# * StatusType.available.as_hash
# * StatusType.available.to_hash
# * StatusType.available.to_hash_groups
#
# The cache is not used if you chain methods before or after calling the cached
# method, as doing so would not be safe:
#
# StatusType.where{number > 1}.available.all
# StatusType.available.where{number > 1}.all
#
# The cache is also not used if you change the class's dataset after caching
# the subset, or in subclasses of the model.
#
# You should not modify any row that is statically cached when using this plugin,
# as otherwise you will get different results for cached and uncached method
# calls.
module SubsetStaticCache
def self.configure(model)
model.class_exec do
@subset_static_caches ||= ({}.compare_by_identity)
end
end

module ClassMethods
# Cache the given subset statically, so that calling the subset method on
# the model will return a dataset that will return cached results instead
# of issuing database queries (assuming the cache has the necessary
# information).
#
# The model must already respond to the given method before cache_subset
# is called.
def cache_subset(meth)
ds = send(meth).with_extend(CachedDatasetMethods)
cache = ds.instance_variable_get(:@cache)

rows, hash = subset_static_cache_rows(ds, meth)
cache[:subset_static_cache_all] = rows
cache[:subset_static_cache_map] = hash

caches = @subset_static_caches
caches[meth] = ds
model = self
subset_static_cache_module.send(:define_method, meth) do
if (model == self) && (cached_dataset = caches[meth])
cached_dataset
else
super()
end
end
nil
end

Plugins.after_set_dataset(self, :clear_subset_static_caches)
Plugins.inherited_instance_variables(self, :@subset_static_caches=>proc{{}.compare_by_identity})

private

# Clear the subset_static_caches. This is used if the model dataset
# changes, to prevent cached values from being used.
def clear_subset_static_caches
@subset_static_caches.clear
end

# A module for the subset static cache methods, so that you can define
# a singleton method in the class with the same name, and call super
# to get default behavior.
def subset_static_cache_module
return @subset_static_cache_module if @subset_static_cache_module

# Ensure dataset_methods module is defined and class is extended with
# it before calling creating this module.
dataset_methods_module

Sequel.synchronize{@subset_static_cache_module ||= Module.new}
extend(@subset_static_cache_module)
@subset_static_cache_module
end

# Return the frozen array and hash used for caching the subset
# of the given dataset.
def subset_static_cache_rows(ds, meth)
all = load_subset_static_cache_rows(ds, meth)
h = {}
all.each do |o|
o.errors.freeze
h[o.pk.freeze] = o.freeze
end
[all, h.freeze]
end

# Return a frozen array for all rows in the dataset.
def load_subset_static_cache_rows(ds, meth)
ret = super if defined?(super)
ret || ds.all.freeze
end
end

module CachedDatasetMethods
# An array of all of the dataset's instances, without issuing a database
# query. If a block is given, yields each instance to the block.
def all(&block)
return super unless all = @cache[:subset_static_cache_all]

array = all.dup
array.each(&block) if block
array
end

# Get the number of records in the cache, without issuing a database query,
# if no arguments or block are provided.
def count(*a, &block)
if a.empty? && !block && (all = @cache[:subset_static_cache_all])
all.size
else
super
end
end

# If a block is given, multiple arguments are given, or a single
# non-Integer argument is given, performs the default behavior of
# issuing a database query. Otherwise, uses the cached values
# to return either the first cached instance (no arguments) or an
# array containing the number of instances specified (single integer
# argument).
def first(*args)
if !defined?(yield) && args.length <= 1 && (args.length == 0 || args[0].is_a?(Integer)) && (all = @cache[:subset_static_cache_all])
all.first(*args)
else
super
end
end

# Return the frozen object with the given pk, or nil if no such object exists
# in the cache, without issuing a database query.
def with_pk(pk)
if cache = @cache[:subset_static_cache_map]
cache[pk]
else
super
end
end

# Yield each of the dataset's frozen instances to the block, without issuing a database
# query.
def each(&block)
return super unless all = @cache[:subset_static_cache_all]
all.each(&block)
end

# Use the cache instead of a query to get the results.
def map(column=nil, &block)
return super unless all = @cache[:subset_static_cache_all]
if column
raise(Error, "Cannot provide both column and block to map") if block
if column.is_a?(Array)
all.map{|r| r.values.values_at(*column)}
else
all.map{|r| r[column]}
end
else
all.map(&block)
end
end

# Use the cache instead of a query to get the results if possible
def as_hash(key_column = nil, value_column = nil, opts = OPTS)
return super unless all = @cache[:subset_static_cache_all]

if key_column.nil? && value_column.nil?
if opts[:hash]
key_column = model.primary_key
else
return Hash[@cache[:subset_static_cache_map]]
end
end

h = opts[:hash] || {}
if value_column
if value_column.is_a?(Array)
if key_column.is_a?(Array)
all.each{|r| h[r.values.values_at(*key_column)] = r.values.values_at(*value_column)}
else
all.each{|r| h[r[key_column]] = r.values.values_at(*value_column)}
end
else
if key_column.is_a?(Array)
all.each{|r| h[r.values.values_at(*key_column)] = r[value_column]}
else
all.each{|r| h[r[key_column]] = r[value_column]}
end
end
elsif key_column.is_a?(Array)
all.each{|r| h[r.values.values_at(*key_column)] = r}
else
all.each{|r| h[r[key_column]] = r}
end
h
end

# Alias of as_hash for backwards compatibility.
def to_hash(*a)
as_hash(*a)
end

# Use the cache instead of a query to get the results
def to_hash_groups(key_column, value_column = nil, opts = OPTS)
return super unless all = @cache[:subset_static_cache_all]

h = opts[:hash] || {}
if value_column
if value_column.is_a?(Array)
if key_column.is_a?(Array)
all.each{|r| (h[r.values.values_at(*key_column)] ||= []) << r.values.values_at(*value_column)}
else
all.each{|r| (h[r[key_column]] ||= []) << r.values.values_at(*value_column)}
end
else
if key_column.is_a?(Array)
all.each{|r| (h[r.values.values_at(*key_column)] ||= []) << r[value_column]}
else
all.each{|r| (h[r[key_column]] ||= []) << r[value_column]}
end
end
elsif key_column.is_a?(Array)
all.each{|r| (h[r.values.values_at(*key_column)] ||= []) << r}
else
all.each{|r| (h[r[key_column]] ||= []) << r}
end
h
end
end
end
end
end
Loading

0 comments on commit 42a8b30

Please sign in to comment.