Datasource

  • Automatically preload associations for your serializers
  • Specify custom SQL snippets for virtual attributes (Query attributes)
  • Write custom preloading logic in a reusable way

Install

Add to Gemfile

gem 'datasource'
bundle install
rails g datasource:install

Upgrade

rails g datasource:install

ORM support

  • ActiveRecord
  • Sequel

Serializer support

  • active_model_serializers

Simple Mode

Datasource is configured to run in Simple mode by default, which makes it easier to start with, but disables some advanced optimizations. See Advanced mode for more information after you understand Simple mode.

Associations

The most noticable magic effect of using Datasource in Simple mode (Advanced mode has other benefits) is that associations will automatically be preloaded using a single query.

class PostSerializer < ActiveModel::Serializer
  attributes :id, :title
end

class UserSerializer < ActiveModel::Serializer
  attributes :id
  has_many :posts
end
SELECT users.* FROM users
SELECT posts.* FROM posts WHERE id IN (?)

This means you do not need to call includes yourself. It will be done automatically by Datasource.

Show action

You will probably want to reuse the same preloading logic in your show action. You will need to call for_serializer on the scope before you call find. You can optionally give it the serializer class as an argument.

class PostsController < ApplicationController
  def show
    post = Post.for_serializer.find(params[:id])
    # more explicit:
    # post = Post.for_serializer(PostSerializer).find(params[:id])

    render json: post
  end
end

You can also use it on an existing record, but doing it this way may result in an additional SQL query (for example if you use query attributes).

class UsersController < ApplicationController
  def show
    user = current_user.for_serializer

    render json: user
  end
end

Query attribute

You can specify a SQL fragment for SELECT and use that as an attribute on your model. As a simple example you can concatenate 2 strings together in SQL:

class User < ActiveRecord::Base
  datasource_module do
    query :full_name do
      "users.first_name || ' ' || users.last_name"
    end
  end
end

class UserSerializer < ActiveModel::Serializer
  attributes :id, :full_name
end
SELECT users.*, (users.first_name || ' ' || users.last_name) AS full_name FROM users

Note: If you need data from another table, use a join in a loader (see below).

Loader

You might want to have some more complex preloading logic. In that case you can use a loader. A loader will receive ids of the records, and needs to return a hash. The key of the hash must be the id of the record for which the value is.

A loader will only be executed if a computed attribute depends on it. See Advanced mode for information about computed attributes (but this works the same way in Simple mode). A more simple alternative to loader which doesn't require computed attributes is to use Loaded. If an attribute depends on multiple loaders, pass an array of loaders like so computed :attr, loaders: [:loader1, :loader2].

Be careful that if your hash does not contain a value for the object ID, the loaded value will be nil. However you can use the default option for such cases (see below example).

class User < ActiveRecord::Base
  datasource_module do
    computed :post_count, loader: :post_counts
    loader :post_counts, array_to_hash: true, default: 0 do |user_ids|
      results = Post
        .where(user_id: user_ids)
        .group(:user_id)
        .pluck("user_id, COUNT(id)")
    end
  end
end

class UserSerializer < ActiveModel::Serializer
  attributes :id, :post_count

  def post_count
    # Will automatically give you the value for this user's ID
    object.loaded_values[:post_counts]
  end
end
SELECT users.* FROM users
SELECT user_id, COUNT(id) FROM posts WHERE user_id IN (?)

Datasource provides shortcuts to transform your data into a hash. Here are examples:

loader :stuff, array_to_hash: true do |ids|
  [[1, "first"], [2, "second"]]
  # will be transformed into
  # { 1 => "first", 2 => "second" }
end

loader :stuff, group_by: :user_id do |ids|
  Post.where(user_id: ids)
  # will be transformed into
  # { 1 => [#<Post>, #<Post>, ...], 2 => [ ... ], ... }
end

loader :stuff, group_by: :user_id, one: true do |ids|
  Post.where(user_id: ids)
  # will be transformed into
  # { 1 => #<Post>, 2 => #<Post>, ... }
end

loader :stuff, group_by: "user_id", one: true do |ids|
  # it works the same way on an array of hashes
  # but be careful about Symbol/String difference
  [{ "title" => "Something", "user_id" => 10 }]
  # will be transformed into
  # { 10 => { "title" => "Something", "user_id" => 10 } }
end

Loaded

Loaded is the same as loader, but it automatically creates a computed attribute and defines a method with the same name on your model.

Here is the previous example with loaded instead of loader:

class User < ActiveRecord::Base
  datasource_module do
    loaded :post_count, array_to_hash: true, default: 0 do |user_ids|
      results = Post
        .where(user_id: user_ids)
        .group(:user_id)
        .pluck("user_id, COUNT(id)")
    end
  end
end

class UserSerializer < ActiveModel::Serializer
  attributes :id, :post_count
  # Note that the User now has a generated post_count method
end

When using loaded, if you already have the method with this name defined in your model, datasource will automatically create a 'wrapper' method that will use the loaded value if available (when you are using a serializer/datasource), otherwise it will fallback to your original method. This way you can still use the same method when you are not using a serializer/datasource. For example:

class User < ActiveRecord::Base
  datasource_module do
    loaded :post_count, array_to_hash: true, default: 0 do |user_ids|
      results = Post
        .where(user_id: user_ids)
        .group(:user_id)
        .pluck("user_id, COUNT(id)")
    end
  end

  def post_count
    posts.count
  end
end

class UserSerializer < ActiveModel::Serializer
  attributes :id, :post_count # <- post_count will be read from loaded_values
end

User.first.post_count # <- your method will be called

Getting Help

If you find a bug, please report an Issue.

If you have a question, you can also open an Issue.

Contributing

  1. Fork it ( https://github.com/mrbrdo/datasource/fork )
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request