formalism 0.2.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.
@@ -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: []