diff --git a/shard.yml b/shard.yml index 647b060a9..c5be7a213 100644 --- a/shard.yml +++ b/shard.yml @@ -34,6 +34,9 @@ dependencies: pulsar: github: luckyframework/pulsar version: ~> 0.2.2 + lucky_cache: + github: luckyframework/lucky_cache + version: ~> 0.1.0 development_dependencies: ameba: diff --git a/spec/queryable_spec.cr b/spec/queryable_spec.cr index 85bc8700d..56e76b777 100644 --- a/spec/queryable_spec.cr +++ b/spec/queryable_spec.cr @@ -32,7 +32,20 @@ end class PostQuery < Post::BaseQuery end +class UserQuery + class_property query_counter : Int32 = 0 + + private def exec_query + @@query_counter += 1 + super + end +end + describe Avram::Queryable do + Spec.before_each do + UserQuery.query_counter = 0 + end + it "can chain scope methods" do ChainedQuery.new.young.named("Paul") end @@ -1358,7 +1371,7 @@ describe Avram::Queryable do users = UserQuery.new.group(&.age).group(&.id) users.query.statement.should eq "SELECT #{User::COLUMN_SQL} FROM users GROUP BY users.age, users.id" - users.map(&.name).should eq ["Dwight", "Michael", "Jim"] + users.map(&.name).sort!.should eq ["Dwight", "Jim", "Michael"] end it "raises an error when grouped incorrectly" do @@ -1471,4 +1484,22 @@ describe Avram::Queryable do query.to_sql.should eq original_query_sql end end + + describe "with query cache" do + it "only runs the query once" do + # We're testing the actual caching + Fiber.current.query_cache = LuckyCache::MemoryStore.new + Avram.temp_config(query_cache_enabled: true) do + UserFactory.create &.name("Amy") + UserQuery.query_counter.should eq(0) + + UserQuery.new.name("Amy").first + UserQuery.new.name("Amy").first + user = UserQuery.new.name("Amy").first + + UserQuery.query_counter.should eq(1) + user.name.should eq("Amy") + end + end + end end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 8aaba2726..2bd030eec 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -21,6 +21,10 @@ Db::VerifyConnection.new(quiet: true).run_task Spec.before_each do TestDatabase.truncate + # All specs seem to run on the same Fiber, + # so we set back to NullStore before each spec + # to ensure queries aren't randomly cached + Fiber.current.query_cache = LuckyCache::NullStore.new end class SampleBackupDatabase < Avram::Database diff --git a/src/avram.cr b/src/avram.cr index 8f153a1bd..098326197 100644 --- a/src/avram.cr +++ b/src/avram.cr @@ -2,6 +2,7 @@ require "dexter" require "wordsmith" require "habitat" require "pulsar" +require "lucky_cache" require "db" require "pg" require "uuid" @@ -22,6 +23,7 @@ module Avram setting database_to_migrate : Avram::Database.class, example: "AppDatabase" setting time_formats : Array(String) = [] of String setting i18n_backend : Avram::I18nBackend = Avram::I18n.new, example: "Avram::I18n.new" + setting query_cache_enabled : Bool = false end Log = ::Log.for(Avram) diff --git a/src/avram/charms/fiber.cr b/src/avram/charms/fiber.cr new file mode 100644 index 000000000..1c8416f57 --- /dev/null +++ b/src/avram/charms/fiber.cr @@ -0,0 +1,12 @@ +# https://crystal-lang.org/api/latest/Fiber.html +class Fiber + # This is stored on Fiber so it's released after each + # HTTP Request. + property query_cache : LuckyCache::BaseStore do + if Avram.settings.query_cache_enabled + LuckyCache::MemoryStore.new + else + LuckyCache::NullStore.new + end + end +end diff --git a/src/avram/model.cr b/src/avram/model.cr index b297e1cc7..7feb2abaf 100644 --- a/src/avram/model.cr +++ b/src/avram/model.cr @@ -18,6 +18,7 @@ abstract class Avram::Model macro inherited COLUMNS = [] of Nil # types are not checked in macros ASSOCIATIONS = [] of Nil # types are not checked in macros + include LuckyCache::Cachable end def self.primary_key_name : Symbol? diff --git a/src/avram/queryable.cr b/src/avram/queryable.cr index 46dfbedb6..4acdc4b87 100644 --- a/src/avram/queryable.cr +++ b/src/avram/queryable.cr @@ -214,10 +214,12 @@ module Avram::Queryable(T) end def any? : Bool - queryable = clone - new_query = queryable.query.limit(1).select("1 AS one") - results = database.query_one?(new_query.statement, args: new_query.args, queryable: schema_class.name, as: Int32) - !results.nil? + cache_store.fetch(cache_key, as: Bool) do + queryable = clone + new_query = queryable.query.limit(1).select("1 AS one") + results = database.query_one?(new_query.statement, args: new_query.args, queryable: schema_class.name, as: Int32) + !results.nil? + end end def none? : Bool @@ -225,12 +227,16 @@ module Avram::Queryable(T) end def select_count : Int64 - table = "(#{query.statement}) AS temp" - new_query = Avram::QueryBuilder.new(table).select_count - result = database.scalar new_query.statement, args: query.args, queryable: schema_class.name - result.as(Int64) - rescue e : DB::NoResultsError - 0_i64 + cache_store.fetch(cache_key, as: Int64) do + begin + table = "(#{query.statement}) AS temp" + new_query = Avram::QueryBuilder.new(table).select_count + result = database.scalar new_query.statement, args: query.args, queryable: schema_class.name + result.as(Int64) + rescue e : DB::NoResultsError + 0_i64 + end + end end def each @@ -245,9 +251,19 @@ module Avram::Queryable(T) @preloads << block end + def cache_store + Fiber.current.query_cache + end + + def cache_key : String + [query.statement, query.args].join(':') + end + def results : Array(T) - exec_query.tap do |records| - preloads.each(&.call(records)) + cache_store.fetch(cache_key, as: Array(T)) do + exec_query.tap do |records| + preloads.each(&.call(records)) + end end end