formalism 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 704ad27d3b6f2c48ceb930195acedcc634642e14d76cdf396c8c005ca8af067f
4
+ data.tar.gz: c38eb79190667e0576bcb0e30bbc5564246ef9d14d6f9d0b4de657857ee4aa33
5
+ SHA512:
6
+ metadata.gz: b5838dc86618cae1f6674154ff496e5f2856665daa9b840122430da9425054c3c17c1329fc96091ef2798a1681b78ea4aa526f2c9b7bab2654ca9233b91fb196
7
+ data.tar.gz: b40a969a78b0f1e820ae9b1fbe1d1cafad933f35155cc2ac5a972475a1d6e8eb5851b6d1d3ba049c2c7e25659edf1a9bd72ccad9715643e979d95f2c5c05c7fe
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## master (unreleased)
4
+
5
+ ## 0.2.0 (2020-09-23)
6
+
7
+ ## 0.1.0 (2020-07-09)
8
+
9
+ ## 0.0.0 (2020-07-09)
10
+
11
+ * Initial version
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Alexander Popov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,310 @@
1
+ # Formalism
2
+
3
+ [![Cirrus CI - Base Branch Build Status](https://img.shields.io/cirrus/github/AlexWayfer/formalism?style=flat-square)](https://cirrus-ci.com/github/AlexWayfer/formalism)
4
+ [![Codecov branch](https://img.shields.io/codecov/c/github/AlexWayfer/formalism/master.svg?style=flat-square)](https://codecov.io/gh/AlexWayfer/formalism)
5
+ [![Code Climate](https://img.shields.io/codeclimate/maintainability/AlexWayfer/formalism.svg?style=flat-square)](https://codeclimate.com/github/AlexWayfer/formalism)
6
+ [![Depfu](https://img.shields.io/depfu/AlexWayfer/benchmark_toys?style=flat-square)](https://depfu.com/repos/github/AlexWayfer/formalism)
7
+ [![Inline docs](https://inch-ci.org/github/AlexWayfer/formalism.svg?branch=master)](https://inch-ci.org/github/AlexWayfer/formalism)
8
+ [![license](https://img.shields.io/github/license/AlexWayfer/formalism.svg?style=flat-square)](https://github.com/AlexWayfer/formalism/blob/master/LICENSE.txt)
9
+ [![Gem](https://img.shields.io/gem/v/formalism.svg?style=flat-square)](https://rubygems.org/gems/formalism)
10
+
11
+ Ruby gem for forms with validations and nesting.
12
+
13
+ ## Why
14
+
15
+ I need for service-like objects.
16
+
17
+ I've explored these projects:
18
+
19
+ * [Reform](https://github.com/trailblazer/reform)
20
+ * [Mutations](https://github.com/cypriss/mutations)
21
+ * [Interactor](https://github.com/collectiveidea/interactor)
22
+ * [dry-rb](https://github.com/dry-rb)
23
+
24
+ But nothing of them supports all features I need for:
25
+
26
+ * nesting (into unlimited levels) of themselves;
27
+ * simple syntax;
28
+ * custom validations and coercions;
29
+ * unified output.
30
+
31
+ So, I've tried to combine these all into one library and got Formalism.
32
+
33
+ ### Why here are forms and what about service objects?
34
+
35
+ I've discovered that form object, only with validations,
36
+ are useless without service objects. So, I've combined them:
37
+ service objects include validations.
38
+
39
+ ### If these are service objects, why they called forms?
40
+
41
+ Because if we're combining them — it's more like forms with logic inside for me
42
+ than service objects built-in forms. Even in HTML we're writing `<form>`.
43
+ So, Formalism can accept all data from any-difficult `<form>` and process it,
44
+ also with nested forms (for example, if you have some request form
45
+ with contact data and want to pass contacts into something like user form).
46
+
47
+ ### And if I need for simple service object without validation?
48
+
49
+ You can use `Formalism::Action`, a parent of `Formalism::Form`.
50
+
51
+ ## Installation
52
+
53
+ Add this line to your application's Gemfile:
54
+
55
+ ```ruby
56
+ gem 'formalism'
57
+ ```
58
+
59
+ And then execute:
60
+
61
+ ```shell
62
+ bundle install
63
+ ```
64
+
65
+ Or install it yourself as:
66
+
67
+ ```shell
68
+ gem install formalism
69
+ ```
70
+
71
+ ## Usage
72
+
73
+ ### Basic example
74
+
75
+ ```ruby
76
+ class FindArtistForm < Formalism::Form
77
+ field :name
78
+
79
+ private
80
+
81
+ def validate
82
+ if name.to_s.empty?
83
+ errors.add 'Name is not provided'
84
+ end
85
+ end
86
+
87
+ def execute
88
+ Artist.first(fields_and_nested_forms)
89
+ end
90
+ end
91
+
92
+ class CreateAlbumForm < Formalism::Form
93
+ field :name, String
94
+ fiels :tags, Array, of: String
95
+ nested :artist, FindArtistForm
96
+
97
+ private
98
+
99
+ def validate
100
+ if name.to_s.empty?
101
+ errors.add 'Name is not provided'
102
+ end
103
+ end
104
+
105
+ def execute
106
+ Album.create(fields_and_nested_forms)
107
+ end
108
+ end
109
+
110
+ form = CreateAlbumForm.new(
111
+ name: 'Hits', tags: %w[Indie Rock Hits], artist: { name: 'Alex' }
112
+ )
113
+ form.run
114
+ ```
115
+
116
+ ### Running
117
+
118
+ Usually you need to initialize a form and execute `#run` method.
119
+ Internally, it runs `#valid?` (public) and `#execute` (private) methods.
120
+ `#valid?` runs `#validate` (private) of a form itself and nested forms.
121
+ `#run` can be redefined for database transaction, for example.
122
+
123
+ Also you can call `.run` with arguments for `#initialize`,
124
+ it's the alias for `#initialize` + `#run`.
125
+
126
+ #### Form outcome
127
+
128
+ Any call of `run` returns `Form::Outcome` instance which has `#success?`,
129
+ `#result` and `#errors` methods. Result is a result of `#execute` method.
130
+ Be careful: calling `#result` for failed outcome will raise `ValidationError`.
131
+
132
+ ### Field type
133
+
134
+ Field receives type as the second argument.
135
+ It's not required.
136
+ It can be a constant, String or Symbol.
137
+ If specified — there is a coercion to specified type,
138
+ if not — data remains unchanged.
139
+
140
+ Nested forms — their class, as constant.
141
+ Type or `:initialize` block is required.
142
+
143
+ Formalism also supports `Array` type with the optional `:of` option
144
+ (type of elements).
145
+ Coercion will be applied to a data itself and to its elements.
146
+
147
+ #### Coercion
148
+
149
+ There is built-in coercion into some types, if you try to coerce
150
+ to undefined type — you'll get `Formalism::Form::NoCoercionError`.
151
+
152
+ You can define a coercion to some type via definition of such class:
153
+
154
+ ```ruby
155
+ # frozen_string_literal: true
156
+
157
+ module Formalism
158
+ class Form < Action
159
+ class Coercion
160
+ ## Class for coercion to String
161
+ class String < Base
162
+ private
163
+
164
+ def execute
165
+ @value&.to_s
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ ```
172
+
173
+ ### Default value
174
+
175
+ `field` and `nested` accepts `:default` option.
176
+ It can be any value, if it's an instance of `Proc` — it'll be executed
177
+ in the form instance scope.
178
+
179
+ ### Different keys
180
+
181
+ `field` supports `:key` option (Symbol) to receive data by a different key,
182
+ not as a field name.
183
+
184
+ ### Custom initialization of nested forms
185
+
186
+ By default, nested forms initialized with data by key as their name
187
+ in parent data. So, if a parent receive `{ foo: 1, bar: { baz: 2 } }`,
188
+ it's nested form `:bar` will receive `{ baz: 2 }`.
189
+
190
+ If you want to prevent initialization at all, or pass custom arguments —
191
+ you should use `:initialize` option which accepts a proc
192
+ with a form class argument.
193
+
194
+ If you want to just refine incoming data (add or remove) — you should define
195
+ `#params_for_nested_*` private method, where `*` is a nested form name.
196
+ You can use `super` inside.
197
+
198
+ ### Order of filling with data
199
+
200
+ Fields and nested forms are filling in order of their definition.
201
+ But sometimes you want to change this order, for example,
202
+ if you have a nested forms in ancestors which depends on data in children forms.
203
+ For such cases you can use `:depends_on` option, which accepts fields
204
+ and nested forms names as Symbol or Array of symbols. They will be filled
205
+ (and initialized) before dependent.
206
+
207
+ ### Merging into final data
208
+
209
+ There is `Form#fields_and_nested_forms` as final data
210
+ (after coercion, defaults, etc). But you may want to not include some fields
211
+ or nested forms into this data. You can do it via `:merge` option,
212
+ which can be `true`, `false` or `Proc` (executed in form's instance scope).
213
+
214
+ For example:
215
+
216
+ ```ruby
217
+ field :bar, merge: true
218
+ nested :only_valid, nested_form_class, merge: ->(form) { form.valid? }
219
+ ```
220
+
221
+ ### Runnable
222
+
223
+ You can disable `#valid?` and `#run` of forms (including nested ones)
224
+ by setting `form.runnable = false`.
225
+ It can be helpful for some cases, for example, with policies (permissions):
226
+
227
+ ```ruby
228
+ def initialize_nested_form(name, options)
229
+ return unless (form = super)
230
+
231
+ form.runnable = allowed_to_change?(name)
232
+ form
233
+ end
234
+ ```
235
+
236
+ ### Inheritance
237
+
238
+ Any `class ChildForm < ParentForm` will have all fields and nested forms
239
+ from `ParentForm`.
240
+
241
+ #### Removing (inherited) field
242
+
243
+ But you're able to remove (usually inherited) fields by:
244
+
245
+ ```ruby
246
+ class ChildForm < ParentForm
247
+ remove_field :field_from_parent
248
+ end
249
+ ```
250
+
251
+ #### Modules
252
+
253
+ You can define modules and use them later like this:
254
+
255
+ ```ruby
256
+ module CommonFields
257
+ include Formalism::Form::Fields
258
+
259
+ field :base_field
260
+ nested :base_nested
261
+ end
262
+
263
+ class SomeForm < Formalism::Form
264
+ include CommonFields
265
+
266
+ field :another_field
267
+ end
268
+ ```
269
+
270
+ ### Convert to params
271
+
272
+ You can convert a Form back to (processed) params, for example, for view render:
273
+
274
+ ```ruby
275
+ form = CreateAlbumForm.new(
276
+ name: 'Hits', tags: %w[Indie Rock Hits], artist: { name: 'Alex' }
277
+ )
278
+
279
+ form.to_params
280
+ # {
281
+ # name: 'Hits',
282
+ # tags: %w[Indie Rock Hits],
283
+ # artist: { name: 'Alex' }
284
+ # }
285
+ ```
286
+
287
+ ### Actions
288
+
289
+ For actions without fields, nesting and validation you can use
290
+ `Formalism::Action` (the parent of `Formalism::Form`).
291
+
292
+ ## Development
293
+
294
+ After checking out the repo, run `bundle install` to install dependencies.
295
+
296
+ Then, run `toys rspec` to run the tests.
297
+
298
+ To install this gem onto your local machine, run `toys gem install`.
299
+
300
+ To release a new version, run `toys gem release %version%`.
301
+ See how it works [here](https://github.com/AlexWayfer/gem_toys#release).
302
+
303
+ ## Contributing
304
+
305
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/AlexWayfer/formalism).
306
+
307
+ ## License
308
+
309
+ The gem is available as open source under the terms of the
310
+ [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'formalism/action'
4
+ require_relative 'formalism/form'
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gorilla_patch/deep_dup'
4
+
5
+ module Formalism
6
+ ## Class for any action
7
+ class Action
8
+ class << self
9
+ def run(*args)
10
+ new(*args).run
11
+ end
12
+ end
13
+
14
+ attr_reader :params
15
+ attr_accessor :runnable
16
+
17
+ using GorillaPatch::DeepDup
18
+
19
+ def initialize(params = {})
20
+ @runnable = true unless defined? @runnable
21
+ @params = params.deep_dup || {}
22
+ end
23
+
24
+ def run
25
+ return unless runnable
26
+
27
+ execute
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'form/fields'
4
+ require_relative 'form/filling'
5
+ require_relative 'form/outcome'
6
+ require_relative 'form/validation_error'
7
+
8
+ module Formalism
9
+ ## Class for forms
10
+ class Form < Action
11
+ include Form::Fields
12
+ include Filling
13
+
14
+ attr_reader :instance
15
+
16
+ def self.inherited(child)
17
+ super
18
+
19
+ child.fields_and_nested_forms.merge!(fields_and_nested_forms)
20
+ end
21
+
22
+ def initialize(params_or_instance = {})
23
+ if params_or_instance.is_a?(Hash)
24
+ super
25
+ else
26
+ super({})
27
+
28
+ @instance = params_or_instance
29
+ end
30
+
31
+ fill_fields_and_nested_forms
32
+ end
33
+
34
+ def valid?
35
+ return unless runnable
36
+
37
+ errors.clear
38
+
39
+ nested_forms.each_value(&:valid?)
40
+
41
+ validate
42
+
43
+ merge_errors_of_nested_forms
44
+
45
+ return false if errors.any?
46
+
47
+ true
48
+ end
49
+
50
+ def run
51
+ return unless runnable
52
+
53
+ return Outcome.new(errors) unless valid?
54
+
55
+ Outcome.new(errors, super)
56
+ end
57
+
58
+ protected
59
+
60
+ def errors
61
+ @errors ||= Set.new
62
+ end
63
+
64
+ private
65
+
66
+ def validate; end
67
+
68
+ def instance_respond_to?(name)
69
+ return false unless defined? @instance
70
+
71
+ @instance.respond_to?(name)
72
+ end
73
+
74
+ def instance_public_send(name)
75
+ return false unless defined? @instance
76
+
77
+ @instance.public_send(name)
78
+ end
79
+
80
+ def merge_errors_of_nested_forms
81
+ nested_forms.each do |name, nested_form|
82
+ should_be_merged = self.class.fields_and_nested_forms[name].fetch(:merge_errors, true)
83
+ should_be_merged = instance_exec(&should_be_merged) if should_be_merged.is_a?(Proc)
84
+
85
+ next unless should_be_merged && nested_form.errors.any?
86
+
87
+ merge_errors_of_nested_form name, nested_form
88
+ end
89
+ end
90
+
91
+ def merge_errors_of_nested_form(_name, nested_form)
92
+ errors.merge nested_form.errors
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gorilla_patch/inflections'
4
+
5
+ Dir[
6
+ File.join(__dir__, 'coercion', '**', '*.rb')
7
+ ]
8
+ .sort_by! { |file| [File.basename(file).start_with?('_') ? 1 : 2, file] }
9
+ .each { |file| require file }
10
+
11
+ ## https://github.com/bbatsov/rubocop/issues/5831
12
+ module Formalism
13
+ class Form < Action
14
+ ## Class for coercion (check, initialization)
15
+ class Coercion
16
+ def initialize(type, of = nil)
17
+ @type = type
18
+ @of = of
19
+ end
20
+
21
+ def check
22
+ ## It's custom error! But cop triggers for single argument anyway.
23
+ # rubocop:disable Style/RaiseArgs
24
+ raise NoCoercionError.new(@type) unless exist?
25
+ # rubocop:enable Style/RaiseArgs
26
+
27
+ return unless const_name == 'Array' && @of
28
+
29
+ self.class.new(@of).check
30
+ end
31
+
32
+ def result_for(value)
33
+ coercion_class = exist? ? const_name : 'Base'
34
+
35
+ self.class.const_get(coercion_class, false).new(value, @of).result
36
+ end
37
+
38
+ private
39
+
40
+ using GorillaPatch::Inflections
41
+
42
+ def const_name
43
+ @type.to_s.camelize
44
+ end
45
+
46
+ def exist?
47
+ self.class.const_defined?(const_name, false)
48
+ rescue NameError
49
+ false
50
+ end
51
+ end
52
+
53
+ ## Error for undefined type in coercion
54
+ class NoCoercionError < ArgumentError
55
+ def initialize(type)
56
+ super "Formalism has no coercion to #{type}"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formalism
4
+ class Form < Action
5
+ class Coercion
6
+ ## Base class for coercion
7
+ class Base
8
+ def initialize(value, *)
9
+ @value = value
10
+
11
+ type_name = self.class.name.split('::')[3..-1].join('::')
12
+
13
+ @type =
14
+ if Object.const_defined?(type_name, false)
15
+ then Object.const_get(type_name, false)
16
+ else type_name
17
+ end
18
+ end
19
+
20
+ def result
21
+ return @value unless should_be_coreced?
22
+
23
+ execute
24
+ end
25
+
26
+ private
27
+
28
+ def should_be_coreced?
29
+ @type != 'Base' && !(@type.is_a?(Class) && @value.is_a?(@type))
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formalism
4
+ class Form < Action
5
+ class Coercion
6
+ ## Base class for coercion to Numeric
7
+ class Numeric < Base
8
+ class << self
9
+ private
10
+
11
+ def wrap_value_regexp(content)
12
+ /\A\s*#{content}\s*\z/.freeze
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def execute
19
+ return unless self.class::VALUE_REGEXP.match? @value.to_s
20
+
21
+ @value.public_send(self.class::CONVERSION_METHOD)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formalism
4
+ class Form < Action
5
+ class Coercion
6
+ ## Class for coercion to Array
7
+ class Array < Base
8
+ def initialize(value, of = nil)
9
+ super
10
+
11
+ @of = of
12
+ end
13
+
14
+ private
15
+
16
+ def should_be_coreced?
17
+ true
18
+ end
19
+
20
+ def execute
21
+ result = @value.to_a
22
+
23
+ return result unless @of
24
+
25
+ result.map! { |element| Coercion.new(@of).result_for(element) }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formalism
4
+ class Form < Action
5
+ class Coercion
6
+ ## Class for coercion to boolean
7
+ class Boolean < Base
8
+ private
9
+
10
+ def execute
11
+ @value && @value.to_s != 'false' ? true : false
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formalism
4
+ class Form < Action
5
+ class Coercion
6
+ ## Class for coercion to Date
7
+ class Date < Base
8
+ private
9
+
10
+ def execute
11
+ return if @value.nil?
12
+
13
+ ::Date.parse(@value)
14
+ rescue ArgumentError => e
15
+ raise unless e.message == 'invalid date'
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formalism
4
+ class Form < Action
5
+ class Coercion
6
+ ## Class for coercion to Float
7
+ class Float < Numeric
8
+ ## https://stackoverflow.com/a/36946626/2630849
9
+ VALUE_REGEXP = wrap_value_regexp '[-+]?(?:\d+(?:\.\d*)?|\.\d+)'
10
+
11
+ CONVERSION_METHOD = :to_f
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formalism
4
+ class Form < Action
5
+ class Coercion
6
+ ## Class for coercion to Hash
7
+ class Hash < Base
8
+ private
9
+
10
+ def execute
11
+ @value&.to_h
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formalism
4
+ class Form < Action
5
+ class Coercion
6
+ ## Class for coercion to Integer
7
+ class Integer < Numeric
8
+ ## https://stackoverflow.com/a/1235990/2630849
9
+ VALUE_REGEXP = wrap_value_regexp '[-+]?\d+'
10
+
11
+ CONVERSION_METHOD = :to_i
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formalism
4
+ class Form < Action
5
+ class Coercion
6
+ ## Class for coercion to String
7
+ class String < Base
8
+ private
9
+
10
+ def execute
11
+ @value&.to_s
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formalism
4
+ class Form < Action
5
+ class Coercion
6
+ ## Class for coercion to Symbol
7
+ class Symbol < Base
8
+ private
9
+
10
+ def execute
11
+ @value&.to_sym
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formalism
4
+ class Form < Action
5
+ class Coercion
6
+ ## Class for coercion to Time
7
+ class Time < Base
8
+ private
9
+
10
+ def execute
11
+ case @value
12
+ when ::String
13
+ ::Time.parse @value
14
+ when ::Integer
15
+ ::Time.at @value
16
+ end
17
+ rescue ArgumentError => e
18
+ raise unless e.message.include? 'out of range'
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'module_methods'
4
+
5
+ require_relative 'coercion'
6
+
7
+ module Formalism
8
+ class Form < Action
9
+ ## Extend some module or class with this module for fields
10
+ module Fields
11
+ extend ::ModuleMethods::Extension
12
+
13
+ ## Module for class methods
14
+ module ClassMethods
15
+ def included(something)
16
+ super
17
+
18
+ fields_and_nested_forms.each do |name, options|
19
+ if options.key?(:form)
20
+ something.nested name, options[:form], **options
21
+ else
22
+ something.field name, options[:type], **options
23
+ end
24
+ end
25
+ end
26
+
27
+ def fields_and_nested_forms
28
+ @fields_and_nested_forms ||= {}
29
+ end
30
+
31
+ def field(name, type = nil, **options)
32
+ Coercion.new(type, options[:of]).check unless type.nil?
33
+
34
+ fields_and_nested_forms[name] = options.merge(type: type)
35
+
36
+ define_field_methods(name)
37
+ end
38
+
39
+ def nested(name, form = nil, **options)
40
+ unless form || options.key?(:initialize)
41
+ raise ArgumentError, 'Neither form class nor initialize block ' \
42
+ 'is not present'
43
+ end
44
+
45
+ fields_and_nested_forms[name] = options.merge(form: form)
46
+
47
+ define_nested_form_methods(name)
48
+ end
49
+
50
+ private
51
+
52
+ def remove_field(name)
53
+ fields_and_nested_forms.delete name
54
+
55
+ undef_method name
56
+ undef_method "#{name}="
57
+ end
58
+
59
+ def define_field_methods(name)
60
+ module_for_accessors.instance_exec do
61
+ define_method(name) { fields[name] }
62
+
63
+ private
64
+
65
+ define_method("#{name}=") do |value|
66
+ options = self.class.fields_and_nested_forms[name]
67
+ coerced_value =
68
+ Coercion.new(*options.values_at(:type, :of)).result_for(value)
69
+ fields[name] = coerced_value
70
+ end
71
+ end
72
+ end
73
+
74
+ def define_nested_form_methods(name)
75
+ module_for_accessors.instance_exec do
76
+ define_method("#{name}_form") { nested_forms[name] }
77
+
78
+ define_method(name) { nested_forms[name].instance }
79
+ end
80
+
81
+ define_params_for_nested_method name
82
+ end
83
+
84
+ def define_params_for_nested_method(name)
85
+ params_method_name = "params_for_nested_#{name}"
86
+ params_method_defined =
87
+ method_defined?(params_method_name) ||
88
+ private_method_defined?(params_method_name)
89
+
90
+ module_for_accessors.instance_exec do
91
+ private
92
+
93
+ define_method(params_method_name) { @params[name] } unless params_method_defined
94
+ end
95
+ end
96
+
97
+ def module_for_accessors
98
+ if const_defined?(:FieldsAccessors, false)
99
+ mod = const_get(:FieldsAccessors)
100
+ else
101
+ mod = const_set(:FieldsAccessors, Module.new)
102
+ include mod
103
+ end
104
+ mod
105
+ end
106
+ end
107
+
108
+ def fields(for_merge: false)
109
+ @fields ||= {}
110
+
111
+ return @fields unless for_merge
112
+
113
+ select_for_merge(:fields)
114
+ end
115
+
116
+ def to_params
117
+ fields.merge(
118
+ nested_forms.each_with_object({}) do |(name, nested_form), result|
119
+ result.merge! nested_form_to_params name, nested_form
120
+ end
121
+ )
122
+ end
123
+
124
+ private
125
+
126
+ def nested_forms
127
+ @nested_forms ||= {}
128
+ end
129
+
130
+ def fields_and_nested_forms(for_merge: true)
131
+ merging_nested_forms =
132
+ for_merge ? select_for_merge(:nested_forms) : nested_forms
133
+
134
+ fields(for_merge: for_merge).merge(
135
+ merging_nested_forms
136
+ .map { |name, _nested_form| [name, public_send(name)] }
137
+ .to_h
138
+ )
139
+ end
140
+
141
+ def select_for_merge(type)
142
+ send(type).select do |name, value|
143
+ merge = self.class.fields_and_nested_forms[name].fetch(:merge, true)
144
+ merge.is_a?(Proc) ? instance_exec(value, &merge) : merge
145
+ end
146
+ end
147
+
148
+ def nested_form_to_params(name_of_nested_form, nested_form)
149
+ { name_of_nested_form => nested_form.to_params }
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formalism
4
+ class Form < Action
5
+ ## Module for filling forms with data
6
+ module Filling
7
+ private
8
+
9
+ def fill_fields_and_nested_forms
10
+ @filled_fields_and_nested_forms = []
11
+
12
+ self.class.fields_and_nested_forms.each_key do |name|
13
+ fill_field_or_nested_form name
14
+ end
15
+ end
16
+
17
+ def fill_field_or_nested_form(name)
18
+ return if @filled_fields_and_nested_forms.include? name
19
+
20
+ options = self.class.fields_and_nested_forms[name]
21
+
22
+ fill_depends(*options[:depends_on])
23
+
24
+ if options.key?(:form)
25
+ fill_nested_form name, options
26
+ else
27
+ fill_field name, options
28
+ end
29
+
30
+ @filled_fields_and_nested_forms.push name
31
+ end
32
+
33
+ def fill_depends(*depends_on)
34
+ depends_on.each do |depends_name|
35
+ next unless self.class.fields_and_nested_forms.key?(depends_name)
36
+
37
+ fill_field_or_nested_form depends_name
38
+ end
39
+ end
40
+
41
+ def fill_field(name, options)
42
+ key = options.fetch(:key, name)
43
+ setter = "#{name}="
44
+
45
+ if @params.key?(key)
46
+ send setter, @params[key]
47
+ elsif instance_respond_to?(key)
48
+ send setter, instance_public_send(key)
49
+ elsif options.key?(:default) && !fields.include?(key)
50
+ send setter, process_default(options[:default])
51
+ end
52
+ end
53
+
54
+ def fill_nested_form(name, options)
55
+ return unless (form = initialize_nested_form(name, options))
56
+
57
+ nested_forms[name] = form
58
+ end
59
+
60
+ def process_default(default)
61
+ default.is_a?(Proc) ? instance_exec(&default) : default
62
+ end
63
+
64
+ def initialize_nested_form(name, options)
65
+ args =
66
+ if @params.key?(name) then [send("params_for_nested_#{name}")]
67
+ elsif instance_respond_to?(name) then [instance_public_send(name)]
68
+ elsif options.key?(:default) then [process_default(options[:default])]
69
+ else []
70
+ end
71
+
72
+ result =
73
+ instance_exec options[:form], &options.fetch(:initialize, ->(form) { form.new(*args) })
74
+ result.runnable = false unless runnable
75
+ result
76
+ end
77
+ end
78
+
79
+ private_constant :Filling
80
+ end
81
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formalism
4
+ ## https://github.com/rubocop-hq/rubocop/issues/5831
5
+ class Form < Action
6
+ ## Private class for results
7
+ class Outcome
8
+ attr_reader :errors
9
+
10
+ def initialize(errors, result = nil)
11
+ @errors = errors
12
+ @result = result
13
+ end
14
+
15
+ def success?
16
+ @errors.empty?
17
+ end
18
+
19
+ def result
20
+ raise ValidationError, errors if errors.any?
21
+
22
+ @result
23
+ end
24
+ end
25
+
26
+ private_constant :Outcome
27
+ end
28
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formalism
4
+ class Form < Action
5
+ ## When trying to get `#result` from unsuccessful outcome
6
+ class ValidationError < StandardError
7
+ attr_reader :errors
8
+
9
+ def initialize(errors)
10
+ @errors = errors
11
+ super "Outcome has errors: #{errors.to_a}"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formalism
4
+ VERSION = '0.2.0'
5
+ end
metadata ADDED
@@ -0,0 +1,238 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: formalism
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Alexander Popov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-09-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: gorilla_patch
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '4'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '4'
27
+ - !ruby/object:Gem::Dependency
28
+ name: module_methods
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.1.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.1.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: pry-byebug
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.9'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.9'
55
+ - !ruby/object:Gem::Dependency
56
+ name: bundler
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '2.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '2.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: gem_toys
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 0.4.0
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 0.4.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: toys
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 0.11.0
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 0.11.0
97
+ - !ruby/object:Gem::Dependency
98
+ name: codecov
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: 0.2.1
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: 0.2.1
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.9'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.9'
125
+ - !ruby/object:Gem::Dependency
126
+ name: simplecov
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: 0.19.0
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: 0.19.0
139
+ - !ruby/object:Gem::Dependency
140
+ name: rubocop
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: 0.91.0
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: 0.91.0
153
+ - !ruby/object:Gem::Dependency
154
+ name: rubocop-performance
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - "~>"
158
+ - !ruby/object:Gem::Version
159
+ version: '1.0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - "~>"
165
+ - !ruby/object:Gem::Version
166
+ version: '1.0'
167
+ - !ruby/object:Gem::Dependency
168
+ name: rubocop-rspec
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - "~>"
172
+ - !ruby/object:Gem::Version
173
+ version: '1.43'
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - "~>"
179
+ - !ruby/object:Gem::Version
180
+ version: '1.43'
181
+ description: 'Simple actions and complex forms with validations, nesting, etc.
182
+
183
+ '
184
+ email: alex.wayfer@gmail.com
185
+ executables: []
186
+ extensions: []
187
+ extra_rdoc_files: []
188
+ files:
189
+ - CHANGELOG.md
190
+ - LICENSE.txt
191
+ - README.md
192
+ - lib/formalism.rb
193
+ - lib/formalism/action.rb
194
+ - lib/formalism/form.rb
195
+ - lib/formalism/form/coercion.rb
196
+ - lib/formalism/form/coercion/_base.rb
197
+ - lib/formalism/form/coercion/_numeric.rb
198
+ - lib/formalism/form/coercion/array.rb
199
+ - lib/formalism/form/coercion/boolean.rb
200
+ - lib/formalism/form/coercion/date.rb
201
+ - lib/formalism/form/coercion/float.rb
202
+ - lib/formalism/form/coercion/hash.rb
203
+ - lib/formalism/form/coercion/integer.rb
204
+ - lib/formalism/form/coercion/string.rb
205
+ - lib/formalism/form/coercion/symbol.rb
206
+ - lib/formalism/form/coercion/time.rb
207
+ - lib/formalism/form/fields.rb
208
+ - lib/formalism/form/filling.rb
209
+ - lib/formalism/form/outcome.rb
210
+ - lib/formalism/form/validation_error.rb
211
+ - lib/formalism/version.rb
212
+ homepage: https://github.com/AlexWayfer/formalism
213
+ licenses:
214
+ - MIT
215
+ metadata:
216
+ source_code_uri: https://github.com/AlexWayfer/formalism
217
+ homepage_uri: https://github.com/AlexWayfer/formalism
218
+ changelog_uri: https://github.com/AlexWayfer/formalism/blob/master/CHANGELOG.md
219
+ post_install_message:
220
+ rdoc_options: []
221
+ require_paths:
222
+ - lib
223
+ required_ruby_version: !ruby/object:Gem::Requirement
224
+ requirements:
225
+ - - "~>"
226
+ - !ruby/object:Gem::Version
227
+ version: '2.5'
228
+ required_rubygems_version: !ruby/object:Gem::Requirement
229
+ requirements:
230
+ - - ">="
231
+ - !ruby/object:Gem::Version
232
+ version: '0'
233
+ requirements: []
234
+ rubygems_version: 3.1.2
235
+ signing_key:
236
+ specification_version: 4
237
+ summary: Forms with input data validations and nesting
238
+ test_files: []