formed 1.0.0

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