rubanok 0.1.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/rubanok.rb CHANGED
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rubanok/version"
4
- require "rubanok/plane"
4
+ require "rubanok/processor"
5
5
 
6
6
  require "rubanok/railtie" if defined?(Rails)
7
7
 
8
+ # @type const ENV: Hash[String, String]
8
9
  if defined?(RSpec) && (ENV["RACK_ENV"] == "test" || ENV["RAILS_ENV"] == "test")
9
10
  require "rubanok/rspec"
10
11
  end
@@ -13,15 +14,15 @@ end
13
14
  #
14
15
  # Example:
15
16
  #
16
- # class CourseSessionPlane < Rubanok::Plane
17
+ # class CourseSessionProcessor < Rubanok::Processor
17
18
  # map :q do |q:|
18
- # raw.searh(q)
19
+ # raw.searh(q)
19
20
  # end
20
21
  # end
21
22
  #
22
23
  # class CourseSessionController < ApplicationController
23
24
  # def index
24
- # @sessions = planish(CourseSession.all)
25
+ # @sessions = rubanok_process(CourseSession.all)
25
26
  # end
26
27
  # end
27
28
  module Rubanok
@@ -30,7 +31,10 @@ module Rubanok
30
31
  # When the value is empty and ignored the corresponding matcher/mapper
31
32
  # is not activated (true by default)
32
33
  attr_accessor :ignore_empty_values
34
+ # Define wheter to fail when `match` rule cannot find matching value
35
+ attr_accessor :fail_when_no_matches
33
36
  end
34
37
 
35
38
  self.ignore_empty_values = true
39
+ self.fail_when_no_matches = false
36
40
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Rubanok
4
4
  module DSL
5
- # Adds `.map` method to Plane to define key-matching rules:
5
+ # Adds `.map` method to Processor to define key-matching rules:
6
6
  #
7
7
  # map :q do |q:|
8
8
  # # this rule is activated iff "q" (or :q) param is present
@@ -20,12 +20,29 @@ module Rubanok
20
20
  end
21
21
  end
22
22
 
23
- def map(*fields, **options, &block)
24
- rule = Rule.new(fields, options)
23
+ module ClassMethods
24
+ def map(*fields, activate_on: fields, activate_always: false, ignore_empty_values: Rubanok.ignore_empty_values, filter_with: nil, &block)
25
+ filter = filter_with
25
26
 
26
- define_method(rule.to_method_name, &block)
27
+ if filter.is_a?(Symbol)
28
+ respond_to?(filter) || raise(
29
+ ArgumentError,
30
+ "Unknown class method #{filter} for #{self}. " \
31
+ "Make sure that a filter method is defined before the call to .map."
32
+ )
33
+ filter = method(filter)
34
+ end
27
35
 
28
- rules << rule
36
+ rule = Rule.new(fields, activate_on: activate_on, activate_always: activate_always, ignore_empty_values: ignore_empty_values, filter_with: filter)
37
+
38
+ define_method(rule.to_method_name, &block)
39
+
40
+ add_rule rule
41
+ end
42
+ end
43
+
44
+ def self.included(base)
45
+ base.extend ClassMethods
29
46
  end
30
47
  end
31
48
  end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubanok
4
+ class UnexpectedInputError < StandardError; end
5
+
4
6
  module DSL
5
- # Adds `.match` method to Plane class to define key-value-matching rules:
7
+ # Adds `.match` method to Processor class to define key-value-matching rules:
6
8
  #
7
9
  # match :sort, :sort_by do |sort:, sort_by:|
8
10
  # # this rule is activated iff both "sort" and "sort_by" params are present
@@ -20,8 +22,8 @@ module Rubanok
20
22
  class Clause < Rubanok::Rule
21
23
  attr_reader :values, :id, :block
22
24
 
23
- def initialize(id, fields, values = [], **options, &block)
24
- super(fields, options)
25
+ def initialize(id, fields, values, activate_on: fields, activate_always: false, &block)
26
+ super(fields, activate_on: activate_on, activate_always: activate_always)
25
27
  @id = id
26
28
  @block = block
27
29
  @values = Hash[fields.take(values.size).zip(values)].freeze
@@ -37,7 +39,7 @@ module Rubanok
37
39
 
38
40
  attr_reader :clauses
39
41
 
40
- def initialize(*)
42
+ def initialize(fields, activate_on: fields, activate_always: false, ignore_empty_values: Rubanok.ignore_empty_values, filter_with: nil)
41
43
  super
42
44
  @clauses = []
43
45
  end
