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.
- checksums.yaml +7 -0
- data/README.md +146 -0
- data/Rakefile +12 -0
- data/lib/active_form.rb +12 -0
- data/lib/formed/acts_like_model.rb +27 -0
- data/lib/formed/association_relation.rb +22 -0
- data/lib/formed/associations/association.rb +193 -0
- data/lib/formed/associations/builder/association.rb +116 -0
- data/lib/formed/associations/builder/collection_association.rb +71 -0
- data/lib/formed/associations/builder/has_many.rb +24 -0
- data/lib/formed/associations/builder/has_one.rb +44 -0
- data/lib/formed/associations/builder/singular_association.rb +46 -0
- data/lib/formed/associations/builder.rb +13 -0
- data/lib/formed/associations/collection_association.rb +296 -0
- data/lib/formed/associations/collection_proxy.rb +519 -0
- data/lib/formed/associations/foreign_association.rb +37 -0
- data/lib/formed/associations/has_many_association.rb +63 -0
- data/lib/formed/associations/has_one_association.rb +27 -0
- data/lib/formed/associations/singular_association.rb +66 -0
- data/lib/formed/associations.rb +62 -0
- data/lib/formed/attributes.rb +42 -0
- data/lib/formed/base.rb +183 -0
- data/lib/formed/core.rb +73 -0
- data/lib/formed/from_model.rb +41 -0
- data/lib/formed/from_params.rb +33 -0
- data/lib/formed/inheritance.rb +179 -0
- data/lib/formed/nested_attributes.rb +287 -0
- data/lib/formed/reflection.rb +781 -0
- data/lib/formed/relation/delegation.rb +147 -0
- data/lib/formed/relation.rb +113 -0
- data/lib/formed/version.rb +3 -0
- data/lib/generators/active_form/form_generator.rb +72 -0
- data/lib/generators/active_form/templates/form.rb.tt +8 -0
- data/lib/generators/active_form/templates/form_spec.rb.tt +5 -0
- data/lib/generators/active_form/templates/module.rb.tt +4 -0
- 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
data/lib/active_form.rb
ADDED
@@ -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
|