rubanok 0.1.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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