formed 1.0.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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +146 -0
  3. data/Rakefile +12 -0
  4. data/lib/active_form.rb +12 -0
  5. data/lib/formed/acts_like_model.rb +27 -0
  6. data/lib/formed/association_relation.rb +22 -0
  7. data/lib/formed/associations/association.rb +193 -0
  8. data/lib/formed/associations/builder/association.rb +116 -0
  9. data/lib/formed/associations/builder/collection_association.rb +71 -0
  10. data/lib/formed/associations/builder/has_many.rb +24 -0
  11. data/lib/formed/associations/builder/has_one.rb +44 -0
  12. data/lib/formed/associations/builder/singular_association.rb +46 -0
  13. data/lib/formed/associations/builder.rb +13 -0
  14. data/lib/formed/associations/collection_association.rb +296 -0
  15. data/lib/formed/associations/collection_proxy.rb +519 -0
  16. data/lib/formed/associations/foreign_association.rb +37 -0
  17. data/lib/formed/associations/has_many_association.rb +63 -0
  18. data/lib/formed/associations/has_one_association.rb +27 -0
  19. data/lib/formed/associations/singular_association.rb +66 -0
  20. data/lib/formed/associations.rb +62 -0
  21. data/lib/formed/attributes.rb +42 -0
  22. data/lib/formed/base.rb +183 -0
  23. data/lib/formed/core.rb +73 -0
  24. data/lib/formed/from_model.rb +41 -0
  25. data/lib/formed/from_params.rb +33 -0
  26. data/lib/formed/inheritance.rb +179 -0
  27. data/lib/formed/nested_attributes.rb +287 -0
  28. data/lib/formed/reflection.rb +781 -0
  29. data/lib/formed/relation/delegation.rb +147 -0
  30. data/lib/formed/relation.rb +113 -0
  31. data/lib/formed/version.rb +3 -0
  32. data/lib/generators/active_form/form_generator.rb +72 -0
  33. data/lib/generators/active_form/templates/form.rb.tt +8 -0
  34. data/lib/generators/active_form/templates/form_spec.rb.tt +5 -0
  35. data/lib/generators/active_form/templates/module.rb.tt +4 -0
  36. metadata +203 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 328d9eb07d649d40bc0d35abcb958b5d8a7481eeef634de0ad15fb29f511449a
