Skip to content

Commit

Permalink
feat: nested processors
Browse files Browse the repository at this point in the history
Closes #18
  • Loading branch information
palkan committed Dec 4, 2023
1 parent 731cba0 commit eae739b
Show file tree
Hide file tree
Showing 9 changed files with 269 additions and 1 deletion.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## master

- Add nested processors support. ([@palkan][])

## 0.4.0 (2021-03-05)

- Ruby 3.0 compatibility. ([@palkan][])
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,28 @@ If in example above you will call `CourseSessionsProcessor.call(CourseSession, f

**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:

```ruby
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.
Expand Down
72 changes: 72 additions & 0 deletions lib/rubanok/dsl/process.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

module Rubanok
module DSL
# Adds `.process` method to Processor to define a nested processor:
#
# process :filter do
# map :status do |status:|
# raw.where(status:)
# end
#
module Process
class Rule < Rubanok::Rule
METHOD_PREFIX = "__process"

def initialize(...)
super
raise ArgumentError, "Nested processor requires exactly one field" if fields.size != 1
@field = fields.first
end

def define_processor(superclass, &block)
@processor = Class.new(superclass, &block)
end

def process(input, params)
return input if params.nil?

subparams = fetch_value(params, field)
return input if subparams == UNDEFINED

return input unless subparams.respond_to?(:transform_keys)

# @type var subparams : params
processor.call(input, subparams)
end

private

attr_reader :processor, :field

def build_method_name
"#{METHOD_PREFIX}#{super}"
end
end

module ClassMethods
def process(field, superclass: ::Rubanok::Processor, activate_on: [field], activate_always: false, ignore_empty_values: Rubanok.ignore_empty_values, filter_with: nil, &block)
filter = filter_with

if filter.is_a?(Symbol)
respond_to?(filter) || raise(
ArgumentError,
"Unknown class method #{filter} for #{self}. " \
"Make sure that a filter method is defined before the call to .map."
)
filter = method(filter)
end

rule = Rule.new([field], activate_on: activate_on, activate_always: activate_always, ignore_empty_values: ignore_empty_values, filter_with: filter)
rule.define_processor(superclass, &block)

define_method(rule.to_method_name) do |params = {}|
rule.process(raw, params)
end

add_rule rule
end
end
end
end
end
3 changes: 3 additions & 0 deletions lib/rubanok/processor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

require "rubanok/dsl/mapping"
require "rubanok/dsl/matching"
require "rubanok/dsl/process"

module Rubanok
# Base class for processors (_planes_)
Expand Down Expand Up @@ -33,6 +34,8 @@ class Processor
include DSL::Matching
extend DSL::Mapping::ClassMethods
include DSL::Mapping
extend DSL::Process::ClassMethods
include DSL::Process

UNDEFINED = Object.new

Expand Down
23 changes: 23 additions & 0 deletions sig/rubanok/dsl/process.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
module Rubanok
module DSL
module Process : _Processor
class Rule < Rubanok::Rule
METHOD_PREFIX: String

attr_reader field: field
attr_reader processor: singleton(Processor)

%a{rbs:test:skip} def define_processor: (singleton(Processor)) { () -> void } -> void
def process: (input, params | nil) -> input

private
def build_method_name: () -> String
end

module ClassMethods : Module, _RulesAdding
%a{rbs:test:skip} def process: (field, superclass: singleton(Processor), ?activate_on: (field | Array[field]), ?activate_always: bool, ?ignore_empty_values: bool, ?filter_with: Symbol) { () -> input } -> void
def define_method: (String | Symbol) ?{ () [self: Rubanok::Processor] -> void } -> Symbol
end
end
end
end
3 changes: 3 additions & 0 deletions sig/rubanok/processor.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ module Rubanok
include DSL::Mapping
extend DSL::Mapping::ClassMethods

include DSL::Process
extend DSL::Process::ClassMethods

extend _RulesAdding

UNDEFINED: Object
Expand Down
10 changes: 10 additions & 0 deletions sig/typeprof.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@
raw
end

process :filter do
map :status do |status:|
raw
end

map :name do |name:|
raw
end
end

match :sort_by, :sort, activate_on: :sort_by do
having "status", "asc" do
raw
Expand Down
133 changes: 133 additions & 0 deletions spec/cases/process_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# frozen_string_literal: true

describe "Plane.process" do
let(:input) do
[
{
name: "Dexter",
occupation: "vocal",
status: "forever"
},
{
name: "Noodles",
occupation: "guitar",
status: "forever"
},
{
name: "Ron",
occupation: "drums",
status: "past"
},
{
name: "Greg",
occupation: "bas",
status: "past"
},
{
name: "Todd",
occupation: "bas",
status: "active"
}
].freeze
end

let(:params) { {} }

subject { plane.call(input, params) }

context "single argument" do
let(:plane) do
Class.new(Rubanok::Plane) do
process :filter do
map :status do |status:|
raw.select { _1[:status] == status }
end

map :occupation do |occupation:|
raw.select { _1[:occupation] == occupation }
end
end
end
end

specify "no matching params" do
expect(subject).to eq input
end

specify "with matching param and value (status)" do
params[:filter] = {status: "past"}

expect(subject).to match_array(
[
{
name: "Ron",
occupation: "drums",
status: "past"
},
{
name: "Greg",
occupation: "bas",
status: "past"
}
]
)
end

specify "with matching param and value (occupation)" do
params[:filter] = {occupation: "bas"}

expect(subject).to match_array([
{
name: "Greg",
occupation: "bas",
status: "past"
},
{
name: "Todd",
occupation: "bas",
status: "active"
}
])
end
end

context "nested process" do
let(:plane) do
Class.new(Rubanok::Plane) do
process :filter do
map :status do |status:|
raw
end

process :name do
map :dexter do |*|
raw.select { _1[:name] == "Dexter" }
end

map :noodles do |*|
raw.select { _1[:name] == "Noodles" }
end
end
end
end
end

specify do
params[:filter] = {name: {"noodles" => "1"}}

expect(subject).to match_array([
{
name: "Noodles",
occupation: "guitar",
status: "forever"
}
])
end

specify "when nested value is not a Hash-like" do
params[:filter] = {name: "noodles"}

expect(subject).to eq input
end
end
end
2 changes: 1 addition & 1 deletion spec/spec_helper.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

begin
require "pry-byebug"
require "debug"
rescue LoadError
end

Expand Down

0 comments on commit eae739b

Please sign in to comment.