• Stars
    star
    200
  • Rank 188,478 (Top 4 %)
  • Language
    Ruby
  • License
    MIT License
  • Created over 5 years ago
  • Updated 5 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

Parameters-based transformation DSL

Gem Version Build

Rubanok

Rubanok provides a DSL to build parameters-based data transformers.

📖 Read the introduction post: "Carve your controllers like Papa Carlo"

The typical usage is to describe all the possible collection manipulation for REST index action, e.g. filtering, sorting, searching, pagination, etc..

So, instead of:

class CourseSessionController < ApplicationController
  def index
    @sessions = CourseSession
      .search(params[:q])
      .by_course_type(params[:course_type_id])
      .by_role(params[:role_id])
      .paginate(page_params)
      .order(ordering_params)
  end
end

You have:

class CourseSessionController < ApplicationController
  def index
    @sessions = rubanok_process(
      # pass input
      CourseSession.all,
      # pass params
      params,
      # provide a processor to use
      with: CourseSessionsProcessor
    )
  end
end

Or we can try to infer all the configuration for you:

class CourseSessionController < ApplicationController
  def index
    @sessions = rubanok_process(CourseSession.all)
  end
end

Requirements:

  • Ruby ~> 2.7
  • (optional*) Rails >= 6.0 (see older releases for Rails <6 support)

* This gem has no dependency on Rails.

Sponsored by Evil Martians

Installation

Add to your Gemfile:

gem "rubanok"

And run bundle install.

Usage

The core concept of this library is a processor (previously called plane or hand plane, or "рубанок" in Russian). Processor is responsible for mapping parameters to transformations.

From the example above:

class CourseSessionsProcessor < Rubanok::Processor
  # You can map keys
  map :q do |q:|
    # `raw` is an accessor for input data
    raw.search(q)
  end
end

# The following code
CourseSessionsProcessor.call(CourseSession.all, q: "xyz")

# is equal to
CourseSession.all.search("xyz")

You can map multiple keys at once:

class CourseSessionsProcessor < Rubanok::Processor
  DEFAULT_PAGE_SIZE = 25

  map :page, :per_page do |page:, per_page: DEFAULT_PAGE_SIZE|
    raw.paginate(page: page, per_page: per_page)
  end
end

There is also match method to handle values:

class CourseSessionsProcessor < Rubanok::Processor
  SORT_ORDERS = %w[asc desc].freeze
  SORTABLE_FIELDS = %w[id name created_at].freeze

  match :sort_by, :sort do
    having "course_id", "desc" do
      raw.joins(:courses).order("courses.id desc nulls last")
    end

    having "course_id", "asc" do
      raw.joins(:courses).order("courses.id asc nulls first")
    end

    # Match any value for the second arg
    having "type" do |sort: "asc"|
      # Prevent SQL injections
      raise "Possible injection: #{sort}" unless SORT_ORDERS.include?(sort)
      raw.joins(:course_type).order("course_types.name #{sort}")
    end

    # Match any value
    default do |sort_by:, sort: "asc"|
      raise "Possible injection: #{sort}" unless SORT_ORDERS.include?(sort)
      raise "The field is not sortable: #{sort_by}" unless SORTABLE_FIELDS.include?(sort_by)
      raw.order(sort_by => sort)
    end
  end

  # strict matching; if Processor will not match parameter, it will raise Rubanok::UnexpectedInputError
  # You can handle it in controller, for example, with sending 422 Unprocessable Entity to client
  match :filter, fail_when_no_matches: true do
    having "active" do
      raw.active
    end

    having "finished" do
      raw.finished
    end
  end
end

By default, Rubanok will not fail if no matches found in match rule. You can change it by setting: Rubanok.fail_when_no_matches = true. If in example above you will call CourseSessionsProcessor.call(CourseSession, filter: 'acitve'), you will get Rubanok::UnexpectedInputError: Unexpected input: {:filter=>'acitve'}.

NOTE: Rubanok only matches exact values; more complex matching could be added in the future.

Nested processors

You can use the .process method to define sub-processors (or nested processors). It's useful when you use nested params, for example:

class CourseSessionsProcessor < Rubanok::Processor
  process :filter do
    match :status do
      having "draft" do
        raw.where(draft: true)
      end

      having "deleted" do
        raw.where.not(deleted_at: nil)
      end
    end

    # You can also use .map or even .process here
  end
end

Default transformation

Sometimes it's useful to perform some transformations before any rule is activated.

There is a special prepare method which allows you to define the default transformation:

class CourseSearchQueryProcessor < Rubanok::Processor
  prepare do
    next if raw&.dig(:query, :bool)

    {query: {bool: {filters: []}}}
  end

  map :ids do |ids:|
    raw.dig(:query, :bool, :filters) << {terms: {id: ids}}
    raw
  end
end

The block should return a new initial value for the raw input or nil (no transformation required).

The prepare callback is not executed if no params match, e.g.:

CourseSearchQueryProcessor.call(nil, {}) #=> nil

# But
CourseSearchQueryProcessor.call(nil, {ids: [1]}) #=> {query {bool: {filters: [{terms: {ids: [1]}}]}}}