@@ -53,7 +55,7 @@ module Rubanok
53
55
  end
54
56
 
55
57
  def default(&block)
56
- clauses << Clause.new("#{to_method_name}_default", fields, activate_always: true, &block)
58
+ clauses << Clause.new("#{to_method_name}_default", fields, [], activate_always: true, &block)
57
59
  end
58
60
 
59
61
  private
@@ -64,23 +66,45 @@ module Rubanok
64
66
  end
65
67
  end
66
68
 
67
- def match(*fields, **options, &block)
68
- rule = Rule.new(fields, options)
69
+ module ClassMethods
70
+ def match(*fields, activate_on: fields, activate_always: false, fail_when_no_matches: nil, &block)
71
+ rule = Rule.new(fields, activate_on: activate_on, activate_always: activate_always)
69
72
 
70
- rule.instance_eval(&block)
73
+ rule.instance_eval(&block)
71
74
 
72
- define_method(rule.to_method_name) do |params|
73
- clause = rule.matching_clause(params)
74
- next raw unless clause
75
+ define_method(rule.to_method_name) do |params = {}|
76
+ params ||= {} if params.nil?
75
77
 
76
- apply_rule! clause.to_method_name, clause.project(params)
77
- end
78
+ clause = rule.matching_clause(params)
79
+
80
+ if clause
81
+ apply_rule! clause, params
82
+ else
83
+ default_match_handler(rule, params, fail_when_no_matches)
84
+ end
85
+ end
86
+
87
+ rule.clauses.each do |clause|
88
+ define_method(clause.to_method_name, &clause.block)
89
+ end
78
90
 
79
- rule.clauses.each do |clause|
80
- define_method(clause.to_method_name, &clause.block)
91
+ add_rule rule
81
92
  end
93
+ end
94
+
95
+ def self.included(base)
96
+ base.extend ClassMethods
97
+ end
98
+
99
+ def default_match_handler(rule, params, fail_when_no_matches)
100
+ fail_when_no_matches = Rubanok.fail_when_no_matches if fail_when_no_matches.nil?
101
+ return raw unless fail_when_no_matches
82
102
 
83
- rules << rule
103
+ raise ::Rubanok::UnexpectedInputError, <<~MSG
104
+ Unexpected input: #{params.slice(*rule.fields)}.
105
+ Available values are:
106
+ #{rule.clauses.map(&:values).join("\n ")}
107
+ MSG
84
108
  end
85
109
  end
86
110
  end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ require "rubanok/rule"
6
+
7
+ require "rubanok/dsl/mapping"
8
+ require "rubanok/dsl/matching"
9
+
10
+ module Rubanok
11
+ # Base class for processors (_planes_)
12
+ #
13
+ # Define transformation rules via `map` and `match` methods
14
+ # and apply them by calling the processor:
15
+ #
16
+ # class MyTransformer < Rubanok::Processor
17
+ # map :type do
18
+ # raw.where(type: type)
19
+ # end
20
+ # end
21
+ #
22
+ # MyTransformer.call(MyModel.all, {type: "public"})
23
+ #
24
+ # NOTE: the second argument (`params`) MUST be a Hash. Keys could be either Symbols
25
+ # or Strings (we automatically transform strings to symbols while matching rules).
26
+ #
27
+ # All transformation methods are called within the context of the instance of
28
+ # a processor class.
29
+ #
30
+ # You can access the input data via `raw` method.
31
+ class Processor
32
+ include DSL::Matching
33
+ include DSL::Mapping
34
+
35
+ UNDEFINED = Object.new
36
+
37
+ class << self
38
+ def call(input, params = UNDEFINED)
39
+ input, params = nil, input if params == UNDEFINED
40
+
41
+ raise ArgumentError, "Params could not be nil" if params.nil?
42
+
43
+ # @type var params: untyped
44
+ new(input).call(params)
45
+ end
46
+
47
+ def add_rule(rule)
48
+ fields_set.merge rule.fields
49
+ rules << rule
50
+ end
51
+
52
+ def rules
53
+ return @rules if instance_variable_defined?(:@rules)
54
+
55
+ @rules =
56
+ if superclass <= Processor
57
+ superclass.rules.dup
58
+ else
59
+ []
60
+ end
61
+ end
62
+
63
+ def fields_set
64
+ return @fields_set if instance_variable_defined?(:@fields_set)
65
+
66
+ @fields_set =
67
+ if superclass <= Processor
68
+ superclass.fields_set.dup
69
+ else
70
+ Set.new
71
+ end
72
+ end
73
+
74
+ # Generates a `params` projection including only the keys used
75
+ # by the rules
76
+ def project(params)
77
+ params = params.transform_keys(&:to_sym)
78
+ params.slice(*fields_set.to_a)
79
+ end
80
+
81
+ # DSL to define the #prepare method
82
+ def prepare(&block)
83
+ define_method(:prepare, &block)
84
+ end
85
+ end
86
+
87
+ def initialize(input)
88
+ @input = input
89
+ @prepared = false
90
+ end
91
+
92
+ def call(params)
93
+ params = params.transform_keys(&:to_sym)
94
+
95
+ rules.each do |rule|
96
+ next unless rule.applicable?(params)
97
+
98
+ prepare! unless prepared?
99
+ apply_rule! rule, params
100
+ end
101
+
102
+ input
103
+ end
104
+
105
+ private
106
+
107
+ attr_accessor :input, :prepared
108
+
109
+ alias raw input
110
+ alias prepared? prepared
111
+
112
+ def apply_rule!(rule, params)
113
+ method_name, data = rule.to_method_name, rule.project(params)
114
+
115
+ return unless data
116
+
117
+ self.input =
118
+ if data.empty?
119
+ send(method_name)
120
+ else
121
+ send(method_name, **data)
122
+ end
123
+ end
124
+
125
+ def prepare
126
+ # no-op
127
+ end
128
+
129
+ def prepare!
130
+ @prepared = true
131
+
132
+ prepared_input = prepare
133
+ return unless prepared_input
134
+
135
+ self.input = prepared_input
136
+ end
137
+
138
+ def rules
139
+ self.class.rules
140
+ end
141
+ end
142
+
143
+ Plane = Processor
144
+ end
@@ -4,33 +4,78 @@ require "active_support/concern"
4
4
 