4
+ data.tar.gz: 3bddab39a1183163ec0c2d3247653b7503b162ace61a8b7340fa85f75519abb7
5
+ SHA512:
6
+ metadata.gz: d376a17f42644a25ef81f1d5a901327405122a248ae78e263f71aa5600345e5e5e8cd275b830ebf97a71f2fafeb4d5f78df65fe2e31a467465f4d466a48647ab
7
+ data.tar.gz: 86315bf92ce6ec953d5712e3d0f8a9ff50be6606716c79761e892e0069c9818c428b1c5fefdaaf725b910b7a565187c38645e3de060a61aded6146d0cbc25188
data/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # Formed
2
+
3
+ Formed is the form object pattern you never knew you needed: uses ActiveModel under the hood, and supports associations just like ActiveRecord.
4
+
5
+ ## Contents
6
+
7
+ * [Usage](#usage)
8
+ * [Installation](*installation)
9
+ * [Acknowledgements](*acknowledgements)
10
+ * [Contributing](*contribuing)
11
+
12
+ ## Usage
13
+
14
+ Formed form objects act just like the ActiveRecord models you started forcing into form helpers.
15
+
16
+ ### Basic form
17
+
18
+ ```ruby
19
+ class ProductForm < Formed::Base
20
+ acts_like_model :product
21
+
22
+ attribute :title
23
+ attribute :content, :text
24
+ end
25
+ ```
26
+
27
+ ### With validations
28
+
29
+ Use all the validations your heart desires.
30
+
31
+ ```ruby
32
+ class PostForm < Formed::Base
33
+ acts_like_model :post
34
+
35
+ attribute :title
36
+ attribute :content, :text
37
+
38
+ validates :title, presence: true
39
+ validates :content, presence: true
40
+ end
41
+ ```
42
+
43
+ ### Associations
44
+
45
+ Here's the big one:
46
+
47
+ ```ruby
48
+ class TicketForm < Formed::Base
49
+ acts_like_model :ticket
50
+
51
+ attribute :name
52
+
53
+ # automatically applies accepts_nested_attributes_for
54
+ has_many :ticket_prices, class_name: "TicketPriceForm"
55
+
56
+ validates :name, presence: true
57
+ end
58
+ ```
59
+
60
+ ```ruby
61
+ class TicketPriceForm < Formed::Base
62
+ acts_like_model :ticket_price
63
+
64
+ attribute :price_in_cents, :integer
65
+
66
+ validates :price_in_cents, presence: true, numericality: { greater_than: 0 }
67
+ end
68
+ ```
69
+
70
+ ### Context
71
+
72
+ Add context:
73
+
74
+ ```ruby
75
+ class OrganizationForm < Formed::Base
76
+ acts_like_model :organization
77
+
78
+ attribute :location_id, :integer
79
+
80
+ def location_id_options
81
+ context.organization.locations
82
+ end
83
+ end
84
+ ```
85
+
86
+ ```ruby
87
+ form = OrganizationForm.new
88
+ form.with_context(organization: @organization)
89
+ ```
90
+
91
+ Context gets passed down to all associations too.
92
+
93
+ ```ruby
94
+ class OrganizationForm < Formed::Base
95
+ acts_like_model :organization
96
+
97
+ has_many :users, class_name: "UserForm"
98
+
99
+ attribute :location_id, :integer
100
+
101
+ def location_id_options
102
+ context.organization.locations
103
+ end
104
+ end
105
+ ```
106
+
107
+ ```ruby
108
+ form = OrganizationForm.new
109
+ form.with_context(organization: @organization)
110
+ user = form.users.new
111
+ user.context == form.context # true
112
+ ```
113
+
114
+ ## Suggestions
115
+
116
+ ### Let forms be forms, not forms with actions
117
+
118
+ Forms should only know do one thing: represent a form and the form's state. Leave logic to its own.
119
+
120
+ If you use something like ActiveDuty, you could do this:
121
+
122
+ ```ruby
123
+ class MyCommand < ApplicationCommand
124
+ def initialize(form)
125
+ @form = form
126
+ end
127
+
128
+ def call
129
+ return broadcast(:invalid, form) unless @form.valid?
130
+
131
+ # ...
132
+ end
133
+ end
134
+ ```
135
+
136
+ ## Contributing
137
+
138
+ By submitting a Pull Request, you disavow any rights or claims to any changes submitted to the Formed project and assign the copyright of those changes to joshmn.
139
+
140
+ If you cannot or do not want to reassign those rights (your employment contract for your employer may not allow this), you should not submit a PR. Open an issue and someone else can do the work.
141
+
142
+ This is a legal way of saying "If you submit a PR to us, that code becomes ours". 99.99% of the time that's what you intend anyways; we hope it doesn't scare you away from contributing.
143
+
144
+ ## Acknowledgements
145
+
146
+ This was heavily inspired by — and tries to be backwards compatible with — AndyPike's Rectify form pattern.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/module/delegation"
5
+ require "mutex_m"
6
+ require "active_model"
7
+
8
+ require_relative "formed/version"
9
+ require "formed/base"
10
+
11
+ module Formed
12
+ ; end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module ActsLikeModel
5
+ extend ActiveSupport::Concern
6
+
7
+ module ClassMethods
8
+ def inherit_model_validations(model, *attributes)
9
+ attributes.each do |attr|
10
+ model._validators[attr].each do |validator|
11
+ if validator.options.none?
12
+ validates attr, validator.kind => true
13
+ else
14
+ validates attr, validator.kind => validator.options
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ def acts_like_model(model)
21
+ self.model = model
22
+ end
23
+ end
24
+
25
+ def map_model(record); end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ class AssociationRelation < Relation # :nodoc:
5
+ def initialize(klass, association, **)
6
+ super(klass)
7
+ @association = association
8
+ end
9
+
10
+ def proxy_association
11
+ @association
12
+ end
13
+
14
+ def ==(other)
15
+ other == records
16
+ end
17
+
18
+ def merge!(other, *rest) # :nodoc:
19
+ # no-op #
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module Associations
5
+ class Association # :nodoc:
6
+ attr_reader :owner, :target, :reflection, :disable_joins
7
+
8
+ delegate :options, to: :reflection
9
+
10
+ def initialize(owner, reflection)
11
+ reflection.check_validity!
12
+
13
+ @owner = owner
14
+ @reflection = reflection
15
+
16
+ reset
17
+ reset_scope
18
+ end
19
+
20
+ def reset
21
+ @loaded = true
22
+ @target = nil
23
+ @stale_state = nil
24
+ end
25
+
26
+ def reset_negative_cache # :nodoc:
27
+ reset if loaded? && target.nil?
28
+ end
29
+
30
+ # Reloads the \target and returns +self+ on success.
31
+ # The QueryCache is cleared if +force+ is true.
32
+ def reload(force = false)
33
+ reset
34
+ reset_scope
35
+ load_target
36
+ self unless target.nil?
37
+ end
38
+
39
+ def loaded?
40
+ @loaded
41
+ end
42
+
43
+ # Asserts the \target has been loaded setting the \loaded flag to +true+.
44
+ def loaded!
45
+ @loaded = true
46
+ @stale_state = stale_state
47
+ end
48
+
49
+ def stale_target?
50
+ loaded? && @stale_state != stale_state
51
+ end
52
+
53
+ def target=(target)
54
+ @target = target
55
+ loaded!
56
+ end
57
+
58
+ def scope
59
+ target
60
+ end
61
+
62
+ def reset_scope
63
+ @association_scope = nil
64
+ end
65
+
66
+ # Set the inverse association, if possible
67
+ def set_inverse_instance(record)
68
+ if (inverse = inverse_association_for(record))
69
+ inverse.inversed_from(owner)
70
+ end
71
+ record
72
+ end
73
+
74
+ def klass
75
+ reflection.klass
76
+ end
77
+
78
+ def extensions
79
+ extensions = reflection.extensions
80
+
81
+ extensions |= reflection.scope_for(klass.unscoped, owner).extensions if reflection.scope
82
+
83
+ extensions
84
+ end
85
+
86
+ def load_target
87
+ @target = find_target if (@stale_state && stale_target?) || find_target?
88
+
89
+ loaded! unless loaded?
90
+ target
91
+ end
92
+
93
+ # We can't dump @reflection and @through_reflection since it contains the scope proc
94
+ def marshal_dump
95
+ ivars = (instance_variables - %i[@reflection @through_reflection]).map do |name|
96
+ [name, instance_variable_get(name)]
97
+ end
98
+ [@reflection.name, ivars]
99
+ end
100
+
101
+ def marshal_load(data)
102
+ reflection_name, ivars = data
103
+ ivars.each { |name, val| instance_variable_set(name, val) }
104
+ @reflection = @owner.class._reflect_on_association(reflection_name)
105
+ end
106
+
107
+ def initialize_attributes(record, except_from_scope_attributes = nil) # :nodoc:
108
+ except_from_scope_attributes ||= {}
109
+ skip_assign = [reflection.foreign_key, reflection.type].compact
110
+ assigned_keys = record.changed
111
+ assigned_keys += except_from_scope_attributes.keys.map(&:to_s)
112
+ attributes = {}.except!(*(assigned_keys - skip_assign))
113
+ record.send(:_assign_attributes, attributes) if attributes.any?
114
+ set_inverse_instance(record)
115
+ end
116
+
117
+ private
118
+
119
+ # Reader and writer methods call this so that consistent errors are presented
120
+ # when the association target class does not exist.
121
+ def ensure_klass_exists!
122
+ klass
123
+ end
124
+
125
+ def find_target
126
+
127
+ end
128
+
129
+ def violates_strict_loading?
130
+ return reflection.strict_loading? if reflection.options.key?(:strict_loading)
131
+
132
+ false # owner.strict_loading? && !owner.strict_loading_n_plus_one_only?
133
+ end
134
+
135
+ def association_scope
136
+ klass
137
+ end
138
+
139
+ def target_scope
140
+ AssociationRelation.create(klass, self).merge!({})
141
+ end
142
+
143
+ def find_target?
144
+ !loaded? && (!owner.new_record?) && klass
145
+ end
146
+
147
+ def inverse_association_for(record)
148
+ return unless invertible_for?(record)
149
+
150
+ record.association(inverse_reflection_for(record).name)
151
+ end
152
+
153
+ # Returns true if inverse association on the given record needs to be set.
154
+ # This method is redefined by subclasses.
155
+ def invertible_for?(record)
156
+ foreign_key_for?(record) && inverse_reflection_for(record)
157
+ end
158
+
159
+ # Returns true if record contains the foreign_key
160
+ def foreign_key_for?(record)
161
+ record._has_attribute?(reflection.foreign_key)
162
+ end
163
+
164
+ # This should be implemented to return the values of the relevant key(s) on the owner,
165
+ # so that when stale_state is different from the value stored on the last find_target,
166
+ # the target is stale.
167
+ #
168
+ # This is only relevant to certain associations, which is why it returns +nil+ by default.
169
+ def stale_state; end
170
+
171
+ def build_record(attributes)
172
+ reflection.build_association(attributes) do |record|
173
+ initialize_attributes(record, attributes)
174
+ yield(record) if block_given?
175
+ end
176
+ end
177
+
178
+ def inversable?(record)
179
+ record &&
180
+ ((!record.persisted? || !owner.persisted?) || matches_foreign_key?(record))
181
+ end
182
+
183
+ def matches_foreign_key?(record)
184
+ if foreign_key_for?(record)
185
+ record.read_attribute(reflection.foreign_key) == owner.id ||
186
+ (foreign_key_for?(owner) && owner.read_attribute(reflection.foreign_key) == record.id)
187
+ else
188
+ owner.read_attribute(reflection.foreign_key) == record.id
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module Associations
5
+ module Builder
6
+ class Association
7
+ class << self
8
+ attr_accessor :extensions
9
+ end
10
+ self.extensions = []
11
+
12
+ VALID_OPTIONS = %i[
13
+ class_name anonymous_class primary_key foreign_key validate inverse_of
14
+ ].freeze # :nodoc:
15
+
16
+ def self.build(model, name, scope, options, &block)
17
+ reflection = create_reflection(model, name, scope, options, &block)
18
+ define_accessors model, reflection
19
+ define_callbacks model, reflection
20
+ define_validations model, reflection
21
+ define_change_tracking_methods model, reflection
22
+ reflection
23
+ end
24
+
25
+ def self.create_reflection(model, name, scope, options, &block)
26
+ raise ArgumentError, "association names must be a Symbol" unless name.is_a?(Symbol)
27
+
28
+ validate_options(options)
29
+
30
+ extension = define_extensions(model, name, &block)
31
+ options[:extend] = [*options[:extend], extension] if extension
32
+
33
+ scope = build_scope(scope)
34
+
35
+ Reflection.create(macro, name, scope, options, model)
36
+ end
37
+
38
+ def self.build_scope(scope)
39
+ if scope&.arity&.zero?
40
+ proc { instance_exec(&scope) }
41
+ else
42
+ scope
43
+ end
44
+ end
45
+
46
+ def self.macro
47
+ raise NotImplementedError
48
+ end
49
+
50
+ def self.valid_options(_options)
51
+ VALID_OPTIONS + Association.extensions.flat_map(&:valid_options)
52
+ end
53
+
54
+ def self.validate_options(options)
55
+ options.assert_valid_keys(valid_options(options))
56
+ end
57
+
58
+ def self.define_extensions(model, name); end
59
+
60
+ def self.define_callbacks(model, reflection)
61
+ Association.extensions.each do |extension|
62
+ extension.build model, reflection
63
+ end
64
+ end
65
+
66
+ # Defines the setter and getter methods for the association
67
+ # class Post < ActiveRecord::Base
68
+ # has_many :comments
69
+ # end
70
+ #
71
+ # Post.first.comments and Post.first.comments= methods are defined by this method...
72
+ def self.define_accessors(model, reflection)
73
+ mixin = model.generated_association_methods
74
+ name = reflection.name
75
+ define_readers(mixin, name)
76
+ define_writers(mixin, name)
77
+ end
78
+
79
+ def self.define_readers(mixin, name)
80
+ mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
81
+ def #{name}
82
+ association(:#{name}).reader
83
+ end
84
+ CODE
85
+ end
86
+
87
+ def self.define_writers(mixin, name)
88
+ mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
89
+ def #{name}=(value)
90
+ association(:#{name}).writer(value)
91
+ end
92
+ CODE
93
+ end
94
+
95
+ def self.define_validations(model, reflection)
96
+ # noop
97
+ end
98
+
99
+ def self.define_change_tracking_methods(model, reflection)
100
+ # noop
101
+ end
102
+
103
+ def self.valid_dependent_options
104
+ raise NotImplementedError
105
+ end
106
+
107
+ def self.check_dependent_options(dependent, model)
108
+ end
109
+
110
+ private_class_method :build_scope, :macro, :valid_options, :validate_options, :define_extensions,
111
+ :define_callbacks, :define_accessors, :define_readers, :define_writers, :define_validations,
112
+ :define_change_tracking_methods, :valid_dependent_options, :check_dependent_options
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module Associations
5
+ module Builder
6
+ class CollectionAssociation < ::Formed::Associations::Builder::Association # :nodoc:
7
+ CALLBACKS = %i[before_add after_add before_remove after_remove].freeze
8
+
9
+ def self.valid_options(options)
10
+ super + %i[before_add after_add before_remove after_remove extend]
11
+ end
12
+
13
+ def self.define_callbacks(model, reflection)
14
+ super
15
+ name = reflection.name
16
+ options = reflection.options
17
+ CALLBACKS.each do |callback_name|
18
+ define_callback(model, callback_name, name, options)
19
+ end
20
+ end
21
+
22
+ def self.define_extensions(model, name, &block)
23
+ return unless block_given?
24
+
25
+ extension_module_name = "#{name.to_s.camelize}AssociationExtension"
26
+ extension = Module.new(&block)
27
+ model.const_set(extension_module_name, extension)
28
+ end
29
+
30
+ def self.define_callback(model, callback_name, name, options)
31
+ full_callback_name = "#{callback_name}_for_#{name}"
32
+
33
+ callback_values = Array(options[callback_name.to_sym])
34
+ method_defined = model.respond_to?(full_callback_name)
35
+
36
+ # If there are no callbacks, we must also check if a superclass had
37
+ # previously defined this association
38
+ return if callback_values.empty? && !method_defined
39
+
40
+ unless method_defined
41
+ model.class_attribute(full_callback_name, instance_accessor: false, instance_predicate: false)
42
+ end
43
+
44
+ callbacks = callback_values.map do |callback|
45
+ case callback
46
+ when Symbol
47
+ ->(_method, owner, record) { owner.send(callback, record) }
48
+ when Proc
49
+ ->(_method, owner, record) { callback.call(owner, record) }
50
+ else
51
+ ->(method, owner, record) { callback.send(method, owner, record) }
52
+ end
53
+ end
54
+ model.send "#{full_callback_name}=", callbacks
55
+ end
56
+
57
+ def self.define_writers(mixin, name)
58
+ super
59
+
60
+ mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
61
+ def #{name.to_s.singularize}_ids=(ids)
62
+ association(:#{name}).ids_writer(ids)
63
+ end
64
+ CODE
65
+ end
66
+
67
+ private_class_method :valid_options, :define_callback, :define_extensions, :define_readers, :define_writers
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module Associations
5
+ module Builder
6
+ class HasMany < ::Formed::Associations::Builder::CollectionAssociation # :nodoc:
7
+ def self.macro
8
+ :has_many
9
+ end
10
+
11
+ def self.valid_options(options)
12
+ valid = super
13
+ valid += [:as] if options[:as]
14
+ valid += %i[through source source_type] if options[:through]
15
+ valid
16
+ end
17
+
18
+ def self.valid_dependent_options; end
19
+
20
+ private_class_method :macro, :valid_options, :valid_dependent_options
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module Associations
5
+ module Builder
6
+ class HasOne < SingularAssociation # :nodoc:
7
+ def self.macro
8
+ :has_one
9
+ end
10
+
11
+ def self.valid_options(options)
12
+ valid = super
13
+ valid += [:as] if options[:as]
14
+ valid += %i[through source source_type] if options[:through]
15
+ valid
16
+ end
17
+
18
+ def self.valid_dependent_options
19
+ []
20
+ end
21
+
22
+ def self.define_callbacks(model, reflection)
23
+ super
24
+ add_touch_callbacks(model, reflection) if reflection.options[:touch]
25
+ end
26
+
27
+ def self.define_validations(model, reflection)
28
+ super
29
+ return unless reflection.options[:required]
30
+
31
+ model.validates_presence_of reflection.name, message: :required
32
+ model.validate :"ensure_#{reflection.name}_valid!"
33
+
34
+ model.define_method "ensure_#{reflection.name}_valid!" do
35
+ errors.add(reflection.name, :invalid) unless public_send(reflection.name).valid?
36
+ end
37
+ end
38
+
39
+ private_class_method :macro, :valid_options, :valid_dependent_options,
40
+ :define_callbacks, :define_validations
41
+ end
42
+ end
43
+ end
44
+ end