From 42a8b305cd105b79d2313d8ef0c83d4b1db4916b Mon Sep 17 00:00:00 2001 From: Jeremy Evans Date: Thu, 19 Dec 2024 14:59:29 -0800 Subject: [PATCH] Add subset_static_cache plugin for statically caching subsets of a model 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. --- CHANGELOG | 2 + lib/sequel/plugins/static_cache_cache.rb | 52 +++- lib/sequel/plugins/subset_static_cache.rb | 262 ++++++++++++++++ spec/extensions/static_cache_cache_spec.rb | 61 +++- spec/extensions/subset_static_cache_spec.rb | 321 ++++++++++++++++++++ spec/integration/plugin_test.rb | 98 ++++++ www/pages/plugins.html.erb | 6 +- 7 files changed, 781 insertions(+), 21 deletions(-) create mode 100644 lib/sequel/plugins/subset_static_cache.rb create mode 100644 spec/extensions/subset_static_cache_spec.rb diff --git a/CHANGELOG b/CHANGELOG index 6b2382a9c..23b4f33ff 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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) diff --git a/lib/sequel/plugins/static_cache_cache.rb b/lib/sequel/plugins/static_cache_cache.rb index 2325ba594..3183c7304 100644 --- a/lib/sequel/plugins/static_cache_cache.rb +++ b/lib/sequel/plugins/static_cache_cache.rb @@ -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: # @@ -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))} @@ -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 diff --git a/lib/sequel/plugins/subset_static_cache.rb b/lib/sequel/plugins/subset_static_cache.rb new file mode 100644 index 000000000..798e07620 --- /dev/null +++ b/lib/sequel/plugins/subset_static_cache.rb @@ -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 diff --git a/spec/extensions/static_cache_cache_spec.rb b/spec/extensions/static_cache_cache_spec.rb index 5b547a05b..7bbf83b1a 100644 --- a/spec/extensions/static_cache_cache_spec.rb +++ b/spec/extensions/static_cache_cache_spec.rb @@ -13,7 +13,7 @@ def @c.name; 'Foo' end File.delete(@file) if File.file?(@file) end - it "should allow dumping and loading static cache rows from a cache file" do + it "should allow dumping and loading when using static_cache plugin" do @c.plugin :static_cache_cache, @file @db.sqls @c.plugin :static_cache @@ -23,9 +23,9 @@ def @c.name; 'Foo' end @c.dump_static_cache_cache @db.fetch = [] - c = Class.new(Sequel::Model(@db[:t])) - def c.name; 'Foo' end - c.columns :id, :name + @c = Class.new(Sequel::Model(@db[:t])) + def @c.name; 'Foo' end + @c.columns :id, :name @c.plugin :static_cache_cache, @file @db.sqls @c.plugin :static_cache @@ -33,21 +33,60 @@ def c.name; 'Foo' end @c.all.must_equal [@c.load(:id=>1, :name=>'A'), @c.load(:id=>2, :name=>'B')] end - it "should sort cache file by model name" do + it "should allow dumping and loading when using subset_static_cache plugin" do + file = @file + @db.sqls + setup_block = proc do + plugin :static_cache_cache, file + dataset_module do + where :a, :b + where :c, :d + end + plugin :subset_static_cache + cache_subset :c + cache_subset :a + end + + @c.class_eval(&setup_block) + @db.sqls.must_equal ['SELECT * FROM t WHERE d', 'SELECT * FROM t WHERE b'] + @c.c.all.must_equal [@c.load(:id=>1, :name=>'A'), @c.load(:id=>2, :name=>'B')] + @db.sqls.must_equal [] + + @c.dump_static_cache_cache + + @db.fetch = [] + @c = Class.new(Sequel::Model(@db[:t])) + def @c.name; 'Foo' end + @c.columns :id, :name + @db.sqls + @c.class_eval(&setup_block) + @db.sqls.must_be_empty + @c.c.all.must_equal [@c.load(:id=>1, :name=>'A'), @c.load(:id=>2, :name=>'B')] + end + + it "should sort cache file by model name, and optionally method name" do @c.plugin :static_cache_cache, @file c1 = Class.new(@c) def c1.name; 'Foo' end c1.plugin :static_cache - c2 = Class.new(@c) - def c2.name; 'Bar' end - c2.plugin :static_cache + Class.new(@c) do + def self.name; 'Bar' end + plugin :static_cache + dataset_module do + where :a, :b + where :c, :d + end + plugin :subset_static_cache + cache_subset :c + cache_subset :a + end - @c.instance_variable_get(:@static_cache_cache).keys.must_equal %w'Foo Bar' + @c.instance_variable_get(:@static_cache_cache).keys.must_equal ['Foo', 'Bar', ['Bar', :c], ['Bar', :a]] @c.dump_static_cache_cache - @c.instance_variable_get(:@static_cache_cache).keys.must_equal %w'Foo Bar' + @c.instance_variable_get(:@static_cache_cache).keys.must_equal ['Foo', 'Bar', ['Bar', :c], ['Bar', :a]] c = Class.new(Sequel::Model) c.plugin :static_cache_cache, @file - c.instance_variable_get(:@static_cache_cache).keys.must_equal %w'Bar Foo' + c.instance_variable_get(:@static_cache_cache).keys.must_equal ['Bar', 'Foo', ['Bar', :a], ['Bar', :c]] end end diff --git a/spec/extensions/subset_static_cache_spec.rb b/spec/extensions/subset_static_cache_spec.rb new file mode 100644 index 000000000..9d9d0b0d0 --- /dev/null +++ b/spec/extensions/subset_static_cache_spec.rb @@ -0,0 +1,321 @@ +require_relative "spec_helper" + +describe "subset_static_cache plugin" do + before do + @db = Sequel.mock + @db.fetch = [{:id=>1}, {:id=>2}] + @db.numrows = 1 + @c = Class.new(Sequel::Model(@db[:t])) do + columns :id, :name + + dataset_module do + where :foo, :bar + end + + plugin :subset_static_cache + end + @db.sqls.must_equal ["SELECT * FROM t LIMIT 0"] + + @c.cache_subset :foo + @db.sqls.must_equal ["SELECT * FROM t WHERE bar"] + + @ds = @c.foo + @c1 = @c.load(:id=>1) + @c2 = @c.load(:id=>2) + end + + it "should have .with_pk use the cache without a query" do + @ds.with_pk(1) + @ds.with_pk(1).must_equal @c1 + @ds.with_pk(2).must_equal @c2 + @ds.with_pk(3).must_be_nil + @ds.with_pk([1,2]).must_be_nil + @ds.with_pk(nil).must_be_nil + @db.sqls.must_equal [] + end + + it "should have .with_pk work on cloned datasets using a query" do + @ds.where(:baz).with_pk(1).must_equal @c1 + @db.sqls.must_equal ["SELECT * FROM t WHERE (bar AND baz AND (t.id = 1)) LIMIT 1"] + end + + it "should have .first without arguments return first cached row without a query" do + @ds.first.must_equal @c1 + @db.sqls.must_equal [] + end + + it "should have .first with single integer argument just returns instances without a query" do + @ds.first(0).must_equal [] + @ds.first(1).must_equal [@c1] + @ds.first(2).must_equal [@c1, @c2] + @ds.first(3).must_equal [@c1, @c2] + @db.sqls.must_equal [] + end + + it "should have .first with other arguments use a query" do + @db.fetch = lambda do |s| + case s + when /id = '?(\d+)'?/ + id = $1.to_i + id <= 2 ? { id: id } : nil + when /id >= '?(\d+)'?/ + id = $1.to_i + id <= 2 ? (id..2).map { |i| { id: i } } : [] + end + end + + @ds.first(id: 2).must_equal @c2 + @ds.first(id: '2').must_equal @c2 + @ds.first(id: 3).must_be_nil + @ds.first { id >= 2 }.must_equal @c2 + @ds.first(2) { id >= 1 }.must_equal [@c1, @c2] + @ds.first(Sequel.lit('id = ?', 2)).must_equal @c2 + @db.sqls.must_equal [ + "SELECT * FROM t WHERE (bar AND (id = 2)) LIMIT 1", + "SELECT * FROM t WHERE (bar AND (id = '2')) LIMIT 1", + "SELECT * FROM t WHERE (bar AND (id = 3)) LIMIT 1", + "SELECT * FROM t WHERE (bar AND (id >= 2)) LIMIT 1", + "SELECT * FROM t WHERE (bar AND (id >= 1)) LIMIT 2", + "SELECT * FROM t WHERE (bar AND (id = 2)) LIMIT 1" + ] + end + + it "should have .first work on cloned datasets using a query" do + @ds.where(:baz).first.must_equal @c1 + @db.sqls.must_equal ["SELECT * FROM t WHERE (bar AND baz) LIMIT 1"] + end + + it "should have .each yield frozen instances without a query" do + a = [] + @ds.each{|o| a << o} + a.must_equal [@c1, @c2] + a.first.must_be :frozen? + a.last.must_be :frozen? + @db.sqls.must_equal [] + end + + it "should have .each work on cloned datasets using a query" do + a = [] + @ds.where(:baz).each{|o| a << o} + a.must_equal [@c1, @c2] + @db.sqls.must_equal ["SELECT * FROM t WHERE (bar AND baz)"] + end + + it "should have .map with block iterate map over instances without a query" do + @ds.map(&:id).sort.must_equal [1, 2] + @db.sqls.must_equal [] + end + + it "should have .map with symbol argument iterate map over instances without a query" do + @ds.map(:id).sort.must_equal [1, 2] + @db.sqls.must_equal [] + end + + it "should have .map with array argument iterate map over instances without a query" do + @ds.map([:id]).sort.must_equal [[1], [2]] + @db.sqls.must_equal [] + end + + it "should have .map without a block not return a frozen object" do + @ds.map(:a).frozen?.must_equal false + end + + it "should have .map without a block or arguments return an Enumerator" do + @ds.map.class.must_equal Enumerator + end + + it "should have .map with a block and argument raise" do + proc{@ds.map(:id){}}.must_raise(Sequel::Error) + end + + it "should have .map work on cloned datasets using a query" do + @ds.where(:baz).map(:id).must_equal [1, 2] + @db.sqls.must_equal ["SELECT * FROM t WHERE (bar AND baz)"] + end + + it "should have .count with no argument or block return result without a query" do + @ds.count.must_equal 2 + @db.sqls.must_equal [] + end + + it "should have .count with argument or block use a query" do + @db.fetch = [[{:count=>1}], [{:count=>2}]] + @ds.count(:a).must_equal 1 + @ds.count{b}.must_equal 2 + @db.sqls.must_equal ["SELECT count(a) AS count FROM t WHERE bar LIMIT 1", "SELECT count(b) AS count FROM t WHERE bar LIMIT 1"] + end + + it "should have .count work on cloned datasets using a query" do + @ds.where(:baz).count.must_equal 1 + @db.sqls.must_equal ["SELECT count(*) AS count FROM t WHERE (bar AND baz) LIMIT 1"] + end + + it "should have other enumerable methods work without sending a query" do + a = @ds.sort_by{|o| o.id} + a.first.must_equal @c1 + a.last.must_equal @c2 + @db.sqls.must_equal [] + end + + it "should have .all work on cloned datasets using a query" do + @ds.where(:baz).sort_by(&:id).must_equal [@c1, @c2] + @db.sqls.must_equal ["SELECT * FROM t WHERE (bar AND baz)"] + end + + it "should have .all return all objects without a query" do + @ds.all.must_equal [@c1, @c2] + @db.sqls.must_equal [] + end + + it "should have .all not return a frozen object" do + @ds.all.frozen?.must_equal false + end + + it "should have .all yield instances to block without a query" do + a = [] + b = @ds.all { |o| a << o } + a.must_equal [@c1, @c2] + a.must_equal b + @db.sqls.must_equal [] + end + + it "should have .all work on cloned datasets using a query" do + @ds.where(:baz).all.must_equal [@c1, @c2] + @db.sqls.must_equal ["SELECT * FROM t WHERE (bar AND baz)"] + end + + it "should have .as_hash/.to_hash without arguments return results without a query" do + a = @ds.to_hash + a.must_equal(1=>@c1, 2=>@c2) + + a = @ds.as_hash + a.must_equal(1=>@c1, 2=>@c2) + @db.sqls.must_equal [] + end + + it "should have .as_hash handle :hash option without a query" do + h = {} + a = @ds.as_hash(nil, nil, :hash=>h) + a.must_be_same_as h + a.must_equal(1=>@c1, 2=>@c2) + + h = {} + a = @ds.as_hash(:id, nil, :hash=>h) + a.must_be_same_as h + a.must_equal(1=>@c1, 2=>@c2) + + @db.sqls.must_equal [] + end + + it "should have .as_hash with arguments return results without a query" do + a = @ds.as_hash(:id) + a.must_equal(1=>@c1, 2=>@c2) + + a = @ds.as_hash([:id]) + a.must_equal([1]=>@c1, [2]=>@c2) + + @ds.as_hash(:id, :id).must_equal(1=>1, 2=>2) + @ds.as_hash([:id], :id).must_equal([1]=>1, [2]=>2) + @ds.as_hash(:id, [:id]).must_equal(1=>[1], 2=>[2]) + @ds.as_hash([:id], [:id]).must_equal([1]=>[1], [2]=>[2]) + + @db.sqls.must_equal [] + end + + it "should have .as_hash not return a frozen object" do + @ds.as_hash.frozen?.must_equal false + end + + it "should have .as_hash work on cloned datasets using a query" do + a = @ds.where(:baz).to_hash + a.must_equal(1=>@c1, 2=>@c2) + @db.sqls.must_equal ["SELECT * FROM t WHERE (bar AND baz)"] + end + + it "should have .to_hash_groups without value_column argument return the cached objects without a query" do + a = @ds.to_hash_groups(:id) + a.must_equal(1=>[@c1], 2=>[@c2]) + a = @ds.to_hash_groups([:id]) + a.must_equal([1]=>[@c1], [2]=>[@c2]) + @db.sqls.must_equal [] + end + + it "should have .to_hash_groups handle :hash option" do + h = {} + a = @ds.to_hash_groups(:id, nil, :hash=>h) + a.must_be_same_as h + a.must_equal(1=>[@c1], 2=>[@c2]) + @db.sqls.must_equal [] + end + + it "should have .to_hash_groups without arguments return the cached objects without a query" do + @ds.to_hash_groups(:id, :id).must_equal(1=>[1], 2=>[2]) + @ds.to_hash_groups([:id], :id).must_equal([1]=>[1], [2]=>[2]) + @ds.to_hash_groups(:id, [:id]).must_equal(1=>[[1]], 2=>[[2]]) + @ds.to_hash_groups([:id], [:id]).must_equal([1]=>[[1]], [2]=>[[2]]) + @db.sqls.must_equal [] + end + + it "should have .to_hash_groups work on cloned datasets using a query" do + a = @ds.where(:baz).to_hash_groups(:id) + a.must_equal(1=>[@c1], 2=>[@c2]) + @db.sqls.must_equal ["SELECT * FROM t WHERE (bar AND baz)"] + end + + it "subclasses should not use the cache" do + c = Class.new(@c) + c.foo.all.must_equal [c.load(:id=>1), c.load(:id=>2)] + @db.sqls.must_equal ['SELECT * FROM t WHERE bar'] + c.foo.as_hash.must_equal(1=>c.load(:id=>1), 2=>c.load(:id=>2)) + @db.sqls.must_equal ['SELECT * FROM t WHERE bar'] + end + + it "methods should be overridable and allow calling super" do + @c.define_singleton_method(:foo){super()} + @c.foo.all.must_equal [@c1, @c2] + @db.sqls.must_equal [] + end + + it "methods after set_dataset should not use the cache" do + ds = @c.dataset.from(:t2).columns(:id).with_fetch(:id=>3) + @c.dataset = ds + @c.foo.all.must_equal [@c.load(:id=>3)] + @db.sqls.must_equal ['SELECT * FROM t2 WHERE bar'] + @c.foo.as_hash.must_equal(3=>@c.load(:id=>3)) + @db.sqls.must_equal ['SELECT * FROM t2 WHERE bar'] + @c.foo.as_hash[3].must_equal @c.load(:id=>3) + @db.sqls.must_equal ['SELECT * FROM t2 WHERE bar'] + end + + it "should work correctly with composite keys" do + @db.fetch = [{:id=>1, :id2=>1}, {:id=>2, :id2=>1}] + @c = Class.new(Sequel::Model(@db[:t])) + @c.columns :id, :id2 + @c.set_primary_key([:id, :id2]) + @c.plugin :static_cache + @db.sqls + @c1 = @c.cache[[1, 2]] + @c2 = @c.cache[[2, 1]] + @c[[1, 2]].must_be_same_as(@c1) + @c[[2, 1]].must_be_same_as(@c2) + @db.sqls.must_equal [] + + @c = Class.new(Sequel::Model(@db[:t])) do + columns :id, :id2 + set_primary_key [:id, :id2] + + dataset_module do + where :foo, :bar + end + + plugin :subset_static_cache + end + @db.sqls.must_equal ["SELECT * FROM t LIMIT 0"] + + @c.cache_subset :foo + @db.sqls.must_equal ["SELECT * FROM t WHERE bar"] + + @c.foo.to_hash.must_equal([1, 1]=>@c.load(:id=>1, :id2=>1), [2, 1]=>@c.load(:id=>2, :id2=>1)) + @db.sqls.must_equal [] + end +end diff --git a/spec/integration/plugin_test.rb b/spec/integration/plugin_test.rb index cbcf239e8..40b8635ec 100644 --- a/spec/integration/plugin_test.rb +++ b/spec/integration/plugin_test.rb @@ -2035,6 +2035,104 @@ def set(k, v, ttl) self[k] = v end @Artist.first{id =~ 1}.must_equal @Artist.load(:id=>1) end end + + describe "subset_static_cache plugin" do + before do + @Album.plugin :subset_static_cache + @id = @db[:albums].insert + @album = @Album.call(:id=>@id, :artist_id=>nil) + @Album.class_exec do + dataset_module do + where :without_artist, :artist_id=>nil + end + cache_subset :without_artist + end + end + after do + @db[:albums].delete + end + + it "#all should return all values" do + @Album.without_artist.all.must_equal [@album] + end + + it "#count without arguments should return number of cached values" do + @Album.without_artist.count.must_equal 1 + end + + it "#first should retrieve correct values" do + @Album.without_artist.first.must_equal @album + @Album.without_artist.first(1).must_equal [@album] + @Album.without_artist.first(:id=>@id).must_equal @album + is_id = @id + @Album.without_artist.first{id =~ is_id}.must_equal @album + end + + it "#each should iterate over cached records" do + a = [] + @Album.without_artist.each{|x| a << x} + a.must_equal [@album] + end + + it "#map should map over cached records" do + a = @Album.without_artist.map{|x| x} + a.must_equal [@album] + + a = @Album.without_artist.map(:id) + a.must_equal [@id] + + a = @Album.without_artist.map([:id]) + a.must_equal [[@id]] + end + + it "#to_hash should return hash" do + a = @Album.without_artist.to_hash + a.must_equal(@id=>@album) + + a = @Album.without_artist.to_hash(:id) + a.must_equal(@id=>@album) + + a = @Album.without_artist.to_hash(:id, :id) + a.must_equal(@id=>@id) + + a = @Album.without_artist.to_hash([:id]) + a.must_equal([@id]=>@album) + + a = @Album.without_artist.to_hash([:id], :id) + a.must_equal([@id]=>@id) + + a = @Album.without_artist.to_hash([:id], [:id]) + a.must_equal([@id]=>[@id]) + + a = @Album.without_artist.to_hash(:id, [:id]) + a.must_equal(@id=>[@id]) + end + + it "#to_hash_groups should return hash with matched groups" do + a = @Album.without_artist.to_hash_groups(:id) + a.must_equal(@id=>[@album]) + + a = @Album.without_artist.to_hash_groups(:id, :id) + a.must_equal(@id=>[@id]) + + a = @Album.without_artist.to_hash_groups([:id]) + a.must_equal([@id]=>[@album]) + + a = @Album.without_artist.to_hash_groups([:id], :id) + a.must_equal([@id]=>[@id]) + + a = @Album.without_artist.to_hash_groups([:id], [:id]) + a.must_equal([@id]=>[[@id]]) + + a = @Album.without_artist.to_hash_groups(:id, [:id]) + a.must_equal(@id=>[[@id]]) + end + + it "#with_pk should return record with pk" do + @Album.without_artist.with_pk(@id).must_equal @album + end + + end end describe "Sequel::Plugins::AutoValidations" do diff --git a/www/pages/plugins.html.erb b/www/pages/plugins.html.erb index a1c1c935d..a0bfef93f 100644 --- a/www/pages/plugins.html.erb +++ b/www/pages/plugins.html.erb @@ -213,7 +213,11 @@
  • static_cache_cache -Support caching rows for static_cache models to a file to avoid database queries during model initialization. +Support caching rows for models using static_cache and subset_static_cache plugins to a file to avoid database queries during model initialization. +
  • +
  • +subset_static_cache +Caches all model instances for subsets of a model class.
  • Hooks