Skip to content

Commit

Permalink
Introduce the way to clear cache manually, add GraphQL integration
Browse files Browse the repository at this point in the history
  • Loading branch information
exAspArk committed Aug 3, 2017
1 parent f4b05d7 commit 588e447
Show file tree
Hide file tree
Showing 14 changed files with 242 additions and 42 deletions.
2 changes: 2 additions & 0 deletions .travis.yml
Expand Up @@ -2,4 +2,6 @@ sudo: false
language: ruby
rvm:
- 2.3.4
env:
- CI=true
before_install: gem install bundler -v 1.15.3
2 changes: 1 addition & 1 deletion CHANGELOG.md
Expand Up @@ -16,7 +16,7 @@ that you can set version constraints properly.

* `Added`: `cache: false` option.
* `Added`: `BatchLoader::Middleware`.
* `Added`: More docs and tests.
* `Added`: more docs and tests.

#### [v0.1.0](https://github.com/exAspArk/batch-loader/compare/ed32edb...v0.1.0) – 2017-07-31

Expand Down
2 changes: 1 addition & 1 deletion Gemfile
Expand Up @@ -2,7 +2,7 @@ source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "pry"
gem 'coveralls', require: false

# Specify your gem's dependencies in batch-loader.gemspec
gemspec
139 changes: 121 additions & 18 deletions README.md
@@ -1,6 +1,10 @@
# BatchLoader

