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