5
5
  module Rubanok
6
6
  # Controller concern.
7
- # Adds `planish` method.
7
+ # Adds `rubanok_process` method.
8
8
  module Controller
9
9
  extend ActiveSupport::Concern
10
10
 
11
- # Planish passed data (e.g. ActiveRecord relation) using
12
- # the corrsponding Plane class.
11
+ included do
12
+ if respond_to?(:helper_method)
13
+ helper_method :rubanok_scope
14
+ helper_method :planish_scope
15
+ end
16
+ end
17
+
18
+ # This method process passed data (e.g. ActiveRecord relation) using
19
+ # the corresponding Processor class.
13
20
  #
14
- # Plane is inferred from controller name, e.g.
15
- # "PostsController -> PostPlane".
21
+ # Processor is inferred from controller name, e.g.
22
+ # "PostsController -> PostProcessor".
16
23
  #
17
- # You can specify the Plane class explicitly via `with` option.
24
+ # You can specify the Processor class explicitly via `with` option.
18
25
  #
19
- # By default, `params` object is passed as paraters, but you
26
+ # By default, `params` object is passed as parameters, but you
20
27
  # can specify the params via `params` option.
21
- def planish(data, plane_params = nil, with: implicit_plane_class)
28
+ def rubanok_process(data, params = nil, with: nil)
29
+ with_inferred_rubanok_params(with, params) do |rubanok_class, rubanok_params|
30
+ rubanok_class.call(data, rubanok_params)
31
+ end
32
+ end
33
+
34
+ # This method filters the passed params and returns the Hash (!)
35
+ # of the params recongnized by the processor.
36
+ #
37
+ # Processor is inferred from controller name, e.g.
38
+ # "PostsController -> PostProcessor".
39
+ #
40
+ # You can specify the Processor class explicitly via `with` option.
41
+ #
42
+ # By default, `params` object is passed as parameters, but you
43
+ # can specify the params via `params` option.
44
+ def rubanok_scope(params = nil, with: nil)
45
+ with_inferred_rubanok_params(with, params) do |rubanok_class, rubanok_params|
46
+ rubanok_class.project(rubanok_params)
47
+ end
48
+ end
49
+
50
+ # Tries to infer the rubanok processor class from controller path
51
+ def implicit_rubanok_class
52
+ "#{controller_path.classify.pluralize}Processor".safe_constantize
53
+ end
54
+
55
+ def with_inferred_rubanok_params(with, params)
56
+ with ||= implicit_rubanok_class
57
+
22
58
  if with.nil?
23
- raise ArgumentError, "Failed to find a plane class for #{self.class.name}. " \
24
- "Please, specify the plane class explicitly via `with` option"
59
+ raise ArgumentError, "Failed to find a processor class for #{self.class.name}. " \
60
+ "Please, specify the processor class explicitly via `with` option"
25
61
  end