# Note that we can omit the first argument altogether
CourseSearchQueryProcessor.call({ids: [1]})

Getting the matching params

Sometimes it could be useful to get the params that were used to process the data by Rubanok processor (e.g., you can use this data in views to display the actual filters state).

In Rails, you can use the #rubanok_scope method for that:

class CourseSessionController < ApplicationController
  def index
    @sessions = rubanok_process(CourseSession.all)
    # Returns the Hash of params recognized by the CourseSessionProcessor.
    # For example:
    #
    #    params == {q: "search", role_id: 2, date: "2019-08-22"}
    #    @session_filter == {q: "search", role_id: 2}
    @sessions_filter = rubanok_scope(
      params.permit(:q, :role_id),
      with: CourseSessionProcessor
    )

    # You can omit all the arguments
    @sessions_filter = rubanok_scope #=> equals to rubanok_scope(params, with: implicit_rubanok_class)
  end
end

You can also accesss rubanok_scope in views (it's a helper method).

Rule activation

Rubanok activates a rule by checking whether the corresponding keys are present in the params object. All the fields must be present to apply the rule.

Some fields may be optional, or perhaps even all of them. You can use activate_on and activate_always options to mark something as an optional key instead of a required one:

# Always apply the rule; use default values for keyword args
map :page, :per_page, activate_always: true do |page: 1, per_page: 2|
  raw.page(page).per(per_page)
end

# Only require `sort_by` to be preset to activate sorting rule
match :sort_by, :sort, activate_on: :sort_by do
 # ...
end

By default, Rubanok ignores empty param values (using #empty? under the hood) and will not run matching rules on those values. For example: { q: "" } and { q: nil } won't activate the map :q rule.

You can change this behaviour by specifying ignore_empty_values: true option for a particular rule or enabling this behaviour globally via Rubanok.ignore_empty_values = true (enabled by default).

Input values filtering

For complex input types, such as arrays, it might be useful to prepare the value before passing to a transforming block or prevent the activation altogether.

We provide a filter_with: option for the .map method, which could be used as follows:

class PostsProcessor < Rubanok::Processor
  # We can pass a Proc
  map :ids, filter_with: ->(vals) { vals.reject(&:blank?).presence } do |ids:|
    raw.where(id: ids)
  end

  # or define a class method
  def self.non_empty_array(val)
    non_blank = val.reject(&:blank?)
    return if non_blank.empty?

    non_blank
  end

  # and pass its name as a filter_with value
  map :ids, filter_with: :non_empty_array do |ids:|
    raw.where(id: ids)
  end
end

# Filtered values are used in rules
PostsProcessor.call(Post.all, {ids: ["1", ""]}) == Post.where(id: ["1"])

# When filter returns empty value, the rule is not applied
PostsProcessor.call(Post.all, {ids: [nil, ""]}) == Post.all

Testing

One of the benefits of having modification logic contained in its own class is the ability to test modifications in isolation:

# For example, with RSpec
RSpec.describe CourseSessionsProcessor do
  let(:input) { CourseSession.all }
  let(:params) { {} }

  subject { described_class.call(input, params) }

  specify "searching" do
    params[:q] = "wood"

    expect(subject).to eq input.search("wood")
  end
end

Now in your controller you only have to test that the specific plane is applied:

RSpec.describe CourseSessionController do
  subject { get :index }

  specify do
    expect { subject }.to have_rubanok_processed(CourseSession.all)
      .with(CourseSessionsProcessor)
  end
end

NOTE: input matching only checks for the class equality.

To use have_rubanok_processed matcher you must add the following line to your spec_helper.rb / rails_helper.rb (it's added automatically if RSpec defined and RAILS_ENV/RACK_ENV is equal to "test"):

require "rubanok/rspec"

Rails vs. non-Rails

Rubanok does not require Rails, but it has some useful Rails extensions such as rubanok_process helper for controllers (included automatically into ActionController::Base and ActionController::API).

If you use ActionController::Metal you must include the Rubanok::Controller module yourself.

Processor class inference in Rails controllers

By default, rubanok_process uses the following algorithm to define a processor class: "#{controller_path.classify.pluralize}Processor".safe_constantize.

You can change this by overriding the #implicit_rubanok_class method:

class ApplicationController < ActionController::Smth
  # override the `implicit_rubanok_class` method
  def implicit_rubanok_class
    "#{controller_path.classify.pluralize}Scoper".safe_constantize
  end
end

Now you can use it like this:

class CourseSessionsController < ApplicationController
  def index
    @sessions = rubanok_process(CourseSession.all, params)
    # which equals to
    @sessions = CourseSessionsScoper.call(CourseSession.all, params.to_unsafe_h)
  end
end

NOTE: the planish method is still available and it uses #{controller_path.classify.pluralize}Plane".safe_constantize under the hood (via the #implicit_plane_class method).

Using with RBS/Steep

Read "Climbing Steep hills, or adopting Ruby 3 types with RBS" for the context.

Rubanok comes with Ruby type signatures (RBS).

To use them with Steep, add library "rubanok" to your Steepfile.

Since Rubanok provides DSL with implicit context switching (via instance_eval), you need to provide type hints for the type checker to help it figure out the current context. Here is an example:

class MyProcessor < Rubanok::Processor
  map :q do |q:|
    # @type self : Rubanok::Processor
    raw
  end

  match :sort_by, :sort, activate_on: :sort_by do
    # @type self : Rubanok::DSL::Matching::Rule
    having "status", "asc" do
      # @type self : Rubanok::Processor
      raw
    end

    # @type self : Rubanok::DSL::Matching::Rule
    default do |sort_by:, sort: "asc"|
      # @type self : Rubanok::Processor
      raw
    end
  end
end

Yeah, a lot of annotations 😞 Welcome to the type-safe world!

Questions & Answers

  • Where to put my processor/plane classes?

I put mine under app/planes (as <resources>_plane.rb) in my Rails app.

  • I don't like the naming ("planes" ✈️?), can I still use the library?

Good news—the default naming has been changed. "Planes" are still available if you prefer them (just like me 😉).

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/palkan/rubanok.

License

The gem is available as open source under the terms of the MIT License.

More Repositories

1

logidze

Database changes log for Rails
Ruby
1,555
star
2

action_policy

Authorization framework for Ruby/Rails applications
Ruby
1,333
star
3

isolator

Detect non-atomic interactions within DB transactions
Ruby
814
star
4

anyway_config

Configuration library for Ruby gems and applications
Ruby
719
star
5

active_delivery

Ruby framework for keeping all types of notifications (mailers, push notifications, whatever) in one place
Ruby
585
star
6

n_plus_one_control

RSpec and Minitest matchers to prevent N+1 queries problem
Ruby
543
star
7

store_attribute

ActiveRecord extension which adds typecasting to store accessors
Ruby
344
star
8

view_component-contrib

A collection of extension and developer tools for ViewComponent
Ruby
318
star
9

litecable

Lightweight Action Cable implementation (Rails-free)
Ruby
285
star
10

acli

Action Cable command-line client
Ruby
222
star
11

action-cable-testing

Action Cable testing utils
Ruby
209
star
12

active_event_store

Rails Event Store in a more Rails way
Ruby
167
star
13

action_policy-graphql

Action Policy integration for GraphQL
Ruby
149
star
14

engems

Rails component-based architecture on top of engines and gems (showroom)
Ruby
136
star
15

influxer

InfluxDB ActiveRecord-style
Ruby
118
star
16

abstract_notifier

ActionMailer-like interface for any type of notifications
Ruby
116
star
17

wsdirector

All the world's a server, and all the men and women merely clients
Ruby
99
star
18

pgrel

ActiveRecord extension for querying hstore and jsonb
Ruby
93
star
19

gem-check

GemCheck: Writing Better Ruby Gems Checklist
CSS
93
star
20

turbo-music-drive

Exploring Turbo future features while building a music library app
Ruby
89
star
21

rbytes

Ruby Bytes helps you build, deploy and install Ruby and Rails application templates
Ruby
65
star
22

faqueue

Researching background jobs fairness
Ruby
63
star
23

downstream

Straightforward way to implement communication between Rails Engines using the Publish-Subscribe pattern.
Ruby
47
star
24

influx_udp

Erlang InfluxDB UDP writer
Erlang
31
star
25

newgem

Custom script to generate new gems
Ruby
30
star
26

ruby-dip

Docker-based development environment for hacking Ruby MRI
Dockerfile
30
star
27

turbo-view-transitions

View Transitions API for Turbo
TypeScript
28
star
28

erlgrpc

GRPC client for Erlang
Erlang
25
star
29

as3_p2plocal

as3 lib for local p2p connections (serverless rtmfp)
ActionScript
25
star
30

rails-intest-views

Generate view templates dynamically in Rails tests
Ruby
20
star
31

sharelatex-vagrant-ansible

Vagrant + Ansible configuration for ShareLatex
Shell
12
star
32

docsify-namespaced

Docsify plugin to work with namespaces
JavaScript
11
star
33

docs-example

Playground for dealing with documentation engines
7
star
34

ruby-russia-2020

RubyRussia 2020 "Frontendless Rails" workshop demo app
Ruby
6
star
35

engine-cable-app

Experimenting with Action Cable and engines
Ruby
6
star
36

palkan

It's me
4
star
37

ruby-compatibility-examples

Collections of reproduction cases for TruffleRuby vs. MRI (in)compatibility
Ruby
3
star
38

erffmpeg

Erlang wrapper for some ffmpeg
C
3
star
39

th-dummy

TH Dummy
Ruby
2
star
40

ulitos

Erlang utils modules
Erlang
2
star
41

meetings

Good old Teachbase Meetings client
ActionScript
2
star
42

macos-setup

Shell
1
star
43

bitrix-orm

Bitrix kinda ORM for IBlockElements and CUser.
PHP
1
star
44

adventofcode2018

https://adventofcode.com
Rust
1
star
45

tb_utils

ActionScript 3 library
ActionScript
1
star
46

rebar_templates

Custom rebar templates
Erlang
1
star