diff --git a/CHANGELOG.md b/CHANGELOG.md index c5e46bb..4a637b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ that you can set version constraints properly. #### [Unreleased](https://github.com/exAspArk/batch-loader/compare/v1.0.4...HEAD) -* WIP +* `Added`: `default_value` override option. +* `Added`: `loader.call {}` block syntax, for memoizing repeat calls to the same item. #### [v1.0.4](https://github.com/exAspArk/batch-loader/compare/v1.0.3...v1.0.4) diff --git a/README.md b/README.md index e0897ba..1a3301c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ This gem provides a generic lazy batching mechanism to avoid N+1 DB queries, HTT * [Highlights](#highlights) * [Usage](#usage) * [Why?](#why) - * [Basic example](#basic-example) + * [Basic examples](#basic-examples) * [How it works](#how-it-works) * [RESTful API example](#restful-api-example) * [GraphQL example](#graphql-example) @@ -97,7 +97,7 @@ puts users # Users But the problem here is that `load_posts` now depends on the child association and knows that it has to preload data for future use. And it'll do it every time, even if it's not necessary. Can we do better? Sure! -### Basic example +### Basic examples With `BatchLoader` we can rewrite the code above: @@ -125,6 +125,51 @@ puts users # Users SELECT * FROM users WHERE id IN As we can see, batching is isolated and described right in a place where it's needed. +For batches where there is no item in response to a call, we normally return nil. However, you can use `default_value:` to return something else instead. This is particularly useful for 1:Many relationships, where you + +```ruby +def load_posts(ids) + Post.where(id: ids) +end + +def load_user(post) + BatchLoader.for(post.user_id).batch(default_value: NullUser.new) do |user_ids, loader| + User.where(id: user_ids).each { |user| loader.call(user.id, user) } + end +end + +posts = load_posts([1, 2, 3]) + + +users = posts.map do |post| + load_user(post) +end + +puts users +``` + +For batches where the value is some kind of collection, such as an Array or Hash, `loader` also supports being called with a block, which yields the _current_ value, and returns the _next_ value. This is extremely useful for 1:Many relationships: + +```ruby +def load_users(ids) + User.where(id: ids) +end + +def load_comments(user) + BatchLoader.for(user.id).batch(default_value: []) do |comment_ids, loader| + Comment.where(user_id: user_ids).each do |comment| + loader.call(user.id) { |memo| memo.push(comment) } + end + end +end + +users = load_users([1, 2, 3]) + +comments = users.map do |user| + load_comments(user) +end +``` + ### How it works In general, `BatchLoader` returns a lazy object. Each lazy object knows which data it needs to load and how to batch the query. As soon as you need to use the lazy objects, they will be automatically loaded once without N+1 queries. diff --git a/lib/batch_loader.rb b/lib/batch_loader.rb index eb8b8dc..5a5a5e6 100644 --- a/lib/batch_loader.rb +++ b/lib/batch_loader.rb @@ -11,6 +11,7 @@ class BatchLoader IMPLEMENTED_INSTANCE_METHODS = %i[object_id __id__ __send__ singleton_method_added __sync respond_to? batch inspect].freeze REPLACABLE_INSTANCE_METHODS = %i[batch inspect].freeze LEFT_INSTANCE_METHODS = (IMPLEMENTED_INSTANCE_METHODS - REPLACABLE_INSTANCE_METHODS).freeze + NULL_VALUE = :batch_loader_null NoBatchError = Class.new(StandardError) @@ -22,7 +23,8 @@ def initialize(item:) @item = item end - def batch(cache: true, &batch_block) + def batch(default_value: nil, cache: true, &batch_block) + @default_value = default_value @cache = cache @batch_block = batch_block __executor_proxy.add(item: @item) @@ -75,12 +77,20 @@ def __ensure_batched return if __executor_proxy.value_loaded?(item: @item) items = __executor_proxy.list_items - loader = ->(item, value) { __executor_proxy.load(item: item, value: value) } + loader = -> (item, value = NULL_VALUE, &block) { + if block + raise ArgumentError, "Please pass a value or a block, not both" if value != NULL_VALUE + next_value = block.call(__executor_proxy.loaded_value(item: item)) + else + next_value = value + end + __executor_proxy.load(item: item, value: next_value) + } @batch_block.call(items, loader) items.each do |item| next if __executor_proxy.value_loaded?(item: item) - loader.call(item, nil) # use "nil" for not loaded item after succesfull batching + loader.call(item, @default_value) end __executor_proxy.delete(items: items) end @@ -109,7 +119,7 @@ def __purge_cache def __executor_proxy @__executor_proxy ||= begin raise NoBatchError.new("Please provide a batch block first") unless @batch_block - BatchLoader::ExecutorProxy.new(&@batch_block) + BatchLoader::ExecutorProxy.new(@default_value, &@batch_block) end end diff --git a/lib/batch_loader/executor_proxy.rb b/lib/batch_loader/executor_proxy.rb index 91e2405..deb6019 100644 --- a/lib/batch_loader/executor_proxy.rb +++ b/lib/batch_loader/executor_proxy.rb @@ -4,9 +4,10 @@ class BatchLoader class ExecutorProxy - attr_reader :block, :global_executor + attr_reader :default_value, :block, :global_executor - def initialize(&block) + def initialize(default_value, &block) + @default_value = default_value @block = block @block_hash_key = block.source_location @global_executor = BatchLoader::Executor.ensure_current @@ -29,7 +30,11 @@ def load(item:, value:) end def loaded_value(item:) - loaded[item] + if value_loaded?(item: item) + loaded[item] + else + @default_value.dup + end end def value_loaded?(item:) diff --git a/spec/batch_loader_spec.rb b/spec/batch_loader_spec.rb index a7009a9..7ec20a2 100644 --- a/spec/batch_loader_spec.rb +++ b/spec/batch_loader_spec.rb @@ -98,6 +98,38 @@ expect(lazy).to eq(2) end + + it 'supports alternative default values' do + lazy = BatchLoader.for(1).batch(default_value: 123) do |nums, loader| + # No-op, so default is returned + end + + expect(lazy).to eq(123) + end + + it 'supports memoizing repeated calls to the same item, via a block' do + lazy = BatchLoader.for(1).batch(default_value: []) do |nums, loader| + nums.each do |num| + loader.call(num) { |memo| memo.push(num) } + loader.call(num) { |memo| memo.push(num + 1) } + loader.call(num) { |memo| memo.push(num + 2) } + end + end + + expect(lazy).to eq([1,2,3]) + end + + context "called with block and value syntax" do + it 'raises ArgumentError' do + lazy = BatchLoader.for(1).batch(default_value: {}) do |nums, loader| + nums.each do |num| + loader.call(num, "one value") { "too many values" } + end + end + + expect { lazy.sync }.to raise_error(ArgumentError) + end + end end describe '#inspect' do