[![Gem Version](https://badge.fury.io/rb/rubanok.svg)](https://rubygems.org/gems/rubanok) [![Build Status](https://travis-ci.org/palkan/rubanok.svg?branch=master)](https://travis-ci.org/palkan/rubanok) # Rubanok Rubanok provides a DSL to build parameters-based data transformers. 📖 Read the introduction post: ["Carve your controllers like Papa Carlo"](https://evilmartians.com/chronicles/rubanok-carve-your-rails-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: ```ruby 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: ```ruby 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: ```ruby class CourseSessionController < ApplicationController def index @sessions = rubanok_process(CourseSession.all) end end ``` Requirements: - Ruby ~> 2.5 - Rails >= 4.2 (only for using with Rails) Sponsored by Evil Martians ## Installation Add to your `Gemfile`: ```ruby 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: ```ruby 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: ```ruby 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: ```ruby 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. ### 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: ```ruby 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: ```ruby # 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 setting: `Rubanok.ignore_empty_values = false`. ### Testing One of the benefits of having modification logic contained in its own class is the ability to test modifications in isolation: ```ruby # 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: ```ruby 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"`): ```ruby 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: ```ruby 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: ```ruby 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). ## Questions & Answers - **Where to put my processor/plane classes?** I put mine under `app/planes` (as `_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](https://github.com/palkan/rubanok/pull/8). "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](https://opensource.org/licenses/MIT).