{"id":13879593,"url":"https://github.com/palkan/rubanok","last_synced_at":"2025-05-15T12:00:23.303Z","repository":{"id":54169643,"uuid":"160834564","full_name":"palkan/rubanok","owner":"palkan","description":"Parameters-based transformation DSL","archived":false,"fork":false,"pushed_at":"2024-11-25T06:44:53.000Z","size":469,"stargazers_count":213,"open_issues_count":3,"forks_count":8,"subscribers_count":2,"default_branch":"master","last_synced_at":"2025-04-14T19:59:14.755Z","etag":null,"topics":["buratino","hacktoberfest","rails","ruby","woodcutting"],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/palkan.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2018-12-07T14:32:52.000Z","updated_at":"2025-03-07T11:00:51.000Z","dependencies_parsed_at":"2025-02-28T22:10:42.146Z","dependency_job_id":"c84c4c39-3aa6-4c50-bbad-f1f0dc385337","html_url":"https://github.com/palkan/rubanok","commit_stats":{"total_commits":80,"total_committers":5,"mean_commits":16.0,"dds":"0.13749999999999996","last_synced_commit":"9b181fc3b652a1d39830b181359b540cf48a61ac"},"previous_names":[],"tags_count":8,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/palkan%2Frubanok","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/palkan%2Frubanok/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/palkan%2Frubanok/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/palkan%2Frubanok/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/palkan","download_url":"https://codeload.github.com/palkan/rubanok/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":254227581,"owners_count":22035664,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["buratino","hacktoberfest","rails","ruby","woodcutting"],"created_at":"2024-08-06T08:02:26.157Z","updated_at":"2025-05-15T12:00:23.228Z","avatar_url":"https://github.com/palkan.png","language":"Ruby","funding_links":[],"categories":["Ruby"],"sub_categories":[],"readme":"[![Gem Version](https://badge.fury.io/rb/rubanok.svg)](https://rubygems.org/gems/rubanok)\n![Build](https://github.com/palkan/rubanok/workflows/Build/badge.svg)\n\n# Rubanok\n\nRubanok provides a DSL to build parameters-based data transformers.\n\n📖 Read the introduction post: [\"Carve your controllers like Papa Carlo\"](https://evilmartians.com/chronicles/rubanok-carve-your-rails-controllers-like-papa-carlo)\n\nThe typical usage is to describe all the possible collection manipulation for REST `index` action, e.g. filtering, sorting, searching, pagination, etc..\n\nSo, instead of:\n\n```ruby\nclass CourseSessionController \u003c ApplicationController\n  def index\n    @sessions = CourseSession\n      .search(params[:q])\n      .by_course_type(params[:course_type_id])\n      .by_role(params[:role_id])\n      .paginate(page_params)\n      .order(ordering_params)\n  end\nend\n```\n\nYou have:\n\n```ruby\nclass CourseSessionController \u003c ApplicationController\n  def index\n    @sessions = rubanok_process(\n      # pass input\n      CourseSession.all,\n      # pass params\n      params,\n      # provide a processor to use\n      with: CourseSessionsProcessor\n    )\n  end\nend\n```\n\nOr we can try to infer all the configuration for you:\n\n```ruby\nclass CourseSessionController \u003c ApplicationController\n  def index\n    @sessions = rubanok_process(CourseSession.all)\n  end\nend\n```\n\nRequirements:\n\n- Ruby ~\u003e 2.7\n- (optional\\*) Rails \u003e= 6.0 (see older releases for Rails \u003c6 support)\n\n\\* This gem has no dependency on Rails.\n\n\u003ca href=\"https://evilmartians.com/\"\u003e\n\u003cimg src=\"https://evilmartians.com/badges/sponsored-by-evil-martians.svg\" alt=\"Sponsored by Evil Martians\" width=\"236\" height=\"54\"\u003e\u003c/a\u003e\n\n## Installation\n\nAdd to your `Gemfile`:\n\n```ruby\ngem \"rubanok\"\n```\n\nAnd run `bundle install`.\n\n## Usage\n\nThe 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.\n\nFrom the example above:\n\n```ruby\nclass CourseSessionsProcessor \u003c Rubanok::Processor\n  # You can map keys\n  map :q do |q:|\n    # `raw` is an accessor for input data\n    raw.search(q)\n  end\nend\n\n# The following code\nCourseSessionsProcessor.call(CourseSession.all, q: \"xyz\")\n\n# is equal to\nCourseSession.all.search(\"xyz\")\n```\n\nYou can map multiple keys at once:\n\n```ruby\nclass CourseSessionsProcessor \u003c Rubanok::Processor\n  DEFAULT_PAGE_SIZE = 25\n\n  map :page, :per_page do |page:, per_page: DEFAULT_PAGE_SIZE|\n    raw.paginate(page: page, per_page: per_page)\n  end\nend\n```\n\nThere is also `match` method to handle values:\n\n```ruby\nclass CourseSessionsProcessor \u003c Rubanok::Processor\n  SORT_ORDERS = %w[asc desc].freeze\n  SORTABLE_FIELDS = %w[id name created_at].freeze\n\n  match :sort_by, :sort do\n    having \"course_id\", \"desc\" do\n      raw.joins(:courses).order(\"courses.id desc nulls last\")\n    end\n\n    having \"course_id\", \"asc\" do\n      raw.joins(:courses).order(\"courses.id asc nulls first\")\n    end\n\n    # Match any value for the second arg\n    having \"type\" do |sort: \"asc\"|\n      # Prevent SQL injections\n      raise \"Possible injection: #{sort}\" unless SORT_ORDERS.include?(sort)\n      raw.joins(:course_type).order(\"course_types.name #{sort}\")\n    end\n\n    # Match any value\n    default do |sort_by:, sort: \"asc\"|\n      raise \"Possible injection: #{sort}\" unless SORT_ORDERS.include?(sort)\n      raise \"The field is not sortable: #{sort_by}\" unless SORTABLE_FIELDS.include?(sort_by)\n      raw.order(sort_by =\u003e sort)\n    end\n  end\n\n  # strict matching; if Processor will not match parameter, it will raise Rubanok::UnexpectedInputError\n  # You can handle it in controller, for example, with sending 422 Unprocessable Entity to client\n  match :filter, fail_when_no_matches: true do\n    having \"active\" do\n      raw.active\n    end\n\n    having \"finished\" do\n      raw.finished\n    end\n  end\nend\n```\n\nBy default, Rubanok will not fail if no matches found in `match` rule. You can change it by setting: `Rubanok.fail_when_no_matches = true`.\nIf in example above you will call `CourseSessionsProcessor.call(CourseSession, filter: 'acitve')`, you will get `Rubanok::UnexpectedInputError: Unexpected input: {:filter=\u003e'acitve'}`.\n\n**NOTE:** Rubanok only matches exact values; more complex matching could be added in the future.\n\n### Nested processors\n\nYou can use the `.process` method to define sub-processors (or nested processors). It's useful when you use nested params, for example:\n\n```ruby\nclass CourseSessionsProcessor \u003c Rubanok::Processor\n  process :filter do\n    match :status do\n      having \"draft\" do\n        raw.where(draft: true)\n      end\n\n      having \"deleted\" do\n        raw.where.not(deleted_at: nil)\n      end\n    end\n\n    # You can also use .map or even .process here\n  end\nend\n```\n\n### Default transformation\n\nSometimes it's useful to perform some transformations before **any** rule is activated.\n\nThere is a special `prepare` method which allows you to define the default transformation:\n\n```ruby\nclass CourseSearchQueryProcessor \u003c Rubanok::Processor\n  prepare do\n    next if raw\u0026.dig(:query, :bool)\n\n    {query: {bool: {filters: []}}}\n  end\n\n  map :ids do |ids:|\n    raw.dig(:query, :bool, :filters) \u003c\u003c {terms: {id: ids}}\n    raw\n  end\nend\n```\n\nThe block should return a new initial value for the _raw_ input or `nil` (no transformation required).\n\nThe `prepare` callback is not executed if no params match, e.g.:\n\n```ruby\nCourseSearchQueryProcessor.call(nil, {}) #=\u003e nil\n\n# But\nCourseSearchQueryProcessor.call(nil, {ids: [1]}) #=\u003e {query {bool: {filters: [{terms: {ids: [1]}}]}}}\n\n# Note that we can omit the first argument altogether\nCourseSearchQueryProcessor.call({ids: [1]})\n```\n\n### Getting the matching params\n\nSometimes 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).\n\nIn Rails, you can use the `#rubanok_scope` method for that:\n\n```ruby\nclass CourseSessionController \u003c ApplicationController\n  def index\n    @sessions = rubanok_process(CourseSession.all)\n    # Returns the Hash of params recognized by the CourseSessionProcessor.\n    # For example:\n    #\n    #    params == {q: \"search\", role_id: 2, date: \"2019-08-22\"}\n    #    @session_filter == {q: \"search\", role_id: 2}\n    @sessions_filter = rubanok_scope(\n      params.permit(:q, :role_id),\n      with: CourseSessionProcessor\n    )\n\n    # You can omit all the arguments\n    @sessions_filter = rubanok_scope #=\u003e equals to rubanok_scope(params, with: implicit_rubanok_class)\n  end\nend\n```\n\nYou can also accesss `rubanok_scope` in views (it's a helper method).\n\n### Rule activation\n\nRubanok _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.\n\nSome 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:\n\n```ruby\n# Always apply the rule; use default values for keyword args\nmap :page, :per_page, activate_always: true do |page: 1, per_page: 2|\n  raw.page(page).per(per_page)\nend\n\n# Only require `sort_by` to be preset to activate sorting rule\nmatch :sort_by, :sort, activate_on: :sort_by do\n # ...\nend\n```\n\nBy 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.\n\nYou 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).\n\n### Input values filtering\n\nFor 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.\n\nWe provide a `filter_with:` option for the `.map` method, which could be used as follows:\n\n```ruby\nclass PostsProcessor \u003c Rubanok::Processor\n  # We can pass a Proc\n  map :ids, filter_with: -\u003e(vals) { vals.reject(\u0026:blank?).presence } do |ids:|\n    raw.where(id: ids)\n  end\n\n  # or define a class method\n  def self.non_empty_array(val)\n    non_blank = val.reject(\u0026:blank?)\n    return if non_blank.empty?\n\n    non_blank\n  end\n\n  # and pass its name as a filter_with value\n  map :ids, filter_with: :non_empty_array do |ids:|\n    raw.where(id: ids)\n  end\nend\n\n# Filtered values are used in rules\nPostsProcessor.call(Post.all, {ids: [\"1\", \"\"]}) == Post.where(id: [\"1\"])\n\n# When filter returns empty value, the rule is not applied\nPostsProcessor.call(Post.all, {ids: [nil, \"\"]}) == Post.all\n```\n\n### Testing\n\nOne of the benefits of having modification logic contained in its own class is the ability to test modifications in isolation:\n\n```ruby\n# For example, with RSpec\nRSpec.describe CourseSessionsProcessor do\n  let(:input) { CourseSession.all }\n  let(:params) { {} }\n\n  subject { described_class.call(input, params) }\n\n  specify \"searching\" do\n    params[:q] = \"wood\"\n\n    expect(subject).to eq input.search(\"wood\")\n  end\nend\n```\n\nNow in your controller you only have to test that the specific _plane_ is applied:\n\n```ruby\nRSpec.describe CourseSessionController do\n  subject { get :index }\n\n  specify do\n    expect { subject }.to have_rubanok_processed(CourseSession.all)\n      .with(CourseSessionsProcessor)\n  end\nend\n```\n\n**NOTE**: input matching only checks for the class equality.\n\nTo 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\"`):\n\n```ruby\nrequire \"rubanok/rspec\"\n```\n\n### Rails vs. non-Rails\n\nRubanok 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`).\n\nIf you use `ActionController::Metal` you must include the `Rubanok::Controller` module yourself.\n\n### Processor class inference in Rails controllers\n\nBy default, `rubanok_process` uses the following algorithm to define a processor class: `\"#{controller_path.classify.pluralize}Processor\".safe_constantize`.\n\nYou can change this by overriding the `#implicit_rubanok_class` method:\n\n```ruby\nclass ApplicationController \u003c ActionController::Smth\n  # override the `implicit_rubanok_class` method\n  def implicit_rubanok_class\n    \"#{controller_path.classify.pluralize}Scoper\".safe_constantize\n  end\nend\n```\n\nNow you can use it like this:\n\n```ruby\nclass CourseSessionsController \u003c ApplicationController\n  def index\n    @sessions = rubanok_process(CourseSession.all, params)\n    # which equals to\n    @sessions = CourseSessionsScoper.call(CourseSession.all, params.to_unsafe_h)\n  end\nend\n```\n\n**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).\n\n## Using with RBS/Steep\n\n_Read [\"Climbing Steep hills, or adopting Ruby 3 types with RBS\"](https://evilmartians.com/chronicles/climbing-steep-hills-or-adopting-ruby-types) for the context._\n\nRubanok comes with Ruby type signatures (RBS).\n\nTo use them with Steep, add `library \"rubanok\"` to your Steepfile.\n\nSince Rubanok provides DSL with implicit context switching (via `instance_eval`), you need to provide type hints for the type checker to help it\nfigure out the current context. Here is an example:\n\n```ruby\nclass MyProcessor \u003c Rubanok::Processor\n  map :q do |q:|\n    # @type self : Rubanok::Processor\n    raw\n  end\n\n  match :sort_by, :sort, activate_on: :sort_by do\n    # @type self : Rubanok::DSL::Matching::Rule\n    having \"status\", \"asc\" do\n      # @type self : Rubanok::Processor\n      raw\n    end\n\n    # @type self : Rubanok::DSL::Matching::Rule\n    default do |sort_by:, sort: \"asc\"|\n      # @type self : Rubanok::Processor\n      raw\n    end\n  end\nend\n```\n\nYeah, a lot of annotations 😞 Welcome to the type-safe world!\n\n## Questions \u0026 Answers\n\n- **Where to put my processor/plane classes?**\n\nI put mine under `app/planes` (as `\u003cresources\u003e_plane.rb`) in my Rails app.\n\n- **I don't like the naming (\"planes\" ✈️?), can I still use the library?**\n\nGood 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 😉).\n\n## Contributing\n\nBug reports and pull requests are welcome on GitHub at https://github.com/palkan/rubanok.\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpalkan%2Frubanok","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpalkan%2Frubanok","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpalkan%2Frubanok/lists"}