[![Build Status](https://travis-ci.org/exAspArk/batch-loader.svg?branch=master)](https://travis-ci.org/exAspArk/batch-loader)
[![Coverage Status](https://coveralls.io/repos/github/exAspArk/batch-loader/badge.svg)](https://coveralls.io/github/exAspArk/batch-loader)
[![Code Climate](https://img.shields.io/codeclimate/github/exAspArk/batch-loader.svg)](https://codeclimate.com/github/exAspArk/batch-loader)
[![Downloads](https://img.shields.io/gem/dt/batch-loader.svg)](https://rubygems.org/gems/batch-loader)
[![Latest Version](https://img.shields.io/gem/v/batch-loader.svg)](https://rubygems.org/gems/batch-loader)

Simple tool to avoid N+1 DB queries, HTTP requests, etc.

Expand All @@ -16,7 +20,6 @@ Simple tool to avoid N+1 DB queries, HTTP requests, etc.
* [Caching](#caching)
* [Installation](#installation)
* [Implementation details](#implementation-details)
* [Testing](#testing)
* [Development](#development)
* [Contributing](#contributing)
* [License](#license)
Expand Down Expand Up @@ -96,7 +99,7 @@ users = load_posts(post.user) # ↓ U ↓
users.map { |u| user.name } # Users
```

But the problem here is that `load_posts` now depends on the child association. Plus it'll preload the association every time, even if it's not necessary. Can we do better? Sure!
But the problem here is that `load_posts` now depends on the child association and knows that it has to preload the data for `load_users`. And it'll do it every time, even if it's not necessary. Can we do better? Sure!

### Basic example

Expand Down Expand Up @@ -130,7 +133,7 @@ As we can see, batching is isolated and described right in a place where it's ne

### How it works

In general, `BatchLoader` returns an object which in other similar implementations is call Promise. Each Promise knows which data it needs to load and how to batch the query. When all the Promises are collected it's possible to resolve them once without N+1 queries.
In general, `BatchLoader` returns a lazy object. In other programming languages it usually called Promise, but I personally prefer to call it lazy, since Ruby already uses the name in standard library :) Each lazy object knows which data it needs to load and how to batch the query. When all the lazy objects are collected it's possible to resolve them once without N+1 queries.

So, when we call `BatchLoader.for` we pass an item (`user_id`) which should be batched. For the `batch` method, we pass a block which uses all the collected items (`user_ids`):

Expand Down Expand Up @@ -172,7 +175,7 @@ end
class PostsController < ApplicationController
def index
posts = Post.limit(10)
serialized_posts = posts.map { |post| {id: post.id, rating: post.rating} }
serialized_posts = posts.map { |post| {id: post.id, rating: post.rating} } # N+1 HTTP requests for each post.rating

render json: serialized_posts
end
Expand All @@ -182,17 +185,14 @@ end
As we can see, the code above will make N+1 HTTP requests, one for each post. Let's batch the requests with a gem called [parallel](https://github.com/grosser/parallel):

```ruby
# app/models/post.rb
class Post < ApplicationRecord
def rating_lazy
BatchLoader.for(post).batch do |posts, batch_loader|
Parallel.each(posts, in_threads: 10) { |post| batch_loader.load(post, post.rating) }
end
end

def rating
HttpClient.request(:get, "https://example.com/ratings/#{id}")
end
# ...
end
```

Expand All @@ -201,7 +201,6 @@ end
Now we can resolve all `BatchLoader` objects in the controller:

```ruby
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
posts = Post.limit(10)
Expand All @@ -211,22 +210,130 @@ class PostsController < ApplicationController
end
```

`BatchLoader` caches the resolved values. To ensure that the cache is purged for each request in the app add the following middleware:
`BatchLoader` caches the resolved values. To ensure that the cache is purged between requests in the app add the following middleware to your `config/application.rb`:

```ruby
# config/application.rb
config.middleware.use BatchLoader::Middleware
```

See the [Caching](#caching) section for more information.

### GraphQL example

TODO
With GraphQL using batching is particularly useful. You can't use usual techniques such as preloading associations in advance to avoid N+1 queries.
Since you don't know which fields user is going to ask in a query.

Let's take a look at the simple [graphql-ruby](https://github.com/rmosolgo/graphql-ruby) schema example:

```ruby
Schema = GraphQL::Schema.define do
query QueryType
end

QueryType = GraphQL::ObjectType.define do
name "Query"
field :posts, !types[PostType], resolve: ->(obj, args, ctx) { Post.all }
end

PostType = GraphQL::ObjectType.define do
name "Post"
field :user, !UserType, resolve: ->(post, args, ctx) { post.user } # N+1 queries
end

UserType = GraphQL::ObjectType.define do
name "User"
field :name, !types.String
end
```

If we want to execute a simple query like:

```ruby
query = "
{
posts {
user {
name
}
}
}
"
Schema.execute(query, variables: {}, context: {})
```

We will get N+1 queries for each `post.user`. To avoid this problem, all we have to do is to change the resolver to use `BatchLoader`:

```ruby
PostType = GraphQL::ObjectType.define do
name "Post"
field :user, !UserType, resolve: ->(post, args, ctx) do
BatchLoader.for(post.user_id).batch do |user_ids, batch_loader|
User.where(id: user_ids).each { |user| batch_loader.load(user.id, user) }
end
end
end
```

And setup GraphQL with built-in `lazy_resolve` method:

```ruby
Schema = GraphQL::Schema.define do
query QueryType
lazy_resolve BatchLoader, :sync
end
```

### Caching

TODO
By default `BatchLoader` caches the resolved values. You can test it by running something like:

```ruby
def user_lazy(id)
BatchLoader.for(id).batch do |ids, batch_loader|
User.where(id: ids).each { |user| batch_loader.load(user.id, user) }
end
end

user_lazy(1) # no request
# => <#BatchLoader>

user_lazy(1).sync # SELECT * FROM users WHERE id IN (1)
# => <#User>

user_lazy(1).sync # no request
# => <#User>
```

To drop the cache manually you can run:

```ruby
user_lazy(1).sync # SELECT * FROM users WHERE id IN (1)
user_lazy(1).sync # no request

BatchLoader::Executor.clear_current

user_lazy(1).sync # SELECT * FROM users WHERE id IN (1)
```

Usually, it's just enough to clear the cache between HTTP requests in the app. To do so, simply add the middleware:

```ruby
# calls "BatchLoader::Executor.clear_current" after each request
use BatchLoader::Middleware
```

In some rare cases it's useful to disable caching for `BatchLoader`. For example, in tests or after data mutations:

```ruby
def user_lazy(id)
BatchLoader.for(id).batch(cache: false) do |ids, batch_loader|
# ...
end
end

user_lazy(1).sync # SELECT * FROM users WHERE id IN (1)
user_lazy(1).sync # SELECT * FROM users WHERE id IN (1)
```

## Installation

Expand All @@ -246,11 +353,7 @@ Or install it yourself as:

## Implementation details

TODO

## Testing

TODO
Coming soon

## Development

Expand Down
2 changes: 2 additions & 0 deletions batch-loader.gemspec
Expand Up @@ -26,4 +26,6 @@ Gem::Specification.new do |spec|
spec.add_development_dependency "bundler", "~> 1.15"
spec.add_development_dependency "rake", "~> 10.0"
spec.add_development_dependency "rspec", "~> 3.0"
spec.add_development_dependency "graphql", "~> 1.6"
spec.add_development_dependency "pry-byebug", "~> 3.4"
end
28 changes: 13 additions & 15 deletions lib/batch_loader.rb
Expand Up @@ -6,22 +6,20 @@ class BatchLoader
NoBatchError = Class.new(StandardError)
BatchAlreadyExistsError = Class.new(StandardError)

class << self
def for(item)
new(item: item)
end
def self.for(item)
new(item: item)
end

def sync!(value)
case value
when Array
value.map! { |v| sync!(v) }
when Hash
value.each { |k, v| value[k] = sync!(v) }
when BatchLoader
sync!(value.sync)
else
value
end
def self.sync!(value)
case value
when Array
value.map! { |v| sync!(v) }
when Hash
value.each { |k, v| value[k] = sync!(v) }
when BatchLoader
sync!(value.sync)
else
value
end
end

Expand Down
6 changes: 5 additions & 1 deletion lib/batch_loader/executor.rb
Expand Up @@ -6,7 +6,11 @@ def self.ensure_current
Thread.current[NAMESPACE] ||= new
end

def self.delete_current
def self.current
Thread.current[NAMESPACE]
end

def self.clear_current
Thread.current[NAMESPACE] = nil
end

Expand Down
3 changes: 1 addition & 2 deletions lib/batch_loader/middleware.rb
Expand Up @@ -6,10 +6,9 @@ def initialize(app)

def call(env)
begin
BatchLoader::Executor.ensure_current
@app.call(env)
ensure
BatchLoader::Executor.delete_current
BatchLoader::Executor.clear_current
end
end
end
Expand Down
24 changes: 24 additions & 0 deletions spec/batch_loader/middleware_spec.rb
@@ -0,0 +1,24 @@
require "spec_helper"

RSpec.describe BatchLoader::Middleware do
describe '#call' do
it 'returns the result from the app' do
app = ->(_env) { 1 }
middleware = BatchLoader::Middleware.new(app)

expect(middleware.call(nil)).to eq(1)
end

it 'clears the Executor' do
app = ->(_) { nil }
middleware = BatchLoader::Middleware.new(app)
BatchLoader::Executor.ensure_current

expect {
middleware.call(nil)
}.to change {
BatchLoader::Executor.current
}.to(nil)
end
end
end
6 changes: 2 additions & 4 deletions spec/batch_loader_spec.rb
@@ -1,10 +1,8 @@
require "spec_helper"

RSpec.describe BatchLoader do
after { BatchLoader::Executor.delete_current }

describe '.sync!' do
it "does something useful" do
it "syncs all BatchLoaders" do
user1 = User.save(id: 1)
post1 = Post.new(user_id: user1.id)
user2 = User.save(id: 2)
Expand All @@ -26,7 +24,7 @@
}.to raise_error(BatchLoader::NoBatchError, "Please provide a batch block first")
end

it 'caches the result between different BatchLoader instances' do
it 'caches the result even between different BatchLoader instances' do
user = User.save(id: 1)
post = Post.new(user_id: user.id)

Expand Down
19 changes: 19 additions & 0 deletions spec/fixtures/graphql_schema.rb
@@ -0,0 +1,19 @@
UserType = GraphQL::ObjectType.define do
name "User"
field :id, !types.ID
end

PostType = GraphQL::ObjectType.define do
name "Post"
field :user, !UserType, resolve: ->(post, args, ctx) { post.user_lazy }
end

QueryType = GraphQL::ObjectType.define do
name "Query"
field :posts, !types[PostType], resolve: ->(obj, args, ctx) { Post.all }
end

GraphqlSchema = GraphQL::Schema.define do
query QueryType
lazy_resolve BatchLoader, :sync
end
9 changes: 9 additions & 0 deletions spec/fixtures/models.rb
@@ -1,6 +1,15 @@
class Post
attr_accessor :user_id, :user_lazy

def self.save(user_id:)
@@posts ||= []
@@posts << new(user_id: user_id)
end

def self.all
@@posts
end

def initialize(user_id:)
self.user_id = user_id
end
Expand Down

0 comments on commit 588e447

Please sign in to comment.