26
62
 
27
- plane_params ||= params
63
+ params ||= self.params
64
+
65
+ params = params.to_unsafe_h if params.is_a?(ActionController::Parameters)
66
+
67
+ yield with, params
68
+ end
69
+
70
+ # Backward compatibility
71
+ def planish(*args, with: implicit_plane_class)
72
+ rubanok_process(*args, with: with)
73
+ end
28
74
 
29
- plane_params = plane_params.to_unsafe_h if plane_params.is_a?(ActionController::Parameters)
30
- with.call(data, plane_params)
75
+ def planish_scope(*args, with: implicit_plane_class)
76
+ rubanok_scope(*args, with: with)
31
77
  end
32
78
 
33
- # Tries to infer the plane class from controller path
34
79
  def implicit_plane_class
35
80
  "#{controller_path.classify.pluralize}Plane".safe_constantize
36
81
  end
@@ -8,6 +8,12 @@ module Rubanok # :nodoc:
8
8
 
9
9
  ActionController::Base.include Rubanok::Controller
10
10
  end
11
+
12
+ ActiveSupport.on_load(:action_controller_api) do
13
+ require "rubanok/rails/controller"
14
+
15
+ ActionController::API.include Rubanok::Controller
16
+ end
11
17
  end
12
18
  end
13
19
  end
data/lib/rubanok/rspec.rb CHANGED
@@ -3,20 +3,26 @@
3
3
  require "rspec/mocks"
4
4
 
5
5
  module Rubanok
6
- class HavePlanished < RSpec::Matchers::BuiltIn::BaseMatcher
6
+ class HaveProcessed < RSpec::Matchers::BuiltIn::BaseMatcher
7
7
  include RSpec::Mocks::ExampleMethods
8
8
 
9
- attr_reader :data_class, :plane, :matcher
9
+ attr_reader :data_class, :processor, :matcher
10
10
 
11
11
  def initialize(data_class = nil)
12
12
  if data_class
13
13
  @data_class = data_class.is_a?(Module) ? data_class : data_class.class
14
14
  end
15
15
  @matcher = have_received(:call)
16
+ @name = "have_rubanok_processed"
16
17
  end
17
18
 
18
- def with(plane)
19
- @plane = plane
19
+ def as(alias_name)
20
+ @name = alias_name
21
+ self
22
+ end
23
+
24
+ def with(processor)
25
+ @processor = processor
20
26
  self
21
27
  end
22
28
 
@@ -25,32 +31,40 @@ module Rubanok
25
31
  end
26
32
 
27
33
  def matches?(proc)
28
- raise ArgumentError, "have_planished only supports block expectations" unless Proc === proc
34
+ raise ArgumentError, "#{name} only supports block expectations" unless Proc === proc
29
35
 
30
- raise ArgumentError, "Plane class is required. Please, specify it using `.with` modifier" if plane.nil?
36
+ raise ArgumentError, "Processor class is required. Please, specify it using `.with` modifier" if processor.nil?
31
37
 
32
- allow(plane).to receive(:call).and_call_original
38
+ allow(processor).to receive(:call).and_call_original
33
39
  proc.call
34
40
 
35
41
  matcher.with(an_instance_of(data_class), anything) if data_class
36
42
 
37
- matcher.matches?(plane)
43
+ matcher.matches?(processor)
38
44
  end
39
45
 
40
46
  def failure_message
41
- "expected to use #{plane.name}#{data_class ? " for #{data_class.name}" : ""}, but didn't"
47
+ "expected to use #{processor.name}#{data_class ? " for #{data_class.name}" : ""}, but didn't"
42
48
  end
43
49
 
44
50
  def failure_message_when_negated
45
- "expected not to use #{plane.name}#{data_class ? " for #{data_class.name} " : ""}, but have used"
51
+ "expected not to use #{processor.name}#{data_class ? " for #{data_class.name} " : ""}, but have used"
46
52
  end
53
+
54
+ private
55
+
56
+ attr_reader :name
47
57
  end
48
58
  end
49
59
 
50
60
  RSpec.configure do |config|
51
61
  config.include(Module.new do
62
+ def have_rubanok_processed(*args)
63
+ Rubanok::HaveProcessed.new(*args)
64
+ end
65
+
52
66
  def have_planished(*args)
53
- Rubanok::HavePlanished.new(*args)
67
+ Rubanok::HaveProcessed.new(*args).as("have_planished")
54
68
  end
55
69
  end)
56
70
  end