Skip to content

Commit

Permalink
Default value + Callable syntax (#8)
Browse files Browse the repository at this point in the history
* support append-able default batch value
* WIP callable syntax
* [default value] do not attempt to call default value
* inline executor callable
* turns out you can pass blocks to lambdas! who knew? not i.
* less magic: dont auto push to default value
* add examples documenting new features
  • Loading branch information
Matt Bessey authored and exAspArk committed Nov 1, 2017
1 parent 46900a7 commit 4735ffa
Show file tree
Hide file tree
Showing 5 changed files with 103 additions and 10 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
49 changes: 47 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down
18 changes: 14 additions & 4 deletions lib/batch_loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
11 changes: 8 additions & 3 deletions lib/batch_loader/executor_proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:)
Expand Down
32 changes: 32 additions & 0 deletions spec/batch_loader_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 4735ffa

Please sign in to comment.